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:
parent
60f6af6494
commit
0ef3adec21
24
frontend/src/composables/useDropdownOptions.ts
Normal file
24
frontend/src/composables/useDropdownOptions.ts
Normal file
@ -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
frontend/src/composables/useEditingRow.ts
Normal file
16
frontend/src/composables/useEditingRow.ts
Normal 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),
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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<FormRules>(() => ({
|
||||
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' }],
|
||||
}))
|
||||
|
||||
@ -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<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
@ -429,8 +421,11 @@ function personnelName(id: number): string {
|
||||
// ==================== 详情抽屉 ====================
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const drawerRowId = ref<number | null>(null)
|
||||
const drawerRow = ref<BusinessItem | null>(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<void> {
|
||||
|
||||
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<businessApi.OwnershipLogItem, object>(
|
||||
(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<number | null>(null)
|
||||
|
||||
/** 留痕展示:负责/协助的新旧人员(姓名随联表,ID 固化) */
|
||||
function ownershipTransition(row: businessApi.OwnershipLogItem): string {
|
||||
const oldOwner = row.old_owner_name ?? '(无)'
|
||||
|
||||
@ -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<BusinessOption[]>([])
|
||||
|
||||
async function loadBusinessOptions(): Promise<void> {
|
||||
try {
|
||||
// 业务下拉数据源:一页 200 条覆盖内部系统规模;超出由后端校验兜底
|
||||
// 业务下拉数据源:一页 200 条覆盖内部系统规模;超出由后端校验兜底
|
||||
const { options: businessOptions, load: loadBusinessOptions } = useDropdownOptions<BusinessOption>(
|
||||
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<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
@ -124,9 +120,7 @@ const shipmentOptions = ref<ShipmentItem[]>([])
|
||||
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 {
|
||||
|
||||
@ -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<BusinessOption[]>([])
|
||||
|
||||
async function loadBusinessOptions(): Promise<void> {
|
||||
try {
|
||||
// 业务下拉数据源:一页 200 条覆盖标签页规模;超出规模由后端校验兜底
|
||||
// 业务下拉数据源:一页 200 条覆盖标签页规模;超出规模由后端校验兜底
|
||||
const { options: businessOptions, load: loadBusinessOptions } = useDropdownOptions<BusinessOption>(
|
||||
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<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
@ -95,9 +87,7 @@ const formRef = ref<FormInstance>()
|
||||
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 {
|
||||
|
||||
@ -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<void> {
|
||||
// ==================== 版本管理抽屉 ====================
|
||||
|
||||
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>>({})
|
||||
|
||||
async function loadBatches(): Promise<void> {
|
||||
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<ParamBatch, Record<string, never>>(paramsApi.listBatches, () => ({}))
|
||||
|
||||
async function detailOf(batch: ParamBatch): Promise<ParamBatchDetail> {
|
||||
if (detailCache[batch.id]) return detailCache[batch.id]
|
||||
@ -299,11 +295,21 @@ function openVersionDrawer(): void {
|
||||
|
||||
const historyVisible = ref(false)
|
||||
const historyItem = ref<ParamCatalogItem | null>(null)
|
||||
const historyRows = ref<ParamHistory[]>([])
|
||||
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<ParamHistory, Record<string, never>>(
|
||||
(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<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)
|
||||
@ -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"
|
||||
/>
|
||||
</div>
|
||||
</el-drawer>
|
||||
@ -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"
|
||||
/>
|
||||
</el-drawer>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user