refactor(frontend): 金额正则/分页/抽屉状态/行编辑与下拉收口

- 金额形态正则收进 utils/validate.ts 单点导出(此前四页各写,精度
  调整漏改一页即「A 页拦得住 B 页放行后端 400」或历史行无法编辑)
- params 版本/历史两抽屉的手写分页五件套换 usePagedList(白得请求
  序号防竞态——快速翻页旧响应覆盖新页的手写坑不再复现)
- 业务抽屉三份 id 状态收为 drawerRowId computed(单一状态源,消除
  「标题 A 业务、留痕表 B 业务」的失同步面)
- useEditingRow(三页逐字相同的 computed)与 useDropdownOptions
  (取数失败不阻断列表的骨架)收口,映射函数由调用方给出
- e2e 冒烟:基础数据 13 步 + 业务全链路 16 步全过

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-15 10:08:24 +08:00
1 parent 60f6af6494
commit 0ef3adec21
8 files changed
+117 -98

No files matched your search

@@ -0,0 +1,24 @@
import { ref, type Ref } from 'vue'
import { handledError } from '@/utils/error'
/**
* 下拉选项的加载状态:取数失败不阻断列表使用(下拉为空 + 拦截器提示)。
*
* 业务/客户/人员等参照下拉在多个页面重复同一「try 取数 → map 成选项 →
* 失败吞掉」骨架,映射函数由调用方给出(各页字段不同),骨架收口单点。
*/
export function useDropdownOptions<T>(fetch: () => Promise<T[]>): {
options: Ref<T[]>
load: () => Promise<void>
} {
const options = ref([]) as Ref<T[]>
async function load(): Promise<void> {
try {
options.value = await fetch()
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
return { options, load }
}
+16
View File
@@ -0,0 +1,16 @@
import { computed, type Ref } from 'vue'
/**
* 编辑中的行:editingId 非空时从当前页记录里找出行(回显兜底用)。
*
* 回款/发货/人员三页此前各持一份逐字相同的 computed(行对象上要展示
* 「编辑中」的派生信息,如业务编号、停用岗位回显),收口后口径一处维护。
*/
export function useEditingRow<T extends { id: number }>(
records: Ref<T[]>,
editingId: Ref<number | null>,
) {
return computed(() =>
editingId.value === null ? null : (records.value.find((r) => r.id === editingId.value) ?? null),
)
}
+15
View File
@@ -12,6 +12,21 @@ export const PASSWORD_MAX_BYTES = 72
const BYTE_HINT = '(中文一个字符约 3 字节)' const BYTE_HINT = '(中文一个字符约 3 字节)'
/**
* 金额形态正则(与后端 `schemas/business._check_amount` 同口径单点维护)。
*
* 「DECIMAL 列 ↔ 前端放行位数」规则此前在业务/发货/回款/人员四页各写一份
* (businesses 与 shipments 逐字重复,receipts/personnel 又各自内联两位小数
* 正则),后端精度调整时漏改任何一页,就会出现「A 页面拦得住、B 页面放行
* 后端 400」或「带精度历史行永远无法编辑」(fba8cfc 的教训)。
*/
/** 非负数字、最多两位小数(对齐 DECIMAL(14,2):金额/数量/成本/运费/工资) */
export const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
/** 非负数字、最多四位小数(对齐 DECIMAL(14,4):单价/指导价) */
export const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/
/** 全零形态(数量「0 或 0.00」须拦:数量 >0 是明细行的硬约束) */
export const ZERO_PATTERN = /^0+(\.0+)?$/
/** 计算 UTF-8 字节数 */ /** 计算 UTF-8 字节数 */
export function passwordByteLength(value: string): number { export function passwordByteLength(value: string): number {
return new TextEncoder().encode(value).length return new TextEncoder().encode(value).length
+4 -4
View File
@@ -9,6 +9,8 @@ import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue' import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction' import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList' import { usePagedList } from '@/composables/usePagedList'
import { useEditingRow } from '@/composables/useEditingRow'
import { AMOUNT_PATTERN } from '@/utils/validate'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
/** /**
@@ -107,9 +109,7 @@ const form = reactive({
}) })
/** 编辑中的行(用于停用岗位/离职经理的回显兜底) */ /** 编辑中的行(用于停用岗位/离职经理的回显兜底) */
const editingRow = computed(() => const editingRow = useEditingRow(records, editingId)
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
)
/** 搜索下拉显示全部岗位(含停用,便于筛出仍绑定停用岗位的人员) */ /** 搜索下拉显示全部岗位(含停用,便于筛出仍绑定停用岗位的人员) */
const searchRoleOptions = computed(() => const searchRoleOptions = computed(() =>
@@ -139,7 +139,7 @@ const rules = computed<FormRules>(() => ({
business_role_id: [{ required: true, message: '请选择岗位', trigger: 'change' }], business_role_id: [{ required: true, message: '请选择岗位', trigger: 'change' }],
phone: [{ max: 20, message: '联系电话最长 20 个字符', trigger: 'blur' }], phone: [{ max: 20, message: '联系电话最长 20 个字符', trigger: 'blur' }],
salary_standard: [ salary_standard: [
{ pattern: /^(\d+)(\.\d{1,2})?$/, message: '工资标准须为非负数字,最多两位小数', trigger: 'blur' }, { pattern: AMOUNT_PATTERN, message: '工资标准须为非负数字,最多两位小数', trigger: 'blur' },
], ],
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }], remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
})) }))
@@ -12,6 +12,7 @@ import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction' import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList' import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
import { AMOUNT_PATTERN, PRICE_PATTERN, ZERO_PATTERN } from '@/utils/validate'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
/** /**
@@ -101,15 +102,6 @@ function personnelLabel(option: PersonnelOption): string {
// ==================== 新增 / 编辑(含明细行编辑器) ==================== // ==================== 新增 / 编辑(含明细行编辑器) ====================
/** 金额/数量形态:非负数字、最多两位小数(纯形态校验,不涉及 float 运算) */
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
// 单价类列(sale_price/guided_price)建表为 Numeric(14,4):后端 str 直出
// 会带最多 4 位小数(如 "5000.0000"),前端必须放行到 4 位——否则任何
// 带小数精度的历史记录在编辑时都会被前端永远拦下
const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/
/** 全零形态:数量用它排除「>0」语义,不走 float 比较 */
const ZERO_PATTERN = /^0+(\.0+)?$/
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingId = ref<number | null>(null) const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */ /** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
@@ -429,8 +421,11 @@ function personnelName(id: number): string {
// ==================== 详情抽屉 ==================== // ==================== 详情抽屉 ====================
const drawerVisible = ref(false) const drawerVisible = ref(false)
const drawerRowId = ref<number | null>(null)
const drawerRow = ref<BusinessItem | null>(null) const drawerRow = ref<BusinessItem | null>(null)
// 单一状态源:drawerRowId 派生自 drawerRow(此前与 ownershipLogsId 三份并存、
// openDrawer 手工同步——新增写入路径漏更新其一就会出现「标题是 A 业务、
// 留痕表拉到 B 业务」且无报错)
const drawerRowId = computed(() => drawerRow.value?.id ?? null)
const drawerTitle = computed(() => const drawerTitle = computed(() =>
drawerRow.value ? `「${drawerRow.value.business_no}」· 业务详情` : '业务详情', drawerRow.value ? `「${drawerRow.value.business_no}」· 业务详情` : '业务详情',
) )
@@ -467,12 +462,10 @@ async function refreshDrawer(id: number): Promise<void> {
function openDrawer(row: BusinessItem): void { function openDrawer(row: BusinessItem): void {
drawerRow.value = row drawerRow.value = row
drawerRowId.value = row.id
drawerVisible.value = true drawerVisible.value = true
detail.value = null detail.value = null
statusLogs.value = [] statusLogs.value = []
gp.value = null gp.value = null
ownershipLogsId.value = row.id
ownershipLogPageNum.value = 1 ownershipLogPageNum.value = 1
void loadDrawerData(row.id) void loadDrawerData(row.id)
void loadOwnershipLogs() void loadOwnershipLogs()
@@ -491,7 +484,7 @@ const {
onSizeChange: onOwnershipLogSizeChange, onSizeChange: onOwnershipLogSizeChange,
} = usePagedList<businessApi.OwnershipLogItem, object>( } = usePagedList<businessApi.OwnershipLogItem, object>(
(params) => { (params) => {
const id = ownershipLogsId.value const id = drawerRowId.value
// 抽屉未指定业务时兜底空数据,避免发出无意义请求 // 抽屉未指定业务时兜底空数据,避免发出无意义请求
return id === null return id === null
? Promise.resolve({ list: [], total: 0, pageNum: 1, pageSize: 10 }) ? Promise.resolve({ list: [], total: 0, pageNum: 1, pageSize: 10 })
@@ -500,8 +493,6 @@ const {
() => ({}), () => ({}),
) )
const ownershipLogsId = ref<number | null>(null)
/** 留痕展示:负责/协助的新旧人员(姓名随联表,ID 固化) */ /** 留痕展示:负责/协助的新旧人员(姓名随联表,ID 固化) */
function ownershipTransition(row: businessApi.OwnershipLogItem): string { function ownershipTransition(row: businessApi.OwnershipLogItem): string {
const oldOwner = row.old_owner_name ?? '(无)' const oldOwner = row.old_owner_name ?? '(无)'
+10 -16
View File
@@ -10,6 +10,9 @@ import type { ShipmentItem } from '@/api/shipments'
import AppPagination from '@/components/table/AppPagination.vue' import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction' import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList' import { usePagedList } from '@/composables/usePagedList'
import { AMOUNT_PATTERN } from '@/utils/validate'
import { useEditingRow } from '@/composables/useEditingRow'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
/** /**
@@ -63,21 +66,17 @@ interface BusinessOption {
name: string name: string
} }
const businessOptions = ref<BusinessOption[]>([]) // 业务下拉数据源:一页 200 条覆盖内部系统规模;超出由后端校验兜底
const { options: businessOptions, load: loadBusinessOptions } = useDropdownOptions<BusinessOption>(
async function loadBusinessOptions(): Promise<void> { async () => {
try {
// 业务下拉数据源:一页 200 条覆盖内部系统规模;超出由后端校验兜底
const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 }) const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 })
businessOptions.value = data.list.map((b) => ({ return data.list.map((b) => ({
id: b.id, id: b.id,
no: b.business_no, no: b.business_no,
name: b.customer_name ?? '', name: b.customer_name ?? '',
})) }))
} catch (error) { },
handledError(error) // 下拉为空不阻断列表使用 )
}
}
function businessLabel(option: BusinessOption): string { function businessLabel(option: BusinessOption): string {
return option.name ? `${option.no} · ${option.name}` : option.no return option.name ? `${option.no} · ${option.name}` : option.no
@@ -85,9 +84,6 @@ function businessLabel(option: BusinessOption): string {
// ==================== 新增 / 编辑 ==================== // ==================== 新增 / 编辑 ====================
/** 金额形态:非负数字、最多两位小数(与后端 `_check_amount` 同口径) */
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingId = ref<number | null>(null) const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */ /** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
@@ -124,9 +120,7 @@ const shipmentOptions = ref<ShipmentItem[]>([])
const shipmentLoading = ref(false) const shipmentLoading = ref(false)
/** 编辑中的回款(回显业务编号/批次语义用) */ /** 编辑中的回款(回显业务编号/批次语义用) */
const editingRow = computed(() => const editingRow = useEditingRow(records, editingId)
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
)
/** 下拉展示:发货单号(发货日期,总额)—— 与任务约定文案一致 */ /** 下拉展示:发货单号(发货日期,总额)—— 与任务约定文案一致 */
function shipmentLabel(row: ShipmentItem): string { function shipmentLabel(row: ShipmentItem): string {
+10 -20
View File
@@ -8,6 +8,9 @@ import type { ShipmentDetail, ShipmentItem } from '@/api/shipments'
import AppPagination from '@/components/table/AppPagination.vue' import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction' import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList' import { usePagedList } from '@/composables/usePagedList'
import { AMOUNT_PATTERN, PRICE_PATTERN, ZERO_PATTERN } from '@/utils/validate'
import { useEditingRow } from '@/composables/useEditingRow'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
/** /**
@@ -55,22 +58,18 @@ interface BusinessOption {
status: number status: number
} }
const businessOptions = ref<BusinessOption[]>([]) // 业务下拉数据源:一页 200 条覆盖标签页规模;超出规模由后端校验兜底
const { options: businessOptions, load: loadBusinessOptions } = useDropdownOptions<BusinessOption>(
async function loadBusinessOptions(): Promise<void> { async () => {
try {
// 业务下拉数据源:一页 200 条覆盖标签页规模;超出规模由后端校验兜底
const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 }) const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 })
businessOptions.value = data.list.map((b) => ({ return data.list.map((b) => ({
id: b.id, id: b.id,
no: b.business_no, no: b.business_no,
name: b.customer_name ?? '', name: b.customer_name ?? '',
status: b.status, status: b.status,
})) }))
} catch (error) { },
handledError(error) // 下拉为空不阻断列表使用 )
}
}
/** 下拉项展示:业务编号 + 客户名;终态业务加后缀标注(创建发货单会被后端拦) */ /** 下拉项展示:业务编号 + 客户名;终态业务加后缀标注(创建发货单会被后端拦) */
function businessLabel(option: BusinessOption): string { function businessLabel(option: BusinessOption): string {
@@ -80,13 +79,6 @@ function businessLabel(option: BusinessOption): string {
// ==================== 新增 / 编辑(含明细行编辑器) ==================== // ==================== 新增 / 编辑(含明细行编辑器) ====================
/** 金额/数量形态:非负数字、最多两位小数(与后端 `_check_amount` 同口径) */
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
// 发货单价列建表为 Numeric(14,4):后端 str 直出带最多 4 位小数,前端放行到 4 位
const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/
/** 全零形态:数量排除「>0」语义,不走 float 比较 */
const ZERO_PATTERN = /^0+(\.0+)?$/
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingId = ref<number | null>(null) const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */ /** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
@@ -95,9 +87,7 @@ const formRef = ref<FormInstance>()
const submitting = ref(false) const submitting = ref(false)
/** 编辑中的发货单(回显业务编号用,编辑挂靠不可改) */ /** 编辑中的发货单(回显业务编号用,编辑挂靠不可改) */
const editingRow = computed(() => const editingRow = useEditingRow(records, editingId)
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
)
/** 明细行草稿(编辑时由详情回显;全量替换,行 id 不参与提交) */ /** 明细行草稿(编辑时由详情回显;全量替换,行 id 不参与提交) */
interface ItemDraft { interface ItemDraft {
+32 -43
View File
@@ -14,6 +14,7 @@ import type {
} from '@/api/params' } from '@/api/params'
import AppPagination from '@/components/table/AppPagination.vue' import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction' import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
/** /**
@@ -189,25 +190,20 @@ async function submitAll(): Promise<void> {
// ==================== 版本管理抽屉 ==================== // ==================== 版本管理抽屉 ====================
const versionVisible = ref(false) const versionVisible = ref(false)
const batches = ref<ParamBatch[]>([])
const batchesTotal = ref(0)
const batchesPage = ref(1)
const batchesPageSize = ref(10)
const batchesLoading = ref(false)
const detailCache = reactive<Record<number, ParamBatchDetail>>({}) const detailCache = reactive<Record<number, ParamBatchDetail>>({})
async function loadBatches(): Promise<void> { // 分页五件套换 usePagedList:手写版没有请求序号防竞态(快速翻页时旧响应
batchesLoading.value = true // 后到会覆盖新页数据)与页码回退——正是该 composable 注释里写明已修过的坑
try { const {
const page = await paramsApi.listBatches({ pageNum: batchesPage.value, pageSize: batchesPageSize.value }) records: batches,
batches.value = page.list total: batchesTotal,
batchesTotal.value = page.total pageNum: batchesPage,
} catch (error) { pageSize: batchesPageSize,
handledError(error) loading: batchesLoading,
} finally { load: loadBatches,
batchesLoading.value = false onPageChange: onBatchesPageChange,
} onSizeChange: onBatchesSizeChange,
} } = usePagedList<ParamBatch, Record<string, never>>(paramsApi.listBatches, () => ({}))
async function detailOf(batch: ParamBatch): Promise<ParamBatchDetail> { async function detailOf(batch: ParamBatch): Promise<ParamBatchDetail> {
if (detailCache[batch.id]) return detailCache[batch.id] if (detailCache[batch.id]) return detailCache[batch.id]
@@ -299,11 +295,21 @@ function openVersionDrawer(): void {
const historyVisible = ref(false) const historyVisible = ref(false)
const historyItem = ref<ParamCatalogItem | null>(null) const historyItem = ref<ParamCatalogItem | null>(null)
const historyRows = ref<ParamHistory[]>([])
const historyTotal = ref(0) // 历史抽屉分页同样收口 usePagedList;fetcher 闭包当前参数的编码
const historyPage = ref(1) const {
const historyPageSize = ref(10) records: historyRows,
const historyLoading = ref(false) total: historyTotal,
pageNum: historyPage,
pageSize: historyPageSize,
loading: historyLoading,
load: loadHistory,
onPageChange: onHistoryPageChange,
onSizeChange: onHistorySizeChange,
} = usePagedList<ParamHistory, Record<string, never>>(
(params) => paramsApi.getParamHistory(historyItem.value!.param_code, params),
() => ({}),
)
function openHistory(item: ParamCatalogItem): void { function openHistory(item: ParamCatalogItem): void {
historyItem.value = item historyItem.value = item
@@ -312,23 +318,6 @@ function openHistory(item: ParamCatalogItem): void {
void loadHistory() void loadHistory()
} }
async function loadHistory(): Promise<void> {
if (!historyItem.value) return
historyLoading.value = true
try {
const page = await paramsApi.getParamHistory(historyItem.value.param_code, {
pageNum: historyPage.value,
pageSize: historyPageSize.value,
})
historyRows.value = page.list
historyTotal.value = page.total
} catch (error) {
handledError(error)
} finally {
historyLoading.value = false
}
}
// ==================== 计算预览抽屉 ==================== // ==================== 计算预览抽屉 ====================
const previewVisible = ref(false) const previewVisible = ref(false)
@@ -667,8 +656,8 @@ onMounted(() => {
:total="batchesTotal" :total="batchesTotal"
:page-num="batchesPage" :page-num="batchesPage"
:page-size="batchesPageSize" :page-size="batchesPageSize"
@page-change="(p: number) => { batchesPage = p; void loadBatches() }" @page-change="onBatchesPageChange"
@size-change="(s: number) => { batchesPageSize = s; batchesPage = 1; void loadBatches() }" @size-change="onBatchesSizeChange"
/> />
</div> </div>
</el-drawer> </el-drawer>
@@ -692,8 +681,8 @@ onMounted(() => {
:total="historyTotal" :total="historyTotal"
:page-num="historyPage" :page-num="historyPage"
:page-size="historyPageSize" :page-size="historyPageSize"
@page-change="(p: number) => { historyPage = p; void loadHistory() }" @page-change="onHistoryPageChange"
@size-change="(s: number) => { historyPageSize = s; historyPage = 1; void loadHistory() }" @size-change="onHistorySizeChange"
/> />
</el-drawer> </el-drawer>