diff --git a/apps/web-antd/src/adapter/form.ts b/apps/web-antd/src/adapter/form.ts index 831c2b32..0354edfd 100644 --- a/apps/web-antd/src/adapter/form.ts +++ b/apps/web-antd/src/adapter/form.ts @@ -40,6 +40,20 @@ async function initSetupVbenForm() { } return true; }, + /** + * 大陆手机号:必填 + 11 位且号段 13-19(与后端 UtilsService::checkPhone 一致) + * 表单字段 rules: 'mobile' 即可,无需再叠 required + */ + mobile: (value, _params, ctx) => { + const phone = typeof value === 'string' ? value.trim() : value; + if (phone === undefined || phone === null || phone === '') { + return $t('ui.formRules.required', [ctx.label]); + } + if (!/^1[3456789]\d{9}$/.test(String(phone))) { + return $t('ui.formRules.mobile', [ctx.label]); + } + return true; + }, }, }); } diff --git a/apps/web-antd/src/components/vip/StoreVipCell.vue b/apps/web-antd/src/components/vip/StoreVipCell.vue index a7ebd325..6aba069b 100644 --- a/apps/web-antd/src/components/vip/StoreVipCell.vue +++ b/apps/web-antd/src/components/vip/StoreVipCell.vue @@ -1,16 +1,22 @@ {{ vip?.level_name || '普通会员' }} - - {{ vip?.duration_label || '点击管理' }} - + {{ subText }} @@ -56,14 +60,14 @@ const emit = defineEmits<{ .store-vip-cell__name { font-size: 13px; font-weight: 600; - color: var(--foreground, #1f1f1f); + color: hsl(var(--foreground)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .store-vip-cell__sub { font-size: 11px; - color: #8c8c8c; + color: hsl(var(--muted-foreground)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -72,12 +76,4 @@ html.dark .store-vip-cell:hover, .dark .store-vip-cell:hover { background: rgba(106, 205, 187, 0.2); } -html.dark .store-vip-cell__name, -.dark .store-vip-cell__name { - color: #f4f4f5; -} -html.dark .store-vip-cell__sub, -.dark .store-vip-cell__sub { - color: #a1a1aa; -} diff --git a/apps/web-antd/src/components/vip/VipHubModal.vue b/apps/web-antd/src/components/vip/VipHubModal.vue index e19cb069..5c6c9098 100644 --- a/apps/web-antd/src/components/vip/VipHubModal.vue +++ b/apps/web-antd/src/components/vip/VipHubModal.vue @@ -10,6 +10,7 @@ import { useVbenModal } from '@vben/common-ui'; import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue'; import VipStoreRecordsModal from '#/components/vip/VipStoreRecordsModal.vue'; import VipUpgradeModal from '#/components/vip/VipUpgradeModal.vue'; +import { formatVipExpireAt } from '#/utils/vip'; const gridApi = ref(); const onSuccess = ref void)>(null); @@ -21,6 +22,7 @@ const title = computed( () => `VIP管理 - ${storeName.value || storeId.value || ''}`, ); +/** 当前等级 + 时长类型 */ const currentLevelText = computed(() => { const v = vip.value; if (!v) return '当前:普通会员'; @@ -32,6 +34,20 @@ const currentLevelText = computed(() => { return duration ? `当前:${level} · ${duration}` : `当前:${level}`; }); +/** 到期时间独立一行,打开弹窗时一眼可见 */ +const currentExpireText = computed(() => { + const v = vip.value; + if (!v) return ''; + const code = String(v.level_code || 'V0').trim() || 'V0'; + if (code === 'V0') return ''; + const expire = formatVipExpireAt(v, { + lifetimeText: '终身', + withPrefix: false, + }); + if (!expire) return ''; + return expire === '终身' ? '到期时间:终身' : `到期时间:${expire}`; +}); + const [UpgradeModalComp, upgradeModalApi] = useVbenModal({ connectedComponent: VipUpgradeModal, }); @@ -107,7 +123,12 @@ function openRecords() { :level-name="vip?.level_name" :duration-label="vip?.duration_label" /> - {{ currentLevelText }} + + {{ currentLevelText }} + + {{ currentExpireText }} + + @@ -140,11 +161,21 @@ function openRecords() { border-radius: 10px; background: hsl(var(--muted) / 0.25); } +.vip-hub-current__meta { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} .vip-hub-current__text { font-size: 13px; font-weight: 500; color: hsl(var(--foreground)); } +.vip-hub-current__expire { + font-size: 12px; + color: hsl(var(--muted-foreground)); +} .vip-hub-grid { display: grid; grid-template-columns: 1fr 1fr; diff --git a/apps/web-antd/src/layouts/basic.vue b/apps/web-antd/src/layouts/basic.vue index 16c5ff93..64448027 100644 --- a/apps/web-antd/src/layouts/basic.vue +++ b/apps/web-antd/src/layouts/basic.vue @@ -23,6 +23,7 @@ import { notification } from 'ant-design-vue'; import { Bell, Users } from 'lucide-vue-next'; import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue'; +import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue'; import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue'; import { useAuthStore } from '#/store'; import { formatTimeToRelative } from '#/util/tool'; @@ -262,15 +263,19 @@ watch( - + + + + + | null, + options?: { + emptyText?: string; + lifetimeText?: string; + withPrefix?: boolean; + }, +): string { + const emptyText = options?.emptyText ?? ''; + const lifetimeText = options?.lifetimeText ?? '终身'; + const withPrefix = options?.withPrefix ?? false; + if (!vip) return emptyText; + const code = String(vip.level_code || 'V0').trim() || 'V0'; + if (code === 'V0') return emptyText; + if (vip.is_lifetime) return lifetimeText; + const raw = vip.expire_at as unknown; + if (raw == null || raw === '' || raw === 0) { + // 无到期日且非 V0:按终身展示(与列表列逻辑一致) + return lifetimeText; + } + // 后端偶发已格式化成 Y-m-d H:i:s + if (typeof raw === 'string' && Number.isNaN(Number(raw))) { + return withPrefix ? `至 ${raw}` : raw; + } + const n = Number(raw); + if (!(n > 0)) return lifetimeText; + const d = new Date(n * 1000); + if (Number.isNaN(d.getTime())) return emptyText; + const pad = (x: number) => String(x).padStart(2, '0'); + const text = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + return withPrefix ? `至 ${text}` : text; +} + +/** + * 列表副文案:时长类型 + 到期(如「月卡 · 至 2026-08-10 …」) + */ +export function formatVipDurationExpireLine( + vip?: StoreVipInfo | Record | null, +): string { + if (!vip) return '点击管理'; + const code = String(vip.level_code || 'V0').trim() || 'V0'; + if (code === 'V0') return '点击管理'; + const duration = String(vip.duration_label || '').trim(); + const expire = formatVipExpireAt(vip, { withPrefix: true, lifetimeText: '终身' }); + if (duration && expire) return `${duration} · ${expire}`; + return duration || expire || '点击管理'; +} diff --git a/apps/web-antd/src/views/business/store/settings/config/form.ts b/apps/web-antd/src/views/business/store/settings/config/form.ts index f651e1e1..72a97725 100644 --- a/apps/web-antd/src/views/business/store/settings/config/form.ts +++ b/apps/web-antd/src/views/business/store/settings/config/form.ts @@ -50,9 +50,10 @@ export const salespersonModalFormProps: VbenFormProps = { component: 'Input', fieldName: 'phone', label: '手机号', - rules: 'required', + // 与后端 checkPhone 一致:必填 + 大陆 11 位号段 + rules: 'mobile', componentProps: { - minLength: 11, + maxlength: 11, }, }, { diff --git a/apps/web-antd/src/views/doctor/doctor/config/form.ts b/apps/web-antd/src/views/doctor/doctor/config/form.ts index 38a26771..00ee8527 100644 --- a/apps/web-antd/src/views/doctor/doctor/config/form.ts +++ b/apps/web-antd/src/views/doctor/doctor/config/form.ts @@ -182,7 +182,8 @@ export const infoModalFormProps: VbenFormProps = { fieldName: 'mobile', formItemClass: 'col-span-6', label: '手机号码', - rules: 'required', + // 与后端 checkPhone 一致:必填 + 大陆 11 位号段 + rules: 'mobile', }, { component: 'VbenInput', diff --git a/apps/web-antd/src/views/doctor/pharmacist/config/form.ts b/apps/web-antd/src/views/doctor/pharmacist/config/form.ts index 1c235d18..fc9a1ecc 100644 --- a/apps/web-antd/src/views/doctor/pharmacist/config/form.ts +++ b/apps/web-antd/src/views/doctor/pharmacist/config/form.ts @@ -157,7 +157,8 @@ export const infoModalFormProps: VbenFormProps = { fieldName: 'mobile', formItemClass: 'col-span-6', label: '手机号码', - rules: 'required', + // 与后端 checkPhone 一致:必填 + 大陆 11 位号段 + rules: 'mobile', }, { component: 'VbenInput', diff --git a/apps/web-antd/src/views/system/admin/_shared/form-schemas.ts b/apps/web-antd/src/views/system/admin/_shared/form-schemas.ts index 206bd759..fd8f92ff 100644 --- a/apps/web-antd/src/views/system/admin/_shared/form-schemas.ts +++ b/apps/web-antd/src/views/system/admin/_shared/form-schemas.ts @@ -140,7 +140,8 @@ export function createAdminModalFormProps( }, fieldName: 'phone', label: '手机号', - rules: 'required', + // 与后端 checkPhone 一致:必填 + 大陆 11 位号段 + rules: 'mobile', formItemClass: 'col-span-6', }, ]; diff --git a/apps/web-antd/src/views/system/admin/config/form.ts b/apps/web-antd/src/views/system/admin/config/form.ts index 862d4e21..c0c97398 100644 --- a/apps/web-antd/src/views/system/admin/config/form.ts +++ b/apps/web-antd/src/views/system/admin/config/form.ts @@ -59,7 +59,8 @@ export const modalFormProps: VbenFormProps = { }, fieldName: 'phone', label: '手机号', - rules: 'required', + // 与后端 checkPhone 一致:必填 + 大陆 11 位号段 + rules: 'mobile', formItemClass: 'col-span-6', }, { diff --git a/apps/web-antd/src/views/system/delivery-warehouse/config/form.ts b/apps/web-antd/src/views/system/delivery-warehouse/config/form.ts index c213b5c8..0500abcf 100644 --- a/apps/web-antd/src/views/system/delivery-warehouse/config/form.ts +++ b/apps/web-antd/src/views/system/delivery-warehouse/config/form.ts @@ -67,7 +67,8 @@ export const modalFormProps: VbenFormProps = { }, fieldName: 'phone', label: '手机号', - rules: 'required', + // 与后端 checkPhone 一致:必填 + 大陆 11 位号段 + rules: 'mobile', help: '默认初始密码 Xk123456@', dependencies: { if({ id }: { id?: number | string }) { diff --git a/apps/web-antd/src/views/system/doctor-input/config/form.ts b/apps/web-antd/src/views/system/doctor-input/config/form.ts index 70c007de..1130f466 100644 --- a/apps/web-antd/src/views/system/doctor-input/config/form.ts +++ b/apps/web-antd/src/views/system/doctor-input/config/form.ts @@ -96,7 +96,8 @@ export const modalFormProps: VbenFormProps = { fieldName: 'mobile', formItemClass: 'col-span-6', label: '手机号码', - rules: 'required', + // 与后端 checkPhone 一致:必填 + 大陆 11 位号段 + rules: 'mobile', }, { component: 'VbenInput', diff --git a/apps/web-antd/src/views/system/pharmacy/config/form.ts b/apps/web-antd/src/views/system/pharmacy/config/form.ts index 387cb9b6..cca97221 100644 --- a/apps/web-antd/src/views/system/pharmacy/config/form.ts +++ b/apps/web-antd/src/views/system/pharmacy/config/form.ts @@ -152,7 +152,8 @@ export const modalFormProps: VbenFormProps = { }, fieldName: 'mobile', label: '联系人手机号', - rules: 'required', + // 与后端 checkPhone 一致:必填 + 大陆 11 位号段 + rules: 'mobile', }, { // 编辑时可选:同步修改药店管理员后台登录手机号 diff --git a/apps/web-antd/src/views/system/store-input/config/form.ts b/apps/web-antd/src/views/system/store-input/config/form.ts index 89c7dc41..71bcfeaf 100644 --- a/apps/web-antd/src/views/system/store-input/config/form.ts +++ b/apps/web-antd/src/views/system/store-input/config/form.ts @@ -137,7 +137,8 @@ export const modalFormProps: VbenFormProps = { }, fieldName: 'mobile', label: '联系人手机号', - rules: 'required', + // 与后端 checkPhone 一致:必填 + 大陆 11 位号段 + rules: 'mobile', }, { component: 'PromoterPicker', diff --git a/apps/web-antd/src/views/system/store/config/form.ts b/apps/web-antd/src/views/system/store/config/form.ts index 241ae8fa..03b8becd 100644 --- a/apps/web-antd/src/views/system/store/config/form.ts +++ b/apps/web-antd/src/views/system/store/config/form.ts @@ -177,7 +177,8 @@ export const modalFormProps: VbenFormProps = { }, fieldName: 'mobile', label: '联系人手机号', - rules: 'required', + // 与后端 checkPhone 一致:必填 + 大陆 11 位号段 + rules: 'mobile', }, { // 编辑时可选:同步修改诊所管理员后台登录手机号 diff --git a/apps/web-antd/src/views/system/store/config/table.ts b/apps/web-antd/src/views/system/store/config/table.ts index bf5b048c..06f05743 100644 --- a/apps/web-antd/src/views/system/store/config/table.ts +++ b/apps/web-antd/src/views/system/store/config/table.ts @@ -34,7 +34,8 @@ export const gridOptions: VxeGridProps = { field: 'vip', align: 'left', title: 'VIP会员', - width: 160, + minWidth: 200, + width: 240, slots: { default: 'vip' }, }, { diff --git a/apps/web-antd/src/views/system/vip/store/config/table.ts b/apps/web-antd/src/views/system/vip/store/config/table.ts index 002cb39e..8b197b2a 100644 --- a/apps/web-antd/src/views/system/vip/store/config/table.ts +++ b/apps/web-antd/src/views/system/vip/store/config/table.ts @@ -1,5 +1,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table'; +import { formatVipExpireAt } from '#/utils/vip'; + import { getVipStoreList } from '../api'; export const gridOptions: VxeGridProps = { @@ -32,21 +34,10 @@ export const gridOptions: VxeGridProps = { { field: 'expire_at', title: '到期时间', - width: 170, - formatter: ({ row }) => { - if (!row.vip) return '-'; - if (row.vip.is_lifetime) return '终身'; - const expireAt = Number(row.vip.expire_at || 0); - if (!expireAt) { - return row.vip.level_code === 'V0' ? '-' : '终身'; - } - if (typeof row.vip.expire_at === 'string' && String(row.vip.expire_at).includes('-')) { - return row.vip.expire_at; - } - const d = new Date(expireAt * 1000); - const pad = (n: number) => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; - }, + width: 180, + formatter: ({ row }) => + formatVipExpireAt(row.vip, { emptyText: '-', lifetimeText: '终身' }) || + '-', }, { title: '操作', slots: { default: 'action' }, width: 100, fixed: 'right' }, ], diff --git a/packages/locales/src/langs/en-US/ui.json b/packages/locales/src/langs/en-US/ui.json index 3b0f94f8..bafab9eb 100644 --- a/packages/locales/src/langs/en-US/ui.json +++ b/packages/locales/src/langs/en-US/ui.json @@ -2,6 +2,7 @@ "formRules": { "required": "Please enter {0}", "selectRequired": "Please select {0}", + "mobile": "Please enter a valid {0}", "minLength": "{0} must be at least {1} characters", "maxLength": "{0} can be at most {1} characters", "length": "{0} must be {1} characters long", diff --git a/packages/locales/src/langs/zh-CN/ui.json b/packages/locales/src/langs/zh-CN/ui.json index cf718b94..79fe4c7d 100644 --- a/packages/locales/src/langs/zh-CN/ui.json +++ b/packages/locales/src/langs/zh-CN/ui.json @@ -2,6 +2,7 @@ "formRules": { "required": "请输入{0}", "selectRequired": "请选择{0}", + "mobile": "请输入正确的{0}", "minLength": "{0}至少{1}个字符", "maxLength": "{0}最多{1}个字符", "length": "{0}长度必须为{1}个字符",