You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

796 lines
26 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { fetchDictByCode } from '@/api/admin/dict'
import TeacherPaperDialog from '@/components/TeacherPaperDialog.vue'
import {
approveTeacher,
createUniversity,
deleteTeacherPaper,
fetchTeacher,
fetchTeacherFilterOptions,
fetchTeacherPapers,
fetchUniversities,
rejectTeacher,
updateTeacher,
type DictItemBrief,
type PaperRow,
type TeacherRow,
} from '@/api/admin/teachers'
import {
demandStatusTagType,
fetchDemandHandleLogs,
fetchDemandsList,
type DemandHandleLogRow,
type DemandRow,
} from '@/api/admin/demands'
import { followRuleHint, previewNextFollowDate } from '@/utils/teacherFollowRule'
import { starDisplay } from '@/utils/teacherStar'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
const auth = useAuthStore()
const isGridMember = computed(() => auth.isGridMember)
const props = defineProps<{
modelValue: boolean
teacherId: number | null
/** 仅查看,不可编辑(如地图页老师详情) */
readonly?: boolean
}>()
const isReadonly = computed(() => props.readonly === true)
const dialogTitle = computed(() => {
const name = teacher.value?.name
if (isReadonly.value) {
return name ? `查看 · ${name}` : '老师详情'
}
return name ? `编辑 · ${name}` : '编辑老师'
})
function displayUniversity(row: TeacherRow) {
if (row.university_name) return row.university_name
if (row.university_text) return `${row.university_text}(未关联高校库)`
return '—'
}
function displayDirections(row: TeacherRow) {
if (row.research_direction) return row.research_direction
return row.research_directions?.map((d) => d.name).join('、') || '—'
}
const emit = defineEmits<{
'update:modelValue': [boolean]
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 teacher = ref<TeacherRow | null>(null)
const loadedStarId = ref<number | null>(null)
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[] = []
const names: string[] = []
for (const value of values) {
if (typeof value === 'number') {
ids.push(value)
continue
}
const name = String(value).trim()
if (name) names.push(name)
}
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: '',
})
const universityDialog = ref(false)
const universitySaving = ref(false)
const universityForm = ref({
name: '',
city: '',
province: '',
longitude: '',
latitude: '',
})
const paperDialog = ref(false)
const summaryVisible = ref(false)
const summaryText = ref('')
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 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 (!known.has(d.id)) {
directionOptions.value.push({ id: d.id, name: d.name })
known.add(d.id)
}
}
}
function openUniversityCreate() {
universityForm.value = {
name: '',
city: form.value.city || '',
province: '',
longitude: '',
latitude: '',
}
universityDialog.value = true
}
async function saveUniversityCreate() {
const f = universityForm.value
if (!f.name.trim()) {
ElMessage.warning('请填写高校名称')
return
}
if (!f.longitude.trim() || !f.latitude.trim()) {
ElMessage.warning('请填写经度与纬度')
return
}
const longitude = Number(f.longitude)
const latitude = Number(f.latitude)
if (Number.isNaN(longitude) || Number.isNaN(latitude)) {
ElMessage.warning('经纬度须为有效数字')
return
}
universitySaving.value = true
try {
const u = await createUniversity({
name: f.name.trim(),
city: f.city.trim() || null,
province: f.province.trim() || null,
longitude,
latitude,
})
if (!universityOptions.value.some((x) => x.id === u.id)) {
universityOptions.value.push(u)
}
form.value.university_id = u.id
if (u.city) {
form.value.city = u.city
}
universityDialog.value = false
ElMessage.success('高校已创建')
} finally {
universitySaving.value = false
}
}
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
}
}
function validateForm(): boolean {
const f = form.value
if (!f.name.trim()) {
ElMessage.warning('请填写姓名')
return false
}
if (!f.university_id) {
ElMessage.warning('请选择高校,或点击「新建高校」')
return false
}
if (!f.city.trim()) {
ElMessage.warning('请填写城市')
return false
}
if (!f.title.trim()) {
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
}
async function save() {
if (!props.teacherId || !teacher.value) return
if (!validateForm()) return
const starChanged = (form.value.star_level_dict_item_id ?? null) !== loadedStarId.value
const directions = splitDirectionValues(form.value.research_direction_values)
await updateTeacher(props.teacherId, {
name: form.value.name.trim(),
university_id: form.value.university_id,
city: form.value.city.trim(),
title: form.value.title.trim(),
department: form.value.department.trim() || null,
bio: form.value.bio.trim() || null,
research_direction_ids: directions.ids,
new_research_directions: directions.names,
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,
...(starChanged
? { recalc_next_follow_date: true }
: { next_follow_date: form.value.next_follow_date || null, recalc_next_follow_date: false }),
})
ElMessage.success('已保存')
emit('saved')
emit('update:modelValue', false)
}
function onStarChange() {
const item = starOptions.value.find((s) => s.id === form.value.star_level_dict_item_id)
form.value.next_follow_date = previewNextFollowDate(item?.value) || ''
}
function onUniversityPick(id: number | undefined) {
if (!id) return
const u = universityOptions.value.find((x) => x.id === id)
if (u?.city && !form.value.city) {
form.value.city = u.city
}
}
function openAddPaper() {
paperDialog.value = true
}
async function reloadPapers() {
if (!props.teacherId) return
papers.value = await fetchTeacherPapers(props.teacherId)
}
async function removePaper(row: PaperRow) {
await ElMessageBox.confirm(`确定删除论文「${row.title}」?`, '确认删除', { type: 'warning' })
await deleteTeacherPaper(props.teacherId!, row.id)
papers.value = await fetchTeacherPapers(props.teacherId!)
}
function showSummary(row: PaperRow) {
summaryText.value = row.summary || '暂无摘要'
summaryVisible.value = true
}
async function openDemandRecords(row: DemandRow) {
demandRecordsTitle.value = row.title
demandRecords.value = await fetchDemandHandleLogs(row.id)
demandRecordsVisible.value = true
}
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()
}
await refreshDirectionOptions()
await load()
}
},
)
watch(
() => form.value.university_id,
(id) => onUniversityPick(id),
)
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="960px"
top="4vh"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading" class="detail-body">
<div v-if="isReadonly && teacher" class="basic-info-block">
<h3 class="basic-info-title">基本信息</h3>
<el-descriptions :column="3" border size="small" class="view-desc">
<el-descriptions-item label="姓名">{{ teacher.name }}</el-descriptions-item>
<el-descriptions-item label="高校">{{ displayUniversity(teacher) }}</el-descriptions-item>
<el-descriptions-item label="城市">{{ teacher.city || '—' }}</el-descriptions-item>
<el-descriptions-item label="职称">{{ teacher.title || '—' }}</el-descriptions-item>
<el-descriptions-item label="研究方向" :span="2">
{{ displayDirections(teacher) }}
</el-descriptions-item>
<el-descriptions-item label="电话">{{ teacher.phone || '—' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ teacher.email || '—' }}</el-descriptions-item>
<el-descriptions-item label="星级">
{{
starDisplay(teacher.star_level_item?.value, teacher.star_level_item?.label)
}}
</el-descriptions-item>
<el-descriptions-item label="状态">
{{ teacher.status_item?.label || '—' }}
</el-descriptions-item>
<el-descriptions-item label="下次跟进日期">
{{ teacher.next_follow_date || '—' }}
</el-descriptions-item>
<el-descriptions-item v-if="teacher.source_item?.label" label="来源">
{{ teacher.source_item.label }}
</el-descriptions-item>
</el-descriptions>
</div>
<div v-else class="basic-info-block">
<h3 class="basic-info-title">基本信息</h3>
<el-form label-position="top" class="form-small">
<el-row :gutter="12">
<el-col :xs="24" :md="4">
<el-form-item label="姓名" required>
<el-input v-model="form.name" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="高校" required>
<p
v-if="teacher?.university_text && !form.university_id"
class="crawl-uni-hint"
>
论文抓取高校:{{ teacher.university_text }}(请在下拉中选择或新建以关联)
</p>
<div class="uni-row">
<el-select
v-model="form.university_id"
filterable
placeholder="选择高校"
style="flex: 1"
>
<el-option v-for="u in universityOptions" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
<el-button
v-if="!isGridMember"
type="primary"
size="small"
class="teachers-primary-btn"
@click="openUniversityCreate"
>
新建高校
</el-button>
</div>
</el-form-item>
</el-col>
<el-col :xs="24" :md="4">
<el-form-item label="城市" required>
<el-input v-model="form.city" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="职称" required>
<el-input v-model="form.title" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="所属学院">
<el-input v-model="form.department" placeholder="如:计算机科学与技术学院" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="16">
<el-form-item label="个人简介">
<el-input v-model="form.bio" type="textarea" :rows="3" placeholder="老师个人简介" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="研究方向" required>
<el-select
v-model="form.research_direction_values"
multiple
filterable
allow-create
default-first-option
collapse-tags
collapse-tags-tooltip
placeholder="选择或输入研究方向"
style="width: 100%"
>
<el-option v-for="d in directionOptions" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="6">
<el-form-item label="电话">
<el-input v-model="form.phone" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="6">
<el-form-item label="邮箱">
<el-input v-model="form.email" type="email" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="4">
<el-form-item label="星级">
<el-select v-model="form.star_level_dict_item_id" style="width: 100%" @change="onStarChange">
<el-option v-for="o in starOptions" :key="o.id" :label="o.label" :value="o.id" />
</el-select>
<span class="star-preview">
{{
starDisplay(
starOptions.find((s) => s.id === form.star_level_dict_item_id)?.value,
starOptions.find((s) => s.id === form.star_level_dict_item_id)?.label,
)
}}
</span>
</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-option v-for="o in statusOptions" :key="o.id" :label="o.label" :value="o.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="6">
<el-form-item label="下次跟进日期">
<el-date-picker
v-model="form.next_follow_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
<p v-if="starFollowRule" class="star-follow-rule">{{ starFollowRule }}</p>
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
<div class="section">
<div class="section-head">
<h3>论文列表</h3>
<span class="muted">共 {{ papers.length }} 篇</span>
<el-button v-if="!isReadonly" size="small" type="primary" plain @click="openAddPaper">
添加论文
</el-button>
</div>
<el-table :data="papers" size="small" row-key="id">
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="authors" label="作者" width="140" />
<el-table-column prop="school_name" label="学校" width="120" />
<el-table-column prop="published_at" label="发表时间" width="110" />
<el-table-column label="链接" width="70">
<template #default="{ row }">
<el-link v-if="row.url" :href="row.url" target="_blank">查看</el-link>
</template>
</el-table-column>
<el-table-column label="操作" :width="isReadonly ? 70 : 120">
<template #default="{ row }">
<el-button link type="primary" @click="showSummary(row)">摘要</el-button>
<el-button v-if="!isReadonly" link type="danger" @click="removePaper(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="section">
<div class="section-head">
<h3>需求列表</h3>
<span class="muted">共 {{ demands.length }} 条</span>
</div>
<el-table :data="demands" size="small" row-key="id">
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag size="small" effect="dark">{{ row.type_item?.label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip />
<el-table-column prop="submitted_at" label="时间" width="110" />
<el-table-column label="处理状态" width="100">
<template #default="{ row }">
<el-tag :type="demandStatusTagType(row.status_item?.value)" size="small" effect="dark">
{{ row.status_item?.label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="跟进记录" width="100">
<template #default="{ row }">
<el-button size="small" @click="openDemandRecords(row)">
{{ row.handle_logs_count || 0 }}条记录
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!demands.length" description="暂无关联需求" :image-size="64" />
</div>
</div>
<template #footer>
<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>
</template>
<el-dialog v-model="universityDialog" title="新建高校" width="480px" append-to-body destroy-on-close>
<el-form label-position="top" class="form-small">
<el-form-item label="高校名称" required>
<el-input v-model="universityForm.name" placeholder="如:复旦大学" />
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="经度" required>
<el-input v-model="universityForm.longitude" placeholder="如121.5031" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="纬度" required>
<el-input v-model="universityForm.latitude" placeholder="如31.2970" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="省份">
<el-input v-model="universityForm.province" placeholder="如:上海市" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="城市">
<el-input v-model="universityForm.city" placeholder="如:上海" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="universityDialog = false">取消</el-button>
<el-button type="primary" class="teachers-primary-btn" :loading="universitySaving" @click="saveUniversityCreate">
保存
</el-button>
</template>
</el-dialog>
<el-dialog v-model="summaryVisible" title="论文摘要" width="520px" append-to-body>
<p class="summary-body">{{ summaryText }}</p>
</el-dialog>
<TeacherPaperDialog
v-model="paperDialog"
:teacher-id="teacherId"
:default-authors="teacher?.name || form.name"
:default-school-name="universityOptions.find((u) => u.id === form.university_id)?.name || teacher?.university_name || ''"
@saved="reloadPapers"
/>
<el-dialog v-model="demandRecordsVisible" title="需求跟进记录" width="640px" append-to-body>
<p class="follow-summary">{{ demandRecordsTitle }}</p>
<div v-for="r in demandRecords" :key="r.id" class="log-card">
<div class="log-head">
<strong>{{ r.operator_name }}</strong>
<span>{{ r.handled_at }}</span>
<el-tag v-if="r.status_item" size="small" :type="demandStatusTagType(r.status_item.value)">
{{ r.status_item.label }}
</el-tag>
</div>
<p>{{ r.content }}</p>
</div>
<el-empty v-if="!demandRecords.length" description="暂无记录" />
</el-dialog>
</el-dialog>
</template>
<style scoped>
.detail-body {
width: 100%;
}
.basic-info-block {
margin-bottom: 16px;
}
.view-desc {
width: 100%;
}
.basic-info-title {
margin: 0 0 12px;
padding-left: 10px;
border-left: 3px solid #244e98;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: var(--el-text-color-primary);
}
.crawl-uni-hint {
margin: 0 0 8px;
font-size: 12px;
line-height: 1.5;
color: var(--el-color-warning);
}
.uni-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.form-small :deep(.el-form-item__label) {
font-size: 13px;
padding-bottom: 4px;
}
.star-preview {
display: block;
margin-top: 4px;
color: #e6a23c;
font-size: 13px;
}
.star-follow-rule {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.45;
color: #6b7280;
}
.section {
margin-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 12px;
}
.section-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.section-head h3 {
margin: 0;
font-size: 15px;
}
.muted {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.section-head .el-button {
margin-left: auto;
}
.summary-body {
white-space: pre-wrap;
margin: 0;
}
.follow-summary {
margin: 0 0 12px;
font-weight: 500;
}
.log-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
padding: 10px;
margin-bottom: 8px;
}
.log-head {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 6px;
font-size: 13px;
}
:global(.teachers-primary-btn.el-button--primary) {
--el-button-text-color: #fff;
--el-button-bg-color: #244e98;
--el-button-border-color: #244e98;
--el-button-hover-text-color: #fff;
--el-button-hover-bg-color: #8b1519;
--el-button-hover-border-color: #8b1519;
--el-button-active-text-color: #fff;
--el-button-active-bg-color: #8b1519;
--el-button-active-border-color: #8b1519;
}
</style>