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;
},
/**
* 大陆手机号:必填 + 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>
/**
* 诊所列表 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;
}>();
const emit = defineEmits<{
click: [];
}>();
/** 副行展示时长类型与到期,方便列表一眼看到过期时间 */
const subText = computed(() => formatVipDurationExpireLine(props.vip));
</script>
<template>
<div
@@ -29,9 +35,7 @@ const emit = defineEmits<{
/>
<div class="store-vip-cell__text">
<div class="store-vip-cell__name">{{ vip?.level_name || '普通会员' }}</div>
<div class="store-vip-cell__sub">
{{ vip?.duration_label || '点击管理' }}
</div>
<div class="store-vip-cell__sub">{{ subText }}</div>
</div>
</div>
</template>
@@ -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;
}
</style>

View File

@@ -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<any>();
const onSuccess = ref<null | (() => 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"
/>
<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 class="vip-hub-grid">
<button type="button" class="vip-hub-card" @click="openUpgrade">
@@ -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;

View File

@@ -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(
<DoctorTransferFloat v-if="showDoctorTransfer" />
</template>
<template #user-dropdown>
<UserDropdown
:avatar
:menus
:text="userStore.userInfo?.nick_name"
:description="userStore.userInfo?.email"
:tag-text="userStore.userInfo?.roles?.name"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
/>
<!-- VIP 徽标放在头像左侧与用户下拉并排 -->
<div class="flex items-center">
<HeaderVipBadge />
<UserDropdown
:avatar
:menus
:text="userStore.userInfo?.nick_name"
:description="userStore.userInfo?.email"
:tag-text="userStore.userInfo?.roles?.name"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
/>
</div>
</template>
<template #notification>
<Notification

View File

@@ -144,3 +144,58 @@ export function useVipPermission(
export function useVipGate() {
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',
fieldName: 'phone',
label: '手机号',
rules: 'required',
// 与后端 checkPhone 一致:必填 + 大陆 11 位号段
rules: 'mobile',
componentProps: {
minLength: 11,
maxlength: 11,
},
},
{

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { formatVipExpireAt } from '#/utils/vip';
import { getVipStoreList } from '../api';
export const gridOptions: VxeGridProps<any> = {
@@ -32,21 +34,10 @@ export const gridOptions: VxeGridProps<any> = {
{
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' },
],

View File

@@ -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",

View File

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