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.
402 lines
14 KiB
402 lines
14 KiB
<script setup lang="ts">
|
|
import PageTitle from '@/components/PageTitle.vue'
|
|
import { ref } from 'vue'
|
|
import { usePageLoad } from '@/composables/usePageLoad'
|
|
import { fetchDictByCode } from '@/api/admin/dict'
|
|
import { fetchAdminUsers } from '@/api/admin/users'
|
|
import type { DictItemBrief } from '@/api/admin/teachers'
|
|
import {
|
|
createDemandHandleLog,
|
|
deleteDemand,
|
|
fetchDemand,
|
|
fetchDemandHandleLogs,
|
|
fetchDemandsList,
|
|
type DemandHandleLogRow,
|
|
type DemandRow,
|
|
} from '@/api/admin/demands'
|
|
import { demandStatusClass, demandTypeClass } from '@/utils/admin-list'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
|
const auth = useAuthStore()
|
|
|
|
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 items = ref<DemandRow[]>([])
|
|
const meta = ref({ current_page: 1, per_page: 20, total: 0 })
|
|
const page = ref(1)
|
|
const keyword = ref('')
|
|
const filterType = ref<number | ''>('')
|
|
const filterStatus = ref<number | ''>('')
|
|
|
|
const typeOptions = ref<DictItemBrief[]>([])
|
|
const statusOptions = ref<DictItemBrief[]>([])
|
|
const adminOptions = ref<{ id: number; label: string }[]>([])
|
|
|
|
const detailVisible = ref(false)
|
|
const detail = ref<DemandRow | null>(null)
|
|
const handleLogs = ref<DemandHandleLogRow[]>([])
|
|
|
|
const recordsVisible = ref(false)
|
|
const recordsTitle = ref('')
|
|
const records = ref<DemandHandleLogRow[]>([])
|
|
|
|
const followVisible = ref(false)
|
|
const followRow = ref<DemandRow | null>(null)
|
|
const followForm = ref({
|
|
handled_at: new Date().toISOString().slice(0, 10),
|
|
admin_user_id: undefined as number | undefined,
|
|
status_dict_item_id: undefined as number | undefined,
|
|
content: '',
|
|
next_plan: '',
|
|
next_follow_date: '',
|
|
})
|
|
|
|
async function loadDicts() {
|
|
const [type, status, admins] = await Promise.all([
|
|
fetchDictByCode('demand_type'),
|
|
fetchDictByCode('demand_status'),
|
|
fetchAdminUsers({ page: 1, page_size: 200, status: 1 }),
|
|
])
|
|
typeOptions.value = type.items
|
|
statusOptions.value = status.items
|
|
adminOptions.value = admins.items.map((u) => ({
|
|
id: u.id,
|
|
label: u.real_name || u.username,
|
|
}))
|
|
}
|
|
|
|
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 (filterType.value !== '') params.type_dict_item_id = filterType.value
|
|
if (filterStatus.value !== '') params.status_dict_item_id = filterStatus.value
|
|
const res = await fetchDemandsList(params)
|
|
items.value = res.items
|
|
meta.value = res.meta
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function resetFilters() {
|
|
keyword.value = ''
|
|
filterType.value = ''
|
|
filterStatus.value = ''
|
|
page.value = 1
|
|
load()
|
|
}
|
|
|
|
function searchDemands() {
|
|
page.value = 1
|
|
load()
|
|
}
|
|
|
|
async function openDetail(row: DemandRow) {
|
|
detail.value = await fetchDemand(row.id)
|
|
handleLogs.value = await fetchDemandHandleLogs(row.id)
|
|
detailVisible.value = true
|
|
}
|
|
|
|
async function openRecords(row: DemandRow) {
|
|
recordsTitle.value = row.title
|
|
records.value = await fetchDemandHandleLogs(row.id)
|
|
recordsVisible.value = true
|
|
}
|
|
|
|
function openFollow(row: DemandRow) {
|
|
followRow.value = row
|
|
followForm.value = {
|
|
handled_at: new Date().toISOString().slice(0, 10),
|
|
admin_user_id: defaultFollowAdminUserId(),
|
|
status_dict_item_id: row.status_dict_item_id,
|
|
content: '',
|
|
next_plan: '',
|
|
next_follow_date: '',
|
|
}
|
|
followVisible.value = true
|
|
}
|
|
|
|
async function saveFollow() {
|
|
if (!followRow.value) return
|
|
const f = followForm.value
|
|
if (!f.content.trim() || !f.status_dict_item_id || !f.admin_user_id) {
|
|
ElMessage.warning('请填写跟进内容、处理状态与跟进人员')
|
|
return
|
|
}
|
|
await createDemandHandleLog(followRow.value.id, { ...f })
|
|
ElMessage.success('已保存跟进')
|
|
followVisible.value = false
|
|
await load()
|
|
}
|
|
|
|
async function remove(row: DemandRow) {
|
|
await ElMessageBox.confirm(`确定删除需求「${row.title}」?`, '确认删除', { type: 'warning' })
|
|
await deleteDemand(row.id)
|
|
ElMessage.success('已删除')
|
|
await load()
|
|
}
|
|
|
|
function openFollowFromDetail() {
|
|
if (!detail.value) return
|
|
detailVisible.value = false
|
|
openFollow(detail.value)
|
|
}
|
|
|
|
usePageLoad(async () => {
|
|
await loadDicts()
|
|
await load()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="list-page demands-page">
|
|
<div class="page-header">
|
|
<PageTitle />
|
|
</div>
|
|
|
|
<el-card shadow="never" class="admin-list-card">
|
|
<div class="list-filter-bar">
|
|
<el-input
|
|
v-model="keyword"
|
|
class="filter-search"
|
|
placeholder="搜索标题、姓名、公司、类型…"
|
|
clearable
|
|
@keyup.enter="searchDemands"
|
|
/>
|
|
<el-select v-model="filterType" placeholder="类型" clearable class="filter-select-wide">
|
|
<el-option v-for="o in typeOptions" :key="o.id" :label="o.label" :value="o.id" />
|
|
</el-select>
|
|
<el-select v-model="filterStatus" placeholder="处理状态" clearable class="filter-select">
|
|
<el-option v-for="o in statusOptions" :key="o.id" :label="o.label" :value="o.id" />
|
|
</el-select>
|
|
<el-button type="primary" class="btn-create" @click="searchDemands">搜索</el-button>
|
|
<el-button @click="resetFilters">重置</el-button>
|
|
</div>
|
|
|
|
<el-table v-loading="loading" :data="items" row-key="id">
|
|
<el-table-column label="类型" width="110">
|
|
<template #default="{ row }">
|
|
<span class="type-badge" :class="demandTypeClass(row.type_item?.value)">
|
|
{{ row.type_item?.label }}
|
|
</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="title" label="标题" min-width="220" show-overflow-tooltip />
|
|
<el-table-column prop="contact_name" label="姓名" width="100" />
|
|
<el-table-column prop="company" label="公司" width="140" show-overflow-tooltip />
|
|
<el-table-column prop="submitted_at" label="时间" width="110" />
|
|
<el-table-column label="处理状态" width="100">
|
|
<template #default="{ row }">
|
|
<span class="status-badge" :class="demandStatusClass(row.status_item?.value)">
|
|
{{ row.status_item?.label }}
|
|
</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="跟进记录" width="96" align="center">
|
|
<template #default="{ row }">
|
|
<el-button size="small" class="btn-action-secondary table-inline-btn" @click="openRecords(row)">
|
|
{{ row.handle_logs_count || 0 }}条记录
|
|
</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="220" fixed="right">
|
|
<template #default="{ row }">
|
|
<div class="table-row-actions">
|
|
<el-button class="btn-action-secondary" @click="openDetail(row)">详情</el-button>
|
|
<el-button class="btn-action-danger" @click="remove(row)">删除</el-button>
|
|
<el-button class="btn-action-info" @click="openFollow(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>
|
|
|
|
<!-- 详情弹窗:对齐 demand-detail.html -->
|
|
<el-dialog v-model="detailVisible" :title="detail?.title || '需求详情'" width="800px" destroy-on-close>
|
|
<template v-if="detail">
|
|
<el-row :gutter="12" class="detail-meta">
|
|
<el-col :span="6">
|
|
<label class="field-label">需求类型</label>
|
|
<el-input :model-value="detail.type_item?.label || ''" readonly />
|
|
</el-col>
|
|
<el-col :span="6">
|
|
<label class="field-label">提交人</label>
|
|
<el-input :model-value="detail.contact_name || ''" readonly />
|
|
</el-col>
|
|
<el-col :span="6">
|
|
<label class="field-label">提交时间</label>
|
|
<el-input :model-value="detail.submitted_at || ''" readonly />
|
|
</el-col>
|
|
<el-col :span="6">
|
|
<label class="field-label">处理状态</label>
|
|
<el-input :model-value="detail.status_item?.label || ''" readonly />
|
|
</el-col>
|
|
<el-col :span="24">
|
|
<label class="field-label">需求描述</label>
|
|
<el-input :model-value="detail.content" type="textarea" :rows="3" readonly />
|
|
</el-col>
|
|
</el-row>
|
|
|
|
<h3 class="sub-title">处理记录</h3>
|
|
<el-table :data="handleLogs" size="small">
|
|
<el-table-column prop="handled_at" label="跟进日期" width="110" />
|
|
<el-table-column prop="operator_name" label="跟进人员" width="100" />
|
|
<el-table-column label="处理状态" width="100">
|
|
<template #default="{ row }">
|
|
<span class="status-badge" :class="demandStatusClass(row.status_item?.value)">
|
|
{{ row.status_item?.label }}
|
|
</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="content" label="跟进内容" min-width="160" show-overflow-tooltip />
|
|
<el-table-column prop="next_plan" label="下次跟进计划" width="140" show-overflow-tooltip />
|
|
<el-table-column prop="next_follow_date" label="下次跟进日期" width="120" />
|
|
</el-table>
|
|
</template>
|
|
<template #footer>
|
|
<el-button @click="detailVisible = false">关闭</el-button>
|
|
<el-button type="primary" class="btn-create" @click="openFollowFromDetail">添加跟进</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<el-dialog v-model="recordsVisible" title="跟进记录" width="640px">
|
|
<div class="follow-teacher-summary">{{ recordsTitle }}</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.operator_name }}</span>
|
|
<span class="follow-history-date">{{ r.handled_at }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="follow-history-body">{{ r.content }}</div>
|
|
</div>
|
|
<el-empty v-if="!records.length" description="暂无记录" />
|
|
</div>
|
|
<template #footer>
|
|
<el-button @click="recordsVisible = false">关闭</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<el-dialog v-model="followVisible" title="需求跟进" width="720px" destroy-on-close>
|
|
<div class="follow-teacher-summary">{{ followRow?.title }}</div>
|
|
<el-form label-position="top" class="form-small" style="margin-top: 12px">
|
|
<el-row :gutter="12">
|
|
<el-col :span="8">
|
|
<el-form-item label="跟进日期" required>
|
|
<el-date-picker v-model="followForm.handled_at" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
|
|
</el-form-item>
|
|
</el-col>
|
|
<el-col :span="8">
|
|
<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="8">
|
|
<el-form-item label="处理状态" required>
|
|
<el-select v-model="followForm.status_dict_item_id" 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 :span="24">
|
|
<el-form-item label="跟进内容" required>
|
|
<el-input v-model="followForm.content" type="textarea" :rows="4" placeholder="请输入本次跟进内容" />
|
|
</el-form-item>
|
|
</el-col>
|
|
<el-col :span="16">
|
|
<el-form-item label="下次跟进计划">
|
|
<el-input v-model="followForm.next_plan" placeholder="请输入下一步计划" />
|
|
</el-form-item>
|
|
</el-col>
|
|
<el-col :span="8">
|
|
<el-form-item label="下次跟进日期">
|
|
<el-date-picker v-model="followForm.next_follow_date" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
|
|
</el-form-item>
|
|
</el-col>
|
|
</el-row>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="followVisible = false">取消</el-button>
|
|
<el-button type="primary" class="btn-create" @click="saveFollow">保存</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.demands-page {
|
|
--brand-bg-soft: #fdf3f3;
|
|
}
|
|
.field-label {
|
|
display: block;
|
|
font-size: 13px;
|
|
margin-bottom: 4px;
|
|
color: var(--el-text-color-regular);
|
|
}
|
|
.detail-meta {
|
|
margin-bottom: 16px;
|
|
}
|
|
.sub-title {
|
|
margin: 0 0 8px;
|
|
font-size: 15px;
|
|
}
|
|
.follow-teacher-summary {
|
|
padding: 10px 12px;
|
|
border-left: 4px solid var(--brand-primary);
|
|
background: var(--brand-bg-soft);
|
|
border-radius: 6px;
|
|
margin-bottom: 12px;
|
|
}
|
|
.follow-history-list {
|
|
display: grid;
|
|
gap: 10px;
|
|
max-height: 50vh;
|
|
overflow: auto;
|
|
}
|
|
.follow-history-card {
|
|
border: 1px solid #dfe4eb;
|
|
border-left: 4px solid var(--brand-primary, #b11e23);
|
|
border-radius: 6px;
|
|
padding: 10px 12px;
|
|
}
|
|
.follow-history-head {
|
|
margin-bottom: 6px;
|
|
}
|
|
.follow-history-title {
|
|
font-weight: 600;
|
|
color: var(--brand-primary, #b11e23);
|
|
}
|
|
.follow-history-date {
|
|
margin-left: 12px;
|
|
color: #6b7280;
|
|
font-size: 13px;
|
|
}
|
|
.follow-history-body {
|
|
font-size: 13px;
|
|
color: #374151;
|
|
}
|
|
</style>
|