feat:
1. 更新框架 2. 修复框架更新后导致的问题 3. 消息系统升级
This commit is contained in:
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || '点击管理';
|
||||
}
|
||||
|
||||
@@ -50,9 +50,10 @@ export const salespersonModalFormProps: VbenFormProps = {
|
||||
component: 'Input',
|
||||
fieldName: 'phone',
|
||||
label: '手机号',
|
||||
rules: 'required',
|
||||
// 与后端 checkPhone 一致:必填 + 大陆 11 位号段
|
||||
rules: 'mobile',
|
||||
componentProps: {
|
||||
minLength: 11,
|
||||
maxlength: 11,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -182,7 +182,8 @@ export const infoModalFormProps: VbenFormProps = {
|
||||
fieldName: 'mobile',
|
||||
formItemClass: 'col-span-6',
|
||||
label: '手机号码',
|
||||
rules: 'required',
|
||||
// 与后端 checkPhone 一致:必填 + 大陆 11 位号段
|
||||
rules: 'mobile',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
|
||||
@@ -157,7 +157,8 @@ export const infoModalFormProps: VbenFormProps = {
|
||||
fieldName: 'mobile',
|
||||
formItemClass: 'col-span-6',
|
||||
label: '手机号码',
|
||||
rules: 'required',
|
||||
// 与后端 checkPhone 一致:必填 + 大陆 11 位号段
|
||||
rules: 'mobile',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
|
||||
@@ -140,7 +140,8 @@ export function createAdminModalFormProps(
|
||||
},
|
||||
fieldName: 'phone',
|
||||
label: '手机号',
|
||||
rules: 'required',
|
||||
// 与后端 checkPhone 一致:必填 + 大陆 11 位号段
|
||||
rules: 'mobile',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -59,7 +59,8 @@ export const modalFormProps: VbenFormProps = {
|
||||
},
|
||||
fieldName: 'phone',
|
||||
label: '手机号',
|
||||
rules: 'required',
|
||||
// 与后端 checkPhone 一致:必填 + 大陆 11 位号段
|
||||
rules: 'mobile',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -96,7 +96,8 @@ export const modalFormProps: VbenFormProps = {
|
||||
fieldName: 'mobile',
|
||||
formItemClass: 'col-span-6',
|
||||
label: '手机号码',
|
||||
rules: 'required',
|
||||
// 与后端 checkPhone 一致:必填 + 大陆 11 位号段
|
||||
rules: 'mobile',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
|
||||
@@ -152,7 +152,8 @@ export const modalFormProps: VbenFormProps = {
|
||||
},
|
||||
fieldName: 'mobile',
|
||||
label: '联系人手机号',
|
||||
rules: 'required',
|
||||
// 与后端 checkPhone 一致:必填 + 大陆 11 位号段
|
||||
rules: 'mobile',
|
||||
},
|
||||
{
|
||||
// 编辑时可选:同步修改药店管理员后台登录手机号
|
||||
|
||||
@@ -137,7 +137,8 @@ export const modalFormProps: VbenFormProps = {
|
||||
},
|
||||
fieldName: 'mobile',
|
||||
label: '联系人手机号',
|
||||
rules: 'required',
|
||||
// 与后端 checkPhone 一致:必填 + 大陆 11 位号段
|
||||
rules: 'mobile',
|
||||
},
|
||||
{
|
||||
component: 'PromoterPicker',
|
||||
|
||||
@@ -177,7 +177,8 @@ export const modalFormProps: VbenFormProps = {
|
||||
},
|
||||
fieldName: 'mobile',
|
||||
label: '联系人手机号',
|
||||
rules: 'required',
|
||||
// 与后端 checkPhone 一致:必填 + 大陆 11 位号段
|
||||
rules: 'mobile',
|
||||
},
|
||||
{
|
||||
// 编辑时可选:同步修改诊所管理员后台登录手机号
|
||||
|
||||
@@ -34,7 +34,8 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
field: 'vip',
|
||||
align: 'left',
|
||||
title: 'VIP会员',
|
||||
width: 160,
|
||||
minWidth: 200,
|
||||
width: 240,
|
||||
slots: { default: 'vip' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"formRules": {
|
||||
"required": "请输入{0}",
|
||||
"selectRequired": "请选择{0}",
|
||||
"mobile": "请输入正确的{0}",
|
||||
"minLength": "{0}至少{1}个字符",
|
||||
"maxLength": "{0}最多{1}个字符",
|
||||
"length": "{0}长度必须为{1}个字符",
|
||||
|
||||
Reference in New Issue
Block a user