feat: 修复部分功能(健康信息、VIP、医生端健康信息导入)

This commit is contained in:
李琦
2026-08-08 10:33:49 +08:00
parent 7a504433da
commit 396a6e5b8b
15 changed files with 1150 additions and 79 deletions

View File

@@ -22,3 +22,17 @@ export async function updateVipFeature(data: Record<string, any>) {
export async function deleteVipFeature(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}
/**
* 功能反绑会员等级
* @param data.id 功能 id
* @param data.code 功能编码(可选,有 id 即可)
* @param data.level_ids 勾选的等级 id 列表
*/
export async function bindVipFeatureLevels(data: {
id: number;
code?: string;
level_ids: number[];
}) {
return requestClient.post<any>(`${prefix}bind-levels`, data);
}

View File

@@ -0,0 +1,281 @@
<script lang="ts" setup>
/**
* VIP 功能反绑会员等级:卡片勾选 + 徽章图 + 全选/取消全选/反选
* 真相源仍是 xk_vip_level.permissions
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Empty, message } from 'ant-design-vue';
import { getVipLevelOption } from '../../level/api';
import { bindVipFeatureLevels } from '../api';
type LevelCard = {
id: number;
name: string;
code: string;
badge_url: string;
weight: number;
price: number;
};
const feature = ref<Record<string, any>>({});
const levelOptions = ref<LevelCard[]>([]);
const selectedIds = ref<number[]>([]);
const loadingOptions = ref(false);
const onSaved = ref<null | (() => void)>(null);
const selectedSet = computed(() => new Set(selectedIds.value));
const selectedCount = computed(() => selectedIds.value.length);
/** 是否已选中某等级 */
function isSelected(id: number) {
return selectedSet.value.has(id);
}
/**
* 点击卡片切换选中(不用 CheckboxGroup,避免嵌套点击问题)
*/
function toggleLevel(id: number) {
const set = new Set(selectedIds.value);
if (set.has(id)) {
set.delete(id);
} else {
set.add(id);
}
selectedIds.value = Array.from(set);
}
/** 全选当前列表中的等级 */
function selectAll() {
selectedIds.value = levelOptions.value.map((row) => row.id);
}
/** 取消全选 */
function clearAll() {
selectedIds.value = [];
}
/** 反选 */
function invertAll() {
const set = new Set(selectedIds.value);
selectedIds.value = levelOptions.value
.map((row) => row.id)
.filter((id) => !set.has(id));
}
/**
* 拉取启用中的等级选项(含徽章)
*/
async function loadLevelOptions() {
loadingOptions.value = true;
try {
const rows = await getVipLevelOption();
const list = Array.isArray(rows) ? rows : [];
levelOptions.value = list
.map((row: any) => ({
id: Number(row.id),
name: String(row.name || row.code || ''),
code: String(row.code || ''),
badge_url: String(row.badge_url || ''),
weight: Number(row.level_weight || 0),
price: Number(row.price || 0),
}))
.filter((row) => row.id > 0)
.sort((a, b) => a.weight - b.weight || a.id - b.id);
} finally {
loadingOptions.value = false;
}
}
const [Modal, modalApi] = useVbenModal({
title: '绑定会员等级',
class: 'w-[780px]',
draggable: true,
confirmText: '保存绑定',
async onConfirm() {
const id = Number(feature.value.id || 0);
if (!id) {
message.warning('功能无效');
return;
}
modalApi.setState({ confirmLoading: true });
try {
await bindVipFeatureLevels({
id,
code: feature.value.code,
level_ids: selectedIds.value.slice(),
});
message.success('绑定已更新(低档勾选会自动补齐更高档)');
onSaved.value?.();
modalApi.close();
} finally {
modalApi.setState({ confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
feature.value = {};
selectedIds.value = [];
onSaved.value = null;
return;
}
const data = modalApi.getData<{
feature?: Record<string, any>;
onSaved?: () => void;
}>();
feature.value = data?.feature || {};
onSaved.value = data?.onSaved || null;
await loadLevelOptions();
const ids = Array.isArray(feature.value.bound_level_ids)
? feature.value.bound_level_ids.map((x: any) => Number(x)).filter(Boolean)
: (feature.value.bound_levels || []).map((x: any) => Number(x.id));
selectedIds.value = ids;
modalApi.setState({
title: feature.value.name
? `绑定会员等级 - ${feature.value.name}`
: '绑定会员等级',
});
},
});
</script>
<template>
<Modal>
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
功能编码:
<span class="font-medium text-[hsl(var(--foreground))]">{{
feature.code || '—'
}}</span>
。点击卡片勾选/取消;勾选较低档时,更高档会自动补齐。
</div>
<div class="mb-3 flex flex-wrap items-center gap-2">
<Button size="small" @click="selectAll">全选</Button>
<Button size="small" @click="clearAll">取消全选</Button>
<Button size="small" @click="invertAll">反选</Button>
<span class="ml-auto text-xs text-[hsl(var(--muted-foreground))]">
已选 {{ selectedCount }} / {{ levelOptions.length }}
</span>
</div>
<Empty
v-if="!loadingOptions && !levelOptions.length"
description="暂无启用中的会员等级"
/>
<div
v-else
class="grid max-h-[420px] grid-cols-2 gap-3 overflow-y-auto pr-1 md:grid-cols-3"
>
<div
v-for="opt in levelOptions"
:key="opt.id"
class="vip-level-card"
:class="{ 'vip-level-card--on': isSelected(opt.id) }"
@click="toggleLevel(opt.id)"
>
<div class="vip-level-card__check">
{{ isSelected(opt.id) ? '✓' : '' }}
</div>
<div class="vip-level-card__badge">
<img
v-if="opt.badge_url"
:src="opt.badge_url"
:alt="opt.name"
class="vip-level-card__img"
/>
<div v-else class="vip-level-card__placeholder">
{{ (opt.name || opt.code || '?').slice(0, 1) }}
</div>
</div>
<div class="vip-level-card__name">{{ opt.name }}</div>
<div class="vip-level-card__meta">
{{ opt.code }} · 权重 {{ opt.weight }}
</div>
</div>
</div>
</Modal>
</template>
<style scoped>
.vip-level-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 14px 10px 12px;
border: 1px solid hsl(var(--border));
border-radius: 10px;
background: hsl(var(--card, var(--background)));
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
user-select: none;
}
.vip-level-card:hover {
border-color: hsl(var(--primary) / 0.45);
}
.vip-level-card--on {
border-color: hsl(var(--primary));
background: hsl(var(--primary) / 0.08);
box-shadow: 0 0 0 1px hsl(var(--primary) / 0.25);
}
.vip-level-card__check {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
color: hsl(var(--primary));
font-size: 12px;
line-height: 18px;
text-align: center;
font-weight: 700;
}
.vip-level-card--on .vip-level-card__check {
border-color: hsl(var(--primary));
background: hsl(var(--primary));
color: #fff;
}
.vip-level-card__badge {
width: 72px;
height: 72px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
.vip-level-card__img {
max-width: 72px;
max-height: 72px;
object-fit: contain;
}
.vip-level-card__placeholder {
width: 56px;
height: 56px;
border-radius: 50%;
background: hsl(var(--muted) / 0.5);
color: hsl(var(--muted-foreground));
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 600;
}
.vip-level-card__name {
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
text-align: center;
}
.vip-level-card__meta {
margin-top: 4px;
font-size: 11px;
color: hsl(var(--muted-foreground));
text-align: center;
}
</style>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
/**
* VIP 功能字典列表:供等级 permissions 勾选的功能码维护
* VIP 功能字典列表:维护功能码,并支持反绑「开放给哪些会员等级」
*/
import { onMounted, reactive, ref } from 'vue';
@@ -20,6 +20,7 @@ import {
import { TableAction } from '#/components/table-action';
import { deleteVipFeature, getVipFeatureList } from './api';
import BindLevelsModal from './components/bind-levels-modal.vue';
import FormModal from './components/modal.vue';
import { STATUS_OPTIONS } from './config/constants';
@@ -38,6 +39,9 @@ const filters = reactive({
const [FormModalComp, formModalApi] = useVbenModal({
connectedComponent: FormModal,
});
const [BindLevelsModalComp, bindLevelsModalApi] = useVbenModal({
connectedComponent: BindLevelsModal,
});
async function loadList() {
loading.value = true;
@@ -77,6 +81,15 @@ const showModal = (data: any = {}, isUpdate = false) => {
formModalApi.open();
};
/** 打开反绑等级弹窗 */
function openBindLevels(item: Record<string, any>) {
bindLevelsModalApi.setData({
feature: item,
onSaved: loadList,
});
bindLevelsModalApi.open();
}
const handleDelete = (id: number) => {
deleteVipFeature({ ids: [id] }).then(() => {
message.success('删除成功');
@@ -89,6 +102,7 @@ onMounted(loadList);
<template>
<Page auto-content-height title="VIP功能字典">
<FormModalComp />
<BindLevelsModalComp />
<div class="p-4">
<div class="mb-4 flex flex-wrap items-center gap-3">
<Input
@@ -135,6 +149,7 @@ onMounted(loadList);
<template #extra>
<TableAction
:actions="[
{ label: '绑定等级', onClick: () => openBindLevels(item) },
{ label: '编辑', onClick: () => showModal(item, true) },
{
label: '删除',
@@ -147,10 +162,38 @@ onMounted(loadList);
]"
/>
</template>
<div class="text-sm text-gray-600">
<div class="text-sm text-[hsl(var(--muted-foreground))]">
<div>编码:{{ item.code }}</div>
<div class="mt-1">说明:{{ item.description || '-' }}</div>
<div class="mt-1">排序:{{ item.sort }}</div>
<div class="mt-2">
<div class="mb-1">已绑等级:</div>
<div
v-if="item.bound_levels && item.bound_levels.length"
class="flex flex-wrap gap-2"
>
<div
v-for="lv in item.bound_levels"
:key="lv.id"
class="bound-vip-chip"
:title="lv.name || lv.code"
>
<img
v-if="lv.badge_url"
:src="lv.badge_url"
:alt="lv.name || lv.code"
class="bound-vip-chip__img"
/>
<span v-else class="bound-vip-chip__fallback">{{
(lv.name || lv.code || '?').slice(0, 1)
}}</span>
<span class="bound-vip-chip__name">{{
lv.name || lv.code
}}</span>
</div>
</div>
<span v-else class="text-xs">未绑定任何会员等级</span>
</div>
</div>
</Card>
</div>
@@ -167,3 +210,40 @@ onMounted(loadList);
</div>
</Page>
</template>
<style scoped>
.bound-vip-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px 4px 4px;
border-radius: 8px;
border: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.25);
}
.bound-vip-chip__img {
width: 28px;
height: 28px;
object-fit: contain;
}
.bound-vip-chip__fallback {
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
background: hsl(var(--muted) / 0.5);
color: hsl(var(--muted-foreground));
}
.bound-vip-chip__name {
font-size: 12px;
color: hsl(var(--foreground));
max-width: 88px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>