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.

1251 lines
40 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 PageTitle from '@/components/PageTitle.vue'
import { computed, ref, watch } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import { fetchDictByCode } from '@/api/admin/dict'
import { createDemand } from '@/api/admin/demands'
import TeacherDetailDialog from '@/views/teachers/components/TeacherDetailDialog.vue'
import TeacherPaperDialog from '@/components/TeacherPaperDialog.vue'
import { fetchAdminUsers } from '@/api/admin/users'
import type { DictItemBrief } from '@/api/admin/teachers'
import {
batchUpdateTeacherStar,
createTeacher,
createTeacherFollowRecord,
createUniversity,
fetchTeacherFilterOptions,
fetchTeacherFollowRecords,
fetchTeachersList,
fetchTeacherStats,
fetchUniversities,
type FollowRecordRow,
type TeacherRow,
type TeacherStats,
} from '@/api/admin/teachers'
import { followRuleHint, previewNextFollowDate } from '@/utils/teacherFollowRule'
import { starDisplay, sourceTagType, statusTagType, urgencyTagType } from '@/utils/teacherStar'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
const auth = useAuthStore()
const isGridMember = computed(() => auth.isGridMember)
function defaultFollowAdminUserId() {
const currentId = auth.user?.id
if (currentId != null && adminOptions.value.some((a) => a.id === currentId)) {
return currentId
}
return adminOptions.value[0]?.id
}
const loading = ref(false)
const detailVisible = ref(false)
const detailTeacherId = ref<number | null>(null)
const items = ref<TeacherRow[]>([])
const meta = ref({ current_page: 1, per_page: 20, total: 0 })
const page = ref(1)
const stats = ref<TeacherStats>({ month_pending: 0, month_followed: 0, overdue: 0, partners: 0 })
const keyword = ref('')
const filterSource = ref<number | ''>('')
const filterStar = ref<number | ''>('')
const filterStatus = ref<number | ''>('')
const filterUniversity = ref<number | ''>('')
const filterDirection = ref<number | ''>('')
const statBucket = ref('')
const sourceOptions = ref<DictItemBrief[]>([])
const starOptions = ref<DictItemBrief[]>([])
const statusOptions = ref<DictItemBrief[]>([])
const methodOptions = ref<DictItemBrief[]>([])
const urgencyOptions = ref<DictItemBrief[]>([])
const universityOptions = ref<{ id: number; name: string; city?: string | null }[]>([])
const directionOptions = ref<{ id: number; name: string }[]>([])
const adminOptions = ref<{ id: number; label: string }[]>([])
const demandTypeOptions = ref<DictItemBrief[]>([])
const selectedRows = ref<TeacherRow[]>([])
const selectedIds = computed(() => selectedRows.value.map((r) => r.id))
const selectedNamesText = computed(() => selectedRows.value.map((r) => r.name).join('、'))
const createDialog = ref(false)
const createForm = ref({
name: '',
university_id: undefined as number | undefined,
city: '',
title: '',
department: '',
bio: '',
research_direction_values: [] as Array<number | string>,
phone: '',
email: '',
source_dict_item_id: undefined as number | undefined,
star_level_dict_item_id: undefined as number | undefined,
status_dict_item_id: undefined as number | undefined,
})
const universityDialog = ref(false)
const universitySaving = ref(false)
const universityForm = ref({
name: '',
city: '',
province: '',
longitude: '',
latitude: '',
})
const batchDialog = ref(false)
const batchStarId = ref<number | undefined>()
const batchStarRule = computed(() => {
const item = starOptions.value.find((s) => s.id === batchStarId.value)
return followRuleHint(item?.value)
})
const followDialog = ref(false)
const followTeacher = ref<TeacherRow | null>(null)
const followNextDateRule = computed(() => followRuleHint(followTeacher.value?.star_level_item?.value))
const followForm = ref({
subject: '',
content: '',
followed_at: new Date().toISOString().slice(0, 10),
follow_method_dict_item_id: undefined as number | undefined,
urgency_dict_item_id: undefined as number | undefined,
admin_user_id: undefined as number | undefined,
next_follow_subject: '',
next_follow_date: '',
})
const recordsDialog = ref(false)
const recordsTeacher = ref<TeacherRow | null>(null)
const records = ref<FollowRecordRow[]>([])
const paperDialog = ref(false)
const paperTeacher = ref<TeacherRow | null>(null)
const demandDialog = ref(false)
const demandTeacher = ref<TeacherRow | null>(null)
const demandForm = ref({
type_dict_item_id: undefined as number | undefined,
title: '',
content: '',
})
const manualSourceId = computed(() => sourceOptions.value.find((o) => o.value === 'manual')?.id)
const pendingStarId = computed(() => starOptions.value.find((s) => s.value === 'pending')?.id)
function onUniversityPick(id: number | undefined) {
if (!id) return
const u = universityOptions.value.find((x) => x.id === id)
if (u?.city && !createForm.value.city) {
createForm.value.city = u.city
}
}
watch(
() => createForm.value.university_id,
(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'),
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,
}))
}
async function loadStats() {
stats.value = await fetchTeacherStats()
}
async function load() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: page.value,
page_size: meta.value.per_page,
}
if (keyword.value) params.keyword = keyword.value
if (filterSource.value !== '') params.source_dict_item_id = filterSource.value
if (filterStar.value !== '') params.star_level_dict_item_id = filterStar.value
if (filterStatus.value !== '') params.status_dict_item_id = filterStatus.value
if (filterUniversity.value !== '') params.university_id = filterUniversity.value
if (filterDirection.value !== '') params.research_direction_id = filterDirection.value
if (statBucket.value) params.stat_bucket = statBucket.value
const res = await fetchTeachersList(params)
items.value = res.items
meta.value = res.meta
selectedRows.value = []
const dirs = await fetchTeacherFilterOptions()
directionOptions.value = dirs.research_directions
} finally {
loading.value = false
}
}
function resetFilters() {
keyword.value = ''
filterSource.value = ''
filterStar.value = ''
filterStatus.value = ''
filterUniversity.value = ''
filterDirection.value = ''
statBucket.value = ''
page.value = 1
load()
}
function searchTeachers() {
page.value = 1
load()
}
function pickStat(bucket: string) {
statBucket.value = statBucket.value === bucket ? '' : bucket
page.value = 1
load()
}
function goDetail(row: TeacherRow) {
detailTeacherId.value = row.id
detailVisible.value = true
}
async function refreshDirectionOptions() {
const filters = await fetchTeacherFilterOptions()
directionOptions.value = filters.research_directions
}
function openUniversityCreate() {
universityForm.value = {
name: '',
city: createForm.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)
}
createForm.value.university_id = u.id
if (u.city) {
createForm.value.city = u.city
}
universityDialog.value = false
ElMessage.success('高校已创建')
} finally {
universitySaving.value = false
}
}
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 }
}
async function openCreate() {
createForm.value = {
name: '',
university_id: undefined,
city: '',
title: '',
department: '',
bio: '',
research_direction_values: [],
phone: '',
email: '',
source_dict_item_id: manualSourceId.value,
star_level_dict_item_id: pendingStarId.value,
status_dict_item_id: statusOptions.value.find((s) => s.value === 'active')?.id,
}
await refreshDirectionOptions()
createDialog.value = true
}
async function saveCreate() {
const f = createForm.value
if (!f.name.trim()) {
ElMessage.warning('请填写姓名')
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
}
if (!f.title.trim()) {
ElMessage.warning('请填写职称')
return
}
if (!f.status_dict_item_id) {
ElMessage.warning('请选择状态')
return
}
if (!f.university_id) {
ElMessage.warning('请选择高校,或点击「新建高校」')
return
}
await createTeacher({
name: f.name.trim(),
university_id: f.university_id,
city: f.city.trim(),
title: f.title.trim(),
department: f.department.trim() || null,
bio: f.bio.trim() || null,
research_direction_ids: directions.ids,
new_research_directions: directions.names,
phone: f.phone || null,
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,
recalc_next_follow_date: true,
})
ElMessage.success('已录入')
createDialog.value = false
await Promise.all([load(), loadStats()])
}
function openBatch() {
if (!selectedRows.value.length) {
ElMessage.warning('请先在列表中勾选需要改星的老师')
return
}
batchStarId.value = undefined
batchDialog.value = true
}
async function saveBatch() {
if (!batchStarId.value) {
ElMessage.warning('请选择目标星级')
return
}
await batchUpdateTeacherStar({
ids: selectedIds.value,
star_level_dict_item_id: batchStarId.value,
recalc_next_follow_date: true,
})
ElMessage.success('已批量更新')
batchDialog.value = false
await Promise.all([load(), loadStats()])
}
function openFollow(row: TeacherRow) {
followTeacher.value = row
followForm.value = {
subject: '',
content: '',
followed_at: new Date().toISOString().slice(0, 10),
follow_method_dict_item_id: methodOptions.value[0]?.id,
urgency_dict_item_id: urgencyOptions.value.find((u) => u.value === 'normal')?.id,
admin_user_id: defaultFollowAdminUserId(),
next_follow_subject: '',
next_follow_date: previewNextFollowDate(row.star_level_item?.value) || '',
}
followDialog.value = true
}
async function saveFollow() {
const f = followForm.value
if (!followTeacher.value) return
if (!f.subject.trim() || !f.next_follow_subject.trim() || !f.next_follow_date) {
ElMessage.warning('请填写必填项')
return
}
if (!f.follow_method_dict_item_id || !f.urgency_dict_item_id || !f.admin_user_id) {
ElMessage.warning('请填写跟进方式、紧急程度与跟进人员')
return
}
await createTeacherFollowRecord(followTeacher.value.id, { ...f })
ElMessage.success('已保存跟进')
followDialog.value = false
await Promise.all([load(), loadStats()])
}
async function openRecords(row: TeacherRow) {
recordsTeacher.value = row
records.value = await fetchTeacherFollowRecords(row.id)
recordsDialog.value = true
}
function openPaper(row: TeacherRow) {
paperTeacher.value = row
paperDialog.value = true
}
function openDemand(row: TeacherRow) {
demandTeacher.value = row
demandForm.value = { type_dict_item_id: undefined, title: '', content: '' }
demandDialog.value = true
}
async function saveDemand() {
if (!demandTeacher.value) return
if (!demandForm.value.type_dict_item_id || !demandForm.value.title.trim() || !demandForm.value.content.trim()) {
ElMessage.warning('请填写需求类型、标题与详细描述')
return
}
await createDemand({
teacher_id: demandTeacher.value.id,
type_dict_item_id: demandForm.value.type_dict_item_id,
title: demandForm.value.title.trim(),
content: demandForm.value.content.trim(),
})
ElMessage.success('已发布需求')
demandDialog.value = false
}
usePageLoad(async () => {
await loadDicts()
await Promise.all([loadStats(), load()])
})
</script>
<template>
<div class="list-page teachers-page">
<div class="page-header">
<PageTitle />
<div class="page-header-actions">
<el-button class="btn-action-secondary" size="small" @click="openBatch"></el-button>
<el-button
v-if="!isGridMember"
type="primary"
size="small"
class="teachers-primary-btn"
@click="openCreate"
>
手动录入
</el-button>
</div>
</div>
<div class="talent-stat-grid">
<button
type="button"
class="talent-stat-card"
:class="{ 'is-active': statBucket === 'month_pending' }"
@click="pickStat('month_pending')"
>
<div class="talent-stat-label">本月待跟进数量</div>
<div class="talent-stat-value">{{ stats.month_pending }}</div>
</button>
<button
type="button"
class="talent-stat-card"
:class="{ 'is-active': statBucket === 'month_followed' }"
@click="pickStat('month_followed')"
>
<div class="talent-stat-label">本月已跟进</div>
<div class="talent-stat-value is-dark">{{ stats.month_followed }}</div>
</button>
<button
type="button"
class="talent-stat-card"
:class="{ 'is-active': statBucket === 'overdue' }"
@click="pickStat('overdue')"
>
<div class="talent-stat-label">已逾期</div>
<div class="talent-stat-value is-danger">{{ stats.overdue }}</div>
</button>
<button
type="button"
class="talent-stat-card"
:class="{ 'is-active': statBucket === 'partner' }"
@click="pickStat('partner')"
>
<div class="talent-stat-label">入孵用户数量</div>
<div class="talent-stat-value is-success">{{ stats.partners }}</div>
</button>
</div>
<el-card shadow="never" class="admin-list-card teachers-list-card">
<div class="list-filter-bar">
<el-input
v-model="keyword"
class="filter-search teachers-filter-search"
placeholder="搜索姓名、高校、学院、简介、研究方向…"
clearable
@keyup.enter="searchTeachers"
/>
<el-select v-model="filterSource" class="filter-source" placeholder="来源" clearable>
<el-option v-for="o in sourceOptions" :key="o.id" :label="o.label" :value="o.id" />
</el-select>
<el-select v-model="filterStar" class="filter-stars" placeholder="星级" clearable>
<el-option v-for="o in starOptions" :key="o.id" :label="o.label" :value="o.id" />
</el-select>
<el-select v-model="filterStatus" class="filter-status" placeholder="状态" clearable>
<el-option v-for="o in statusOptions" :key="o.id" :label="o.label" :value="o.id" />
</el-select>
<el-select
v-model="filterUniversity"
class="filter-school"
placeholder="学校"
clearable
filterable
>
<el-option v-for="u in universityOptions" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
<el-select
v-model="filterDirection"
class="filter-direction"
placeholder="研究方向"
clearable
filterable
>
<el-option v-for="d in directionOptions" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
<el-button type="primary" @click="searchTeachers">搜索</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
<el-table
v-loading="loading"
:data="items"
row-key="id"
@selection-change="(rows: TeacherRow[]) => (selectedRows = rows)"
>
<el-table-column type="selection" width="44" />
<el-table-column prop="name" label="姓名" width="108">
<template #default="{ row }">
<a class="name-link" href="#" @click.prevent="goDetail(row)">{{ row.name }}</a>
<el-tag v-if="row.is_overdue" type="danger" size="small" class="tag-overdue">逾期</el-tag>
</template>
</el-table-column>
<el-table-column prop="university_name" label="高校" min-width="130" />
<el-table-column prop="department" label="所属学院" min-width="120" show-overflow-tooltip />
<el-table-column prop="title" label="职称" width="96" />
<el-table-column label="研究方向" min-width="160">
<template #default="{ row }">
<template v-if="row.research_directions?.length">
<el-tag
v-for="d in row.research_directions"
:key="d.id"
size="small"
type="info"
style="margin: 2px 4px 2px 0"
>
{{ d.name }}
</el-tag>
</template>
<span v-else class="text-muted">—</span>
</template>
</el-table-column>
<el-table-column label="来源" width="108" align="center">
<template #default="{ row }">
<el-tag
:type="sourceTagType(row.source_item?.value)"
size="small"
effect="dark"
class="source-tag"
:class="{ 'source-tag-manual': row.source_item?.value === 'manual' }"
>
{{ row.source_item?.label || '—' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="星级" width="108">
<template #default="{ row }">
<span class="star-text" :title="row.star_level_item?.label || ''">
{{ starDisplay(row.star_level_item?.value, row.star_level_item?.label) }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status_item?.value)" size="small" effect="dark">
{{ row.status_item?.label || '—' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="跟进记录" width="96" align="center">
<template #default="{ row }">
<el-button size="small" class="btn-action-secondary teachers-table-btn" @click="openRecords(row)">
{{ row.follow_records_count || 0 }}条记录
</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="176" fixed="right">
<template #default="{ row }">
<div class="table-row-actions teachers-table-actions">
<el-button size="small" class="btn-action-info teachers-table-btn" @click="openFollow(row)">
跟进
</el-button>
<el-button size="small" class="btn-action-primary teachers-table-btn" @click="openPaper(row)">
论文
</el-button>
<el-button
v-if="row.is_partner || row.status_item?.value === 'partner'"
type="primary"
size="small"
class="teachers-primary-btn teachers-table-btn"
@click="openDemand(row)"
>
需求
</el-button>
<el-button size="small" class="btn-action-primary teachers-table-btn" @click="goDetail(row)">
编辑
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="list-pager">
<el-pagination
v-model:current-page="page"
:page-size="meta.per_page"
:total="meta.total"
layout="total, prev, pager, next"
@current-change="load"
/>
</div>
</el-card>
<!-- 手动录入:对齐 createTeacherModal -->
<el-dialog v-model="createDialog" title="手动录入老师" width="960px" destroy-on-close>
<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="createForm.name" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="高校" required>
<div class="uni-row">
<el-select
v-model="createForm.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="btn-create 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="createForm.city" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="职称" required>
<el-input v-model="createForm.title" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="所属学院">
<el-input v-model="createForm.department" placeholder="如:计算机科学与技术学院" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="16">
<el-form-item label="个人简介">
<el-input v-model="createForm.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="createForm.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="createForm.phone" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="6">
<el-form-item label="邮箱">
<el-input v-model="createForm.email" type="email" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="4">
<el-form-item label="星级">
<el-select v-model="createForm.star_level_dict_item_id" style="width: 100%">
<el-option v-for="o in starOptions" :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="状态" required>
<el-select v-model="createForm.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-row>
</el-form>
<template #footer>
<el-button @click="createDialog = false">取消</el-button>
<el-button type="primary" @click="saveCreate">保存</el-button>
</template>
</el-dialog>
<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" :loading="universitySaving" @click="saveUniversityCreate">保存</el-button>
</template>
</el-dialog>
<!-- 批量改星 -->
<el-dialog v-model="batchDialog" title="批量修改星级" width="480px">
<el-alert
v-if="!selectedRows.length"
type="warning"
:closable="false"
title="请先在列表中勾选需要改星的老师。"
show-icon
/>
<template v-else>
<div class="follow-teacher-summary">
已选择 <span class="text-brand">{{ selectedRows.length }}</span> 位老师
<span v-if="selectedNamesText" class="names-muted">{{ selectedNamesText }}</span>
</div>
<el-form label-position="top" style="margin-top: 12px">
<el-form-item label="目标星级" required>
<el-select v-model="batchStarId" placeholder="请选择目标星级" style="width: 100%">
<el-option v-for="o in starOptions" :key="o.id" :label="o.label" :value="o.id" />
</el-select>
</el-form-item>
<p class="batch-rule">{{ batchStarRule }}</p>
</el-form>
</template>
<template #footer>
<el-button @click="batchDialog = false">取消</el-button>
<el-button type="primary" :disabled="!selectedRows.length" @click="saveBatch">
保存并重算跟进日
</el-button>
</template>
</el-dialog>
<!-- 新增跟进 -->
<el-dialog v-model="followDialog" title="新增跟进" width="720px" destroy-on-close>
<div v-if="followTeacher" class="follow-teacher-summary">
{{ followTeacher.name }} · {{ followTeacher.university_name }} · {{ followTeacher.title }}
</div>
<el-form label-position="top" class="form-small" style="margin-top: 12px">
<el-row :gutter="12">
<el-col :span="10">
<el-form-item label="跟进主题" required>
<el-input v-model="followForm.subject" placeholder="请输入跟进主题" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="跟进日期" required>
<el-date-picker
v-model="followForm.followed_at"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="跟进方式" required>
<el-select v-model="followForm.follow_method_dict_item_id" style="width: 100%">
<el-option v-for="o in methodOptions" :key="o.id" :label="o.label" :value="o.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="跟进人员" required>
<el-select v-model="followForm.admin_user_id" style="width: 100%">
<el-option v-for="a in adminOptions" :key="a.id" :label="a.label" :value="a.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="跟进记录">
<el-input
v-model="followForm.content"
type="textarea"
:rows="4"
placeholder="请输入跟进内容"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="下次跟进主题" required>
<el-input v-model="followForm.next_follow_subject" placeholder="请输入下次跟进主题" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="下次跟进日期" required>
<el-date-picker
v-model="followForm.next_follow_date"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
<p v-if="followNextDateRule" class="follow-date-rule">{{ followNextDateRule }}</p>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="紧急程度" required>
<el-select v-model="followForm.urgency_dict_item_id" placeholder="请选择紧急程度" style="width: 100%">
<el-option v-for="o in urgencyOptions" :key="o.id" :label="o.label" :value="o.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="followDialog = false">取消</el-button>
<el-button type="primary" @click="saveFollow">保存</el-button>
</template>
</el-dialog>
<!-- 跟进记录时间线 -->
<el-dialog v-model="recordsDialog" title="跟进记录" width="960px">
<div v-if="recordsTeacher" class="follow-teacher-summary">
<span>{{ recordsTeacher.name }}</span>
<span class="dot">·</span>
<span>{{ recordsTeacher.university_name }}</span>
<span class="dot">·</span>
<span>{{ recordsTeacher.title }}</span>
<span class="dot">·</span>
<span class="star-text">
{{ starDisplay(recordsTeacher.star_level_item?.value, recordsTeacher.star_level_item?.label) }}
</span>
</div>
<div class="follow-history-list">
<div v-for="r in records" :key="r.id" class="follow-history-card">
<div class="follow-history-head">
<div>
<span class="follow-history-title">{{ r.subject }}</span>
<span class="follow-history-date">{{ r.followed_at }}</span>
</div>
<el-tag
v-if="r.urgency_item"
:type="urgencyTagType((r.urgency_item as { value?: string }).value)"
size="small"
>
{{ r.urgency_item.label }}
</el-tag>
</div>
<div class="follow-history-body">
<div class="follow-history-row">
<span>跟进主题:</span>
<div>{{ r.subject }}</div>
</div>
<div class="follow-history-row">
<span>跟进方式:</span>
<div>{{ r.follow_method_item?.label || '—' }}</div>
</div>
<div class="follow-history-row">
<span>跟进记录:</span>
<div>{{ r.content || '—' }}</div>
</div>
<div class="follow-history-row">
<span>下次跟进:</span>
<div>主题:{{ r.next_follow_subject }}<br />日期:{{ r.next_follow_date }}</div>
</div>
</div>
<div class="follow-history-footer">跟进人:{{ r.operator_name || '—' }}</div>
</div>
<el-empty v-if="!records.length" description="暂无跟进记录" />
</div>
<template #footer>
<el-button @click="recordsDialog = false">关闭</el-button>
</template>
</el-dialog>
<TeacherPaperDialog
v-model="paperDialog"
:teacher-id="paperTeacher?.id ?? null"
:default-authors="paperTeacher?.name"
:default-school-name="paperTeacher?.university_name || ''"
@saved="load"
/>
<!-- 发布需求(伙伴老师,需求模块待接) -->
<el-dialog v-model="demandDialog" title="发布需求" width="640px" destroy-on-close>
<el-form label-position="top" class="form-small">
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="关联老师">
<el-input :model-value="demandTeacher?.name || ''" readonly />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="需求类型" required>
<el-select v-model="demandForm.type_dict_item_id" placeholder="请选择需求类型" style="width: 100%">
<el-option v-for="o in demandTypeOptions" :key="o.id" :label="o.label" :value="o.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="标题" required>
<el-input v-model="demandForm.title" placeholder="请输入需求标题" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="详细描述" required>
<el-input
v-model="demandForm.content"
type="textarea"
:rows="5"
placeholder="请描述需求背景、目标、期望资源或合作方式"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="demandDialog = false">取消</el-button>
<el-button type="primary" @click="saveDemand"></el-button>
</template>
</el-dialog>
<TeacherDetailDialog v-model="detailVisible" :teacher-id="detailTeacherId" @saved="load" />
</div>
</template>
<style scoped>
.teachers-page {
--brand-primary: #244e98;
--brand-primary-hover: #8b1519;
--brand-bg-soft: #fdf3f3;
display: flex;
flex-direction: column;
gap: 12px;
}
.talent-stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.teachers-list-card {
margin-top: 0;
}
.teachers-filter-search {
width: 180px;
}
.talent-stat-card {
min-height: 68px;
padding: 10px 16px;
border: 1px solid #d8dde4;
border-radius: 8px;
background: #fff;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.08);
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 6px;
text-align: left;
position: relative;
cursor: pointer;
font: inherit;
}
.talent-stat-card:hover,
.talent-stat-card.is-active {
border-color: var(--brand-primary);
}
.talent-stat-card.is-active::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--brand-primary);
}
.talent-stat-label {
color: #6b7280;
font-size: 14px;
}
.talent-stat-value {
color: var(--brand-primary);
font-size: 24px;
line-height: 1.1;
font-weight: 600;
}
.talent-stat-value.is-dark {
color: #111827;
}
.talent-stat-value.is-danger {
color: var(--el-color-danger);
}
.talent-stat-value.is-success {
color: var(--el-color-success);
}
.filter-source {
width: 132px;
}
.filter-stars {
width: 100px;
}
.filter-status {
width: 112px;
}
.filter-school {
width: 184px;
}
.filter-direction {
width: 146px;
}
.name-link {
color: inherit;
text-decoration: none;
}
.name-link:hover {
color: var(--brand-primary);
}
.tag-overdue {
margin-left: 4px;
vertical-align: middle;
}
.star-text {
color: #e6a23c;
letter-spacing: 1px;
}
.source-tag {
border: none;
font-weight: 500;
}
.source-tag-manual {
--el-tag-bg-color: #6c757d;
--el-tag-border-color: #6c757d;
--el-tag-text-color: #fff;
}
.teachers-table-actions {
flex-wrap: nowrap;
gap: 4px;
}
.teachers-table-btn {
padding: 2px 6px !important;
min-height: 22px;
font-size: 12px;
}
.follow-teacher-summary {
padding: 10px 12px;
border-left: 4px solid var(--brand-primary);
background: var(--brand-bg-soft);
border-radius: 6px;
font-size: 14px;
}
.text-brand {
color: var(--brand-primary);
font-weight: 600;
}
.names-muted {
margin-left: 8px;
color: #6b7280;
font-size: 13px;
}
.batch-rule {
font-size: 12px;
color: #6b7280;
margin: 0;
}
.follow-date-rule {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.45;
color: #6b7280;
}
.follow-history-list {
display: grid;
gap: 14px;
margin-top: 12px;
max-height: 60vh;
overflow: auto;
}
.follow-history-card {
border: 1px solid #dfe4eb;
border-left: 4px solid var(--brand-primary);
border-radius: 6px;
background: #fff;
}
.follow-history-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid #e6eaf1;
}
.follow-history-title {
color: var(--brand-primary);
font-weight: 600;
}
.follow-history-date {
margin-left: 14px;
color: #6b7280;
font-size: 13px;
}
.follow-history-body {
padding: 14px;
font-size: 13px;
}
.follow-history-row {
display: grid;
grid-template-columns: 96px 1fr;
gap: 14px;
margin-bottom: 10px;
}
.follow-history-row > span {
color: #6b7280;
}
.follow-history-footer {
padding: 0 14px 14px;
color: #6b7280;
font-size: 13px;
}
.dot {
margin: 0 6px;
color: #9ca3af;
}
.uni-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
: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;
}
.form-small :deep(.el-form-item__label) {
font-size: 13px;
padding-bottom: 4px;
}
@media (max-width: 992px) {
.talent-stat-grid {
grid-template-columns: 1fr 1fr;
}
}
</style>