周报小程序

master
lion 4 weeks ago
parent bdccbd2a96
commit 9989006f3d

@ -11,6 +11,7 @@ export interface UniversityRow {
longitude?: number | null longitude?: number | null
address?: string | null address?: string | null
remark?: string | null remark?: string | null
sort_order?: number
} }
export interface PaperListRow { export interface PaperListRow {

@ -43,6 +43,9 @@ export interface TeacherRow {
phone?: string | null phone?: string | null
email?: string | null email?: string | null
remark?: string | null remark?: string | null
library_status?: 'pending' | 'active' | 'rejected'
library_status_label?: string | null
approved_at?: string | null
} }
export interface TeacherStats { export interface TeacherStats {
@ -50,6 +53,7 @@ export interface TeacherStats {
month_followed: number month_followed: number
overdue: number overdue: number
partners: number partners: number
library_pending?: number
} }
export interface FollowRecordRow { export interface FollowRecordRow {
@ -127,6 +131,21 @@ export async function batchUpdateTeacherStar(payload: {
return data.data return data.data
} }
export async function approveTeacher(id: number) {
const { data } = await http.post<ApiBody<TeacherRow>>(`/admin/v1/teachers/${id}/approve`)
return data.data
}
export async function rejectTeacher(id: number) {
const { data } = await http.post<ApiBody<TeacherRow>>(`/admin/v1/teachers/${id}/reject`)
return data.data
}
export async function batchApproveTeachers(ids: number[]) {
const { data } = await http.post<ApiBody<{ updated: number }>>('/admin/v1/teachers/batch-approve', { ids })
return data.data
}
export async function fetchTeacherFollowRecords(teacherId: number) { export async function fetchTeacherFollowRecords(teacherId: number) {
const { data } = await http.get<ApiBody<{ items: FollowRecordRow[] }>>( const { data } = await http.get<ApiBody<{ items: FollowRecordRow[] }>>(
`/admin/v1/teachers/${teacherId}/follow-records`, `/admin/v1/teachers/${teacherId}/follow-records`,

@ -55,6 +55,7 @@ async function loadTeachers(page = teacherPage.value) {
page, page,
page_size: teacherMeta.value.per_page, page_size: teacherMeta.value.per_page,
keyword: teacherKeyword.value.trim() || undefined, keyword: teacherKeyword.value.trim() || undefined,
library_status: 'linkable',
}) })
teacherItems.value = res.items teacherItems.value = res.items
teacherMeta.value = res.meta teacherMeta.value = res.meta

@ -32,6 +32,7 @@ const form = ref({
latitude: '', latitude: '',
city: '', city: '',
province: '', province: '',
sort_order: 0,
}) })
const regionOptions = [ const regionOptions = [
@ -71,7 +72,7 @@ function searchUniversities() {
function openCreate() { function openCreate() {
editing.value = null editing.value = null
form.value = { name: '', longitude: '', latitude: '', city: '', province: '' } form.value = { name: '', longitude: '', latitude: '', city: '', province: '', sort_order: 0 }
dialog.value = true dialog.value = true
} }
@ -83,6 +84,7 @@ function openEdit(row: UniversityRow) {
latitude: row.latitude != null ? String(row.latitude) : '', latitude: row.latitude != null ? String(row.latitude) : '',
city: row.city || '', city: row.city || '',
province: row.province || '', province: row.province || '',
sort_order: row.sort_order ?? 0,
} }
dialog.value = true dialog.value = true
} }
@ -130,6 +132,7 @@ async function save() {
latitude: Number(form.value.latitude), latitude: Number(form.value.latitude),
city: form.value.city || null, city: form.value.city || null,
province: form.value.province || null, province: form.value.province || null,
sort_order: Number(form.value.sort_order) || 0,
} }
if (editing.value) { if (editing.value) {
await updateUniversity(editing.value.id, payload) await updateUniversity(editing.value.id, payload)
@ -177,6 +180,7 @@ usePageLoad(load)
</div> </div>
<el-table v-loading="loading" :data="items" row-key="id"> <el-table v-loading="loading" :data="items" row-key="id">
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
<el-table-column prop="name" label="高校名称" min-width="180" /> <el-table-column prop="name" label="高校名称" min-width="180" />
<el-table-column label="经度" width="120"> <el-table-column label="经度" width="120">
<template #default="{ row }">{{ row.longitude ?? '—' }}</template> <template #default="{ row }">{{ row.longitude ?? '—' }}</template>
@ -239,6 +243,10 @@ usePageLoad(load)
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-form-item label="排序">
<el-input-number v-model="form.sort_order" :min="0" :max="99999" />
<p class="list-page-hint" style="margin-top: 8px">数值越小越靠前雷达地图与列表均按此排序</p>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialog = false">取消</el-button> <el-button @click="dialog = false">取消</el-button>

@ -3,12 +3,14 @@ import { computed, ref, watch } from 'vue'
import { fetchDictByCode } from '@/api/admin/dict' import { fetchDictByCode } from '@/api/admin/dict'
import TeacherPaperDialog from '@/components/TeacherPaperDialog.vue' import TeacherPaperDialog from '@/components/TeacherPaperDialog.vue'
import { import {
approveTeacher,
createUniversity, createUniversity,
deleteTeacherPaper, deleteTeacherPaper,
fetchTeacher, fetchTeacher,
fetchTeacherFilterOptions, fetchTeacherFilterOptions,
fetchTeacherPapers, fetchTeacherPapers,
fetchUniversities, fetchUniversities,
rejectTeacher,
updateTeacher, updateTeacher,
type DictItemBrief, type DictItemBrief,
type PaperRow, type PaperRow,
@ -62,6 +64,25 @@ const emit = defineEmits<{
saved: [] saved: []
}>() }>()
async function approveTeacherAction() {
if (!teacher.value) return
await approveTeacher(teacher.value.id)
ElMessage.success('已确认入库')
emit('saved')
emit('update:modelValue', false)
}
async function rejectTeacherAction() {
if (!teacher.value) return
await ElMessageBox.confirm(`确定驳回「${teacher.value.name}」?驳回后记录会保留。`, '确认驳回', {
type: 'warning',
})
await rejectTeacher(teacher.value.id)
ElMessage.success('已驳回')
emit('saved')
emit('update:modelValue', false)
}
const loading = ref(false) const loading = ref(false)
const teacher = ref<TeacherRow | null>(null) const teacher = ref<TeacherRow | null>(null)
const loadedStarId = ref<number | null>(null) const loadedStarId = ref<number | null>(null)
@ -584,6 +605,20 @@ watch(
<template #footer> <template #footer>
<el-button @click="emit('update:modelValue', false)">关闭</el-button> <el-button @click="emit('update:modelValue', false)">关闭</el-button>
<el-button
v-if="teacher?.library_status === 'pending' && !isReadonly"
type="success"
@click="approveTeacherAction"
>
确认入库
</el-button>
<el-button
v-if="teacher?.library_status === 'pending' && !isReadonly"
type="danger"
@click="rejectTeacherAction"
>
驳回
</el-button>
<el-button v-if="!isReadonly" type="primary" class="teachers-primary-btn" @click="save"> <el-button v-if="!isReadonly" type="primary" class="teachers-primary-btn" @click="save">
保存 保存
</el-button> </el-button>

@ -9,7 +9,9 @@ import TeacherPaperDialog from '@/components/TeacherPaperDialog.vue'
import { fetchAdminUsers } from '@/api/admin/users' import { fetchAdminUsers } from '@/api/admin/users'
import type { DictItemBrief } from '@/api/admin/teachers' import type { DictItemBrief } from '@/api/admin/teachers'
import { import {
approveTeacher,
batchUpdateTeacherStar, batchUpdateTeacherStar,
batchApproveTeachers,
createTeacher, createTeacher,
createTeacherFollowRecord, createTeacherFollowRecord,
createUniversity, createUniversity,
@ -18,6 +20,7 @@ import {
fetchTeachersList, fetchTeachersList,
fetchTeacherStats, fetchTeacherStats,
fetchUniversities, fetchUniversities,
rejectTeacher,
type FollowRecordRow, type FollowRecordRow,
type TeacherRow, type TeacherRow,
type TeacherStats, type TeacherStats,
@ -25,7 +28,7 @@ import {
import { followRuleHint, previewNextFollowDate } from '@/utils/teacherFollowRule' import { followRuleHint, previewNextFollowDate } from '@/utils/teacherFollowRule'
import { starDisplay, sourceTagType, statusTagType, urgencyTagType } from '@/utils/teacherStar' import { starDisplay, sourceTagType, statusTagType, urgencyTagType } from '@/utils/teacherStar'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
const auth = useAuthStore() const auth = useAuthStore()
const isGridMember = computed(() => auth.isGridMember) const isGridMember = computed(() => auth.isGridMember)
@ -44,7 +47,8 @@ const detailTeacherId = ref<number | null>(null)
const items = ref<TeacherRow[]>([]) const items = ref<TeacherRow[]>([])
const meta = ref({ current_page: 1, per_page: 20, total: 0 }) const meta = ref({ current_page: 1, per_page: 20, total: 0 })
const page = ref(1) const page = ref(1)
const stats = ref<TeacherStats>({ month_pending: 0, month_followed: 0, overdue: 0, partners: 0 }) const stats = ref<TeacherStats>({ month_pending: 0, month_followed: 0, overdue: 0, partners: 0, library_pending: 0 })
const libraryTab = ref<'active' | 'pending' | 'rejected'>('active')
const keyword = ref('') const keyword = ref('')
const filterSource = ref<number | ''>('') const filterSource = ref<number | ''>('')
@ -196,6 +200,7 @@ async function load() {
if (filterUniversity.value !== '') params.university_id = filterUniversity.value if (filterUniversity.value !== '') params.university_id = filterUniversity.value
if (filterDirection.value !== '') params.research_direction_id = filterDirection.value if (filterDirection.value !== '') params.research_direction_id = filterDirection.value
if (statBucket.value) params.stat_bucket = statBucket.value if (statBucket.value) params.stat_bucket = statBucket.value
params.library_status = libraryTab.value
const res = await fetchTeachersList(params) const res = await fetchTeachersList(params)
items.value = res.items items.value = res.items
meta.value = res.meta meta.value = res.meta
@ -225,16 +230,56 @@ function searchTeachers() {
} }
function pickStat(bucket: string) { function pickStat(bucket: string) {
if (libraryTab.value !== 'active') {
libraryTab.value = 'active'
}
statBucket.value = statBucket.value === bucket ? '' : bucket statBucket.value = statBucket.value === bucket ? '' : bucket
page.value = 1 page.value = 1
load() load()
} }
function switchLibraryTab(tab: 'active' | 'pending' | 'rejected') {
libraryTab.value = tab
statBucket.value = ''
page.value = 1
selectedRows.value = []
load()
}
async function approveRow(row: TeacherRow) {
await approveTeacher(row.id)
ElMessage.success('已确认入库')
await Promise.all([load(), loadStats()])
}
async function rejectRow(row: TeacherRow) {
await ElMessageBox.confirm(`确定驳回「${row.name}」?驳回后记录会保留。`, '确认驳回', {
type: 'warning',
})
await rejectTeacher(row.id)
ElMessage.success('已驳回')
await Promise.all([load(), loadStats()])
}
async function batchApproveSelected() {
if (!selectedIds.value.length) {
ElMessage.warning('请先选择老师')
return
}
const res = await batchApproveTeachers(selectedIds.value)
ElMessage.success(`已确认入库 ${res.updated} 位老师`)
await Promise.all([load(), loadStats()])
}
function goDetail(row: TeacherRow) { function goDetail(row: TeacherRow) {
detailTeacherId.value = row.id detailTeacherId.value = row.id
detailVisible.value = true detailVisible.value = true
} }
async function onDetailSaved() {
await Promise.all([load(), loadStats()])
}
async function refreshDirectionOptions() { async function refreshDirectionOptions() {
const filters = await fetchTeacherFilterOptions() const filters = await fetchTeacherFilterOptions()
directionOptions.value = filters.research_directions directionOptions.value = filters.research_directions
@ -473,6 +518,14 @@ usePageLoad(async () => {
<div class="page-header"> <div class="page-header">
<PageTitle /> <PageTitle />
<div class="page-header-actions"> <div class="page-header-actions">
<el-button
v-if="libraryTab === 'pending' && selectedIds.length"
type="success"
size="small"
@click="batchApproveSelected"
>
批量确认入库
</el-button>
<el-button class="btn-action-secondary" size="small" @click="openBatch"></el-button> <el-button class="btn-action-secondary" size="small" @click="openBatch"></el-button>
<el-button <el-button
v-if="!isGridMember" v-if="!isGridMember"
@ -487,6 +540,15 @@ usePageLoad(async () => {
</div> </div>
<div class="talent-stat-grid"> <div class="talent-stat-grid">
<button
type="button"
class="talent-stat-card"
:class="{ 'is-active': libraryTab === 'pending' }"
@click="switchLibraryTab('pending')"
>
<div class="talent-stat-label">待确认入库</div>
<div class="talent-stat-value is-warning">{{ stats.library_pending ?? 0 }}</div>
</button>
<button <button
type="button" type="button"
class="talent-stat-card" class="talent-stat-card"
@ -526,6 +588,13 @@ usePageLoad(async () => {
</div> </div>
<el-card shadow="never" class="admin-list-card teachers-list-card"> <el-card shadow="never" class="admin-list-card teachers-list-card">
<div class="library-tabs">
<el-radio-group v-model="libraryTab" @change="switchLibraryTab(libraryTab)">
<el-radio-button value="active">正式老师</el-radio-button>
<el-radio-button value="pending">待确认</el-radio-button>
<el-radio-button value="rejected">已驳回</el-radio-button>
</el-radio-group>
</div>
<div class="list-filter-bar"> <div class="list-filter-bar">
<el-input <el-input
v-model="keyword" v-model="keyword"
@ -631,9 +700,21 @@ usePageLoad(async () => {
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="176" fixed="right"> <el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="table-row-actions teachers-table-actions"> <div class="table-row-actions teachers-table-actions">
<template v-if="row.library_status === 'pending'">
<el-button size="small" type="success" class="teachers-table-btn" @click="approveRow(row)">
确认入库
</el-button>
<el-button size="small" type="danger" class="teachers-table-btn" @click="rejectRow(row)">
驳回
</el-button>
<el-button size="small" class="btn-action-primary teachers-table-btn" @click="openPaper(row)">
论文
</el-button>
</template>
<template v-else-if="libraryTab === 'active'">
<el-button size="small" class="btn-action-info teachers-table-btn" @click="openFollow(row)"> <el-button size="small" class="btn-action-info teachers-table-btn" @click="openFollow(row)">
跟进 跟进
</el-button> </el-button>
@ -649,6 +730,7 @@ usePageLoad(async () => {
> >
需求 需求
</el-button> </el-button>
</template>
<el-button size="small" class="btn-action-primary teachers-table-btn" @click="goDetail(row)"> <el-button size="small" class="btn-action-primary teachers-table-btn" @click="goDetail(row)">
编辑 编辑
</el-button> </el-button>
@ -1021,7 +1103,11 @@ usePageLoad(async () => {
</template> </template>
</el-dialog> </el-dialog>
<TeacherDetailDialog v-model="detailVisible" :teacher-id="detailTeacherId" @saved="load" /> <TeacherDetailDialog
v-model="detailVisible"
:teacher-id="detailTeacherId"
@saved="onDetailSaved"
/>
</div> </div>
</template> </template>
@ -1094,6 +1180,14 @@ usePageLoad(async () => {
.talent-stat-value.is-success { .talent-stat-value.is-success {
color: var(--el-color-success); color: var(--el-color-success);
} }
.talent-stat-value.is-warning {
color: #d97706;
}
.library-tabs {
margin-bottom: 16px;
}
.filter-source { .filter-source {
width: 132px; width: 132px;
} }

Loading…
Cancel
Save