1. 更新框架
2. 修复框架更新后导致的问题
3. 消息系统升级
This commit is contained in:
李琦
2026-08-11 13:30:21 +08:00
parent b82754e36b
commit 6db736a6f4
19 changed files with 158 additions and 53 deletions

View File

@@ -40,6 +40,20 @@ async function initSetupVbenForm() {
} }
return true; 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;
},
}, },
}); });
} }

View File

@@ -1,16 +1,22 @@
<script lang="ts" setup> <script lang="ts" setup>
/** /**
* 诊所列表 VIP 单元格:徽标+会员名,点击打开 VIP 入口弹窗 * 诊所列表 VIP 单元格:徽标+会员名+到期时间,点击打开 VIP 入口弹窗
*/ */
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue'; import { computed } from 'vue';
defineProps<{ import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
import { formatVipDurationExpireLine } from '#/utils/vip';
const props = defineProps<{
vip?: Record<string, any> | null; vip?: Record<string, any> | null;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
click: []; click: [];
}>(); }>();
/** 副行展示时长类型与到期,方便列表一眼看到过期时间 */
const subText = computed(() => formatVipDurationExpireLine(props.vip));
</script> </script>
<template> <template>
<div <div
@@ -29,9 +35,7 @@ const emit = defineEmits<{
/> />
<div class="store-vip-cell__text"> <div class="store-vip-cell__text">
<div class="store-vip-cell__name">{{ vip?.level_name || '普通会员' }}</div> <div class="store-vip-cell__name">{{ vip?.level_name || '普通会员' }}</div>
<div class="store-vip-cell__sub"> <div class="store-vip-cell__sub">{{ subText }}</div>
{{ vip?.duration_label || '点击管理' }}
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -56,14 +60,14 @@ const emit = defineEmits<{
.store-vip-cell__name { .store-vip-cell__name {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--foreground, #1f1f1f); color: hsl(var(--foreground));
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.store-vip-cell__sub { .store-vip-cell__sub {
font-size: 11px; font-size: 11px;
color: #8c8c8c; color: hsl(var(--muted-foreground));
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -72,12 +76,4 @@ html.dark .store-vip-cell:hover,
.dark .store-vip-cell:hover { .dark .store-vip-cell:hover {
background: rgba(106, 205, 187, 0.2); 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;
}
</style> </style>

View File

@@ -10,6 +10,7 @@ import { useVbenModal } from '@vben/common-ui';
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue'; import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
import VipStoreRecordsModal from '#/components/vip/VipStoreRecordsModal.vue'; import VipStoreRecordsModal from '#/components/vip/VipStoreRecordsModal.vue';
import VipUpgradeModal from '#/components/vip/VipUpgradeModal.vue'; import VipUpgradeModal from '#/components/vip/VipUpgradeModal.vue';
import { formatVipExpireAt } from '#/utils/vip';
const gridApi = ref<any>(); const gridApi = ref<any>();
const onSuccess = ref<null | (() => void)>(null); const onSuccess = ref<null | (() => void)>(null);
@@ -21,6 +22,7 @@ const title = computed(
() => `VIP管理 - ${storeName.value || storeId.value || ''}`, () => `VIP管理 - ${storeName.value || storeId.value || ''}`,
); );
/** 当前等级 + 时长类型 */
const currentLevelText = computed(() => { const currentLevelText = computed(() => {
const v = vip.value; const v = vip.value;
if (!v) return '当前:普通会员'; if (!v) return '当前:普通会员';
@@ -32,6 +34,20 @@ const currentLevelText = computed(() => {
return duration ? `当前:${level} · ${duration}` : `当前:${level}`; 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({ const [UpgradeModalComp, upgradeModalApi] = useVbenModal({
connectedComponent: VipUpgradeModal, connectedComponent: VipUpgradeModal,
}); });
@@ -107,7 +123,12 @@ function openRecords() {
:level-name="vip?.level_name" :level-name="vip?.level_name"
:duration-label="vip?.duration_label" :duration-label="vip?.duration_label"
/> />
<div class="vip-hub-current__text">{{ currentLevelText }}</div> <div class="vip-hub-current__meta">
<div class="vip-hub-current__text">{{ currentLevelText }}</div>
<div v-if="currentExpireText" class="vip-hub-current__expire">
{{ currentExpireText }}
</div>
</div>
</div> </div>
<div class="vip-hub-grid"> <div class="vip-hub-grid">
<button type="button" class="vip-hub-card" @click="openUpgrade"> <button type="button" class="vip-hub-card" @click="openUpgrade">
@@ -140,11 +161,21 @@ function openRecords() {
border-radius: 10px; border-radius: 10px;
background: hsl(var(--muted) / 0.25); background: hsl(var(--muted) / 0.25);
} }
.vip-hub-current__meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.vip-hub-current__text { .vip-hub-current__text {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: hsl(var(--foreground)); color: hsl(var(--foreground));
} }
.vip-hub-current__expire {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.vip-hub-grid { .vip-hub-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;

View File

@@ -23,6 +23,7 @@ import { notification } from 'ant-design-vue';
import { Bell, Users } from 'lucide-vue-next'; import { Bell, Users } from 'lucide-vue-next';
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue'; import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue';
import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue'; import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue';
import { useAuthStore } from '#/store'; import { useAuthStore } from '#/store';
import { formatTimeToRelative } from '#/util/tool'; import { formatTimeToRelative } from '#/util/tool';
@@ -262,15 +263,19 @@ watch(
<DoctorTransferFloat v-if="showDoctorTransfer" /> <DoctorTransferFloat v-if="showDoctorTransfer" />
</template> </template>
<template #user-dropdown> <template #user-dropdown>
<UserDropdown <!-- VIP 徽标放在头像左侧,与用户下拉并排 -->
:avatar <div class="flex items-center">
:menus <HeaderVipBadge />
:text="userStore.userInfo?.nick_name" <UserDropdown
:description="userStore.userInfo?.email" :avatar
:tag-text="userStore.userInfo?.roles?.name" :menus
@clear-preferences-and-logout="handleLogout" :text="userStore.userInfo?.nick_name"
@logout="handleLogout" :description="userStore.userInfo?.email"
/> :tag-text="userStore.userInfo?.roles?.name"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
/>
</div>
</template> </template>
<template #notification> <template #notification>
<Notification <Notification

View File

@@ -144,3 +144,58 @@ export function useVipPermission(
export function useVipGate() { export function useVipGate() {
return useVipPermission(); return useVipPermission();
} }
/**
* 格式化 VIP 到期时间文案(列表/弹窗共用)
* @param vip 门店 VIP 信息
* @param options.emptyText 普通会员/无数据时的文案,默认空串
* @param options.lifetimeText 终身文案,默认「终身」
* @param options.withPrefix 是否加「至」前缀(非终身时)
*/
export function formatVipExpireAt(
vip?: StoreVipInfo | Record<string, any> | 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<string, any> | 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 || '点击管理';
}

View File

@@ -50,9 +50,10 @@ export const salespersonModalFormProps: VbenFormProps = {
component: 'Input', component: 'Input',
fieldName: 'phone', fieldName: 'phone',
label: '手机号', label: '手机号',
rules: 'required', // 与后端 checkPhone 一致:必填 + 大陆 11 位号段
rules: 'mobile',
componentProps: { componentProps: {
minLength: 11, maxlength: 11,
}, },
}, },
{ {

View File

@@ -182,7 +182,8 @@ export const infoModalFormProps: VbenFormProps = {
fieldName: 'mobile', fieldName: 'mobile',
formItemClass: 'col-span-6', formItemClass: 'col-span-6',
label: '手机号码', label: '手机号码',
rules: 'required', // 与后端 checkPhone 一致:必填 + 大陆 11 位号段
rules: 'mobile',
}, },
{ {
component: 'VbenInput', component: 'VbenInput',

View File

@@ -157,7 +157,8 @@ export const infoModalFormProps: VbenFormProps = {
fieldName: 'mobile', fieldName: 'mobile',
formItemClass: 'col-span-6', formItemClass: 'col-span-6',
label: '手机号码', label: '手机号码',
rules: 'required', // 与后端 checkPhone 一致:必填 + 大陆 11 位号段
rules: 'mobile',
}, },
{ {
component: 'VbenInput', component: 'VbenInput',

View File

@@ -140,7 +140,8 @@ export function createAdminModalFormProps(
}, },
fieldName: 'phone', fieldName: 'phone',
label: '手机号', label: '手机号',
rules: 'required', // 与后端 checkPhone 一致:必填 + 大陆 11 位号段
rules: 'mobile',
formItemClass: 'col-span-6', formItemClass: 'col-span-6',
}, },
]; ];

View File

@@ -59,7 +59,8 @@ export const modalFormProps: VbenFormProps = {
}, },
fieldName: 'phone', fieldName: 'phone',
label: '手机号', label: '手机号',
rules: 'required', // 与后端 checkPhone 一致:必填 + 大陆 11 位号段
rules: 'mobile',
formItemClass: 'col-span-6', formItemClass: 'col-span-6',
}, },
{ {

View File

@@ -67,7 +67,8 @@ export const modalFormProps: VbenFormProps = {
}, },
fieldName: 'phone', fieldName: 'phone',
label: '手机号', label: '手机号',
rules: 'required', // 与后端 checkPhone 一致:必填 + 大陆 11 位号段
rules: 'mobile',
help: '默认初始密码 Xk123456@', help: '默认初始密码 Xk123456@',
dependencies: { dependencies: {
if({ id }: { id?: number | string }) { if({ id }: { id?: number | string }) {

View File

@@ -96,7 +96,8 @@ export const modalFormProps: VbenFormProps = {
fieldName: 'mobile', fieldName: 'mobile',
formItemClass: 'col-span-6', formItemClass: 'col-span-6',
label: '手机号码', label: '手机号码',
rules: 'required', // 与后端 checkPhone 一致:必填 + 大陆 11 位号段
rules: 'mobile',
}, },
{ {
component: 'VbenInput', component: 'VbenInput',

View File

@@ -152,7 +152,8 @@ export const modalFormProps: VbenFormProps = {
}, },
fieldName: 'mobile', fieldName: 'mobile',
label: '联系人手机号', label: '联系人手机号',
rules: 'required', // 与后端 checkPhone 一致:必填 + 大陆 11 位号段
rules: 'mobile',
}, },
{ {
// 编辑时可选:同步修改药店管理员后台登录手机号 // 编辑时可选:同步修改药店管理员后台登录手机号

View File

@@ -137,7 +137,8 @@ export const modalFormProps: VbenFormProps = {
}, },
fieldName: 'mobile', fieldName: 'mobile',
label: '联系人手机号', label: '联系人手机号',
rules: 'required', // 与后端 checkPhone 一致:必填 + 大陆 11 位号段
rules: 'mobile',
}, },
{ {
component: 'PromoterPicker', component: 'PromoterPicker',

View File

@@ -177,7 +177,8 @@ export const modalFormProps: VbenFormProps = {
}, },
fieldName: 'mobile', fieldName: 'mobile',
label: '联系人手机号', label: '联系人手机号',
rules: 'required', // 与后端 checkPhone 一致:必填 + 大陆 11 位号段
rules: 'mobile',
}, },
{ {
// 编辑时可选:同步修改诊所管理员后台登录手机号 // 编辑时可选:同步修改诊所管理员后台登录手机号

View File

@@ -34,7 +34,8 @@ export const gridOptions: VxeGridProps<RowType> = {
field: 'vip', field: 'vip',
align: 'left', align: 'left',
title: 'VIP会员', title: 'VIP会员',
width: 160, minWidth: 200,
width: 240,
slots: { default: 'vip' }, slots: { default: 'vip' },
}, },
{ {

View File

@@ -1,5 +1,7 @@
import type { VxeGridProps } from '#/adapter/vxe-table'; import type { VxeGridProps } from '#/adapter/vxe-table';
import { formatVipExpireAt } from '#/utils/vip';
import { getVipStoreList } from '../api'; import { getVipStoreList } from '../api';
export const gridOptions: VxeGridProps<any> = { export const gridOptions: VxeGridProps<any> = {
@@ -32,21 +34,10 @@ export const gridOptions: VxeGridProps<any> = {
{ {
field: 'expire_at', field: 'expire_at',
title: '到期时间', title: '到期时间',
width: 170, width: 180,
formatter: ({ row }) => { formatter: ({ row }) =>
if (!row.vip) return '-'; formatVipExpireAt(row.vip, { emptyText: '-', lifetimeText: '终身' }) ||
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())}`;
},
}, },
{ title: '操作', slots: { default: 'action' }, width: 100, fixed: 'right' }, { title: '操作', slots: { default: 'action' }, width: 100, fixed: 'right' },
], ],

View File

@@ -2,6 +2,7 @@
"formRules": { "formRules": {
"required": "Please enter {0}", "required": "Please enter {0}",
"selectRequired": "Please select {0}", "selectRequired": "Please select {0}",
"mobile": "Please enter a valid {0}",
"minLength": "{0} must be at least {1} characters", "minLength": "{0} must be at least {1} characters",
"maxLength": "{0} can be at most {1} characters", "maxLength": "{0} can be at most {1} characters",
"length": "{0} must be {1} characters long", "length": "{0} must be {1} characters long",

View File

@@ -2,6 +2,7 @@
"formRules": { "formRules": {
"required": "请输入{0}", "required": "请输入{0}",
"selectRequired": "请选择{0}", "selectRequired": "请选择{0}",
"mobile": "请输入正确的{0}",
"minLength": "{0}至少{1}个字符", "minLength": "{0}至少{1}个字符",
"maxLength": "{0}最多{1}个字符", "maxLength": "{0}最多{1}个字符",
"length": "{0}长度必须为{1}个字符", "length": "{0}长度必须为{1}个字符",