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

3 months ago
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { fetchDictByCode } from '@/api/admin/dict'
3 months ago
import TeacherPaperDialog from '@/components/TeacherPaperDialog.vue'
3 months ago
import {
1 month ago
approveTeacher,
3 months ago
createUniversity,
deleteTeacherPaper,
fetchTeacher,
fetchTeacherFilterOptions,
fetchTeacherPapers,
fetchUniversities,
1 month ago
rejectTeacher,
3 months ago
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: []
}>()
1 month ago
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)
}
3 months ago
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 }[]>([])
2 months ago
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 }
}
3 months ago
const form = ref({
name: '',
university_id: undefined as number | undefined,
city: '',
title: '',
2 months ago
department: '',
bio: '',
research_direction_values: [] as Array<number | string>,
3 months ago
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
}
2 months ago
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)
}
}
}
3 months ago
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
2 months ago
mergeTeacherDirectionOptions(detail)
3 months ago
form.value = {
name: detail.name || '',
university_id: detail.university_id ?? undefined,
2 months ago
research_direction_values: detail.research_direction_ids?.length
3 months ago
? [...detail.research_direction_ids]
: detail.research_directions?.map((d) => d.id) || [],
city: detail.city || '',
title: detail.title || '',
2 months ago
department: detail.department || '',
bio: detail.bio || '',
3 months ago
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
}
2 months ago
const directions = splitDirectionValues(f.research_direction_values)
if (!directions.ids.length && !directions.names.length) {
ElMessage.warning('请至少选择一个或新增研究方向')
3 months ago
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
2 months ago
const directions = splitDirectionValues(form.value.research_direction_values)
3 months ago
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(),
2 months ago
department: form.value.department.trim() || null,
bio: form.value.bio.trim() || null,
research_direction_ids: directions.ids,
new_research_directions: directions.names,
3 months ago
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
}
3 months ago
async function reloadPapers() {
3 months ago
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>
2 months ago
<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>
3 months ago
<el-col :xs="24" :md="8">
<el-form-item label="研究方向" required>
<el-select
2 months ago
v-model="form.research_direction_values"
3 months ago
multiple
filterable
2 months ago
allow-create
default-first-option
3 months ago
collapse-tags
collapse-tags-tooltip
2 months ago
placeholder="选择或输入研究方向"
3 months ago
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>
1 month ago
<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>
3 months ago
<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>
3 months ago
<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"
/>
3 months ago
<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;
2 months ago
border-left: 3px solid #244e98;
3 months ago
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;
2 months ago
--el-button-bg-color: #244e98;
--el-button-border-color: #244e98;
3 months ago
--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>