diff --git a/.cursor/rules/Code-Standards.mdc b/.cursor/rules/Code-Standards.mdc index 5400dfa9..ced6a183 100644 --- a/.cursor/rules/Code-Standards.mdc +++ b/.cursor/rules/Code-Standards.mdc @@ -68,9 +68,54 @@ alwaysApply: true ## 表单校验 -- 必填用字符串规则 `rules: 'required'`(选择项用 `'selectRequired'`),规则定义在 `#/adapter/form.ts` 的 `defineRules` -- 校验调用统一用 `formApi.validate().then(e => { if (e.valid) {...} })` +- 必填用字符串规则 `rules: 'required'`(选择项用 `'selectRequired'`),规则定义在 `#/adapter/form.ts` 的 `rules` - 必填字段在 schema 中加 `rules: 'required'`,禁止散落 `validator` 写法 +- 校验调用统一用 **单次 await**(禁止 `.then` 与 `validateAndSubmitForm` 并用): + +```ts +const e = await formApi.validate(); +if (!e.valid) return; +const values = await formApi.getValues(); +``` + +## 弹窗 onConfirm(强制,防「点确定无反应」) + +弹窗内提交必须按下列写法,**禁止**复制旧模板: + +```ts +onConfirm: async () => { + const e = await formApi.validate(); + if (!e.valid) return; + const values = await formApi.getValues(); + modalApi.setState({ loading: true, confirmLoading: true }); + try { + await submitApi(values); + message.success('保存成功'); + gridApi.value?.reload?.(); + modalApi.close(); + } catch (err) { + console.error(err); + } finally { + modalApi.setState({ loading: false, confirmLoading: false }); + } +}; +``` + +- **禁止** `formApi.validate().then(...)` 后再 `await formApi.validateAndSubmitForm()`(并发二次校验,表现为点确定无提交) +- **禁止**无 `handleSubmit` 时调用 `validateAndSubmitForm` / `validateAndSubmit` +- 参考:`product-order/components/refund.vue`、`audit-prescription/components/modal.vue`、`delivery-warehouse-order/components/modal.vue` + +## 表单 Divider / 分区标题 + +- `component: 'Divider'` 仅作分区展示,**禁止**加 `rules: 'required'`(无业务值,必失败) +- **禁止**用占位 `label: 'x'`(会渲染成校验文案「请输入x」) +- `fieldName` 用唯一占位如 `_section_basic`,不要空字符串 + +## 敏感字段展示(SensitiveText) + +- 模板使用 `` 时,**必须**显式:`import SensitiveText from '#/components/sensitive-text/SensitiveText.vue'` +- 该组件**未**全局注册;表单选择器(`form/components/*-picker.vue`)同样要 import,对齐 `doctor-picker` / `store-picker` +- **禁止**只写模板标签不写 import(会报 `Failed to resolve component: SensitiveText`) ## 字典/枚举管理 @@ -177,10 +222,10 @@ alwaysApply: true - 表单必须用 `useVbenForm`,禁止直接 `
` 或 antd `Form` - 动态修改 schema 必须用 `formApi.updateSchema([...])`,**vben form 没有 `setComponentProps` 这个方法**(这是已踩过的坑) -- 重置表单用 `formApi.resetForm()`,**严禁 `formApi.resetFields()`**(那是 antd Form 的 API,Vben Form 没有,会报 `resetFields is not a function`;已踩过坑) +- 重置表单用 `formApi.reset()`(`resetForm` 已弃用);**严禁 `formApi.resetFields()`**(那是 antd Form 的 API,Vben Form 没有,会报 `resetFields is not a function`;已踩过坑) - 取值/赋值用 `formApi.getValues()` / `formApi.setValues(obj)`,禁止自己 `v-model` 收集 -- 校验用 `formApi.validate().then(e => { if (e.valid) {...} })` 或 `formApi.validateAndSubmitForm()` -- 必填规则用字符串 `'required'` / `'selectRequired'`,规则在 `#/adapter/form.ts` 的 `defineRules` 注册 +- 校验用 `const e = await formApi.validate(); if (!e.valid) return;`,**禁止** `validate().then` 与 `validateAndSubmitForm` 并用(见上文「弹窗 onConfirm」) +- 必填规则用字符串 `'required'` / `'selectRequired'`,规则在 `#/adapter/form.ts` 的 `rules` 注册 - schema 中字段名(`fieldName`)必须与后端字段 snake_case 对齐,禁止前端 camelCase - 密码/敏感字段用 `VbenInputPassword` 组件,禁止用 `VbenInput` 配 `type="password"` - 单选/多选选项来自接口时,必须在弹窗/页面 `onMounted` 或 `onOpenChange` 里拉取后通过 `updateSchema` 注入,禁止写死常量 diff --git a/README.md b/README.md index cc951b47..08c16855 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,13 @@ ```vue v-access:code="['Admin'] ``` + + +# Web + 桌面一起打 +pnpm build:antd-desktop + +# 只打 Web +node build-antd-desktop.mjs --only=web + +# 只打桌面(需已有 apps/web-antd/dist) +node build-antd-desktop.mjs --only=desktop diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 294d9789..710bef69 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -10,6 +10,9 @@ pnpm dev:desktop # 打包:turbo 先构建 web-antd dist,再构建主进程并出安装包(产物在 apps/desktop/release/) pnpm build:desktop + +# 一键同时打 Web + 桌面(根目录 build-antd-desktop.mjs,先 web 再桌面) +pnpm build:antd-desktop ``` - Windows 机器出 NSIS 安装包;mac 包必须在 macOS 机器上执行同一命令(electron-builder 不支持交叉编译 mac 包)。 diff --git a/apps/web-antd/index.html b/apps/web-antd/index.html index e3b9ae5c..73f2ce29 100644 --- a/apps/web-antd/index.html +++ b/apps/web-antd/index.html @@ -1,19 +1,71 @@ - + - - - + + + + + + + %VITE_APP_TITLE% - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web-antd/src/components/public-account-lock/LockPayPanel.vue b/apps/web-antd/src/components/public-account-lock/LockPayPanel.vue new file mode 100644 index 00000000..7b0b7677 --- /dev/null +++ b/apps/web-antd/src/components/public-account-lock/LockPayPanel.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/apps/web-antd/src/components/public-account-lock/LockWaitPanel.vue b/apps/web-antd/src/components/public-account-lock/LockWaitPanel.vue new file mode 100644 index 00000000..ad4967ce --- /dev/null +++ b/apps/web-antd/src/components/public-account-lock/LockWaitPanel.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/apps/web-antd/src/components/public-account-lock/LockWarningCanvas.vue b/apps/web-antd/src/components/public-account-lock/LockWarningCanvas.vue new file mode 100644 index 00000000..bd4a3301 --- /dev/null +++ b/apps/web-antd/src/components/public-account-lock/LockWarningCanvas.vue @@ -0,0 +1,306 @@ + + + + + diff --git a/apps/web-antd/src/components/public-account-lock/PublicAccountLockBanner.vue b/apps/web-antd/src/components/public-account-lock/PublicAccountLockBanner.vue new file mode 100644 index 00000000..0e0a25f8 --- /dev/null +++ b/apps/web-antd/src/components/public-account-lock/PublicAccountLockBanner.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/apps/web-antd/src/components/public-account-lock/PublicAccountLockHeaderChip.vue b/apps/web-antd/src/components/public-account-lock/PublicAccountLockHeaderChip.vue new file mode 100644 index 00000000..daf581cf --- /dev/null +++ b/apps/web-antd/src/components/public-account-lock/PublicAccountLockHeaderChip.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/apps/web-antd/src/components/public-account-lock/PublicAccountLockOverlay.vue b/apps/web-antd/src/components/public-account-lock/PublicAccountLockOverlay.vue new file mode 100644 index 00000000..f8e979d4 --- /dev/null +++ b/apps/web-antd/src/components/public-account-lock/PublicAccountLockOverlay.vue @@ -0,0 +1,720 @@ + + + + + + + diff --git a/apps/web-antd/src/layouts/basic.vue b/apps/web-antd/src/layouts/basic.vue index 835d7065..f7f02926 100644 --- a/apps/web-antd/src/layouts/basic.vue +++ b/apps/web-antd/src/layouts/basic.vue @@ -23,6 +23,9 @@ import { notification } from 'ant-design-vue'; import { Bell, Users } from 'lucide-vue-next'; import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue'; +import PublicAccountLockBanner from '#/components/public-account-lock/PublicAccountLockBanner.vue'; +import PublicAccountLockHeaderChip from '#/components/public-account-lock/PublicAccountLockHeaderChip.vue'; +import PublicAccountLockOverlay from '#/components/public-account-lock/PublicAccountLockOverlay.vue'; import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue'; import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue'; import { useAuthStore } from '#/store'; @@ -294,6 +297,7 @@ watch( @logout="handleLogout" > + + diff --git a/apps/web-antd/src/layouts/components/UpdatePasswordModal.vue b/apps/web-antd/src/layouts/components/UpdatePasswordModal.vue index 0e2f4f41..0dc00baa 100644 --- a/apps/web-antd/src/layouts/components/UpdatePasswordModal.vue +++ b/apps/web-antd/src/layouts/components/UpdatePasswordModal.vue @@ -15,20 +15,19 @@ const [Modal, modalApi] = useVbenModal({ modalApi.close(); }, onConfirm: async () => { - formApi.validate().then(async (e: any) => { - if (e.valid) { - const values = await formApi.getValues(); - modalApi.setState({loading: true, confirmLoading: true}); - updatePassword(values).then(() => { - modalApi.close(); - message.success('修改成功,请重新登录'); - }) - .finally(() => { - modalApi.setState({loading: false, confirmLoading: false}); - }); - } - }); - await formApi.validateAndSubmitForm(); + const e = await formApi.validate(); + if (!e.valid) return; + const values = await formApi.getValues(); + modalApi.setState({ loading: true, confirmLoading: true }); + try { + await updatePassword(values); + modalApi.close(); + message.success('修改成功,请重新登录'); + } catch (err) { + console.error(err); + } finally { + modalApi.setState({ loading: false, confirmLoading: false }); + } }, }); diff --git a/apps/web-antd/src/router/routes/modules/dashboard.ts b/apps/web-antd/src/router/routes/modules/dashboard.ts index 44f15ef9..28a392dd 100644 --- a/apps/web-antd/src/router/routes/modules/dashboard.ts +++ b/apps/web-antd/src/router/routes/modules/dashboard.ts @@ -1,38 +1,38 @@ -import type { RouteRecordRaw } from 'vue-router'; - -import { $t } from '#/locales'; - -const routes: RouteRecordRaw[] = [ - { - meta: { - icon: 'lucide:layout-dashboard', - order: -1, - title: $t('page.dashboard.title'), - }, - name: 'Dashboard', - path: '/dashboard', - children: [ - { - name: 'Analytics', - path: 'analytics', - component: () => import('#/views/dashboard/analytics/index.vue'), - meta: { - affixTab: true, - icon: 'lucide:area-chart', - title: $t('page.dashboard.analytics'), - }, - }, - { - name: 'Workspace', - path: 'workspace', - component: () => import('#/views/dashboard/workspace/index.vue'), - meta: { - icon: 'carbon:workspace', - title: $t('page.dashboard.workspace'), - }, - }, - ], - }, -]; - -export default routes; +// import type { RouteRecordRaw } from 'vue-router'; +// +// import { $t } from '#/locales'; +// +// const routes: RouteRecordRaw[] = [ +// { +// meta: { +// icon: 'lucide:layout-dashboard', +// order: -1, +// title: $t('page.dashboard.title'), +// }, +// name: 'Dashboard', +// path: '/dashboard', +// children: [ +// { +// name: 'Analytics', +// path: 'analytics', +// component: () => import('#/views/dashboard/analytics/index.vue'), +// meta: { +// affixTab: true, +// icon: 'lucide:area-chart', +// title: $t('page.dashboard.analytics'), +// }, +// }, +// { +// name: 'Workspace', +// path: 'workspace', +// component: () => import('#/views/dashboard/workspace/index.vue'), +// meta: { +// icon: 'carbon:workspace', +// title: $t('page.dashboard.workspace'), +// }, +// }, +// ], +// }, +// ]; +// +// export default routes; diff --git a/apps/web-antd/src/store/auth.ts b/apps/web-antd/src/store/auth.ts index 01822513..82ff13d7 100644 --- a/apps/web-antd/src/store/auth.ts +++ b/apps/web-antd/src/store/auth.ts @@ -13,6 +13,7 @@ import { defineStore } from 'pinia'; import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api'; import { $t } from '#/locales'; +import { applyPublicAccountLockSession } from '#/utils/publicAccountLock'; const { updateWatermark } = useWatermark(); @@ -143,6 +144,10 @@ export const useAuthStore = defineStore('auth', () => { let userInfo: null | UserInfo = null; userInfo = await getUserInfoApi(); userStore.setUserInfo(userInfo); + const lock = (userInfo as any)?.public_account_lock; + if (lock) { + applyPublicAccountLockSession(lock); + } return userInfo; } diff --git a/apps/web-antd/src/utils/publicAccountLock.ts b/apps/web-antd/src/utils/publicAccountLock.ts new file mode 100644 index 00000000..c776cb04 --- /dev/null +++ b/apps/web-antd/src/utils/publicAccountLock.ts @@ -0,0 +1,179 @@ +/** + * 公账逾期锁店弹窗 + 工作台条幅状态 + * locked/visible=身份硬锁全屏;bannerVisible=门店逾期且未硬锁时的顶部条幅 + */ +import { reactive } from 'vue'; + +export type PublicAccountLockAlertColor = 'blue' | 'yellow' | 'red'; + +export interface PublicAccountLockContact { + name?: string; + phone: string; + /** 明文拨号号码(拦截器/后端写出,不走 phone 加密脱敏) */ + phone_txt?: string; + /** 兼容旧字段,同 phone_txt */ + tel?: string; +} + +export interface PublicAccountLockPayload { + action?: string; + daily_id?: number; + store_id?: number; + /** 诊所名称,锁框问候用 */ + store_name?: string; + platform_amount?: string; + /** 多天逾期合计应付款 */ + platform_amount_total?: string; + /** 逾期日账单张数 */ + bill_count?: number; + bill_date?: number; + status?: number; + bill_status?: number; + alert_color?: PublicAccountLockAlertColor | string; + unlock_mode?: 'voucher' | 'confirm' | string; + lock_time?: string; + /** 账单日起算宽限天数,默认 1 */ + lock_days?: number; + bill_date_from?: number; + bill_date_to?: number; + bill_date_range_txt?: string; + overdue_daily_ids?: number[]; + /** 合并批次 id(预览=最早日账单 id) */ + merge_id?: number; + /** 当前身份是否硬锁(全屏) */ + locked?: boolean; + /** 门店是否逾期(条幅) */ + store_locked?: boolean; + contact_phones?: PublicAccountLockContact[]; +} + +/** 后台配置的提醒框颜色,非法值回落黄色 */ +export function normalizeLockAlertColor( + raw: unknown, +): PublicAccountLockAlertColor { + const color = String(raw || 'yellow'); + if (color === 'blue' || color === 'red') { + return color; + } + return 'yellow'; +} + +const state = reactive({ + /** 身份硬锁全屏 */ + visible: false, + /** 门店逾期条幅(未硬锁时) */ + bannerVisible: false, + /** 条幅已收起到顶栏小胶囊 */ + bannerDocked: false, + /** 条幅「去处理」打开的软处理层(可返回,非硬锁) */ + handleVisible: false, + payload: {} as PublicAccountLockPayload, +}); + +/** 锁层是 2100,antd message 默认 1010,锁屏后提示会被挡住 */ +export const PUBLIC_ACCOUNT_LOCK_PROMPT_Z_INDEX = 5000; +const LOCK_BODY_CLASS = 'pap-lock-open'; + +export function getPublicAccountLockState() { + return state; +} + +/** 锁屏时抬高 message/notification,不改锁层自己的 z-index */ +function syncLockPromptLayer(visible: boolean) { + if (typeof document === 'undefined') { + return; + } + document.body.classList.toggle(LOCK_BODY_CLASS, visible); +} + +function isTruthyFlag(v: unknown): boolean { + return v === true || v === 1 || v === '1'; +} + +/** 是否门店逾期(条幅) */ +export function isStoreLockedPayload(payload?: PublicAccountLockPayload | null) { + if (!payload || typeof payload !== 'object') { + return false; + } + if (isTruthyFlag(payload.store_locked) || isTruthyFlag(payload.locked)) { + return true; + } + return !!(payload.daily_id || payload.bill_date); +} + +/** + * 登录 my-info / lock-status:按 locked / store_locked 分别驱动全屏与条幅 + */ +export function applyPublicAccountLockSession(payload?: PublicAccountLockPayload | null) { + const data = (payload && typeof payload === 'object' ? payload : {}) as PublicAccountLockPayload; + state.payload = data; + const hard = isTruthyFlag(data.locked); + const storeLocked = isStoreLockedPayload(data); + state.visible = hard; + const showBanner = storeLocked && !hard; + // 新出现条幅时重新展开几秒,避免一直贴顶无人注意 + if (showBanner && !state.bannerVisible) { + state.bannerDocked = false; + } + state.bannerVisible = showBanner; + if (!showBanner) { + state.bannerDocked = false; + } + if (!hard) { + // 硬锁解除时,处理层由业务自行关 + } + syncLockPromptLayer(hard || state.handleVisible); +} + +/** 4101:强制全屏硬锁 */ +export function showPublicAccountLock(payload: PublicAccountLockPayload) { + const data = { + ...(payload || {}), + locked: true, + store_locked: true, + } as PublicAccountLockPayload; + state.payload = data; + state.visible = true; + state.bannerVisible = false; + state.bannerDocked = false; + state.handleVisible = false; + syncLockPromptLayer(true); +} + +/** 条幅收起到顶栏 */ +export function dockPublicAccountBanner() { + if (state.bannerVisible) { + state.bannerDocked = true; + } +} + +/** 顶栏胶囊再展开大条幅 */ +export function expandPublicAccountBanner() { + state.bannerDocked = false; +} + +/** + * 条幅「去处理」:打开软处理层(上传/看进度),避免已在账单页时 router.push 无感 + */ +export function openPublicAccountLockHandle() { + if (!isStoreLockedPayload(state.payload) && !state.bannerVisible && !state.visible) { + return; + } + state.handleVisible = true; + syncLockPromptLayer(true); +} + +/** 关闭软处理层(硬锁时不可用) */ +export function closePublicAccountLockHandle() { + state.handleVisible = false; + syncLockPromptLayer(state.visible); +} + +export function clearPublicAccountLock() { + state.visible = false; + state.bannerVisible = false; + state.bannerDocked = false; + state.handleVisible = false; + state.payload = {}; + syncLockPromptLayer(false); +} diff --git a/apps/web-antd/src/views/_core/profile/base-setting.vue b/apps/web-antd/src/views/_core/profile/base-setting.vue index aae04904..5c1225f6 100644 --- a/apps/web-antd/src/views/_core/profile/base-setting.vue +++ b/apps/web-antd/src/views/_core/profile/base-setting.vue @@ -97,23 +97,22 @@ const saving = ref(false); * 保存资料:校验 → 提交白名单字段 → 重拉全局登录态 → 通知父组件刷新 * 为什么要 fetchUserInfo:后端已刷新 Redis 会话,前端重拉 my-info 才能让顶栏/工作台立即显示新头像昵称 */ -function handleSave() { - formApi.validate().then(async (e: any) => { - if (!e.valid) return; - const values = await formApi.getValues(); - saving.value = true; - try { - await updateMyProfile({ - nick_name: values.nick_name, - avatar: values.avatar || '', - }); - message.success('保存成功'); - await authStore.fetchUserInfo(); - emits('saved'); - } finally { - saving.value = false; - } - }); +async function handleSave() { + const e = await formApi.validate(); + if (!e.valid) return; + const values = await formApi.getValues(); + saving.value = true; + try { + await updateMyProfile({ + nick_name: values.nick_name, + avatar: values.avatar || '', + }); + message.success('保存成功'); + await authStore.fetchUserInfo(); + emits('saved'); + } finally { + saving.value = false; + } }