feat: VIP模块
This commit is contained in:
30
apps/web-antd/src/components/vip/HeaderVipBadge.vue
Normal file
30
apps/web-antd/src/components/vip/HeaderVipBadge.vue
Normal 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>
|
||||
83
apps/web-antd/src/components/vip/StoreVipCell.vue
Normal file
83
apps/web-antd/src/components/vip/StoreVipCell.vue
Normal 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>
|
||||
107
apps/web-antd/src/components/vip/StoreVipMemberCard.vue
Normal file
107
apps/web-antd/src/components/vip/StoreVipMemberCard.vue
Normal 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>
|
||||
117
apps/web-antd/src/components/vip/VipBadgeCombo.vue
Normal file
117
apps/web-antd/src/components/vip/VipBadgeCombo.vue
Normal 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>
|
||||
41
apps/web-antd/src/components/vip/VipGate.vue
Normal file
41
apps/web-antd/src/components/vip/VipGate.vue
Normal 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>
|
||||
440
apps/web-antd/src/components/vip/VipUpgradeModal.vue
Normal file
440
apps/web-antd/src/components/vip/VipUpgradeModal.vue
Normal 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>
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from 'lucide-vue-next';
|
||||
|
||||
// import { $t } from '#/locales';
|
||||
import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue';
|
||||
import { useAuthStore } from '#/store';
|
||||
import LoginForm from '#/views/_core/authentication/login.vue';
|
||||
import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue';
|
||||
@@ -234,14 +235,17 @@ watch(
|
||||
<template>
|
||||
<BasicLayout @clear-preferences-and-logout="handleLogout">
|
||||
<template #user-dropdown>
|
||||
<UserDropdown
|
||||
:avatar
|
||||
:description="userStore.userInfo?.email"
|
||||
:menus
|
||||
:tag-text="userStore.userInfo?.roles?.name"
|
||||
:text="userStore.userInfo?.nick_name"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
<HeaderVipBadge />
|
||||
<UserDropdown
|
||||
:avatar
|
||||
:description="userStore.userInfo?.email"
|
||||
:menus
|
||||
:tag-text="userStore.userInfo?.roles?.name"
|
||||
:text="userStore.userInfo?.nick_name"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #notification>
|
||||
<Notification
|
||||
|
||||
68
apps/web-antd/src/utils/vip.ts
Normal file
68
apps/web-antd/src/utils/vip.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* VIP 权限判断工具(前端口子)
|
||||
* 当前从登录态 userInfo.vip 读取;后续可扩展为按 storeId 请求接口
|
||||
*/
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 是否拥有指定权限码 */
|
||||
export function hasVipPermission(code: string): boolean {
|
||||
if (!code) return false;
|
||||
const vip = getCurrentVip();
|
||||
const list = vip?.permissions;
|
||||
return Array.isArray(list) && list.includes(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 等级是否不低于目标(按 level_weight;若无 weight 则仅精确匹配编码时返回 true)
|
||||
* 简化:仅当当前 level_code 与 minCode 相同,或 weight 足够时通过
|
||||
*/
|
||||
export function levelAtLeast(minCode: string): boolean {
|
||||
const vip = getCurrentVip();
|
||||
if (!vip?.level_code) return false;
|
||||
if (vip.level_code === minCode) return true;
|
||||
// 无完整等级表时,仅做字符串相等判断;业务侧应以后端 Gate 为准
|
||||
const weight = Number(vip.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;
|
||||
}
|
||||
|
||||
/** 组合式:供组件调用 */
|
||||
export function useVipGate() {
|
||||
return {
|
||||
vip: getCurrentVip(),
|
||||
hasVipPermission,
|
||||
levelAtLeast,
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
import DoctorQrCodePreview from '#/components/modal/DoctorQrCodePreview.vue';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import StoreVipMemberCard from '#/components/vip/StoreVipMemberCard.vue';
|
||||
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
|
||||
import {
|
||||
deleteNavApi,
|
||||
@@ -31,7 +33,6 @@ import {
|
||||
updateNavSortApi,
|
||||
updateClinicTypeApi,
|
||||
} from '#/views/business/store/settings/api';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
|
||||
import UploadModal from './components/uploadModal.vue';
|
||||
import SalespersonCard from "#/views/business/store/settings/components/SalespersonCard.vue";
|
||||
@@ -63,6 +64,8 @@ interface StoreInfo {
|
||||
qr_code: string;
|
||||
doctor_list: any[];
|
||||
nav: { id: number; pic: string }[];
|
||||
/** 门店 VIP:等级徽标 + 期限丝带 */
|
||||
vip?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
const [DoctorQrCodePreviewModal, DoctorQrCodePreviewModalApi] = useVbenModal({
|
||||
@@ -375,6 +378,8 @@ const handleSwitchClinicType = () => {
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<Card v-if="activeTabBar === 1" :title="isPharmacy ? '药店资料' : '诊所资料'">
|
||||
<!-- 门店 VIP 会员卡:大尺寸徽标 + 期限丝带 -->
|
||||
<StoreVipMemberCard :vip="data.vip" />
|
||||
<!-- 头部信息 -->
|
||||
<div class="mb-8 flex flex-col gap-6 md:flex-row md:gap-8">
|
||||
<div class="flex-1">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
@@ -10,11 +10,14 @@ import {
|
||||
Bell,
|
||||
CalendarClock,
|
||||
Clock,
|
||||
Crown,
|
||||
Megaphone,
|
||||
User,
|
||||
} from 'lucide-vue-next';
|
||||
import { MdPreview } from 'md-editor-v3';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
|
||||
import { formatTimeToRelative } from '#/util/tool';
|
||||
import { getNoticeDetailApi } from '#/views/notice/api';
|
||||
|
||||
@@ -22,7 +25,6 @@ import UnreadUserList from './UnreadUserList.vue';
|
||||
|
||||
// eslint-disable-next-line n/no-extraneous-import
|
||||
import 'md-editor-v3/lib/style.css';
|
||||
import {Icon} from "#/components/icon";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -34,7 +36,7 @@ const error = ref(null);
|
||||
|
||||
const { setTabTitle } = useTabs();
|
||||
|
||||
// 消息类型定义
|
||||
// 消息类型定义(含 VIP 通知 type=3)
|
||||
const messageTypes = [
|
||||
{
|
||||
id: 0,
|
||||
@@ -69,8 +71,40 @@ const messageTypes = [
|
||||
borderColor: 'border-emerald-200',
|
||||
darkBorderColor: 'dark:border-emerald-800',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'VIP通知',
|
||||
icon: Crown,
|
||||
color: 'text-violet-500',
|
||||
darkColor: 'dark:text-violet-400',
|
||||
bgColor: 'bg-violet-50',
|
||||
darkBgColor: 'dark:bg-violet-900/30',
|
||||
borderColor: 'border-violet-200',
|
||||
darkBorderColor: 'dark:border-violet-800',
|
||||
},
|
||||
];
|
||||
|
||||
/** 解析 VIP 站内信 content JSON */
|
||||
const vipContent = computed(() => {
|
||||
if (!notice.value || Number(notice.value.type) !== 3) return null;
|
||||
const raw = notice.value.content;
|
||||
if (!raw) return null;
|
||||
if (typeof raw === 'object') return raw;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const vipSceneLabel = computed(() => {
|
||||
const type = vipContent.value?.type;
|
||||
if (type === 'vip_open_success') return '开通成功';
|
||||
if (type === 'vip_expiring') return '即将到期';
|
||||
if (type === 'vip_expired') return '已到期';
|
||||
return 'VIP通知';
|
||||
});
|
||||
|
||||
// 获取通知详情
|
||||
const getNoticeDetail = async () => {
|
||||
loading.value = true;
|
||||
@@ -232,9 +266,36 @@ const showUnreadList = () => {
|
||||
class="mb-6 h-px w-full bg-gray-100 transition-colors dark:bg-gray-700"
|
||||
></div>
|
||||
|
||||
<!-- VIP 通知:解析 content JSON,展示时效徽标 + 等级徽标 -->
|
||||
<div
|
||||
v-if="vipContent"
|
||||
class="rounded-lg bg-violet-50/80 p-4 transition-colors dark:bg-violet-900/20"
|
||||
>
|
||||
<div class="mb-3 text-sm font-medium text-violet-700 dark:text-violet-300">
|
||||
{{ vipSceneLabel }}
|
||||
</div>
|
||||
<div class="mb-3 flex items-center gap-4">
|
||||
<VipBadgeCombo
|
||||
size="lg"
|
||||
:badge-url="vipContent.badge_url"
|
||||
:duration-badge-url="vipContent.duration_badge_url"
|
||||
:level-name="vipContent.level_name"
|
||||
:duration-label="vipContent.duration_label"
|
||||
/>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
<div>{{ vipContent.duration_label || '-' }} · {{ vipContent.level_name || '-' }}({{ vipContent.level_code || '-' }})</div>
|
||||
<div class="mt-1 text-gray-500">门店:{{ vipContent.store_name || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
原价 ¥{{ vipContent.original_price || '0.00' }}
|
||||
· 实付 ¥{{ vipContent.pay_amount || '0.00' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div
|
||||
v-if="notice.edit_type === 0"
|
||||
v-else-if="notice.edit_type === 0"
|
||||
class="rounded-lg bg-gray-50 p-4 transition-colors dark:bg-gray-700"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -30,6 +30,13 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
width: 280,
|
||||
slots: { default: 'store_basic_info' },
|
||||
},
|
||||
{
|
||||
field: 'vip',
|
||||
align: 'left',
|
||||
title: 'VIP会员',
|
||||
width: 160,
|
||||
slots: { default: 'vip' },
|
||||
},
|
||||
{
|
||||
field: 'online_consultation_config',
|
||||
align: 'left',
|
||||
|
||||
@@ -41,6 +41,8 @@ import StoreExternalFieldModal from './components/StoreExternalFieldModal.vue';
|
||||
import SalespersonCommissionDrawer from './components/SalespersonCommissionDrawer.vue';
|
||||
import AddDoctorFromStoreModal from './components/AddDoctorFromStoreModal.vue';
|
||||
import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
|
||||
import StoreVipCell from '#/components/vip/StoreVipCell.vue';
|
||||
import VipUpgradeModal from '#/components/vip/VipUpgradeModal.vue';
|
||||
import BankCardReportModal from '#/views/system/store-bank-card/components/BankCardReportModal.vue';
|
||||
import BankCardStatusModal from '#/views/system/store-bank-card/components/BankCardStatusModal.vue';
|
||||
import BankCardStoreEditModal from '#/views/system/store-bank-card/components/BankCardStoreEditModal.vue';
|
||||
@@ -101,6 +103,22 @@ const [StoreCardModalComp, StoreCardModalApi] = useVbenModal({
|
||||
connectedComponent: StoreCardModal,
|
||||
});
|
||||
|
||||
const [VipUpgradeModalComp, vipUpgradeModalApi] = useVbenModal({
|
||||
connectedComponent: VipUpgradeModal,
|
||||
});
|
||||
|
||||
/** 打开门店 VIP 升级弹窗(列表徽标点击) */
|
||||
function openVipUpgrade(row: Record<string, any>) {
|
||||
if (!row?.id) return;
|
||||
vipUpgradeModalApi.setData({
|
||||
store_id: Number(row.id),
|
||||
store_name: row.name || '',
|
||||
vip: row.vip || null,
|
||||
gridApi,
|
||||
});
|
||||
vipUpgradeModalApi.open();
|
||||
}
|
||||
|
||||
/** 添加医生弹窗:诊所列表行操作和详情医生团队共用 */
|
||||
const [AddDoctorModalComp, addDoctorModalApi] = useVbenModal({
|
||||
connectedComponent: AddDoctorFromStoreModal,
|
||||
@@ -444,6 +462,7 @@ const handleSwitchClinicType = (row: any) => {
|
||||
<BankCardStatusModalComponent />
|
||||
<BankCardStoreEditModalComponent />
|
||||
<StoreCardModalComp />
|
||||
<VipUpgradeModalComp />
|
||||
<AddDoctorModalComp />
|
||||
<QrCodePreviewModal />
|
||||
<DrugPriceModalComponent />
|
||||
@@ -511,6 +530,9 @@ const handleSwitchClinicType = (row: any) => {
|
||||
:on-open-store="openStoreCard"
|
||||
/>
|
||||
</template>
|
||||
<template #vip="{ row }">
|
||||
<StoreVipCell :vip="row.vip" @click="openVipUpgrade(row)" />
|
||||
</template>
|
||||
<template #store_config_1="{ row }">
|
||||
<StoreConfigTogglesCell
|
||||
:row="row"
|
||||
|
||||
15
apps/web-antd/src/views/system/vip/duration/api/index.ts
Normal file
15
apps/web-antd/src/views/system/vip/duration/api/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'vip-duration-type/';
|
||||
|
||||
export async function getVipDurationList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function getVipDurationOption(data?: any) {
|
||||
return requestClient.get<any>(`${prefix}option`, { params: data });
|
||||
}
|
||||
|
||||
export async function updateVipDuration(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 时效类型徽标编辑(主要上传体验/周/月/年/终身徽标)
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
import { updateVipDuration } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
const gridApi = ref();
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
await updateVipDuration(values);
|
||||
message.success('保存成功');
|
||||
gridApi.value?.query();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (!isOpen) return;
|
||||
const { values } = modalApi.getData<Record<string, any>>() || {};
|
||||
if (values) formApi.setValues(values);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal title="编辑时效类型徽标" class="w-[560px]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
57
apps/web-antd/src/views/system/vip/duration/config/form.ts
Normal file
57
apps/web-antd/src/views/system/vip/duration/config/form.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
export const modalFormProps: VbenFormProps = {
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'code',
|
||||
label: '编码',
|
||||
componentProps: { disabled: true },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'name',
|
||||
label: '名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Avatar',
|
||||
fieldName: 'badge_url',
|
||||
label: '类型徽标',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
formItemClass: 'col-span-6',
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
],
|
||||
},
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
27
apps/web-antd/src/views/system/vip/duration/config/search.ts
Normal file
27
apps/web-antd/src/views/system/vip/duration/config/search.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
export const formOptions: VbenFormProps = {
|
||||
layout: 'inline',
|
||||
showResetButton: true,
|
||||
showSubmitButton: true,
|
||||
schemas: [
|
||||
{
|
||||
fieldName: 'name',
|
||||
component: 'Input',
|
||||
label: '名称',
|
||||
componentProps: { placeholder: '时效名称', allowClear: true },
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
component: 'Select',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
33
apps/web-antd/src/views/system/vip/duration/config/table.ts
Normal file
33
apps/web-antd/src/views/system/vip/duration/config/table.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { getVipDurationList } from '../api';
|
||||
|
||||
export const gridOptions: VxeGridProps<any> = {
|
||||
columnConfig: { useKey: true },
|
||||
rowConfig: { useKey: true, isHover: true },
|
||||
columns: [
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{ field: 'code', title: '编码', width: 120 },
|
||||
{ field: 'name', title: '名称', minWidth: 140 },
|
||||
{ field: 'badge_url', title: '丝带徽标', width: 160, slots: { default: 'badge' } },
|
||||
{ field: 'sort', title: '排序', width: 80 },
|
||||
{ field: 'status_txt', title: '状态', width: 80 },
|
||||
{ field: 'updated_at', title: '更新时间', width: 170 },
|
||||
{ title: '操作', slots: { default: 'action' }, width: 100, fixed: 'right' },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getVipDurationList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
border: false,
|
||||
toolbarConfig: { search: true, refresh: true, zoom: true },
|
||||
};
|
||||
197
apps/web-antd/src/views/system/vip/duration/index.vue
Normal file
197
apps/web-antd/src/views/system/vip/duration/index.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* VIP 时效类型徽标:卡片布局 + 筛选(名称/编码/状态)
|
||||
* 卡片突出展示宽幅丝带图
|
||||
*/
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Input,
|
||||
Pagination,
|
||||
Select,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { getVipDurationList } from './api';
|
||||
import FormModal from './components/modal.vue';
|
||||
|
||||
defineOptions({ name: 'VipDuration' });
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const page = reactive({ current: 1, pageSize: 10 });
|
||||
const filters = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
status: undefined as number | undefined,
|
||||
});
|
||||
|
||||
const [FormModalComp, formModalApi] = useVbenModal({
|
||||
connectedComponent: FormModal,
|
||||
});
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getVipDurationList({
|
||||
page: page.current,
|
||||
pageSize: page.pageSize,
|
||||
code: filters.code || undefined,
|
||||
name: filters.name || undefined,
|
||||
status: filters.status,
|
||||
});
|
||||
list.value = res?.items || [];
|
||||
total.value = Number(res?.total || 0);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.current = 1;
|
||||
loadList();
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
filters.code = '';
|
||||
filters.name = '';
|
||||
filters.status = undefined;
|
||||
onSearch();
|
||||
}
|
||||
|
||||
const showModal = (row: any) => {
|
||||
formModalApi.setData({
|
||||
values: row,
|
||||
gridApi: { query: loadList, reload: loadList },
|
||||
});
|
||||
formModalApi.open();
|
||||
};
|
||||
|
||||
onMounted(loadList);
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height title="时效类型徽标">
|
||||
<FormModalComp />
|
||||
<div class="p-4">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
v-model:value="filters.code"
|
||||
allow-clear
|
||||
placeholder="时效编码"
|
||||
class="w-36"
|
||||
@press-enter="onSearch"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="filters.name"
|
||||
allow-clear
|
||||
placeholder="时效名称"
|
||||
class="w-40"
|
||||
@press-enter="onSearch"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="filters.status"
|
||||
allow-clear
|
||||
placeholder="状态"
|
||||
class="w-28"
|
||||
:options="STATUS_OPTIONS"
|
||||
/>
|
||||
<Button type="primary" @click="onSearch">查询</Button>
|
||||
<Button @click="onReset">重置</Button>
|
||||
</div>
|
||||
<Empty v-if="!loading && !list.length" description="暂无时效类型" />
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<Card
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
size="small"
|
||||
class="vip-duration-card"
|
||||
:loading="loading"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="vip-duration-card__ribbon-wrap mb-4">
|
||||
<img
|
||||
v-if="item.badge_url"
|
||||
:src="item.badge_url"
|
||||
alt=""
|
||||
class="vip-duration-card__ribbon"
|
||||
/>
|
||||
<div v-else class="vip-duration-card__ribbon-empty">
|
||||
未上传丝带
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 text-lg font-semibold dark:text-gray-100">{{ item.name }}</div>
|
||||
<div class="mb-2 text-sm text-gray-500 dark:text-gray-400">{{ item.code }}</div>
|
||||
<Tag
|
||||
class="mb-3"
|
||||
:color="item.status === 1 ? 'success' : 'default'"
|
||||
>
|
||||
{{ item.status === 1 ? '启用' : '禁用' }}
|
||||
</Tag>
|
||||
<TableAction
|
||||
:actions="[{ label: '编辑上传丝带', onClick: () => showModal(item) }]"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Pagination
|
||||
v-model:current="page.current"
|
||||
v-model:page-size="page.pageSize"
|
||||
:total="total"
|
||||
:page-size-options="['10', '20', '50']"
|
||||
show-size-changer
|
||||
@change="loadList"
|
||||
@show-size-change="loadList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
<style scoped>
|
||||
.vip-duration-card :deep(.ant-card-body) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
.vip-duration-card__ribbon-wrap {
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 12px;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
.vip-duration-card__ribbon {
|
||||
max-width: 100%;
|
||||
height: 56px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.vip-duration-card__ribbon-empty {
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
html.dark .vip-duration-card__ribbon-wrap,
|
||||
.dark .vip-duration-card__ribbon-wrap {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
html.dark .vip-duration-card__ribbon-empty,
|
||||
.dark .vip-duration-card__ribbon-empty {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
</style>
|
||||
28
apps/web-antd/src/views/system/vip/level/api/index.ts
Normal file
28
apps/web-antd/src/views/system/vip/level/api/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/** VIP 等级配置 API,路由前缀 vip-level/ */
|
||||
const prefix = 'vip-level/';
|
||||
|
||||
export async function getVipLevelList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function getVipLevelOption(data?: any) {
|
||||
return requestClient.get<any>(`${prefix}option`, { params: data });
|
||||
}
|
||||
|
||||
export async function getVipLevelInfo(id: number) {
|
||||
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||
}
|
||||
|
||||
export async function createVipLevel(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateVipLevel(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteVipLevel(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* VIP 等级新增/编辑弹窗
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
import { createVipLevel, updateVipLevel } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const gridApi = ref();
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
const api = isUpdate.value ? updateVipLevel : createVipLevel;
|
||||
await api(values);
|
||||
message.success('保存成功');
|
||||
gridApi.value?.query();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (!isOpen) {
|
||||
formApi.resetFields();
|
||||
return;
|
||||
}
|
||||
const { values, update } = modalApi.getData<Record<string, any>>() || {};
|
||||
isUpdate.value = !!update;
|
||||
if (values && update) {
|
||||
formApi.setValues({
|
||||
...values,
|
||||
permissions: Array.isArray(values.permissions) ? values.permissions : [],
|
||||
});
|
||||
} else {
|
||||
formApi.resetFields();
|
||||
formApi.setValues({
|
||||
tier_group: 0,
|
||||
level_weight: 0,
|
||||
price: 0,
|
||||
sort: 0,
|
||||
status: 1,
|
||||
permissions: [],
|
||||
badge_url: '',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}VIP等级`" class="w-[640px]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
15
apps/web-antd/src/views/system/vip/level/config/constants.ts
Normal file
15
apps/web-antd/src/views/system/vip/level/config/constants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** VIP 等级组选项 */
|
||||
export const VIP_TIER_GROUP_OPTIONS = [
|
||||
{ label: '普通', value: 0 },
|
||||
{ label: '黄金', value: 1 },
|
||||
{ label: '铂金', value: 2 },
|
||||
{ label: '钻石', value: 3 },
|
||||
{ label: '黑金', value: 4 },
|
||||
{ label: '定制', value: 5 },
|
||||
];
|
||||
|
||||
/** 状态选项 */
|
||||
export const STATUS_OPTIONS = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
100
apps/web-antd/src/views/system/vip/level/config/form.ts
Normal file
100
apps/web-antd/src/views/system/vip/level/config/form.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { STATUS_OPTIONS, VIP_TIER_GROUP_OPTIONS } from './constants';
|
||||
|
||||
export const modalFormProps: VbenFormProps = {
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'code',
|
||||
label: '等级编码',
|
||||
rules: 'required',
|
||||
componentProps: { placeholder: '如 V5 / BLACK_GOLD' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'name',
|
||||
label: '等级名称',
|
||||
rules: 'required',
|
||||
componentProps: { placeholder: '如铂金会员' },
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'tier_group',
|
||||
label: '等级组',
|
||||
rules: 'selectRequired',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { options: VIP_TIER_GROUP_OPTIONS },
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'level_weight',
|
||||
label: '权重',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { min: 0, max: 9999 },
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'Avatar',
|
||||
fieldName: 'badge_url',
|
||||
label: '等级徽标',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'price',
|
||||
label: '标价',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { min: 0, precision: 2, placeholder: '0 表示免费' },
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { min: 0 },
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'permissions',
|
||||
label: '权限码',
|
||||
componentProps: {
|
||||
mode: 'tags',
|
||||
placeholder: '输入权限码后回车,预留业务权益',
|
||||
tokenSeparators: [','],
|
||||
},
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'description',
|
||||
label: '说明',
|
||||
componentProps: { type: 'textarea', rows: 2 },
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { options: STATUS_OPTIONS },
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
43
apps/web-antd/src/views/system/vip/level/config/search.ts
Normal file
43
apps/web-antd/src/views/system/vip/level/config/search.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
import { STATUS_OPTIONS, VIP_TIER_GROUP_OPTIONS } from './constants';
|
||||
|
||||
export const formOptions: VbenFormProps = {
|
||||
layout: 'inline',
|
||||
showResetButton: true,
|
||||
showSubmitButton: true,
|
||||
schemas: [
|
||||
{
|
||||
fieldName: 'code',
|
||||
component: 'Input',
|
||||
label: '编码',
|
||||
componentProps: { placeholder: '如 V5', allowClear: true },
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
component: 'Input',
|
||||
label: '名称',
|
||||
componentProps: { placeholder: '等级名称', allowClear: true },
|
||||
},
|
||||
{
|
||||
fieldName: 'tier_group',
|
||||
component: 'Select',
|
||||
label: '等级组',
|
||||
componentProps: {
|
||||
placeholder: '请选择',
|
||||
allowClear: true,
|
||||
options: VIP_TIER_GROUP_OPTIONS,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
component: 'Select',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
placeholder: '请选择',
|
||||
allowClear: true,
|
||||
options: STATUS_OPTIONS,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
50
apps/web-antd/src/views/system/vip/level/config/table.ts
Normal file
50
apps/web-antd/src/views/system/vip/level/config/table.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { getVipLevelList } from '../api';
|
||||
import { VIP_TIER_GROUP_OPTIONS } from './constants';
|
||||
|
||||
export const gridOptions: VxeGridProps<any> = {
|
||||
checkboxConfig: { highlight: true, labelField: '' },
|
||||
columnConfig: { useKey: true },
|
||||
rowConfig: { useKey: true, isHover: true },
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{ field: 'code', title: '编码', width: 120 },
|
||||
{ field: 'name', title: '名称', minWidth: 120 },
|
||||
{
|
||||
field: 'tier_group',
|
||||
title: '等级组',
|
||||
width: 90,
|
||||
formatter: ({ cellValue }) =>
|
||||
VIP_TIER_GROUP_OPTIONS.find((o) => o.value === cellValue)?.label || '-',
|
||||
},
|
||||
{ field: 'level_weight', title: '权重', width: 80 },
|
||||
{ field: 'badge_url', title: '等级徽标', width: 100, slots: { default: 'badge' } },
|
||||
{ field: 'price', title: '标价', width: 100 },
|
||||
{ field: 'status_txt', title: '状态', width: 80 },
|
||||
{ field: 'sort', title: '排序', width: 80 },
|
||||
{ field: 'created_at', title: '创建时间', width: 170 },
|
||||
{ title: '操作', slots: { default: 'action' }, width: 160, fixed: 'right' },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getVipLevelList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
border: false,
|
||||
toolbarConfig: {
|
||||
search: true,
|
||||
refresh: true,
|
||||
zoom: true,
|
||||
slots: { buttons: 'toolbar-buttons' },
|
||||
},
|
||||
};
|
||||
222
apps/web-antd/src/views/system/vip/level/index.vue
Normal file
222
apps/web-antd/src/views/system/vip/level/index.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* VIP 等级配置:卡片布局 + 筛选(名称/编码/等级组/状态)
|
||||
*/
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Input,
|
||||
Pagination,
|
||||
Select,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { deleteVipLevel, getVipLevelList } from './api';
|
||||
import FormModal from './components/modal.vue';
|
||||
import {
|
||||
STATUS_OPTIONS,
|
||||
VIP_TIER_GROUP_OPTIONS,
|
||||
} from './config/constants';
|
||||
|
||||
defineOptions({ name: 'VipLevel' });
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const page = reactive({ current: 1, pageSize: 12 });
|
||||
const filters = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
tier_group: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
});
|
||||
|
||||
const [FormModalComp, formModalApi] = useVbenModal({
|
||||
connectedComponent: FormModal,
|
||||
});
|
||||
|
||||
/** 拉取卡片数据 */
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getVipLevelList({
|
||||
page: page.current,
|
||||
pageSize: page.pageSize,
|
||||
code: filters.code || undefined,
|
||||
name: filters.name || undefined,
|
||||
tier_group: filters.tier_group,
|
||||
status: filters.status,
|
||||
});
|
||||
list.value = res?.items || [];
|
||||
total.value = Number(res?.total || 0);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.current = 1;
|
||||
loadList();
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
filters.code = '';
|
||||
filters.name = '';
|
||||
filters.tier_group = undefined;
|
||||
filters.status = undefined;
|
||||
onSearch();
|
||||
}
|
||||
|
||||
const showModal = (data: any = {}, isUpdate = false) => {
|
||||
formModalApi.setData({
|
||||
values: data,
|
||||
update: isUpdate,
|
||||
gridApi: { query: loadList, reload: loadList },
|
||||
});
|
||||
formModalApi.open();
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
deleteVipLevel({ ids: [id] }).then(() => {
|
||||
message.success('删除成功');
|
||||
loadList();
|
||||
});
|
||||
};
|
||||
|
||||
function tierLabel(v: number) {
|
||||
return VIP_TIER_GROUP_OPTIONS.find((o) => o.value === v)?.label || '-';
|
||||
}
|
||||
|
||||
onMounted(loadList);
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height title="VIP等级配置">
|
||||
<FormModalComp />
|
||||
<div class="p-4">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
v-model:value="filters.code"
|
||||
allow-clear
|
||||
placeholder="等级编码"
|
||||
class="w-36"
|
||||
@press-enter="onSearch"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="filters.name"
|
||||
allow-clear
|
||||
placeholder="等级名称"
|
||||
class="w-40"
|
||||
@press-enter="onSearch"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="filters.tier_group"
|
||||
allow-clear
|
||||
placeholder="等级组"
|
||||
class="w-32"
|
||||
:options="VIP_TIER_GROUP_OPTIONS"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="filters.status"
|
||||
allow-clear
|
||||
placeholder="状态"
|
||||
class="w-28"
|
||||
:options="STATUS_OPTIONS"
|
||||
/>
|
||||
<Button type="primary" @click="onSearch">查询</Button>
|
||||
<Button @click="onReset">重置</Button>
|
||||
<div class="ml-auto">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增等级',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: () => showModal({}, false),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Empty v-if="!loading && !list.length" description="暂无等级数据" />
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<Card
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
size="small"
|
||||
class="vip-level-card"
|
||||
:loading="loading"
|
||||
>
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<img
|
||||
v-if="item.badge_url"
|
||||
:src="item.badge_url"
|
||||
alt=""
|
||||
class="mb-3 h-24 w-24 object-contain"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="mb-3 flex h-24 w-24 items-center justify-center rounded-full bg-gray-100 text-2xl font-bold text-gray-400 dark:bg-gray-800 dark:text-gray-500"
|
||||
>
|
||||
{{ item.code }}
|
||||
</div>
|
||||
<div class="mb-1 text-base font-semibold dark:text-gray-100">
|
||||
{{ item.name }}
|
||||
<span class="ml-1 text-sm font-normal text-gray-500 dark:text-gray-400">{{ item.code }}</span>
|
||||
</div>
|
||||
<div class="mb-2 flex flex-wrap justify-center gap-1">
|
||||
<Tag>{{ tierLabel(item.tier_group) }}</Tag>
|
||||
<Tag :color="item.status === 1 ? 'success' : 'default'">
|
||||
{{ item.status === 1 ? '启用' : '禁用' }}
|
||||
</Tag>
|
||||
<Tag color="blue">¥{{ item.price }}</Tag>
|
||||
</div>
|
||||
<div class="mb-3 line-clamp-2 min-h-10 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ item.description || '暂无说明' }}
|
||||
</div>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{ label: '编辑', onClick: () => showModal(item, true) },
|
||||
{
|
||||
label: '删除',
|
||||
color: 'error',
|
||||
popConfirm: {
|
||||
title: '确定删除该等级?',
|
||||
onConfirm: () => handleDelete(item.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Pagination
|
||||
v-model:current="page.current"
|
||||
v-model:page-size="page.pageSize"
|
||||
:total="total"
|
||||
:page-size-options="['12', '24', '48']"
|
||||
show-size-changer
|
||||
show-quick-jumper
|
||||
@change="loadList"
|
||||
@show-size-change="loadList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
<style scoped>
|
||||
.vip-level-card :deep(.ant-card-body) {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
</style>
|
||||
7
apps/web-antd/src/views/system/vip/record/api/index.ts
Normal file
7
apps/web-antd/src/views/system/vip/record/api/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'vip-record/';
|
||||
|
||||
export async function getVipRecordList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
33
apps/web-antd/src/views/system/vip/record/config/search.ts
Normal file
33
apps/web-antd/src/views/system/vip/record/config/search.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
export const formOptions: VbenFormProps = {
|
||||
layout: 'inline',
|
||||
showResetButton: true,
|
||||
showSubmitButton: true,
|
||||
schemas: [
|
||||
{
|
||||
fieldName: 'store_id',
|
||||
component: 'Input',
|
||||
label: '门店ID',
|
||||
componentProps: { placeholder: '门店ID', allowClear: true },
|
||||
},
|
||||
{
|
||||
fieldName: 'level_code',
|
||||
component: 'Input',
|
||||
label: '等级编码',
|
||||
componentProps: { placeholder: '如 V5', allowClear: true },
|
||||
},
|
||||
{
|
||||
fieldName: 'open_type',
|
||||
component: 'Select',
|
||||
label: '开通方式',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '管理员开通', value: 1 },
|
||||
{ label: '门店自购', value: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
44
apps/web-antd/src/views/system/vip/record/config/table.ts
Normal file
44
apps/web-antd/src/views/system/vip/record/config/table.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { getVipRecordList } from '../api';
|
||||
|
||||
export const gridOptions: VxeGridProps<any> = {
|
||||
columnConfig: { useKey: true },
|
||||
rowConfig: { useKey: true, isHover: true },
|
||||
columns: [
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{ field: 'store_id', title: '门店ID', width: 90 },
|
||||
{ field: 'store_name', title: '门店', minWidth: 140 },
|
||||
{
|
||||
field: 'vip_badge',
|
||||
title: 'VIP徽标',
|
||||
width: 100,
|
||||
slots: { default: 'vip_badge' },
|
||||
},
|
||||
{ field: 'duration_label', title: '会员类型', width: 110 },
|
||||
{ field: 'level_name', title: '等级', width: 110 },
|
||||
{ field: 'level_code', title: '编码', width: 100 },
|
||||
{ field: 'original_price', title: '原价', width: 90 },
|
||||
{ field: 'pay_amount', title: '实付', width: 90 },
|
||||
{ field: 'open_type_txt', title: '开通方式', width: 110 },
|
||||
{ field: 'operator_name', title: '操作人', width: 110 },
|
||||
{ field: 'expire_at', title: '到期时间', width: 170 },
|
||||
{ field: 'created_at', title: '开通时间', width: 170 },
|
||||
{ field: 'remark', title: '备注', minWidth: 120 },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getVipRecordList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
border: false,
|
||||
toolbarConfig: { search: true, refresh: true, zoom: true },
|
||||
};
|
||||
33
apps/web-antd/src/views/system/vip/record/index.vue
Normal file
33
apps/web-antd/src/views/system/vip/record/index.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* VIP 开通记录列表:组合徽标(等级+丝带)+ 原价/实付
|
||||
*/
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
|
||||
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
defineOptions({ name: 'VipRecord' });
|
||||
|
||||
const [Grid] = useVbenVxeGrid({ formOptions, gridOptions });
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height title="VIP开通记录">
|
||||
<div class="p-4">
|
||||
<Grid>
|
||||
<template #vip_badge="{ row }">
|
||||
<VipBadgeCombo
|
||||
size="md"
|
||||
:badge-url="row.badge_url"
|
||||
:duration-badge-url="row.duration_badge_url"
|
||||
:level-name="row.level_name"
|
||||
:duration-label="row.duration_label"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
19
apps/web-antd/src/views/system/vip/store/api/index.ts
Normal file
19
apps/web-antd/src/views/system/vip/store/api/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'vip-store/';
|
||||
|
||||
export async function getVipStoreList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function getVipStoreInfo(storeId: number) {
|
||||
return requestClient.get<any>(`${prefix}info`, { params: { store_id: storeId } });
|
||||
}
|
||||
|
||||
export async function openVipStore(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}open`, data);
|
||||
}
|
||||
|
||||
export async function getVipDurationPresets() {
|
||||
return requestClient.get<any>(`${prefix}duration-presets`);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 门店 VIP 开通弹窗:选等级、时长预设(含 X 年)、备注;管理员开通实付 0
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
import { getVipLevelOption } from '../../level/api';
|
||||
import { getVipDurationPresets, openVipStore } from '../api';
|
||||
|
||||
const gridApi = ref();
|
||||
const storeId = ref(0);
|
||||
const storeName = ref('');
|
||||
const levelOptions = ref<{ label: string; value: number }[]>([]);
|
||||
const presetOptions = ref<{ label: string; value: string }[]>([]);
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'level_id',
|
||||
label: '会员等级',
|
||||
rules: 'selectRequired',
|
||||
componentProps: { options: [], placeholder: '请选择等级' },
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'duration_preset',
|
||||
label: '开通时长',
|
||||
rules: 'selectRequired',
|
||||
componentProps: { options: [], placeholder: '请选择时长' },
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'custom_years',
|
||||
label: '自定义年数',
|
||||
dependencies: {
|
||||
show: (values: any) => values.duration_preset === 'xy',
|
||||
triggerFields: ['duration_preset'],
|
||||
},
|
||||
componentProps: { min: 1, max: 99, placeholder: '请输入年数' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
componentProps: { type: 'textarea', rows: 2 },
|
||||
defaultValue: '',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const title = computed(() => `开通VIP - ${storeName.value || storeId.value}`);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
if (values.duration_preset === 'xy' && !(Number(values.custom_years) > 0)) {
|
||||
message.warning('请填写有效年数');
|
||||
return;
|
||||
}
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
await openVipStore({
|
||||
store_id: storeId.value,
|
||||
level_id: values.level_id,
|
||||
duration_preset: values.duration_preset,
|
||||
custom_years: values.custom_years || 0,
|
||||
remark: values.remark || '',
|
||||
});
|
||||
message.success('开通成功');
|
||||
gridApi.value?.query();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formApi.resetFields();
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<Record<string, any>>() || {};
|
||||
gridApi.value = data.gridApi;
|
||||
storeId.value = Number(data.store_id || 0);
|
||||
storeName.value = data.store_name || '';
|
||||
const [levels, presets] = await Promise.all([
|
||||
getVipLevelOption(),
|
||||
getVipDurationPresets(),
|
||||
]);
|
||||
levelOptions.value = (levels || []).map((i: any) => ({
|
||||
label: `${i.name}(${i.code})¥${i.price}`,
|
||||
value: i.id,
|
||||
}));
|
||||
presetOptions.value = (presets || []).map((i: any) => ({
|
||||
label: i.label,
|
||||
value: i.value,
|
||||
}));
|
||||
formApi.updateSchema([
|
||||
{
|
||||
fieldName: 'level_id',
|
||||
componentProps: { options: levelOptions.value },
|
||||
},
|
||||
{
|
||||
fieldName: 'duration_preset',
|
||||
componentProps: { options: presetOptions.value },
|
||||
},
|
||||
]);
|
||||
formApi.setValues({ remark: '', custom_years: undefined });
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="title" class="w-[560px]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
21
apps/web-antd/src/views/system/vip/store/config/search.ts
Normal file
21
apps/web-antd/src/views/system/vip/store/config/search.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
export const formOptions: VbenFormProps = {
|
||||
layout: 'inline',
|
||||
showResetButton: true,
|
||||
showSubmitButton: true,
|
||||
schemas: [
|
||||
{
|
||||
fieldName: 'name',
|
||||
component: 'Input',
|
||||
label: '门店名称',
|
||||
componentProps: { placeholder: '门店名称', allowClear: true },
|
||||
},
|
||||
{
|
||||
fieldName: 'level_code',
|
||||
component: 'Input',
|
||||
label: '等级编码',
|
||||
componentProps: { placeholder: '如 V5 / V0', allowClear: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
68
apps/web-antd/src/views/system/vip/store/config/table.ts
Normal file
68
apps/web-antd/src/views/system/vip/store/config/table.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { getVipStoreList } from '../api';
|
||||
|
||||
export const gridOptions: VxeGridProps<any> = {
|
||||
columnConfig: { useKey: true },
|
||||
rowConfig: { useKey: true, isHover: true },
|
||||
columns: [
|
||||
{ field: 'store_id', title: '门店ID', width: 90 },
|
||||
{ field: 'store_name', title: '门店名称', minWidth: 160 },
|
||||
{
|
||||
field: 'vip_badge',
|
||||
title: 'VIP徽标',
|
||||
width: 100,
|
||||
slots: { default: 'vip_badge' },
|
||||
},
|
||||
{
|
||||
field: 'duration_label',
|
||||
title: '会员类型',
|
||||
width: 110,
|
||||
formatter: ({ row }) => row.vip?.duration_label || '-',
|
||||
},
|
||||
{
|
||||
field: 'level_name',
|
||||
title: '会员等级',
|
||||
minWidth: 140,
|
||||
formatter: ({ row }) =>
|
||||
row.vip
|
||||
? `${row.vip.level_name || ''}(${row.vip.level_code || ''})`
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
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())}`;
|
||||
},
|
||||
},
|
||||
{ title: '操作', slots: { default: 'action' }, width: 100, fixed: 'right' },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getVipStoreList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
border: false,
|
||||
toolbarConfig: { search: true, refresh: true, zoom: true },
|
||||
};
|
||||
59
apps/web-antd/src/views/system/vip/store/index.vue
Normal file
59
apps/web-antd/src/views/system/vip/store/index.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 门店 VIP 开通页:复用全局 VipUpgradeModal
|
||||
*/
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
|
||||
import VipUpgradeModal from '#/components/vip/VipUpgradeModal.vue';
|
||||
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
defineOptions({ name: 'VipStore' });
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ formOptions, gridOptions });
|
||||
const [UpgradeModalComp, upgradeModalApi] = useVbenModal({
|
||||
connectedComponent: VipUpgradeModal,
|
||||
});
|
||||
|
||||
const openVip = (row: any) => {
|
||||
upgradeModalApi.setData({
|
||||
store_id: row.store_id,
|
||||
store_name: row.store_name,
|
||||
vip: row.vip,
|
||||
gridApi,
|
||||
});
|
||||
upgradeModalApi.open();
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height title="门店VIP开通">
|
||||
<UpgradeModalComp />
|
||||
<div class="p-4">
|
||||
<Grid>
|
||||
<template #vip_badge="{ row }">
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
@click="openVip(row)"
|
||||
>
|
||||
<VipBadgeCombo
|
||||
size="md"
|
||||
:badge-url="row.vip?.badge_url"
|
||||
:duration-badge-url="row.vip?.duration_badge_url"
|
||||
:level-name="row.vip?.level_name"
|
||||
:duration-label="row.vip?.duration_label"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[{ label: '开通/升级', onClick: () => openVip(row) }]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
Reference in New Issue
Block a user