Files
xk-admin/apps/web-antd/src/utils/vip.ts
李琦 6db736a6f4 feat:
1. 更新框架
2. 修复框架更新后导致的问题
3. 消息系统升级
2026-08-11 13:30:21 +08:00

202 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* VIP 权限判断(全局方法)
* - 默认读登录态 userInfo.vip
* - 接诊/复诊等场景务必传入「履约诊所」vip避免用错门店权益
* - 模板显隐优先用 <VipGate code="xxx" :vip="storeVip" />
*/
import { computed, type ComputedRef, type Ref } from 'vue';
import { useUserStore } from '@vben/stores';
/** 与 xk_vip_feature.code / 等级 permissions 对齐的常用功能码 */
export const VIP_FEATURE = {
MEDICAL_RECORD: 'medical_record',
AI_MEDICAL_RECORD: 'ai_medical_record',
AI_PRESCRIPTION: 'ai_prescription',
GOLDEN_FORMULA: 'golden_formula',
} as const;
export type VipFeatureCode = (typeof VIP_FEATURE)[keyof typeof VIP_FEATURE] | string;
export interface StoreVipInfo {
level_code?: string;
level_name?: string;
level_weight?: number;
badge_url?: string;
duration_type?: string;
duration_label?: string;
duration_badge_url?: string;
permissions?: string[];
expire_at?: number;
is_lifetime?: boolean;
}
/** 读取当前登录用户 VIP 信息 */
export function getCurrentVip(): StoreVipInfo | null {
const userStore = useUserStore();
return ((userStore.userInfo as any)?.vip as StoreVipInfo) || null;
}
/**
* 从 get-current-store-type / 同类接口响应中取出 vip 对象
* 兼容 result / data / 已解包
*/
export function pickVipFromApiPayload(payload: unknown): StoreVipInfo | null {
if (!payload || typeof payload !== 'object') return null;
const raw = payload as Record<string, any>;
const data = raw.result ?? raw.data ?? raw;
if (!data || typeof data !== 'object') return null;
const vip = (data as any).vip;
return vip && typeof vip === 'object' ? (vip as StoreVipInfo) : null;
}
/** 规范化 permissions 列表 */
export function normalizeVipPermissions(
vipOrPermissions?: StoreVipInfo | string[] | null,
): string[] {
if (Array.isArray(vipOrPermissions)) {
return vipOrPermissions.filter((x) => typeof x === 'string' && x);
}
if (vipOrPermissions && typeof vipOrPermissions === 'object') {
const list = vipOrPermissions.permissions;
return Array.isArray(list) ? list.filter((x) => typeof x === 'string' && x) : [];
}
const cur = getCurrentVip()?.permissions;
return Array.isArray(cur) ? cur.filter((x) => typeof x === 'string' && x) : [];
}
/**
* 是否拥有指定权限码(全局判断方法)
* @param code 权限码,如 VIP_FEATURE.MEDICAL_RECORD
* @param vipOrPermissions 挂号侧 vip / permissions
* - 不传:用登录态 userInfo.vip
* - 显式传 null视为无履约诊所权益不回落登录态避免接诊未就绪时误放行
*/
export function hasVipPermission(
code: string,
vipOrPermissions?: StoreVipInfo | string[] | null,
): boolean {
if (!code) return false;
if (arguments.length < 2) {
return normalizeVipPermissions().includes(code);
}
if (vipOrPermissions == null) {
return false;
}
return normalizeVipPermissions(vipOrPermissions).includes(code);
}
/**
* 等级是否不低于目标(按 level_weight若无 weight 则仅精确匹配编码时返回 true
*/
export function levelAtLeast(minCode: string, vip?: StoreVipInfo | null): boolean {
const current = vip ?? getCurrentVip();
if (!current?.level_code) return false;
if (current.level_code === minCode) return true;
const weight = Number(current.level_weight ?? 0);
const minWeight = parseLevelWeight(minCode);
if (minWeight >= 0) {
return weight >= minWeight;
}
return false;
}
/** 粗略解析 V0-V11 / BLACK_GOLD / CUSTOM 权重 */
function parseLevelWeight(code: string): number {
if (/^V\d+$/i.test(code)) {
return Number(code.slice(1));
}
if (code === 'BLACK_GOLD') return 12;
if (code === 'CUSTOM') return 13;
return -1;
}
/**
* 组合式:基于可选的门店 vip ref 做权限判断(接诊台推荐)
* @example
* const { has, canUseMedicalRecord } = useVipPermission(receptionVip)
* const ok = has(VIP_FEATURE.MEDICAL_RECORD)
*/
export function useVipPermission(
vipSource?: Ref<StoreVipInfo | null | undefined> | ComputedRef<StoreVipInfo | null | undefined>,
) {
/** 传入了 vipSource 时始终走履约诊所分支(含 null=无权益) */
const has = (code: string) => {
if (vipSource) {
return hasVipPermission(code, vipSource.value ?? null);
}
return hasVipPermission(code);
};
return {
vip: computed(() => (vipSource ? vipSource.value : getCurrentVip())),
has,
hasVipPermission: has,
levelAtLeast: (minCode: string) =>
levelAtLeast(minCode, vipSource ? vipSource.value ?? null : getCurrentVip()),
canUseMedicalRecord: computed(() => has(VIP_FEATURE.MEDICAL_RECORD)),
canUseAiMedicalRecord: computed(() => has(VIP_FEATURE.AI_MEDICAL_RECORD)),
canUseAiPrescription: computed(() => has(VIP_FEATURE.AI_PRESCRIPTION)),
canUseGoldenFormula: computed(() => has(VIP_FEATURE.GOLDEN_FORMULA)),
};
}
/** @deprecated 请用 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 || '点击管理';
}