feat: 管理端公账支付与确认、锁店遮罩及表单提交修复

新增公账账单/到账确认页与全局锁店组件;系统配置补公账参数面板;
统一弹窗 onConfirm 校验提交,并完善订单详情与门店相关交互。
This commit is contained in:
李琦
2026-08-21 12:51:42 +08:00
parent 66e666754a
commit 879efb8f29
145 changed files with 7326 additions and 884 deletions

View File

@@ -46,6 +46,12 @@ const TestInput = defineComponent({
emit('change', event);
return;
}
// 模拟 VbenInputv-model 发字符串attrs 透传的 onChange 再收原生 Event
if (props.eventMode === 'model-value-and-change') {
emit('update:modelValue', target.value);
emit('change', event);
return;
}
emit('update:modelValue', target.value);
}
@@ -53,7 +59,7 @@ const TestInput = defineComponent({
h('input', {
...attrs,
onInput: handleInput,
value: attrs.modelValue ?? '',
value: attrs.modelValue ?? attrs.value ?? '',
});
},
});
@@ -351,6 +357,33 @@ describe('useVbenForm integration', () => {
expect(await formApi.getValues()).toEqual({ name: 'fallback' });
});
// 回归:未声明 modelPropName 的输入组件 + changeEventFallback 时,
// 原生 change 的 Event 不得覆盖 update:modelValue登录页曾显示 [object Event]
it('does not persist native Event objects for modelValue inputs when changeEventFallback is on', async () => {
const [Form, formApi] = useVbenForm({
commonConfig: {
changeEventFallback: true,
},
schema: [
{
component: TestInput,
componentProps: { eventMode: 'model-value-and-change' },
fieldName: 'account',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
await wrapper.get('input').setValue('13800138000');
await flushPromises();
const values = await formApi.getValues();
expect(values).toEqual({ account: '13800138000' });
expect(typeof values.account).toBe('string');
});
it('warns once for legacy dependency callbacks', async () => {
resetDeprecationWarnings();
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});

View File

@@ -88,4 +88,14 @@ export function setupVbenForm<
COMPONENT_BIND_EVENT_MAP[key] = modelPropNameMap[key];
}
}
// 内置 Vben* 等不在 globalShareState 里时,仍要能按 modelPropNameMap 绑定
// (否则 baseModelPropName=value + changeEventFallback 会把原生 Event 写进登录表单)
if (modelPropNameMap) {
for (const [component, propName] of Object.entries(modelPropNameMap)) {
if (propName) {
COMPONENT_BIND_EVENT_MAP[component as BaseFormComponentType] = propName;
}
}
}
}

View File

@@ -314,6 +314,36 @@ function resolveModelPropName() {
);
}
/**
* 从 change/input 回调参数里取出真正的字段值。
* antd Input 等会把原生 Event 丢进 onChange必须取 target.value
* 否则表单会写成 "[object Event]"(登录页账号/密码已踩过)。
*/
function resolveChangePayload(
event: Record<string, any>,
eventField?: null | string,
) {
if (!isEventObjectLike(event)) {
return event;
}
const target = event?.target;
if (!target || !isObject(target)) {
return event;
}
// v-model:value → target.valuev-model:modelValue 时原生节点没有 modelValue回退 value
if (
eventField &&
eventField !== 'modelValue' &&
Reflect.has(target, eventField)
) {
return target[eventField];
}
if (Reflect.has(target, 'value')) {
return target.value;
}
return event;
}
function fieldBindEvent(
componentField: Record<string, any>,
bindEventField: null | string | undefined,
@@ -325,7 +355,7 @@ function fieldBindEvent(
// antd design 的一些组件会传递一个 event 对象
if (modelValue && isObject(modelValue) && bindEventField) {
value = isEventObjectLike(modelValue)
? modelValue?.target?.[bindEventField]
? resolveChangePayload(modelValue, bindEventField)
: (modelValue?.[bindEventField] ?? modelValue);
}
@@ -333,21 +363,26 @@ function fieldBindEvent(
const eventField = bindEventField;
function handleChangeEvent(event: Record<string, any>) {
const value = isEventObjectLike(event)
? (event?.target?.[eventField] ?? event)
: event;
return handler?.(value);
return handler?.(resolveChangePayload(event, eventField));
}
// modelValue 组件已有 onUpdate:modelValue再挂原生 onChange 会把 Event 写进表单
const shouldBindChangeFallback =
changeEventFallback && eventField !== 'modelValue';
return {
[`onUpdate:${eventField}`]: handler,
[eventField]: value === undefined ? emptyStateValue : value,
onChange: changeEventFallback ? handleChangeEvent : undefined,
onChange: shouldBindChangeFallback ? handleChangeEvent : undefined,
onInput: undefined,
};
}
// 未配置 bind 字段名时走默认 modelValue不要把原生 Event 原样交给 handleChange
return {
onChange: changeEventFallback ? componentField.onChange : undefined,
onChange: changeEventFallback
? (event: Record<string, any>) =>
componentField.onChange?.(resolveChangePayload(event, 'value'))
: undefined,
onInput: undefined,
};
}

View File

@@ -82,6 +82,13 @@ function handleKeyDownEnter(event: KeyboardEvent) {
if (target?.closest('.ant-select-open')) {
return;
}
// 门店多选气泡Teleport 到 body面板打开时回车是选用跳过提交
if (
target?.closest('.store-multi-search') &&
document.querySelector('.store-multi-search__panel')
) {
return;
}
// 日期/时间选择器:与 Select 一致的两段式语义——
// 面板打开时回车是「确认日期选择」keydown 同步阶段面板 DOM 尚未被异步移除,仍可命中),跳过提交;
// 面板关闭后的下一次回车才提交,即「选完日期第二次回车即搜索」

View File

@@ -238,3 +238,76 @@ describe('generateAccessible - redirect normalization', () => {
expect(findByName(result, 'Custom')?.redirect).toBe('/custom/keep');
});
});
describe('generateAccessible - Root path=/ flatten', () => {
/**
* 模拟框架 Rootpath=/、name=Root用于断言后端 Dashboard(path=/) 被扁平合并
*/
function createRootRouterStub() {
const root: RouteRecordRaw = {
name: 'Root',
path: '/',
redirect: '/home',
children: [],
meta: { title: 'Root' },
};
const routes = [root];
return {
addRoute: (route: RouteRecordRaw) => {
const idx = routes.findIndex((r) => r.name === route.name);
if (idx === -1) {
routes.push(route);
} else {
routes[idx] = route;
}
},
getRoutes: () => routes,
removeRoute: (name: string | symbol) => {
const idx = routes.findIndex((r) => r.name === name);
if (idx !== -1) {
routes.splice(idx, 1);
}
},
_root: root,
} as any;
}
it('后端 Dashboard path=/ 不嵌套进 Root子路由扁平挂到 Root.children', async () => {
const router = createRootRouterStub();
const { accessibleRoutes } = await generateAccessible('frontend', {
router,
routes: [
{
name: 'Dashboard',
path: '/',
redirect: '/workspace',
children: [
{
name: 'Workspace',
path: '/workspace',
meta: { title: 'workspace' },
},
],
meta: { title: 'dashboard' },
},
{
name: 'System',
path: '/system',
meta: { title: 'system' },
},
] as unknown as RouteRecordRaw[],
});
const root = router._root as RouteRecordRaw;
const childNames = (root.children ?? []).map((c) => c.name);
// 不应再挂一层同名 Dashboard
expect(childNames).not.toContain('Dashboard');
expect(childNames).toContain('Workspace');
expect(childNames).toContain('System');
expect(root.redirect).toBe('/workspace');
// 菜单树仍保留原始 Dashboard侧栏结构不变
expect(findByName(accessibleRoutes, 'Dashboard')?.path).toBe('/');
});
});

View File

@@ -29,10 +29,33 @@ async function generateAccessible(
// 生成路由
const accessibleRoutes = await generateRoutes(mode, options);
const root = router.getRoutes().find((item) => item.path === '/');
// 优先按 name=Root 定位布局根,避免误抓到后端 path=/ 的 Dashboard
const root =
router.getRoutes().find((item) => item.name === 'Root') ??
router.getRoutes().find((item) => item.path === '/');
// 获取已有的路由名称列表
const names = root?.children?.map((item) => item.name) ?? [];
// 获取已有的路由名称列表(合并过程中同步维护,保证同批去重)
const names: (string | symbol | undefined)[] =
root?.children?.map((item) => item.name) ?? [];
/**
* 将一条路由挂到 Root.children同名则替换否则追加
* 为什么:切换用户时一级目录要更新,且 path=/ 扁平合并时也要复用同一套去重
*/
const upsertRootChild = (child: RouteRecordRaw) => {
if (!root?.children) {
return;
}
if (names.includes(child.name)) {
const index = root.children.findIndex((item) => item.name === child.name);
if (index !== -1) {
root.children[index] = child;
}
return;
}
root.children.push(child);
names.push(child.name);
};
// 动态添加到router实例内
accessibleRoutes.forEach((route) => {
@@ -42,18 +65,19 @@ async function generateAccessible(
if (route.children && route.children.length > 0) {
delete route.component;
}
// 根据router name判断如果路由已经存在则不再添加
if (names?.includes(route.name)) {
// 找到已存在的路由索引并更新不更新会造成切换用户时一级目录未更新homePath 在二级目录导致的404问题
const index = root.children?.findIndex(
(item) => item.name === route.name,
);
if (index !== undefined && index !== -1 && root.children) {
root.children[index] = route;
// 后端「概览」等与 Root 同 path=/:扁平合并子路由,禁止再嵌套一层同名 Dashboard
if (route.path === '/' || route.path === root.path) {
if (route.redirect) {
root.redirect = route.redirect;
}
} else {
root.children?.push(route);
for (const child of route.children ?? []) {
upsertRootChild(child);
}
return;
}
upsertRootChild(route);
} else {
router.addRoute(route);
}
@@ -66,7 +90,7 @@ async function generateAccessible(
router.addRoute(root);
}
// 生成菜单
// 生成菜单(仍用原始树,侧栏「概览」结构不变;仅 router 挂载做了扁平)
const accessibleMenus = generateMenus(accessibleRoutes, options.router);
return { accessibleMenus, accessibleRoutes };