feat: VIP模块

This commit is contained in:
李琦
2026-08-04 14:53:11 +08:00
parent feebf4b14e
commit 212b16a8ec
34 changed files with 2327 additions and 13 deletions

View File

@@ -0,0 +1,30 @@
<script lang="ts" setup>
/**
* 顶栏 VIP 徽标:等级徽标 + 期限丝带叠加(丝带透明底绑在徽标上),放大显示
*/
import { computed } from 'vue';
import { useUserStore } from '@vben/stores';
import VipBadgeCombo from './VipBadgeCombo.vue';
const userStore = useUserStore();
const vip = computed(() => (userStore.userInfo as any)?.vip || null);
const show = computed(() => {
if (!vip.value) return false;
return !!(vip.value.duration_badge_url || vip.value.badge_url);
});
</script>
<template>
<div v-if="show" class="mr-2 flex items-center">
<VipBadgeCombo
size="sm"
:badge-url="vip.badge_url"
:duration-badge-url="vip.duration_badge_url"
:level-name="vip.level_name"
:duration-label="vip.duration_label"
/>
</div>
</template>

View File

@@ -0,0 +1,83 @@
<script lang="ts" setup>
/**
* 诊所列表 VIP 单元格:徽标+会员名,点击打开升级弹窗
*/
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
defineProps<{
vip?: Record<string, any> | null;
}>();
const emit = defineEmits<{
click: [];
}>();
</script>
<template>
<div
class="store-vip-cell"
role="button"
tabindex="0"
@click.stop="emit('click')"
@keydown.enter.prevent="emit('click')"
>
<VipBadgeCombo
size="sm"
:badge-url="vip?.badge_url"
:duration-badge-url="vip?.duration_badge_url"
:level-name="vip?.level_name"
:duration-label="vip?.duration_label"
/>
<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>
</div>
</template>
<style scoped>
.store-vip-cell {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px 4px 4px;
border-radius: 10px;
transition: background 0.15s ease;
max-width: 100%;
}
.store-vip-cell:hover {
background: rgba(106, 205, 187, 0.12);
}
.store-vip-cell__text {
min-width: 0;
line-height: 1.25;
}
.store-vip-cell__name {
font-size: 13px;
font-weight: 600;
color: var(--foreground, #1f1f1f);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.store-vip-cell__sub {
font-size: 11px;
color: #8c8c8c;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
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

@@ -0,0 +1,107 @@
<script lang="ts" setup>
/**
* 诊所设置「基础资料」会员卡片
* 大尺寸等级徽标 + 期限丝带叠加,展示等级名与到期时间
*/
import { computed } from 'vue';
import VipBadgeCombo from './VipBadgeCombo.vue';
const props = defineProps<{
vip?: Record<string, any> | null;
}>();
const hasVipVisual = computed(() => {
const v = props.vip;
return !!(v && (v.badge_url || v.duration_badge_url));
});
const expireText = computed(() => {
const v = props.vip;
if (!v) return '默认会员';
if (v.is_lifetime) return '永久有效';
const expireAt = Number(v.expire_at || 0);
if (!expireAt) {
if ((v.level_code || 'V0') === 'V0') return '默认会员';
return '永久有效';
}
if (typeof v.expire_at === 'string' && String(v.expire_at).includes('-')) {
return `有效期至 ${v.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())}`;
});
</script>
<template>
<div class="store-vip-card">
<VipBadgeCombo
v-if="hasVipVisual"
size="xl"
:badge-url="vip?.badge_url"
:duration-badge-url="vip?.duration_badge_url"
:level-name="vip?.level_name"
:duration-label="vip?.duration_label"
/>
<div v-else class="store-vip-card__placeholder">V</div>
<div class="store-vip-card__info">
<div class="store-vip-card__title">
{{ vip?.level_name || '普通会员' }}
</div>
<div class="store-vip-card__sub">
{{ vip?.duration_label || '普通会员' }}
· {{ vip?.level_code || 'V0' }}
</div>
<div class="store-vip-card__expire">{{ expireText }}</div>
</div>
</div>
</template>
<style scoped>
.store-vip-card {
display: flex;
align-items: center;
gap: 24px;
padding: 20px 28px;
border-radius: 16px;
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
box-shadow: 0 8px 24px rgba(44, 62, 80, 0.16);
margin-bottom: 24px;
}
.store-vip-card__placeholder {
width: 120px;
height: 120px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
color: #fff;
font-size: 48px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.store-vip-card__info {
min-width: 0;
color: #fff;
}
.store-vip-card__title {
font-size: 22px;
font-weight: 700;
line-height: 1.3;
margin-bottom: 6px;
}
.store-vip-card__sub {
font-size: 14px;
opacity: 0.9;
margin-bottom: 6px;
}
.store-vip-card__expire {
font-size: 13px;
opacity: 0.78;
}
html.dark .store-vip-card,
.dark .store-vip-card {
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
</style>

View File

@@ -0,0 +1,117 @@
<script lang="ts" setup>
/**
* VIP 组合徽标:等级徽标在下,期限丝带(透明底)叠在上方绑定
* size: sm 顶栏 / md 表格 / lg 详情 / xl 诊所设置会员卡
*/
withDefaults(
defineProps<{
badgeUrl?: string;
durationBadgeUrl?: string;
levelName?: string;
durationLabel?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
}>(),
{
badgeUrl: '',
durationBadgeUrl: '',
levelName: '',
durationLabel: '',
size: 'md',
},
);
</script>
<template>
<div
v-if="badgeUrl || durationBadgeUrl"
class="vip-badge-combo"
:class="[`vip-badge-combo--${size}`]"
:title="[durationLabel, levelName].filter(Boolean).join(' · ')"
>
<img
v-if="badgeUrl"
class="vip-badge-combo__level"
:src="badgeUrl"
:alt="levelName || '会员等级'"
/>
<img
v-if="durationBadgeUrl"
class="vip-badge-combo__ribbon"
:src="durationBadgeUrl"
:alt="durationLabel || '会员类型'"
/>
</div>
<span v-else>-</span>
</template>
<style scoped>
.vip-badge-combo {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
vertical-align: middle;
}
.vip-badge-combo__level {
object-fit: contain;
display: block;
position: relative;
z-index: 1;
}
.vip-badge-combo__ribbon {
position: absolute;
left: 50%;
bottom: 8%;
transform: translateX(-50%);
z-index: 2;
object-fit: contain;
pointer-events: none;
}
.vip-badge-combo--sm {
width: 52px;
height: 52px;
}
.vip-badge-combo--sm .vip-badge-combo__level {
width: 48px;
height: 48px;
}
.vip-badge-combo--sm .vip-badge-combo__ribbon {
width: 72px;
height: 28px;
}
.vip-badge-combo--md {
width: 64px;
height: 64px;
}
.vip-badge-combo--md .vip-badge-combo__level {
width: 56px;
height: 56px;
}
.vip-badge-combo--md .vip-badge-combo__ribbon {
width: 84px;
height: 30px;
}
.vip-badge-combo--lg {
width: 96px;
height: 96px;
}
.vip-badge-combo--lg .vip-badge-combo__level {
width: 84px;
height: 84px;
}
.vip-badge-combo--lg .vip-badge-combo__ribbon {
width: 120px;
height: 42px;
}
.vip-badge-combo--xl {
width: 140px;
height: 140px;
}
.vip-badge-combo--xl .vip-badge-combo__level {
width: 120px;
height: 120px;
}
.vip-badge-combo--xl .vip-badge-combo__ribbon {
width: 168px;
height: 58px;
}
</style>

View File

@@ -0,0 +1,41 @@
<script lang="ts" setup>
/**
* VIP 权限显隐口子组件
* 用法:<VipGate code="xxx" :min-level="'V5'">内容</VipGate>
* 后续业务权益接入时直接包一层即可;当前从 userInfo.vip 判断
*/
import { computed } from 'vue';
import { hasVipPermission, levelAtLeast } from '#/utils/vip';
const props = withDefaults(
defineProps<{
/** 权限码(与等级 permissions 对齐) */
code?: string;
/** 最低等级编码 */
minLevel?: string;
/** 无权限时是否渲染占位(默认不渲染) */
fallback?: boolean;
}>(),
{
code: '',
minLevel: '',
fallback: false,
},
);
const allowed = computed(() => {
if (props.code) {
return hasVipPermission(props.code);
}
if (props.minLevel) {
return levelAtLeast(props.minLevel);
}
// 未指定条件时默认展示,方便后续逐步接入
return true;
});
</script>
<template>
<slot v-if="allowed" />
<slot v-else-if="fallback" name="fallback" />
</template>

View File

@@ -0,0 +1,440 @@
<script lang="ts" setup>
/**
* VIP 升级/开通弹窗
* 等级/时长用两行横向滑动单选卡片,预览丝带随所选时长自动切换
*/
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Input, InputNumber, message } from 'ant-design-vue';
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
import { getVipDurationOption } from '#/views/system/vip/duration/api';
import { getVipLevelOption } from '#/views/system/vip/level/api';
import {
getVipDurationPresets,
openVipStore,
} from '#/views/system/vip/store/api';
/** 时长预设 → 时效类型 code与后端 VipDurationHelper 对齐) */
const PRESET_TO_DURATION: Record<string, string> = {
'7d': 'trial',
'1w': 'week',
'1m': 'month',
'3m': 'month',
'6m': 'month',
'1y': 'year',
xy: 'year',
lifetime: 'lifetime',
};
const gridApi = ref<any>();
const onSuccess = ref<null | (() => void)>(null);
const storeId = ref(0);
const storeName = ref('');
const levelId = ref<number | undefined>();
const durationPreset = ref<string | undefined>();
const customYears = ref<number | undefined>();
const remark = ref('');
const levels = ref<any[]>([]);
const presets = ref<{ label: string; value: string }[]>([]);
const durationTypes = ref<any[]>([]);
const selectedLevel = computed(() =>
levels.value.find((i) => i.id === levelId.value),
);
/** 按时长预设解析对应丝带资源 */
function ribbonByPreset(preset?: string) {
const code = PRESET_TO_DURATION[preset || ''] || '';
if (!code) return null;
return durationTypes.value.find((i) => i.code === code) || null;
}
const selectedDurationType = computed(() =>
ribbonByPreset(durationPreset.value),
);
/** 时长卡片数据:附带丝带图,避免模板重复查找 */
const durationCards = computed(() =>
presets.value.map((p) => {
const ribbon = ribbonByPreset(p.value);
return {
...p,
ribbonUrl: ribbon?.badge_url || '',
ribbonName: ribbon?.name || '',
};
}),
);
const previewBadgeUrl = computed(() => selectedLevel.value?.badge_url || '');
const previewRibbonUrl = computed(
() => selectedDurationType.value?.badge_url || '',
);
const previewLevelName = computed(
() => selectedLevel.value?.name || '请选择等级',
);
const previewDurationLabel = computed(() => {
if (!durationPreset.value) return '请选择时长';
const preset = presets.value.find((p) => p.value === durationPreset.value);
return preset?.label || selectedDurationType.value?.name || '请选择时长';
});
const title = computed(() => `升级VIP - ${storeName.value || storeId.value}`);
/** 选择会员等级 */
function selectLevel(id: number) {
levelId.value = id;
}
/** 选择开通时长(同步驱动预览丝带) */
function selectDuration(value: string) {
durationPreset.value = value;
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
if (!levelId.value) {
message.warning('请选择会员等级');
return;
}
if (!durationPreset.value) {
message.warning('请选择开通时长');
return;
}
if (
durationPreset.value === 'xy' &&
!(Number(customYears.value) > 0)
) {
message.warning('请填写有效年数');
return;
}
modalApi.setState({ confirmLoading: true });
try {
await openVipStore({
store_id: storeId.value,
level_id: levelId.value,
duration_preset: durationPreset.value,
custom_years: customYears.value || 0,
remark: remark.value || '',
});
message.success('开通成功');
gridApi.value?.query?.();
onSuccess.value?.();
modalApi.close();
} finally {
modalApi.setState({ confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
levelId.value = undefined;
durationPreset.value = undefined;
customYears.value = undefined;
remark.value = '';
return;
}
const data = modalApi.getData<Record<string, any>>() || {};
gridApi.value = data.gridApi;
onSuccess.value = data.onSuccess || null;
storeId.value = Number(data.store_id || 0);
storeName.value = data.store_name || '';
const [levelList, presetList, durationList] = await Promise.all([
getVipLevelOption(),
getVipDurationPresets(),
getVipDurationOption(),
]);
levels.value = levelList || [];
presets.value = (presetList || []).map((i: any) => ({
label: i.label,
value: i.value,
}));
durationTypes.value = durationList || [];
// 回填当前 VIP 等级(便于续期)
if (data.vip?.level_id) {
levelId.value = Number(data.vip.level_id);
}
},
});
watch(durationPreset, (v) => {
if (v !== 'xy') customYears.value = undefined;
});
</script>
<template>
<Modal :title="title" class="w-[760px]">
<!-- 预览徽标卡片 + 丝带随时长自动适配 -->
<div class="vip-upgrade-preview mb-5">
<VipBadgeCombo
size="xl"
:badge-url="previewBadgeUrl"
:duration-badge-url="previewRibbonUrl"
:level-name="previewLevelName"
:duration-label="previewDurationLabel"
/>
<div class="vip-upgrade-preview__text">
<div class="vip-upgrade-preview__title">{{ previewLevelName }}</div>
<div class="vip-upgrade-preview__sub">{{ previewDurationLabel }}</div>
<div v-if="selectedLevel" class="vip-upgrade-preview__price">
原价 ¥{{ selectedLevel.price }} · 管理员开通实付 ¥0
</div>
</div>
</div>
<div class="space-y-4">
<div>
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">
会员等级
</div>
<div class="vip-pick-scroll" role="listbox" aria-label="会员等级">
<button
v-for="item in levels"
:key="item.id"
type="button"
class="vip-pick-card"
:class="{ 'vip-pick-card--active': levelId === item.id }"
role="option"
:aria-selected="levelId === item.id"
@click="selectLevel(item.id)"
>
<img
v-if="item.badge_url"
:src="item.badge_url"
alt=""
class="vip-pick-card__badge"
/>
<div
v-else
class="vip-pick-card__badge vip-pick-card__badge--empty"
>
{{ item.code }}
</div>
<div class="vip-pick-card__name">{{ item.name }}</div>
<div class="vip-pick-card__meta">
{{ item.code }} · ¥{{ item.price }}
</div>
</button>
</div>
</div>
<div>
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">
开通时长
</div>
<div class="vip-pick-scroll" role="listbox" aria-label="开通时长">
<button
v-for="item in durationCards"
:key="item.value"
type="button"
class="vip-pick-card vip-pick-card--duration"
:class="{ 'vip-pick-card--active': durationPreset === item.value }"
role="option"
:aria-selected="durationPreset === item.value"
@click="selectDuration(item.value)"
>
<img
v-if="item.ribbonUrl"
:src="item.ribbonUrl"
alt=""
class="vip-pick-card__ribbon"
/>
<div
v-else
class="vip-pick-card__ribbon vip-pick-card__ribbon--empty"
>
丝带
</div>
<div class="vip-pick-card__name">{{ item.label }}</div>
</button>
</div>
</div>
<div v-if="durationPreset === 'xy'">
<div class="mb-1 text-sm text-gray-600 dark:text-gray-300">
自定义年数
</div>
<InputNumber
v-model:value="customYears"
class="w-full"
:min="1"
:max="99"
placeholder="请输入年数"
/>
</div>
<div>
<div class="mb-1 text-sm text-gray-600 dark:text-gray-300">备注</div>
<Input.TextArea v-model:value="remark" :rows="2" placeholder="选填" />
</div>
</div>
</Modal>
</template>
<style scoped>
.vip-upgrade-preview {
display: flex;
align-items: center;
gap: 20px;
padding: 20px 24px;
border-radius: 14px;
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
color: #fff;
}
.vip-upgrade-preview__title {
font-size: 20px;
font-weight: 700;
margin-bottom: 4px;
}
.vip-upgrade-preview__sub {
font-size: 13px;
opacity: 0.9;
margin-bottom: 6px;
}
.vip-upgrade-preview__price {
font-size: 12px;
opacity: 0.8;
}
/* 两行 + 横向滑动的单选卡片轨道 */
.vip-pick-scroll {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(2, minmax(0, auto));
grid-auto-columns: 112px;
gap: 10px;
overflow-x: auto;
overflow-y: hidden;
padding: 4px 2px 10px;
scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch;
}
.vip-pick-scroll::-webkit-scrollbar {
height: 6px;
}
.vip-pick-scroll::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.18);
border-radius: 999px;
}
.vip-pick-card {
scroll-snap-align: start;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 108px;
padding: 10px 8px;
border: 1.5px solid #e8e8e8;
border-radius: 12px;
background: #fff;
cursor: pointer;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background 0.15s ease;
text-align: center;
}
.vip-pick-card:hover {
border-color: #6acdbb;
}
.vip-pick-card--active {
border-color: #6acdbb;
background: rgba(106, 205, 187, 0.1);
box-shadow: 0 0 0 2px rgba(106, 205, 187, 0.25);
}
.vip-pick-card__badge {
width: 48px;
height: 48px;
object-fit: contain;
}
.vip-pick-card__badge--empty {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f5f5f5;
color: #999;
font-size: 12px;
font-weight: 600;
}
.vip-pick-card__ribbon {
width: 88px;
height: 28px;
object-fit: contain;
}
.vip-pick-card__ribbon--empty {
display: flex;
align-items: center;
justify-content: center;
width: 88px;
height: 28px;
border-radius: 6px;
background: #f5f5f5;
color: #bbb;
font-size: 11px;
}
.vip-pick-card--duration {
min-height: 88px;
}
.vip-pick-card__name {
font-size: 13px;
font-weight: 600;
color: #1f1f1f;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vip-pick-card__meta {
font-size: 11px;
color: #8c8c8c;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ========== 暗色适配html.dark / .dark========== */
html.dark .vip-upgrade-preview,
.dark .vip-upgrade-preview {
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
box-shadow: inset 0 0 0 1px rgba(106, 205, 187, 0.18);
}
html.dark .vip-pick-scroll::-webkit-scrollbar-thumb,
.dark .vip-pick-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.22);
}
html.dark .vip-pick-card,
.dark .vip-pick-card {
border-color: #3f3f46;
background: #1f1f23;
}
html.dark .vip-pick-card:hover,
.dark .vip-pick-card:hover {
border-color: #6acdbb;
background: #27272a;
}
html.dark .vip-pick-card--active,
.dark .vip-pick-card--active {
border-color: #6acdbb;
background: rgba(106, 205, 187, 0.16);
box-shadow: 0 0 0 2px rgba(106, 205, 187, 0.28);
}
html.dark .vip-pick-card__badge--empty,
.dark .vip-pick-card__badge--empty,
html.dark .vip-pick-card__ribbon--empty,
.dark .vip-pick-card__ribbon--empty {
background: #2a2a2e;
color: #a1a1aa;
}
html.dark .vip-pick-card__name,
.dark .vip-pick-card__name {
color: #f4f4f5;
}
html.dark .vip-pick-card__meta,
.dark .vip-pick-card__meta {
color: #a1a1aa;
}
</style>