master
lion 2 days ago
parent 9989006f3d
commit 1115921d92

@ -85,6 +85,21 @@ export interface UniversityOption {
city?: string | null
}
export async function fetchTeacherBootstrap() {
const { data } = await http.get<
ApiBody<{
dicts: {
teacher_source: DictItemBrief[]
teacher_level: DictItemBrief[]
teacher_status: DictItemBrief[]
}
research_directions: ResearchDirectionBrief[]
universities: UniversityOption[]
}>
>('/admin/v1/teachers/bootstrap')
return data.data
}
export async function fetchTeacherFilterOptions() {
const { data } = await http.get<
ApiBody<{ research_directions: ResearchDirectionBrief[] }>

@ -1,15 +1,13 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { fetchDictByCode } from '@/api/admin/dict'
import { computed, nextTick, ref, watch } from 'vue'
import TeacherPaperDialog from '@/components/TeacherPaperDialog.vue'
import {
approveTeacher,
createUniversity,
deleteTeacherPaper,
fetchTeacher,
fetchTeacherFilterOptions,
fetchTeacherBootstrap,
fetchTeacherPapers,
fetchUniversities,
rejectTeacher,
updateTeacher,
type DictItemBrief,
@ -34,14 +32,21 @@ const isGridMember = computed(() => auth.isGridMember)
const props = defineProps<{
modelValue: boolean
teacherId: number | null
/** 列表行预填,用于弹窗立刻展示,不等接口 */
previewTeacher?: TeacherRow | null
/** 仅查看,不可编辑(如地图页老师详情) */
readonly?: boolean
/** 父页已加载的下拉,传入后不再重复请求 */
starOptions?: DictItemBrief[]
statusOptions?: DictItemBrief[]
universityOptions?: { id: number; name: string; city?: string | null }[]
directionOptions?: { id: number; name: string }[]
}>()
const isReadonly = computed(() => props.readonly === true)
const dialogTitle = computed(() => {
const name = teacher.value?.name
const name = teacher.value?.name || props.previewTeacher?.name || form.value.name
if (isReadonly.value) {
return name ? `查看 · ${name}` : '老师详情'
}
@ -62,6 +67,8 @@ function displayDirections(row: TeacherRow) {
const emit = defineEmits<{
'update:modelValue': [boolean]
saved: []
'university-created': [{ id: number; name: string; city?: string | null }]
'directions-changed': [{ id: number; name: string }[]]
}>()
async function approveTeacherAction() {
@ -84,19 +91,38 @@ async function rejectTeacherAction() {
}
const loading = ref(false)
const selectsReady = ref(false)
const teacher = ref<TeacherRow | null>(null)
const loadedStarId = ref<number | null>(null)
const localStarOptions = ref<DictItemBrief[]>([])
const localStatusOptions = ref<DictItemBrief[]>([])
const localUniversityOptions = ref<{ id: number; name: string; city?: string | null }[]>([])
const localDirectionOptions = ref<{ id: number; name: string }[]>([])
const starOptions = computed(() =>
props.starOptions?.length ? props.starOptions : localStarOptions.value,
)
const statusOptions = computed(() =>
props.statusOptions?.length ? props.statusOptions : localStatusOptions.value,
)
const universityOptions = computed(() =>
props.universityOptions?.length ? props.universityOptions : localUniversityOptions.value,
)
const directionOptions = computed(() =>
props.directionOptions?.length ? props.directionOptions : localDirectionOptions.value,
)
const universitySelectOptions = computed(() =>
universityOptions.value.map((u) => ({ value: u.id, label: u.name })),
)
const starFollowRule = computed(() => {
const item = starOptions.value.find((s) => s.id === form.value.star_level_dict_item_id)
return followRuleHint(item?.value)
})
const papers = ref<PaperRow[]>([])
const demands = ref<DemandRow[]>([])
const starOptions = ref<DictItemBrief[]>([])
const statusOptions = ref<DictItemBrief[]>([])
const universityOptions = ref<{ id: number; name: string; city?: string | null }[]>([])
const directionOptions = ref<{ id: number; name: string }[]>([])
function splitDirectionValues(values: Array<number | string>) {
const ids: number[] = []
@ -112,20 +138,24 @@ function splitDirectionValues(values: Array<number | string>) {
return { ids, names }
}
const form = ref({
name: '',
university_id: undefined as number | undefined,
city: '',
title: '',
department: '',
bio: '',
research_direction_values: [] as Array<number | string>,
phone: '',
email: '',
star_level_dict_item_id: undefined as number | undefined,
status_dict_item_id: undefined as number | undefined,
next_follow_date: '',
})
function emptyForm() {
return {
name: '',
university_id: undefined as number | undefined,
city: '',
title: '',
department: '',
bio: '',
research_direction_values: [] as Array<number | string>,
phone: '',
email: '',
star_level_dict_item_id: undefined as number | undefined,
status_dict_item_id: undefined as number | undefined,
next_follow_date: '',
}
}
const form = ref(emptyForm())
const universityDialog = ref(false)
const universitySaving = ref(false)
@ -145,29 +175,40 @@ const demandRecordsVisible = ref(false)
const demandRecordsTitle = ref('')
const demandRecords = ref<DemandHandleLogRow[]>([])
async function loadUniversityOptions() {
const uni = await fetchUniversities({ page: 1, page_size: 200 })
const scopeIds = auth.user?.grid_scope?.university_ids
universityOptions.value = scopeIds?.length
? uni.items.filter((u) => scopeIds.includes(u.id))
: uni.items
}
async function refreshDirectionOptions() {
const filters = await fetchTeacherFilterOptions()
const scopeDirIds = auth.user?.grid_scope?.research_direction_ids
directionOptions.value = scopeDirIds?.length
? filters.research_directions.filter((d) => scopeDirIds.includes(d.id))
: filters.research_directions
function applyTeacherToForm(detail: TeacherRow) {
teacher.value = detail
loadedStarId.value = detail.star_level_dict_item_id ?? null
form.value = {
name: detail.name || '',
university_id: detail.university_id ?? undefined,
research_direction_values: detail.research_direction_ids?.length
? [...detail.research_direction_ids]
: detail.research_directions?.map((d) => d.id) || [],
city: detail.city || '',
title: detail.title || '',
department: detail.department || '',
bio: detail.bio || '',
phone: detail.phone || '',
email: detail.email || '',
star_level_dict_item_id: detail.star_level_dict_item_id ?? undefined,
status_dict_item_id: detail.status_dict_item_id ?? undefined,
next_follow_date: detail.next_follow_date || '',
}
}
function mergeTeacherDirectionOptions(detail: {
research_directions?: { id: number; name: string }[]
}) {
const known = new Set(directionOptions.value.map((d) => d.id))
for (const d of detail.research_directions || []) {
if (!detail.research_directions?.length) return
if (props.directionOptions?.length) {
//
emit('directions-changed', detail.research_directions)
return
}
const known = new Set(localDirectionOptions.value.map((d) => d.id))
for (const d of detail.research_directions) {
if (!known.has(d.id)) {
directionOptions.value.push({ id: d.id, name: d.name })
localDirectionOptions.value.push({ id: d.id, name: d.name })
known.add(d.id)
}
}
@ -209,13 +250,14 @@ async function saveUniversityCreate() {
longitude,
latitude,
})
if (!universityOptions.value.some((x) => x.id === u.id)) {
universityOptions.value.push(u)
if (!props.universityOptions?.length && !localUniversityOptions.value.some((x) => x.id === u.id)) {
localUniversityOptions.value.push(u)
}
form.value.university_id = u.id
if (u.city) {
form.value.city = u.city
}
emit('university-created', u)
universityDialog.value = false
ElMessage.success('高校已创建')
} finally {
@ -223,39 +265,16 @@ async function saveUniversityCreate() {
}
}
async function load() {
if (!props.teacherId) return
loading.value = true
try {
const detail = await fetchTeacher(props.teacherId)
teacher.value = detail
loadedStarId.value = detail.star_level_dict_item_id ?? null
mergeTeacherDirectionOptions(detail)
form.value = {
name: detail.name || '',
university_id: detail.university_id ?? undefined,
research_direction_values: detail.research_direction_ids?.length
? [...detail.research_direction_ids]
: detail.research_directions?.map((d) => d.id) || [],
city: detail.city || '',
title: detail.title || '',
department: detail.department || '',
bio: detail.bio || '',
phone: detail.phone || '',
email: detail.email || '',
star_level_dict_item_id: detail.star_level_dict_item_id ?? undefined,
status_dict_item_id: detail.status_dict_item_id ?? undefined,
next_follow_date: detail.next_follow_date || '',
}
const [paperList, demandRes] = await Promise.all([
fetchTeacherPapers(props.teacherId),
fetchDemandsList({ teacher_id: props.teacherId, page: 1, page_size: 50 }),
])
papers.value = paperList
demands.value = demandRes.items
} finally {
loading.value = false
async function ensureOptions() {
if (isReadonly.value) return
if (starOptions.value.length && statusOptions.value.length && universityOptions.value.length) {
return
}
const data = await fetchTeacherBootstrap()
if (!starOptions.value.length) localStarOptions.value = data.dicts.teacher_level || []
if (!statusOptions.value.length) localStatusOptions.value = data.dicts.teacher_status || []
if (!universityOptions.value.length) localUniversityOptions.value = data.universities || []
if (!directionOptions.value.length) localDirectionOptions.value = data.research_directions || []
}
function validateForm(): boolean {
@ -276,15 +295,6 @@ function validateForm(): boolean {
ElMessage.warning('请填写职称')
return false
}
const directions = splitDirectionValues(f.research_direction_values)
if (!directions.ids.length && !directions.names.length) {
ElMessage.warning('请至少选择一个或新增研究方向')
return false
}
if (!f.status_dict_item_id) {
ElMessage.warning('请选择状态')
return false
}
return true
}
@ -308,7 +318,7 @@ async function save() {
phone: form.value.phone || null,
email: form.value.email || null,
star_level_dict_item_id: form.value.star_level_dict_item_id ?? null,
status_dict_item_id: form.value.status_dict_item_id,
status_dict_item_id: form.value.status_dict_item_id ?? null,
...(starChanged
? { recalc_next_follow_date: true }
: { next_follow_date: form.value.next_follow_date || null, recalc_next_follow_date: false }),
@ -360,18 +370,64 @@ async function openDemandRecords(row: DemandRow) {
watch(
() => [props.modelValue, props.teacherId] as const,
async ([open, id]) => {
if (open && id) {
if (!starOptions.value.length) {
const [star, status] = await Promise.all([
fetchDictByCode('teacher_level'),
fetchDictByCode('teacher_status'),
])
starOptions.value = star.items
statusOptions.value = status.items
await loadUniversityOptions()
if (!open || !id) {
if (!open) {
teacher.value = null
form.value = emptyForm()
papers.value = []
demands.value = []
loadedStarId.value = null
selectsReady.value = false
}
return
}
const requestId = id
papers.value = []
demands.value = []
selectsReady.value = false
//
if (props.previewTeacher && props.previewTeacher.id === id) {
applyTeacherToForm(props.previewTeacher)
loading.value = false
} else {
teacher.value = null
form.value = emptyForm()
loadedStarId.value = null
loading.value = true
}
//
await nextTick()
requestAnimationFrame(() => {
if (props.modelValue && props.teacherId === requestId) {
selectsReady.value = true
}
})
try {
const detailPromise = fetchTeacher(requestId)
void ensureOptions()
const detail = await detailPromise
if (props.teacherId !== requestId || !props.modelValue) return
applyTeacherToForm(detail)
mergeTeacherDirectionOptions(detail)
loading.value = false
const [paperList, demandRes] = await Promise.all([
fetchTeacherPapers(requestId),
fetchDemandsList({ teacher_id: requestId, page: 1, page_size: 50 }),
])
if (props.teacherId !== requestId || !props.modelValue) return
papers.value = paperList
demands.value = demandRes.items
} finally {
if (props.teacherId === requestId) {
loading.value = false
}
await refreshDirectionOptions()
await load()
}
},
)
@ -388,7 +444,7 @@ watch(
:title="dialogTitle"
width="960px"
top="4vh"
destroy-on-close
append-to-body
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading" class="detail-body">
@ -439,14 +495,16 @@ watch(
论文抓取高校{{ teacher.university_text }}请在下拉中选择或新建以关联
</p>
<div class="uni-row">
<el-select
<el-select-v2
v-if="selectsReady"
v-model="form.university_id"
filterable
clearable
placeholder="选择高校"
style="flex: 1"
>
<el-option v-for="u in universityOptions" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
:options="universitySelectOptions"
/>
<el-input v-else disabled model-value="" style="flex: 1" />
<el-button
v-if="!isGridMember"
type="primary"
@ -480,8 +538,9 @@ watch(
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="研究方向" required>
<el-form-item label="研究方向">
<el-select
v-if="selectsReady"
v-model="form.research_direction_values"
multiple
filterable
@ -489,11 +548,13 @@ watch(
default-first-option
collapse-tags
collapse-tags-tooltip
placeholder="选择或输入研究方向"
clearable
placeholder="可选,选择或输入研究方向"
style="width: 100%"
>
<el-option v-for="d in directionOptions" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
<el-input v-else disabled model-value="" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="6">
@ -522,8 +583,8 @@ watch(
</el-form-item>
</el-col>
<el-col :xs="24" :md="6">
<el-form-item label="状态" required>
<el-select v-model="form.status_dict_item_id" placeholder="请选择状态" style="width: 100%">
<el-form-item label="状态">
<el-select v-model="form.status_dict_item_id" clearable placeholder="可选" style="width: 100%">
<el-option v-for="o in statusOptions" :key="o.id" :label="o.label" :value="o.id" />
</el-select>
</el-form-item>

@ -15,11 +15,10 @@ import {
createTeacher,
createTeacherFollowRecord,
createUniversity,
fetchTeacherFilterOptions,
fetchTeacherBootstrap,
fetchTeacherFollowRecords,
fetchTeachersList,
fetchTeacherStats,
fetchUniversities,
rejectTeacher,
type FollowRecordRow,
type TeacherRow,
@ -44,6 +43,7 @@ function defaultFollowAdminUserId() {
const loading = ref(false)
const detailVisible = ref(false)
const detailTeacherId = ref<number | null>(null)
const detailPreview = ref<TeacherRow | null>(null)
const items = ref<TeacherRow[]>([])
const meta = ref({ current_page: 1, per_page: 20, total: 0 })
const page = ref(1)
@ -150,36 +150,36 @@ watch(
(id) => onUniversityPick(id),
)
async function loadDicts() {
const [source, star, status, method, urgency, demandType, uni, admins, filters] = await Promise.all([
fetchDictByCode('teacher_source'),
fetchDictByCode('teacher_level'),
fetchDictByCode('teacher_status'),
const bootstrapReady = ref(false)
const secondaryOptionsReady = ref(false)
async function loadBootstrap() {
if (bootstrapReady.value) return
const data = await fetchTeacherBootstrap()
sourceOptions.value = data.dicts.teacher_source || []
starOptions.value = data.dicts.teacher_level || []
statusOptions.value = data.dicts.teacher_status || []
universityOptions.value = data.universities || []
directionOptions.value = data.research_directions || []
bootstrapReady.value = true
}
async function ensureSecondaryOptions() {
if (secondaryOptionsReady.value) return
const [method, urgency, demandType, admins] = await Promise.all([
fetchDictByCode('follow_method'),
fetchDictByCode('follow_urgency'),
fetchDictByCode('demand_type'),
fetchUniversities({ page: 1, page_size: 200 }),
fetchAdminUsers({ page: 1, page_size: 200, status: 1 }),
fetchTeacherFilterOptions(),
])
sourceOptions.value = source.items
starOptions.value = star.items
statusOptions.value = status.items
methodOptions.value = method.items
urgencyOptions.value = urgency.items
demandTypeOptions.value = demandType.items
const scopeIds = auth.user?.grid_scope?.university_ids
universityOptions.value = scopeIds?.length
? uni.items.filter((u) => scopeIds.includes(u.id))
: uni.items
const scopeDirIds = auth.user?.grid_scope?.research_direction_ids
directionOptions.value = scopeDirIds?.length
? filters.research_directions.filter((d) => scopeDirIds.includes(d.id))
: filters.research_directions
adminOptions.value = admins.items.map((u) => ({
id: u.id,
label: u.real_name || u.username,
}))
secondaryOptionsReady.value = true
}
async function loadStats() {
@ -205,8 +205,6 @@ async function load() {
items.value = res.items
meta.value = res.meta
selectedRows.value = []
const dirs = await fetchTeacherFilterOptions()
directionOptions.value = dirs.research_directions
} finally {
loading.value = false
}
@ -272,6 +270,7 @@ async function batchApproveSelected() {
}
function goDetail(row: TeacherRow) {
detailPreview.value = row
detailTeacherId.value = row.id
detailVisible.value = true
}
@ -280,9 +279,20 @@ async function onDetailSaved() {
await Promise.all([load(), loadStats()])
}
async function refreshDirectionOptions() {
const filters = await fetchTeacherFilterOptions()
directionOptions.value = filters.research_directions
function onUniversityCreatedFromDetail(u: { id: number; name: string; city?: string | null }) {
if (!universityOptions.value.some((x) => x.id === u.id)) {
universityOptions.value.push(u)
}
}
function onDirectionsChangedFromDetail(dirs: { id: number; name: string }[]) {
const known = new Set(directionOptions.value.map((d) => d.id))
for (const d of dirs) {
if (!known.has(d.id)) {
directionOptions.value.push(d)
known.add(d.id)
}
}
}
function openUniversityCreate() {
@ -364,7 +374,6 @@ async function openCreate() {
star_level_dict_item_id: pendingStarId.value,
status_dict_item_id: statusOptions.value.find((s) => s.value === 'active')?.id,
}
await refreshDirectionOptions()
createDialog.value = true
}
@ -375,10 +384,6 @@ async function saveCreate() {
return
}
const directions = splitDirectionValues(f.research_direction_values)
if (!directions.ids.length && !directions.names.length) {
ElMessage.warning('请至少选择一个或新增研究方向')
return
}
if (!f.city.trim()) {
ElMessage.warning('请填写城市')
return
@ -387,10 +392,6 @@ async function saveCreate() {
ElMessage.warning('请填写职称')
return
}
if (!f.status_dict_item_id) {
ElMessage.warning('请选择状态')
return
}
if (!f.university_id) {
ElMessage.warning('请选择高校,或点击「新建高校」')
@ -410,7 +411,7 @@ async function saveCreate() {
email: f.email || null,
source_dict_item_id: f.source_dict_item_id ?? manualSourceId.value,
star_level_dict_item_id: f.star_level_dict_item_id ?? pendingStarId.value,
status_dict_item_id: f.status_dict_item_id,
status_dict_item_id: f.status_dict_item_id ?? null,
recalc_next_follow_date: true,
})
ElMessage.success('已录入')
@ -442,7 +443,8 @@ async function saveBatch() {
await Promise.all([load(), loadStats()])
}
function openFollow(row: TeacherRow) {
async function openFollow(row: TeacherRow) {
await ensureSecondaryOptions()
followTeacher.value = row
followForm.value = {
subject: '',
@ -485,7 +487,8 @@ function openPaper(row: TeacherRow) {
paperDialog.value = true
}
function openDemand(row: TeacherRow) {
async function openDemand(row: TeacherRow) {
await ensureSecondaryOptions()
demandTeacher.value = row
demandForm.value = { type_dict_item_id: undefined, title: '', content: '' }
demandDialog.value = true
@ -508,7 +511,11 @@ async function saveDemand() {
}
usePageLoad(async () => {
await loadDicts()
// bootstrap + stats + list keep-alive /
if (!bootstrapReady.value) {
await Promise.all([loadBootstrap(), loadStats(), load()])
return
}
await Promise.all([loadStats(), load()])
})
</script>
@ -599,7 +606,7 @@ usePageLoad(async () => {
<el-input
v-model="keyword"
class="filter-search teachers-filter-search"
placeholder="搜索姓名、高校、学院、简介、研究方向…"
placeholder="搜索姓名、高校、学院、研究方向…"
clearable
@keyup.enter="searchTeachers"
/>
@ -803,7 +810,7 @@ usePageLoad(async () => {
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="研究方向" required>
<el-form-item label="研究方向">
<el-select
v-model="createForm.research_direction_values"
multiple
@ -812,7 +819,8 @@ usePageLoad(async () => {
default-first-option
collapse-tags
collapse-tags-tooltip
placeholder="选择或输入研究方向"
clearable
placeholder="可选,选择或输入研究方向"
style="width: 100%"
>
<el-option v-for="d in directionOptions" :key="d.id" :label="d.name" :value="d.id" />
@ -837,8 +845,8 @@ usePageLoad(async () => {
</el-form-item>
</el-col>
<el-col :xs="24" :md="6">
<el-form-item label="状态" required>
<el-select v-model="createForm.status_dict_item_id" placeholder="请选择状态" style="width: 100%">
<el-form-item label="状态">
<el-select v-model="createForm.status_dict_item_id" clearable placeholder="可选" style="width: 100%">
<el-option v-for="o in statusOptions" :key="o.id" :label="o.label" :value="o.id" />
</el-select>
</el-form-item>
@ -1106,7 +1114,14 @@ usePageLoad(async () => {
<TeacherDetailDialog
v-model="detailVisible"
:teacher-id="detailTeacherId"
:preview-teacher="detailPreview"
:star-options="starOptions"
:status-options="statusOptions"
:university-options="universityOptions"
:direction-options="directionOptions"
@saved="onDetailSaved"
@university-created="onUniversityCreatedFromDetail"
@directions-changed="onDirectionsChangedFromDetail"
/>
</div>
</template>

Loading…
Cancel
Save