feat/m3-increments #1

Merged
liuwenxin merged 100 commits from feat/m3-increments into main 2026-10-19 04:59:57 -04:00
8 changed files with 117 additions and 98 deletions
Showing only changes of commit 0ef3adec21 - Show all commits

View 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 }
}

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),
)
}

View File

@ -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

View File

@ -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' }],
}))

View File

@ -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 ?? '(无)'

View File

@ -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 {

View File

@ -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 {

View File

@ -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>