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

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

View File

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

View File

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

View File

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

View 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);
}

View File

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

View 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,
};

View 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 },
],
},
},
],
};

View 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 },
};

View 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>

View 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);
}

View File

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

View 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 },
];

View 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,
};

View 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,
},
},
],
};

View 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' },
},
};

View 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>

View 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 });
}

View 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 },
],
},
},
],
};

View 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 },
};

View 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>

View 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`);
}

View File

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

View 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 },
},
],
};

View 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 },
};

View 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>