feat: 修复部分功能(健康信息、VIP、医生端健康信息导入)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user