feat: 管理端公账支付与确认、锁店遮罩及表单提交修复
新增公账账单/到账确认页与全局锁店组件;系统配置补公账参数面板; 统一弹窗 onConfirm 校验提交,并完善订单详情与门店相关交互。
This commit is contained in:
@@ -46,6 +46,12 @@ const TestInput = defineComponent({
|
||||
emit('change', event);
|
||||
return;
|
||||
}
|
||||
// 模拟 VbenInput:v-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(() => {});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.value;v-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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 尚未被异步移除,仍可命中),跳过提交;
|
||||
// 面板关闭后的下一次回车才提交,即「选完日期第二次回车即搜索」
|
||||
|
||||
@@ -238,3 +238,76 @@ describe('generateAccessible - redirect normalization', () => {
|
||||
expect(findByName(result, 'Custom')?.redirect).toBe('/custom/keep');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateAccessible - Root path=/ flatten', () => {
|
||||
/**
|
||||
* 模拟框架 Root:path=/、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('/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user