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