- customers 表新增 rating_score/rating_time(V13 迁移 + V10 SQL + init-dev 同步)
- 新端点 PUT /customers/{id}/rating:评分是档案属性,只存总分与评级时间(五项明细不落库)
- 列表排序 order_by=rating_score,未评分客户升序/降序都垫底,id desc 兜底稳定分页
- 前端 RatingCalculatorDialog 并入客户管理(评分列 + 评级计算按钮),删除独立页与路由
- 测试:评级保存/越界/排序垫底 3 例,test_customers.py 18 passed;schema 同步 216 passed
674 lines
24 KiB
Vue
674 lines
24 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||
|
||
import * as customerApi from '@/api/customers'
|
||
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
|
||
import * as personnelApi from '@/api/personnel'
|
||
import AppPagination from '@/components/table/AppPagination.vue'
|
||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||
import { usePagedList } from '@/composables/usePagedList'
|
||
import { formatDateTime } from '@/utils/format'
|
||
import { handledError } from '@/utils/error'
|
||
import RatingCalculatorDialog from './RatingCalculatorDialog.vue'
|
||
|
||
/**
|
||
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
|
||
*
|
||
* 与后端约定对齐:
|
||
* - 编辑弹窗不含归属字段 —— 归属调整是独立操作(「归属变更」按钮),
|
||
* 走 PUT /{id}/ownership,是唯一写归属留痕的入口
|
||
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
|
||
* (对照表 #19 的业务口径)
|
||
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
|
||
* - 评级计算器并入本页(原 /business/rating 独立页已移除):行内弹窗打分,
|
||
* 保存评分写入档案字段 rating_score/rating_time
|
||
*/
|
||
const { confirmAndRun } = useConfirmAction()
|
||
|
||
// ==================== 列表 ====================
|
||
|
||
const filters = reactive({
|
||
keyword: '',
|
||
customer_type: undefined as string | undefined,
|
||
owner_personnel_id: undefined as number | undefined,
|
||
})
|
||
|
||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||
usePagedList<CustomerItem, customerApi.CustomerQueryParams>(
|
||
customerApi.listCustomers,
|
||
() => ({
|
||
keyword: filters.keyword,
|
||
customer_type: filters.customer_type,
|
||
owner_personnel_id: filters.owner_personnel_id,
|
||
order_by: sortState.order_by,
|
||
order: sortState.order,
|
||
}),
|
||
)
|
||
|
||
function resetFilters(): void {
|
||
filters.keyword = ''
|
||
filters.customer_type = undefined
|
||
filters.owner_personnel_id = undefined
|
||
search()
|
||
}
|
||
|
||
// ==================== 排序(评分列) ====================
|
||
|
||
/**
|
||
* 表头排序是查询参数而非本地排序:分页下本地排序只能排当前页。
|
||
* 目前唯一可排序列是评分;el-table 第三次点击取消排序,回到建档序(id 倒序)。
|
||
*/
|
||
const sortState = reactive<{ order_by: 'id' | 'rating_score'; order: 'asc' | 'desc' }>({
|
||
order_by: 'id',
|
||
order: 'desc',
|
||
})
|
||
|
||
function onSortChange({
|
||
prop,
|
||
order,
|
||
}: {
|
||
prop: string
|
||
order: 'ascending' | 'descending' | null
|
||
}): void {
|
||
if (prop === 'rating_score' && order) {
|
||
sortState.order_by = 'rating_score'
|
||
sortState.order = order === 'ascending' ? 'asc' : 'desc'
|
||
} else {
|
||
sortState.order_by = 'id'
|
||
sortState.order = 'desc'
|
||
}
|
||
search()
|
||
}
|
||
|
||
// ==================== 评级计算 ====================
|
||
|
||
const ratingVisible = ref(false)
|
||
const ratingTarget = ref<CustomerItem | null>(null)
|
||
|
||
function openRating(row: CustomerItem): void {
|
||
ratingTarget.value = row
|
||
ratingVisible.value = true
|
||
}
|
||
|
||
/** 保存评分后回写列表:按评分排序时刷新(位置随分变化),否则原位更新不打乱浏览 */
|
||
function onRatingSaved(item: CustomerItem): void {
|
||
if (sortState.order_by === 'rating_score') {
|
||
void load()
|
||
return
|
||
}
|
||
const index = records.value.findIndex((record) => record.id === item.id)
|
||
if (index !== -1) records.value[index] = item
|
||
}
|
||
|
||
// ==================== 归属人员下拉数据源 ====================
|
||
|
||
interface PersonnelOption {
|
||
id: number
|
||
name: string
|
||
status: number
|
||
}
|
||
|
||
const { options: ownerOptions, load: loadOwnerOptions } = useDropdownOptions<PersonnelOption>(
|
||
async () => {
|
||
// 归属下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
|
||
// 极端场景由后端对归属合法性的校验兜底
|
||
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
||
return data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
|
||
},
|
||
)
|
||
|
||
/** 下拉项展示名:离职人员加后缀标注(仍可被选作归属吗?由后端校验兜底) */
|
||
function ownerLabel(option: PersonnelOption): string {
|
||
return option.status === 1 ? option.name : `${option.name}(离职)`
|
||
}
|
||
|
||
function personnelName(id: number): string {
|
||
return ownerOptions.value.find((o) => o.id === id)?.name ?? `人员#${id}`
|
||
}
|
||
|
||
// ==================== 新增 / 编辑 ====================
|
||
|
||
const dialogVisible = ref(false)
|
||
const editingId = ref<number | null>(null)
|
||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||
const isEdit = computed(() => editingId.value !== null)
|
||
const formRef = ref<FormInstance>()
|
||
const submitting = ref(false)
|
||
|
||
const form = reactive({
|
||
company_name: '',
|
||
owner_name: '',
|
||
contact_person: '',
|
||
contact_phone: '',
|
||
address: '',
|
||
industry: '',
|
||
customer_type: '',
|
||
owner_personnel_id: undefined as number | undefined,
|
||
remark: '',
|
||
})
|
||
|
||
const rules = computed<FormRules>(() => ({
|
||
company_name: [
|
||
{ required: true, message: '请输入公司名称', trigger: 'blur' },
|
||
{ max: 128, message: '公司名称最长 128 个字符', trigger: 'blur' },
|
||
],
|
||
owner_name: [
|
||
{ required: true, message: '请输入客户负责人', trigger: 'blur' },
|
||
{ max: 64, message: '负责人最长 64 个字符', trigger: 'blur' },
|
||
],
|
||
contact_person: [{ max: 64, message: '联系人最长 64 个字符', trigger: 'blur' }],
|
||
contact_phone: [{ max: 20, message: '联系电话最长 20 个字符', trigger: 'blur' }],
|
||
address: [{ max: 255, message: '地址最长 255 个字符', trigger: 'blur' }],
|
||
industry: [{ max: 64, message: '行业最长 64 个字符', trigger: 'blur' }],
|
||
customer_type: [{ required: true, message: '请选择客户分类', trigger: 'change' }],
|
||
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
|
||
}))
|
||
|
||
function openCreate(): void {
|
||
editingId.value = null
|
||
Object.assign(form, {
|
||
company_name: '',
|
||
owner_name: '',
|
||
contact_person: '',
|
||
contact_phone: '',
|
||
address: '',
|
||
industry: '',
|
||
customer_type: '',
|
||
owner_personnel_id: undefined,
|
||
remark: '',
|
||
})
|
||
dialogVisible.value = true
|
||
formRef.value?.clearValidate()
|
||
}
|
||
|
||
function openEdit(row: CustomerItem): void {
|
||
editingId.value = row.id
|
||
Object.assign(form, {
|
||
company_name: row.company_name,
|
||
owner_name: row.owner_name,
|
||
contact_person: row.contact_person ?? '',
|
||
contact_phone: row.contact_phone ?? '',
|
||
address: row.address ?? '',
|
||
industry: row.industry ?? '',
|
||
customer_type: row.customer_type,
|
||
owner_personnel_id: undefined, // 编辑弹窗不含归属,归属走「归属变更」按钮
|
||
remark: row.remark ?? '',
|
||
})
|
||
dialogVisible.value = true
|
||
formRef.value?.clearValidate()
|
||
}
|
||
|
||
async function handleSubmit(): Promise<void> {
|
||
const valid = await formRef.value?.validate().catch(() => false)
|
||
if (!valid) return
|
||
|
||
submitting.value = true
|
||
try {
|
||
if (editingId.value !== null) {
|
||
// 编辑端点不含归属:归属调整统一走「归属变更」,保证留痕完整
|
||
const { owner_personnel_id: _ignored, ...rest } = form
|
||
await customerApi.updateCustomer(editingId.value, {
|
||
...rest,
|
||
company_name: rest.company_name.trim(),
|
||
owner_name: rest.owner_name.trim(),
|
||
contact_person: rest.contact_person.trim() || null,
|
||
contact_phone: rest.contact_phone.trim() || null,
|
||
address: rest.address.trim() || null,
|
||
industry: rest.industry.trim() || null,
|
||
remark: rest.remark.trim() || null,
|
||
})
|
||
ElMessage.success('已保存')
|
||
} else {
|
||
await customerApi.createCustomer({
|
||
company_name: form.company_name.trim(),
|
||
owner_name: form.owner_name.trim(),
|
||
contact_person: form.contact_person.trim() || null,
|
||
contact_phone: form.contact_phone.trim() || null,
|
||
address: form.address.trim() || null,
|
||
industry: form.industry.trim() || null,
|
||
customer_type: form.customer_type,
|
||
owner_personnel_id: form.owner_personnel_id ?? null,
|
||
remark: form.remark.trim() || null,
|
||
})
|
||
ElMessage.success('客户已创建')
|
||
}
|
||
dialogVisible.value = false
|
||
await load()
|
||
} catch (error) {
|
||
handledError(error) // 失败提示由请求拦截器统一完成
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
// ==================== 归属变更 ====================
|
||
|
||
const ownershipVisible = ref(false)
|
||
const ownershipTarget = ref<CustomerItem | null>(null)
|
||
const ownershipSubmitting = ref(false)
|
||
const ownershipForm = reactive({
|
||
new_owner_personnel_id: undefined as number | undefined,
|
||
reason: '',
|
||
})
|
||
|
||
const ownershipCurrentLabel = computed(() => {
|
||
const target = ownershipTarget.value
|
||
if (!target) return '—'
|
||
return target.owner_personnel_name ?? '(无归属)'
|
||
})
|
||
|
||
function openOwnership(row: CustomerItem): void {
|
||
ownershipTarget.value = row
|
||
ownershipForm.new_owner_personnel_id = row.owner_personnel_id ?? undefined
|
||
ownershipForm.reason = ''
|
||
ownershipVisible.value = true
|
||
}
|
||
|
||
async function handleOwnershipSubmit(): Promise<void> {
|
||
const target = ownershipTarget.value
|
||
if (!target) return
|
||
|
||
const nextId = ownershipForm.new_owner_personnel_id ?? null
|
||
const currentId = target.owner_personnel_id ?? null
|
||
if (nextId === currentId) {
|
||
ElMessage.warning('归属未变化,请选择新的归属人员,或清空下拉后提交以清空归属')
|
||
return
|
||
}
|
||
|
||
const nextName = nextId === null ? '(清空归属)' : personnelName(nextId)
|
||
await confirmAndRun({
|
||
// 业务口径(对照表 #19):仅影响变更后核算,不回溯历史 —— 文案必须保留
|
||
confirmText: `确定调整客户「${target.company_name}」的归属为「${nextName}」?归属变更仅影响变更后核算,不回溯历史。`,
|
||
title: '归属变更确认',
|
||
confirmButtonText: '确认变更',
|
||
successText: '归属已调整',
|
||
run: async () => {
|
||
ownershipSubmitting.value = true
|
||
try {
|
||
await customerApi.changeOwnership(target.id, {
|
||
new_owner_personnel_id: nextId,
|
||
reason: ownershipForm.reason.trim() || null,
|
||
})
|
||
} finally {
|
||
ownershipSubmitting.value = false
|
||
}
|
||
},
|
||
after: async () => {
|
||
ownershipVisible.value = false
|
||
await load()
|
||
// 归属记录抽屉开着且正是该客户时同步刷新
|
||
if (logsVisible.value && logsCustomerId.value === target.id) await loadLogs()
|
||
},
|
||
})
|
||
}
|
||
|
||
// ==================== 归属变更记录 ====================
|
||
|
||
const logsVisible = ref(false)
|
||
const logsCustomerId = ref<number | null>(null)
|
||
const logsCustomerName = ref('')
|
||
const logsTitle = computed(() =>
|
||
logsCustomerId.value === null ? '归属变更记录' : `「${logsCustomerName.value}」· 归属变更记录`,
|
||
)
|
||
|
||
const {
|
||
records: logRecords,
|
||
total: logTotal,
|
||
pageNum: logPageNum,
|
||
pageSize: logPageSize,
|
||
loading: logsLoading,
|
||
load: loadLogs,
|
||
onPageChange: onLogsPageChange,
|
||
onSizeChange: onLogsSizeChange,
|
||
} = usePagedList<OwnershipLogItem, object>(
|
||
(params) => {
|
||
const id = logsCustomerId.value
|
||
// 抽屉未指定客户时兜底空数据,避免发出无意义请求
|
||
return id === null
|
||
? Promise.resolve({ list: [], total: 0, pageNum: 1, pageSize: 10 })
|
||
: customerApi.listOwnershipLogs(id, params)
|
||
},
|
||
() => ({}),
|
||
)
|
||
|
||
function openLogs(row: CustomerItem): void {
|
||
logsCustomerId.value = row.id
|
||
logsCustomerName.value = row.company_name
|
||
logPageNum.value = 1
|
||
logsVisible.value = true
|
||
void loadLogs()
|
||
}
|
||
|
||
/** 留痕展示:新旧归属任一为 null 表示该时点无归属(ID 固化、姓名随联表) */
|
||
function ownerTransition(row: OwnershipLogItem): string {
|
||
const oldName = row.old_owner_name ?? '(无)'
|
||
const newName = row.new_owner_name ?? '(无)'
|
||
return `${oldName} → ${newName}`
|
||
}
|
||
|
||
// ==================== 删除 ====================
|
||
|
||
async function handleDelete(row: CustomerItem): Promise<void> {
|
||
await confirmAndRun({
|
||
confirmText: `确定删除客户「${row.company_name}」?删除为物理删除、不可恢复;归属变更留痕将一并失去展示主体。`,
|
||
title: '删除确认',
|
||
confirmButtonText: '删除',
|
||
successText: '客户已删除',
|
||
run: () => customerApi.deleteCustomer(row.id),
|
||
after: reloadAfterDelete,
|
||
})
|
||
}
|
||
|
||
// ==================== 初始化 ====================
|
||
|
||
onMounted(() => {
|
||
// 两个请求各自容错(load 内部已兜底),互不拖累
|
||
void load()
|
||
void loadOwnerOptions()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<div class="page-head">
|
||
<h1 class="page-title">客户管理</h1>
|
||
<p class="page-desc">
|
||
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,评分由「评级计算」维护并保存进档案
|
||
</p>
|
||
<span class="waterline"></span>
|
||
</div>
|
||
|
||
<!--
|
||
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
|
||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||
-->
|
||
<el-form class="filter-bar" @submit.prevent>
|
||
<el-form-item label="关键词">
|
||
<el-input
|
||
v-model="filters.keyword"
|
||
placeholder="公司名称或客户负责人"
|
||
clearable
|
||
style="width: 190px"
|
||
@keyup.enter="search"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="分类">
|
||
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
|
||
<el-option
|
||
v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS"
|
||
:key="item.value"
|
||
:label="item.label"
|
||
:value="item.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="归属人员">
|
||
<el-select
|
||
v-model="filters.owner_personnel_id"
|
||
placeholder="全部"
|
||
clearable
|
||
filterable
|
||
style="width: 160px"
|
||
>
|
||
<el-option
|
||
v-for="item in ownerOptions"
|
||
:key="item.id"
|
||
:label="ownerLabel(item)"
|
||
:value="item.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<div class="filter-bar__actions">
|
||
<el-button type="primary" @click="search">查询</el-button>
|
||
<el-button @click="resetFilters">重置</el-button>
|
||
</div>
|
||
</el-form>
|
||
|
||
<div class="toolbar">
|
||
<el-button type="primary" @click="openCreate">新增客户</el-button>
|
||
<span class="toolbar-note">归属调整走行内「归属变更」,系统自动留痕</span>
|
||
</div>
|
||
|
||
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
|
||
<div class="card ys-rise">
|
||
<el-table
|
||
v-loading="loading"
|
||
:data="records"
|
||
stripe
|
||
empty-text="还没有客户 —— 点「新增客户」建第一个,或换个关键词再查"
|
||
@sort-change="onSortChange"
|
||
>
|
||
<el-table-column prop="company_name" label="公司名称" min-width="180" />
|
||
<el-table-column prop="rating_score" label="评分" width="88" align="center" sortable="custom">
|
||
<template #default="{ row }">
|
||
<span
|
||
v-if="row.rating_score !== null"
|
||
:title="row.rating_time ? `评级时间:${formatDateTime(row.rating_time)}` : undefined"
|
||
>
|
||
{{ row.rating_score }}
|
||
</span>
|
||
<span v-else class="cell-muted" title="尚未评级">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="owner_name" label="负责人" min-width="90" />
|
||
<el-table-column prop="contact_person" label="联系人" min-width="90">
|
||
<template #default="{ row }">
|
||
<span v-if="row.contact_person">{{ row.contact_person }}</span>
|
||
<span v-else class="cell-muted">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="contact_phone" label="电话" min-width="120">
|
||
<template #default="{ row }">
|
||
<span v-if="row.contact_phone">{{ row.contact_phone }}</span>
|
||
<span v-else class="cell-muted">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="分类" width="110" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag
|
||
:type="
|
||
row.customer_type === 'personal'
|
||
? 'info'
|
||
: row.customer_type === 'manager_personal'
|
||
? 'warning'
|
||
: 'primary'
|
||
"
|
||
size="small"
|
||
disable-transitions
|
||
>
|
||
{{ customerApi.customerTypeLabel(row.customer_type) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="归属人员" min-width="110">
|
||
<template #default="{ row }">
|
||
<span v-if="row.owner_personnel_name">{{ row.owner_personnel_name }}</span>
|
||
<span v-else class="cell-muted">(无归属)</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="340" fixed="right" align="center">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-button link type="primary" size="small" @click="openRating(row)">评级计算</el-button>
|
||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||
<el-button link type="primary" size="small" @click="openOwnership(row)">
|
||
归属变更
|
||
</el-button>
|
||
<el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button>
|
||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div class="card-foot">
|
||
<AppPagination
|
||
:total="total"
|
||
:page-num="pageNum"
|
||
:page-size="pageSize"
|
||
@page-change="onPageChange"
|
||
@size-change="onSizeChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 新增 / 编辑(编辑不含归属,归属走独立按钮保证留痕) -->
|
||
<el-dialog
|
||
v-model="dialogVisible"
|
||
:title="isEdit ? '编辑客户' : '新增客户'"
|
||
width="560px"
|
||
destroy-on-close
|
||
>
|
||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||
<el-form-item label="公司名称" prop="company_name">
|
||
<el-input v-model="form.company_name" maxlength="128" placeholder="与负责人构成组合唯一键" />
|
||
</el-form-item>
|
||
<el-form-item label="负责人" prop="owner_name">
|
||
<el-input v-model="form.owner_name" maxlength="64" placeholder="客户公司的负责人姓名" />
|
||
</el-form-item>
|
||
<el-form-item label="联系人" prop="contact_person">
|
||
<el-input v-model="form.contact_person" maxlength="64" placeholder="选填" />
|
||
</el-form-item>
|
||
<el-form-item label="电话" prop="contact_phone">
|
||
<el-input v-model="form.contact_phone" maxlength="20" placeholder="选填" />
|
||
</el-form-item>
|
||
<el-form-item label="地址" prop="address">
|
||
<el-input v-model="form.address" maxlength="255" placeholder="选填" />
|
||
</el-form-item>
|
||
<el-form-item label="行业" prop="industry">
|
||
<el-input v-model="form.industry" maxlength="64" placeholder="选填" />
|
||
</el-form-item>
|
||
<el-form-item label="客户分类" prop="customer_type">
|
||
<el-select v-model="form.customer_type" placeholder="决定计提规则匹配" style="width: 100%">
|
||
<el-option
|
||
v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS"
|
||
:key="item.value"
|
||
:label="item.label"
|
||
:value="item.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item v-if="!isEdit" label="初始归属" prop="owner_personnel_id">
|
||
<el-select
|
||
v-model="form.owner_personnel_id"
|
||
placeholder="不选择=暂无归属(可先建档后派归属)"
|
||
clearable
|
||
filterable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="item in ownerOptions"
|
||
:key="item.id"
|
||
:label="ownerLabel(item)"
|
||
:value="item.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="备注" prop="remark">
|
||
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 归属变更(唯一写归属留痕的入口) -->
|
||
<el-dialog v-model="ownershipVisible" title="归属变更" width="480px" destroy-on-close>
|
||
<el-form label-width="110px">
|
||
<el-form-item label="客户">
|
||
<span>{{ ownershipTarget?.company_name }}</span>
|
||
</el-form-item>
|
||
<el-form-item label="当前归属">
|
||
<span>{{ ownershipCurrentLabel }}</span>
|
||
</el-form-item>
|
||
<el-form-item label="新归属人员">
|
||
<el-select
|
||
v-model="ownershipForm.new_owner_personnel_id"
|
||
placeholder="不选择并提交=清空归属"
|
||
clearable
|
||
filterable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="item in ownerOptions"
|
||
:key="item.id"
|
||
:label="ownerLabel(item)"
|
||
:value="item.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="变更原因">
|
||
<el-input
|
||
v-model="ownershipForm.reason"
|
||
type="textarea"
|
||
:rows="3"
|
||
maxlength="255"
|
||
placeholder="选填,将写入归属变更留痕"
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
<div class="ownership-warn">
|
||
归属变更仅影响变更后核算,不回溯历史。
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="ownershipVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="ownershipSubmitting" @click="handleOwnershipSubmit">
|
||
提交变更
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 归属变更记录(分页留痕,按变更时间倒序) -->
|
||
<el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close>
|
||
<el-table
|
||
v-loading="logsLoading"
|
||
:data="logRecords"
|
||
stripe
|
||
size="small"
|
||
empty-text="这个客户还没有归属变更记录 —— 调整一次归属后这里就会出现留痕"
|
||
>
|
||
<el-table-column label="变更时间" width="160">
|
||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="原归属 → 新归属" min-width="220">
|
||
<template #default="{ row }">{{ ownerTransition(row) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="变更原因" min-width="180">
|
||
<template #default="{ row }">{{ row.change_reason ?? '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="operator_name" label="操作人" width="100" />
|
||
</el-table>
|
||
<AppPagination
|
||
:total="logTotal"
|
||
:page-num="logPageNum"
|
||
:page-size="logPageSize"
|
||
@page-change="onLogsPageChange"
|
||
@size-change="onLogsSizeChange"
|
||
/>
|
||
</el-drawer>
|
||
|
||
<!-- 评级计算器(原 /business/rating 独立页并入;保存评分回写档案) -->
|
||
<RatingCalculatorDialog
|
||
v-model:visible="ratingVisible"
|
||
:customer="ratingTarget"
|
||
@saved="onRatingSaved"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.ownership-warn {
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
background: var(--ys-indigo-wash);
|
||
color: var(--ys-ink-2);
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
}
|
||
</style>
|