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

@@ -107,6 +107,37 @@ alwaysApply: true
- 上一部分代码和下一部分代码中间空行不超过 2 行
## VIP 功能判断PC 管理端强制)
- **判断方法**:统一用 `#/utils/vip` 的 `hasVipPermission` / `useVipPermission` / `VIP_FEATURE` 常量
- **显隐组件**:模板包一层 `<VipGate code="medical_record" :vip="receptionVip">``#/components/vip/VipGate.vue`
- 接诊/复诊/开方:**必须传履约诊所 vip**`get-current-store-type` 的 `vip`),禁止只靠登录态判断门店权益
- 显式传 `null` 表示「尚无履约诊所权益」,**不会**回落登录态;不传第二参才用 `userInfo.vip`
- 禁止在业务页再手写 `permissions.indexOf('medical_record')` 或复制一套 VIP 拉取逻辑
## 暗色模式适配PC 管理端强制)
后台支持亮/暗主题切换,**新增或改样式时必须同时适配暗色**,禁止只按白底写死颜色。
### 优先用法(推荐)
- 颜色、边框、背景一律用主题 CSS 变量,例如:
- 文字:`hsl(var(--foreground))` / `hsl(var(--muted-foreground))`
- 背景:`hsl(var(--background))` / `hsl(var(--card, var(--background)))` / `hsl(var(--muted) / 0.25)`
- 边框:`hsl(var(--border))`
- 强调/选中:`hsl(var(--primary))`、`hsl(var(--primary) / 0.1)`
- 警告:`hsl(var(--warning))`、`hsl(var(--warning) / 0.12)`(参考 `AiDisclaimerBanner.vue`
- **禁止**业务样式写死 `#fff`、`#000`、`#0f172a`、`#f8fafc`、`#64748b` 等仅适合亮色的色值
- **禁止**用 `:global(.dark)` 改全局变量以免污染整站配色
- 优先「一套变量样式自动跟主题」,避免再写一套 `.dark .xxx { ... }`;仅当变量无法表达(如图片反色、特殊阴影)才用局部 `.dark .xxx`(且必须 `scoped`
### 自检清单
- 弹窗 / 抽屉 / 卡片 / 预览对照区:在暗色下背景、文字、边框对比度可读
- 选中态、成功/导入高亮:用 `--primary` 透明度,不要写死浅绿底 `#f0faf8`
- 表格空态、分割线、次要文案:用 `--muted` / `--muted-foreground` / `--border`
- 改完后切换暗色主题肉眼过一遍,不要只测亮色
## Vben 组件使用规范
### VbenModal来自 @vben/common-ui

View File

@@ -1,20 +1,30 @@
<script lang="ts" setup>
/**
* VIP 权限显隐口子组件
* 用法:<VipGate code="xxx" :min-level="'V5'">内容</VipGate>
* 后续业务权益接入时直接包一层即可;当前从 userInfo.vip 判断
* VIP 权限显隐全局组件
* 用法:
* <VipGate code="medical_record">有权限才渲染</VipGate>
* <VipGate :code="VIP_FEATURE.AI_PRESCRIPTION" :vip="receptionVip">...</VipGate>
* 接诊/复诊务必传 :vip="履约诊所vip",不要只靠登录态
*/
import { computed } from 'vue';
import { hasVipPermission, levelAtLeast } from '#/utils/vip';
import {
hasVipPermission,
levelAtLeast,
type StoreVipInfo,
} from '#/utils/vip';
const props = withDefaults(
defineProps<{
/** 权限码(与等级 permissions 对齐) */
/** 权限码(与 xk_vip_feature.code / 等级 permissions 对齐) */
code?: string;
/** 最低等级编码 */
minLevel?: string;
/** 无权限时是否渲染占位(默认不渲染) */
/**
* 指定 VIP 来源(挂号履约诊所);不传则用登录态 userInfo.vip
*/
vip?: StoreVipInfo | string[] | null;
/** 无权限时是否渲染 fallback 插槽(默认不渲染) */
fallback?: boolean;
}>(),
{
@@ -26,15 +36,20 @@ const props = withDefaults(
const allowed = computed(() => {
if (props.code) {
// 传了 :vip 则按履约诊所判;未传则回落登录态
if (props.vip !== undefined) {
return hasVipPermission(props.code, props.vip);
}
return hasVipPermission(props.code);
}
if (props.minLevel) {
return levelAtLeast(props.minLevel);
const vipObj = Array.isArray(props.vip) ? null : (props.vip ?? null);
return levelAtLeast(props.minLevel, vipObj);
}
// 未指定条件时默认展示,方便后续逐步接入
return true;
});
</script>
<template>
<slot v-if="allowed" />
<slot v-else-if="fallback" name="fallback" />

View File

@@ -336,6 +336,14 @@ const orderColumns = [
bordered
size="small"
>
<Descriptions.Item label="现病史">
{{
historyText(
profile.health_inquiry.present_status,
profile.health_inquiry.present_history,
)
}}
</Descriptions.Item>
<Descriptions.Item label="既往史">
{{
historyText(

View File

@@ -1,9 +1,23 @@
/**
* VIP 权限判断工具(前端口子
* 默认登录态 userInfo.vip 读取;在线复诊等场景可传入挂号履约诊所的 vip
* VIP 权限判断(全局方法
* - 默认登录态 userInfo.vip
* - 接诊/复诊等场景务必传入「履约诊所」vip避免用错门店权益
* - 模板显隐优先用 <VipGate code="xxx" :vip="storeVip" />
*/
import { computed, type ComputedRef, type Ref } from 'vue';
import { useUserStore } from '@vben/stores';
/** 与 xk_vip_feature.code / 等级 permissions 对齐的常用功能码 */
export const VIP_FEATURE = {
MEDICAL_RECORD: 'medical_record',
AI_MEDICAL_RECORD: 'ai_medical_record',
AI_PRESCRIPTION: 'ai_prescription',
GOLDEN_FORMULA: 'golden_formula',
} as const;
export type VipFeatureCode = (typeof VIP_FEATURE)[keyof typeof VIP_FEATURE] | string;
export interface StoreVipInfo {
level_code?: string;
level_name?: string;
@@ -24,29 +38,56 @@ export function getCurrentVip(): StoreVipInfo | null {
}
/**
* 是否拥有指定权限码
* @param code 权限码
* @param vipOrPermissions 可选:挂号侧 vip 对象,或 permissions 字符串数组;不传则用登录态
* 从 get-current-store-type / 同类接口响应中取出 vip 对象
* 兼容 result / data / 已解包
*/
export function pickVipFromApiPayload(payload: unknown): StoreVipInfo | null {
if (!payload || typeof payload !== 'object') return null;
const raw = payload as Record<string, any>;
const data = raw.result ?? raw.data ?? raw;
if (!data || typeof data !== 'object') return null;
const vip = (data as any).vip;
return vip && typeof vip === 'object' ? (vip as StoreVipInfo) : null;
}
/** 规范化 permissions 列表 */
export function normalizeVipPermissions(
vipOrPermissions?: StoreVipInfo | string[] | null,
): string[] {
if (Array.isArray(vipOrPermissions)) {
return vipOrPermissions.filter((x) => typeof x === 'string' && x);
}
if (vipOrPermissions && typeof vipOrPermissions === 'object') {
const list = vipOrPermissions.permissions;
return Array.isArray(list) ? list.filter((x) => typeof x === 'string' && x) : [];
}
const cur = getCurrentVip()?.permissions;
return Array.isArray(cur) ? cur.filter((x) => typeof x === 'string' && x) : [];
}
/**
* 是否拥有指定权限码(全局判断方法)
* @param code 权限码,如 VIP_FEATURE.MEDICAL_RECORD
* @param vipOrPermissions 挂号侧 vip / permissions
* - 不传:用登录态 userInfo.vip
* - 显式传 null视为无履约诊所权益不回落登录态避免接诊未就绪时误放行
*/
export function hasVipPermission(
code: string,
vipOrPermissions?: StoreVipInfo | string[] | null,
): boolean {
if (!code) return false;
let list: string[] | undefined;
if (Array.isArray(vipOrPermissions)) {
list = vipOrPermissions;
} else if (vipOrPermissions && typeof vipOrPermissions === 'object') {
list = vipOrPermissions.permissions;
} else {
list = getCurrentVip()?.permissions;
if (arguments.length < 2) {
return normalizeVipPermissions().includes(code);
}
return Array.isArray(list) && list.includes(code);
if (vipOrPermissions == null) {
return false;
}
return normalizeVipPermissions(vipOrPermissions).includes(code);
}
/**
* 等级是否不低于目标(按 level_weight若无 weight 则仅精确匹配编码时返回 true
* 简化:仅当当前 level_code 与 minCode 相同,或 weight 足够时通过
*/
export function levelAtLeast(minCode: string, vip?: StoreVipInfo | null): boolean {
const current = vip ?? getCurrentVip();
@@ -70,11 +111,36 @@ function parseLevelWeight(code: string): number {
return -1;
}
/** 组合式:供组件调用 */
export function useVipGate() {
/**
* 组合式:基于可选的门店 vip ref 做权限判断(接诊台推荐)
* @example
* const { has, canUseMedicalRecord } = useVipPermission(receptionVip)
* const ok = has(VIP_FEATURE.MEDICAL_RECORD)
*/
export function useVipPermission(
vipSource?: Ref<StoreVipInfo | null | undefined> | ComputedRef<StoreVipInfo | null | undefined>,
) {
/** 传入了 vipSource 时始终走履约诊所分支(含 null=无权益) */
const has = (code: string) => {
if (vipSource) {
return hasVipPermission(code, vipSource.value ?? null);
}
return hasVipPermission(code);
};
return {
vip: getCurrentVip(),
hasVipPermission,
levelAtLeast,
vip: computed(() => (vipSource ? vipSource.value : getCurrentVip())),
has,
hasVipPermission: has,
levelAtLeast: (minCode: string) =>
levelAtLeast(minCode, vipSource ? vipSource.value ?? null : getCurrentVip()),
canUseMedicalRecord: computed(() => has(VIP_FEATURE.MEDICAL_RECORD)),
canUseAiMedicalRecord: computed(() => has(VIP_FEATURE.AI_MEDICAL_RECORD)),
canUseAiPrescription: computed(() => has(VIP_FEATURE.AI_PRESCRIPTION)),
canUseGoldenFormula: computed(() => has(VIP_FEATURE.GOLDEN_FORMULA)),
};
}
/** @deprecated 请用 useVipPermission保留兼容旧调用 */
export function useVipGate() {
return useVipPermission();
}

View File

@@ -44,7 +44,11 @@ import {
saveWestCommonPrescriptionApi,
} from '#/views/business/chat/api';
import { usePrescriptionStore } from '#/store/prescription';
import { hasVipPermission } from '#/utils/vip';
import {
hasVipPermission,
useVipPermission,
VIP_FEATURE,
} from '#/utils/vip';
import MedicalRecordPanel from '#/views/doctor/medical-record/MedicalRecordPanel.vue';
import EntryKeywordBubble from '#/views/doctor/medical-record/components/EntryKeywordBubble.vue';
import CommonDxOrderChips from '#/views/doctor/medical-record/components/CommonDxOrderChips.vue';
@@ -98,11 +102,20 @@ const registerSideVip = computed(
() => prescriptionStore.registerStoreInfo?.vip ?? null,
);
/** 履约诊所 VIP 能力(金方 / AI 等) */
const {
canUseAiMedicalRecord,
canUseAiPrescription,
canUseGoldenFormula,
} = useVipPermission(registerSideVip);
/**
* 仅 chat-shop非诊所前缀且门店 VIP 含 medical_record 时显示病历 Tab
* 病历 Tab履约诊所 medical_record + 弹窗场景开关(诊所前缀默认关)
*/
const canUseMedicalRecord = computed(() => {
if (!hasVipPermission('medical_record', registerSideVip.value)) return false;
if (!hasVipPermission(VIP_FEATURE.MEDICAL_RECORD, registerSideVip.value)) {
return false;
}
if (modalData.value?.enableMedicalRecord === false) return false;
if (modalData.value?.enableMedicalRecord === true) return true;
const prefix = modalData.value?.storagePrefix || 'onlineConsultation-';
@@ -186,19 +199,6 @@ const allowInsuranceCategory = computed(
const canUseCommonPrescription = computed(() =>
[1, 2].includes(prescriptionStore.activeCategory),
);
/** 金方导入 VIP优先挂号履约诊所 vip */
const canUseGoldenFormula = computed(() =>
hasVipPermission('golden_formula', registerSideVip.value),
);
/** AI 辅助出方 VIP */
const canUseAiPrescription = computed(() =>
hasVipPermission('ai_prescription', registerSideVip.value),
);
/** AI 写病历 VIP */
const canUseAiMedicalRecord = computed(() =>
hasVipPermission('ai_medical_record', registerSideVip.value),
);
const aiPrescriptionDrawerRef = ref<InstanceType<typeof AiPrescriptionDrawer> | null>(null);
/** 按字典 id 取展示名(提交仍存旧字段 id */
@@ -929,12 +929,24 @@ const [PatientRxHistoryDrawerComp, patientRxHistoryDrawerApi] = useVbenDrawer({
connectedComponent: PatientPrescriptionHistoryDrawer,
});
/** 健康档案史写入病历草稿并切到病历 Tab */
function importHealthHistoryToMedicalRecord(fields: Record<string, string>) {
if (!canUseMedicalRecord.value) {
message.warning('未开通病历功能');
return;
}
rxMrTab.value = 'mr';
medicalRecordPanelRef.value?.patchFields?.(fields || {});
}
function openPatientInfoDrawer() {
patientInfoDrawerApi.setData({
patient: prescriptionStore.activePatient,
register: prescriptionStore.patientInfo,
healthInquiry: prescriptionStore.userPatientHealthInquiry,
patientName: prescriptionStore.activePatient?.name,
canImportMedicalRecord: canUseMedicalRecord.value,
onImportHistory: importHealthHistoryToMedicalRecord,
});
patientInfoDrawerApi.open();
}
@@ -2212,6 +2224,7 @@ const cancelSaveCommonPrescription = () => {
:patient-sex="Number(prescriptionStore.activePatient?.sex || 0)"
:patient-age="Number(prescriptionStore.activePatient?.age || 0)"
:storage-prefix="medicalRecordStoragePrefix"
:store-vip="registerSideVip"
:show-toolbar="false"
@sync-to-prescription="onMrSyncToPrescription"
@apply-tcm-ids="onApplyAiTcmIds"

View File

@@ -6,9 +6,10 @@
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { Descriptions, Empty, Tag } from 'ant-design-vue';
import { Button, Descriptions, Empty, Modal, Tag, message } from 'ant-design-vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { mapHealthInquiryToMrHistory } from '#/views/doctor/medical-record/utils/mapHealthInquiryToMrHistory';
defineOptions({ name: 'PatientInfoDrawer' });
@@ -17,6 +18,10 @@ type DrawerData = {
register?: Record<string, any> | null;
healthInquiry?: Record<string, any> | null;
patientName?: string;
/** 门店开通病历 VIP 时展示「导入到病历」 */
canImportMedicalRecord?: boolean;
/** 父级回写病历草稿(不落库) */
onImportHistory?: (fields: Record<string, string>) => void;
};
const meta = ref<DrawerData>({});
@@ -28,12 +33,55 @@ const sexText = computed(() => {
return '未填写';
});
const patientSex = computed(() => Number(meta.value.patient?.sex || 0));
const patientAge = computed(() => Number(meta.value.patient?.age || 0));
/** 月经史仅女;婚育史男>22 或 女>20与病历显隐一致 */
const showMenstrual = computed(() => patientSex.value === 2);
const showMarital = computed(
() =>
(patientSex.value === 1 && patientAge.value > 22) ||
(patientSex.value === 2 && patientAge.value > 20),
);
/** 逗号分隔标签拆分(与接诊页 splitString 一致) */
function splitString(str: unknown): string[] {
if (!str || typeof str !== 'string') return [];
return str.split(/[,]/).map((s) => s.trim()).filter(Boolean);
}
/** 史字段status=0 无;=1 有并展示内容 */
function historyStatusText(status: unknown): string {
return Number(status) === 1 ? '有' : '无';
}
const canImportMedicalRecord = computed(
() => !!meta.value.canImportMedicalRecord && !!meta.value.healthInquiry,
);
/**
* 确认后把档案史写入病历草稿,由父级 patchFields + 切病历 Tab
*/
function handleImportToMedicalRecord() {
if (!canImportMedicalRecord.value) return;
Modal.confirm({
title: '导入到病历',
content:
'将用当前健康档案中的病史覆盖病历对应字段(既往/过敏/家族/流行病学/个人/月经/婚育等),导入后请检查并保存病历。',
okText: '确认导入',
cancelText: '取消',
onOk() {
const fields = mapHealthInquiryToMrHistory(meta.value.healthInquiry, {
sex: patientSex.value,
age: patientAge.value,
});
meta.value.onImportHistory?.(fields);
message.success('已导入到病历草稿');
drawerApi.close();
},
});
}
const [Drawer, drawerApi] = useVbenDrawer({
class: 'w-[720px]',
title: '患者信息',
@@ -91,12 +139,25 @@ defineExpose({ drawerApi });
</Descriptions>
<Empty v-else description="暂无患者信息" class="py-6" />
<div
v-if="meta.healthInquiry"
class="mb-2 flex items-center justify-between"
>
<span class="text-sm font-medium">健康信息</span>
<Button
v-if="canImportMedicalRecord"
type="primary"
size="small"
@click="handleImportToMedicalRecord"
>
导入到病历
</Button>
</div>
<Descriptions
v-if="meta.healthInquiry"
:column="2"
bordered
size="small"
title="健康信息"
class="mb-4"
>
<Descriptions.Item label="肝功能">
@@ -127,19 +188,21 @@ defineExpose({ drawerApi });
</Tag>
</div>
</Descriptions.Item>
<Descriptions.Item label="现病史" :span="2">
<span>{{ historyStatusText(meta.healthInquiry.present_status) }}</span>
<p v-if="Number(meta.healthInquiry.present_status) === 1" class="mt-2 mb-0">
{{ meta.healthInquiry.present_history || '' }}
</p>
</Descriptions.Item>
<Descriptions.Item label="既往史" :span="2">
<span>{{
meta.healthInquiry.person_status === 0 ? '无' : '有'
}}</span>
<p v-if="meta.healthInquiry.person_status === 1" class="mt-2 mb-0">
<span>{{ historyStatusText(meta.healthInquiry.person_status) }}</span>
<p v-if="Number(meta.healthInquiry.person_status) === 1" class="mt-2 mb-0">
{{ meta.healthInquiry.person_history || '' }}
</p>
</Descriptions.Item>
<Descriptions.Item label="过敏史" :span="2">
<span>{{
meta.healthInquiry.allergic_status === 0 ? '无' : '有'
}}</span>
<div v-if="meta.healthInquiry.allergic_status === 1" class="mt-2">
<span>{{ historyStatusText(meta.healthInquiry.allergic_status) }}</span>
<div v-if="Number(meta.healthInquiry.allergic_status) === 1" class="mt-2">
<Tag
v-for="(item, idx) in splitString(
meta.healthInquiry.allergic_history,
@@ -152,13 +215,35 @@ defineExpose({ drawerApi });
</div>
</Descriptions.Item>
<Descriptions.Item label="家庭遗传史" :span="2">
<span>{{
meta.healthInquiry.family_status === 0 ? '无' : '有'
}}</span>
<p v-if="meta.healthInquiry.family_status === 1" class="mt-2 mb-0">
<span>{{ historyStatusText(meta.healthInquiry.family_status) }}</span>
<p v-if="Number(meta.healthInquiry.family_status) === 1" class="mt-2 mb-0">
{{ meta.healthInquiry.family_history || '—' }}
</p>
</Descriptions.Item>
<Descriptions.Item label="流行病学史" :span="2">
<span>{{ historyStatusText(meta.healthInquiry.epidemic_status) }}</span>
<p v-if="Number(meta.healthInquiry.epidemic_status) === 1" class="mt-2 mb-0">
{{ meta.healthInquiry.epidemic_history || '—' }}
</p>
</Descriptions.Item>
<Descriptions.Item label="个人史" :span="2">
<span>{{ historyStatusText(meta.healthInquiry.personal_status) }}</span>
<p v-if="Number(meta.healthInquiry.personal_status) === 1" class="mt-2 mb-0">
{{ meta.healthInquiry.personal_history || '—' }}
</p>
</Descriptions.Item>
<Descriptions.Item v-if="showMenstrual" label="月经史" :span="2">
<span>{{ historyStatusText(meta.healthInquiry.menstrual_status) }}</span>
<p v-if="Number(meta.healthInquiry.menstrual_status) === 1" class="mt-2 mb-0">
{{ meta.healthInquiry.menstrual_history || '—' }}
</p>
</Descriptions.Item>
<Descriptions.Item v-if="showMarital" label="婚育史" :span="2">
<span>{{ historyStatusText(meta.healthInquiry.marital_status) }}</span>
<p v-if="Number(meta.healthInquiry.marital_status) === 1" class="mt-2 mb-0">
{{ meta.healthInquiry.marital_history || '—' }}
</p>
</Descriptions.Item>
</Descriptions>
<Empty
v-else

View File

@@ -46,7 +46,10 @@ import {
import {debounce} from 'lodash-es'; // 或者使用自定义防抖函数
import {getRegisterStatus} from '#/util/tool';
import { hasVipPermission, type StoreVipInfo } from '#/utils/vip';
import {
useVipPermission,
type StoreVipInfo,
} from '#/utils/vip';
import MedicalRecordPanel from '#/views/doctor/medical-record/MedicalRecordPanel.vue';
import EntryKeywordBubble from '#/views/doctor/medical-record/components/EntryKeywordBubble.vue';
import CommonDxOrderChips from '#/views/doctor/medical-record/components/CommonDxOrderChips.vue';
@@ -167,12 +170,22 @@ interface UserPatientHealthInquiry {
renal_function: number;
liver_index: string;
renal_index: string;
present_status?: number;
present_history?: string;
person_history: string;
allergic_history: string;
person_status: number;
allergic_status: number;
family_status: number;
family_history: number;
family_history: string;
epidemic_status?: number;
epidemic_history?: string;
personal_status?: number;
personal_history?: string;
menstrual_status?: number;
menstrual_history?: string;
marital_status?: number;
marital_history?: string;
is_delete: number;
}
@@ -182,21 +195,13 @@ const rxMrTab = ref<'rx' | 'mr'>('rx');
const medicalRecordPanelRef = ref<InstanceType<typeof MedicalRecordPanel> | null>(null);
/** 当前接诊挂号对应履约诊所 VIP来自 getCurrentStoreType */
const receptionVip = ref<StoreVipInfo | null>(null);
const canUseMedicalRecord = computed(() =>
hasVipPermission('medical_record', receptionVip.value),
);
/** AI 辅助出方 VIP */
const canUseAiPrescription = computed(() =>
hasVipPermission('ai_prescription', receptionVip.value),
);
/** AI 写病历 VIP */
const canUseAiMedicalRecord = computed(() =>
hasVipPermission('ai_medical_record', receptionVip.value),
);
/** 金方导入 VIP */
const canUseGoldenFormula = computed(() =>
hasVipPermission('golden_formula', receptionVip.value),
);
/** 统一用全局 useVipPermission避免各处手写 hasVipPermission 字符串 */
const {
canUseMedicalRecord,
canUseAiMedicalRecord,
canUseAiPrescription,
canUseGoldenFormula,
} = useVipPermission(receptionVip);
watch(rxMrTab, (tab) => {
const rid = getRegisterId();
@@ -2303,6 +2308,18 @@ const [PatientRxHistoryDrawerComp, patientRxHistoryDrawerApi] = useVbenDrawer({
connectedComponent: PatientPrescriptionHistoryDrawer,
});
/**
* 患者信息抽屉:健康档案导入病历(有 medical_record VIP 时)
*/
function importHealthHistoryToMedicalRecord(fields: Record<string, string>) {
if (!canUseMedicalRecord.value) {
message.warning('未开通病历功能');
return;
}
rxMrTab.value = 'mr';
medicalRecordPanelRef.value?.patchFields?.(fields || {});
}
/** 打开患者信息抽屉 */
function openPatientInfoDrawer() {
patientInfoDrawerApi.setData({
@@ -2310,6 +2327,8 @@ function openPatientInfoDrawer() {
register: patientInfo.value,
healthInquiry: userPatientHealthInquiry.value,
patientName: activePatient.value?.name,
canImportMedicalRecord: canUseMedicalRecord.value,
onImportHistory: importHealthHistoryToMedicalRecord,
});
patientInfoDrawerApi.open();
}
@@ -4115,6 +4134,7 @@ function onStoreSelectOpenChange(open: boolean) {
:patient-sex="Number(activePatient?.sex || 0)"
:patient-age="Number(activePatient?.age || 0)"
:patient-name="String(activePatient?.name || '')"
:store-vip="receptionVip"
:show-toolbar="false"
/>
</div>

View File

@@ -24,7 +24,11 @@ import {
} from '@ant-design/icons-vue';
import { getSystemConfigByKeys } from '#/views/system/system-config/api';
import { hasVipPermission } from '#/utils/vip';
import {
hasVipPermission,
VIP_FEATURE,
type StoreVipInfo,
} from '#/utils/vip';
import {
clearMedicalRecord,
@@ -38,6 +42,9 @@ import CommonDxOrderChips from './components/CommonDxOrderChips.vue';
import PatientMedicalRecordDrawer from './components/PatientMedicalRecordDrawer.vue';
import TextareaExpandModal from './components/TextareaExpandModal.vue';
import AiMedicalRecordModal from './components/AiMedicalRecordModal.vue';
import HistoryImportModal, {
type HistoryImportDiffItem,
} from './components/HistoryImportModal.vue';
import { emptyMedicalRecord, isHistoryFieldVisible } from './config/constants';
import {
clearMedicalRecordDraft,
@@ -67,6 +74,10 @@ const props = withDefaults(
patientAge?: number;
/** 就诊人姓名AI 写病历确认用) */
patientName?: string;
/**
* 履约诊所 VIP接诊/复诊传入);不传则回落登录态
*/
storeVip?: StoreVipInfo | null;
/**
* 是否在面板内展示操作工具栏
* 接诊台/弹窗已把按钮提到一级 Tab 下吸顶工具栏时传 false
@@ -81,10 +92,19 @@ const props = withDefaults(
patientSex: 0,
patientAge: 0,
patientName: '',
storeVip: undefined,
showToolbar: true,
},
);
/** 按履约诊所或登录态判断 VIP */
function checkVip(code: string) {
if (props.storeVip !== undefined) {
return hasVipPermission(code, props.storeVip);
}
return hasVipPermission(code);
}
const emit = defineEmits<{
'update:diagnosis': [string];
'update:medicalAdvice': [string];
@@ -134,7 +154,7 @@ const showCommonInBubble = computed(
);
/** AI写病历入口门店 VIP 需开通 ai_medical_record */
const canUseAiMedicalRecord = computed(() =>
hasVipPermission('ai_medical_record'),
checkVip(VIP_FEATURE.AI_MEDICAL_RECORD),
);
/** 标签区刷新(气泡内新增/加常用后) */
const commonChipsTick = ref(0);
@@ -288,6 +308,78 @@ const [CiteHistoryDrawer, citeHistoryDrawerApi] = useVbenDrawer({
connectedComponent: PatientMedicalRecordDrawer,
});
/** 档案史与病历史不一致时确认导入 */
const [HistoryImportModalHost, historyImportModalApi] = useVbenModal({
connectedComponent: HistoryImportModal,
});
/** 空串与「无」视为同一默认态(与后端 normalizeHistoryCompare 对齐) */
function normalizeHistoryCompare(value: unknown): string {
const t = String(value ?? '').trim();
return t === '' || t === '无' ? '无' : t;
}
/** 本会话已对某挂号提示过导入,避免切 Tab 反复弹 */
function historyImportSkipKey(registerId: number) {
return `xk_mr_hist_import_skip_${props.storagePrefix || ''}${registerId}`;
}
/**
* 草稿合并后按「当前表单 vs 档案」再滤一遍差异,有差异才弹预览确认
*/
function maybeAskHistoryImport(rawDiff: HistoryImportDiffItem[]) {
const rid = Number(props.registerId || 0);
if (!rid || !Array.isArray(rawDiff) || !rawDiff.length) return;
try {
if (sessionStorage.getItem(historyImportSkipKey(rid)) === '1') return;
} catch {
/* ignore */
}
const rows: HistoryImportDiffItem[] = [];
for (const item of rawDiff) {
if (!item?.field) continue;
const current = normalizeHistoryCompare((form as any)[item.field]);
const importVal = normalizeHistoryCompare(item.import);
if (current === importVal) continue;
rows.push({
field: item.field,
label: item.label || item.field,
current,
import: importVal,
});
}
if (!rows.length) return;
historyImportModalApi.setData({
rows,
onConfirm: (list: HistoryImportDiffItem[]) => {
const patch: Record<string, string> = {};
for (const row of list || []) {
if (row?.field) patch[row.field] = row.import || '无';
}
Object.keys(patch).forEach((k) => {
if (Object.prototype.hasOwnProperty.call(form, k)) {
(form as any)[k] = patch[k];
}
});
persistLocalDraft();
try {
sessionStorage.setItem(historyImportSkipKey(rid), '1');
} catch {
/* ignore */
}
message.success('已导入档案病史,请检查后保存');
},
onCancel: () => {
try {
sessionStorage.setItem(historyImportSkipKey(rid), '1');
} catch {
/* ignore */
}
},
});
historyImportModalApi.open();
}
/** 组装当前可落盘的病历草稿(含诊断/医嘱) */
function buildDraftPayload() {
return {
@@ -315,15 +407,22 @@ const debouncedPersistLocal = useDebounceFn(persistLocalDraft, 300);
async function loadRecord() {
if (!props.registerId) return;
// 无病历 VIP 不请求避免弹出「未开通病历VIP」普通诊所只看病历 Tab 隐藏即可
if (!hasVipPermission('medical_record')) return;
if (!checkVip(VIP_FEATURE.MEDICAL_RECORD)) return;
loading.value = true;
hydrating.value = true;
let historyDiff: HistoryImportDiffItem[] = [];
try {
const data = await getMedicalRecord({
register_id: props.registerId,
store_id: props.storeId || undefined,
});
historyDiff = Array.isArray(data?.history_import_diff)
? data.history_import_diff
: [];
Object.assign(form, emptyMedicalRecord(props.registerId, props.storeId), data || {});
// 提示字段不进表单/草稿
delete (form as any).history_import_diff;
delete (form as any).patient_history_defaults;
const draftKey = `${props.storagePrefix || ''}${props.registerId}`;
const draft = loadMedicalRecordDraft(draftKey);
if (draft) {
@@ -364,6 +463,8 @@ async function loadRecord() {
} finally {
loading.value = false;
hydrating.value = false;
// 草稿合并完成后再判断是否弹导入确认
nextTick(() => maybeAskHistoryImport(historyDiff));
}
}
@@ -1286,6 +1387,7 @@ const visibleNormalFields = computed(() =>
<ExpandModal />
<CiteHistoryDrawer />
<HistoryImportModalHost />
<AiMedicalRecordModal
ref="aiMedicalRecordModalRef"
@import="handleImportAiMedicalRecord"

View File

@@ -0,0 +1,138 @@
<script lang="ts" setup>
/**
* 接诊进病历:患者档案史与当前病历史不一致时,预览并确认是否导入
* 用 VbenModal 展示差异对照,确认后把档案侧内容写回病历草稿
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
export type HistoryImportDiffItem = {
field: string;
label: string;
current: string;
import: string;
};
const rows = ref<HistoryImportDiffItem[]>([]);
const onConfirmCb = ref<((list: HistoryImportDiffItem[]) => void) | null>(
null,
);
const onCancelCb = ref<(() => void) | null>(null);
const [Modal, modalApi] = useVbenModal({
title: '导入患者档案病史?',
class: 'w-[640px]',
draggable: true,
confirmText: '确认导入',
cancelText: '暂不导入',
closeOnClickModal: false,
onConfirm() {
const list = rows.value.slice();
const cb = onConfirmCb.value;
modalApi.close();
cb?.(list);
},
onCancel() {
onCancelCb.value?.();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) {
rows.value = [];
onConfirmCb.value = null;
onCancelCb.value = null;
return;
}
const data = modalApi.getData<{
rows?: HistoryImportDiffItem[];
onConfirm?: (list: HistoryImportDiffItem[]) => void;
onCancel?: () => void;
}>();
rows.value = Array.isArray(data?.rows) ? data!.rows! : [];
onConfirmCb.value = data?.onConfirm || null;
onCancelCb.value = data?.onCancel || null;
},
});
</script>
<template>
<Modal>
<div class="hist-import">
<p class="hist-import__tip">
就诊人档案中的病史与当前病历不一致请确认是否用档案内容覆盖对应字段导入后仍需保存病历
</p>
<div
v-for="row in rows"
:key="row.field"
class="hist-import__card"
>
<div class="hist-import__label">{{ row.label }}</div>
<div class="hist-import__cols">
<div class="hist-import__col">
<span class="hist-import__tag">当前病历</span>
<div class="hist-import__val">{{ row.current || '无' }}</div>
</div>
<div class="hist-import__col hist-import__col--import">
<span class="hist-import__tag hist-import__tag--import">将导入</span>
<div class="hist-import__val">{{ row.import || '无' }}</div>
</div>
</div>
</div>
</div>
</Modal>
</template>
<style scoped>
/* 颜色一律主题变量,亮/暗色自动适配,禁止写死 #fff / #0f172a */
.hist-import__tip {
margin: 0 0 12px;
font-size: 13px;
color: hsl(var(--muted-foreground));
line-height: 1.5;
}
.hist-import__card {
border: 1px solid hsl(var(--border));
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
background: hsl(var(--muted) / 0.25);
}
.hist-import__label {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
color: hsl(var(--foreground));
}
.hist-import__cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.hist-import__col {
background: hsl(var(--card, var(--background)));
border-radius: 6px;
padding: 8px 10px;
border: 1px solid hsl(var(--border));
}
.hist-import__col--import {
border-color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.hist-import__tag {
display: inline-block;
font-size: 11px;
color: hsl(var(--muted-foreground));
margin-bottom: 4px;
}
.hist-import__tag--import {
color: hsl(var(--primary));
font-weight: 600;
}
.hist-import__val {
font-size: 13px;
color: hsl(var(--foreground));
white-space: pre-wrap;
word-break: break-all;
line-height: 1.45;
}
</style>

View File

@@ -0,0 +1,116 @@
/**
* 就诊人健康问诊 → 病历「史」字段
* 映射与后端 MedicalRecordService::resolveHistoryDefaultsFromPatient 一致:
* present→现病史person→既往史allergic→过敏史family→家族史其余同名
*/
type HealthInquiryLike = Record<string, any> | null | undefined;
const HISTORY_MAP: Array<{
statusKey: string;
historyKey: string;
mrKey: string;
/** menstrual | marital | always */
visibility: 'always' | 'menstrual' | 'marital';
}> = [
{
statusKey: 'present_status',
historyKey: 'present_history',
mrKey: 'present_illness',
visibility: 'always',
},
{
statusKey: 'person_status',
historyKey: 'person_history',
mrKey: 'past_history',
visibility: 'always',
},
{
statusKey: 'allergic_status',
historyKey: 'allergic_history',
mrKey: 'allergy_history',
visibility: 'always',
},
{
statusKey: 'family_status',
historyKey: 'family_history',
mrKey: 'family_history',
visibility: 'always',
},
{
statusKey: 'epidemic_status',
historyKey: 'epidemic_history',
mrKey: 'epidemic_history',
visibility: 'always',
},
{
statusKey: 'personal_status',
historyKey: 'personal_history',
mrKey: 'personal_history',
visibility: 'always',
},
{
statusKey: 'menstrual_status',
historyKey: 'menstrual_history',
mrKey: 'menstrual_history',
visibility: 'menstrual',
},
{
statusKey: 'marital_status',
historyKey: 'marital_history',
mrKey: 'marital_history',
visibility: 'marital',
},
];
function isFieldVisible(
visibility: 'always' | 'menstrual' | 'marital',
sex: number,
age: number,
): boolean {
if (visibility === 'menstrual') return sex === 2;
if (visibility === 'marital') {
return (sex === 1 && age > 22) || (sex === 2 && age > 20);
}
return true;
}
/** status≠1 或内容空 →「无」;有内容用顿号拼接 */
function resolveHistoryText(status: unknown, raw: unknown): string {
if (Number(status) !== 1) return '无';
if (Array.isArray(raw)) {
const parts = raw.map((v) => String(v ?? '').trim()).filter(Boolean);
return parts.length ? parts.join('、') : '无';
}
const str = String(raw ?? '').trim();
if (!str || str === '[]' || str === 'null') return '无';
try {
const decoded = JSON.parse(str);
if (Array.isArray(decoded)) {
const parts = decoded.map((v) => String(v ?? '').trim()).filter(Boolean);
return parts.length ? parts.join('、') : '无';
}
} catch {
/* 非 JSON按分隔符拆 */
}
const parts = str.split(/[,,、]+/).map((s) => s.trim()).filter(Boolean);
return parts.length ? parts.join('、') : '无';
}
/**
* 将健康问诊转为可 patch 到病历的史字段对象
*/
export function mapHealthInquiryToMrHistory(
health: HealthInquiryLike,
opts?: { sex?: number; age?: number },
): Record<string, string> {
const hi = health || {};
const sex = Number(opts?.sex || 0);
const age = Number(opts?.age || 0);
const out: Record<string, string> = {};
for (const row of HISTORY_MAP) {
if (!isFieldVisible(row.visibility, sex, age)) continue;
out[row.mrKey] = resolveHistoryText(hi[row.statusKey], hi[row.historyKey]);
}
return out;
}

View File

@@ -121,6 +121,8 @@ export interface PrescriptionItem {
type: number;
/** 状态 */
status: number;
/** 是否支付 1已支付 0未支付 */
is_pay: number;
/** 临床诊断 */
clinical_diagnose: string;
/** 患者信息 */

View File

@@ -58,12 +58,22 @@ interface UserPatientHealthInquiry {
renal_function: number;
liver_index: string;
renal_index: string;
present_status?: number;
present_history?: string;
person_history: string;
allergic_history: string;
person_status: number;
allergic_status: number;
family_status: number;
family_history: string;
epidemic_status?: number;
epidemic_history?: string;
personal_status?: number;
personal_history?: string;
menstrual_status?: number;
menstrual_history?: string;
marital_status?: number;
marital_history?: string;
is_delete: number;
}
@@ -236,6 +246,17 @@ const prescriptionColumns = [
},
},
{ title: '诊断', dataIndex: 'clinical_diagnose', key: 'clinical_diagnose' },
{
title: '支付状态',
dataIndex: 'is_pay',
key: 'is_pay',
customRender: ({ text }: { text: number }) =>
h(
Tag,
{ color: Number(text) === 1 ? 'green' : 'orange' },
() => (Number(text) === 1 ? '已支付' : '未支付'),
),
},
{
title: '状态',
dataIndex: 'status',
@@ -561,6 +582,20 @@ function getSexText(sex: number): string {
</Tag>
</p>
</Descriptions.Item>
<Descriptions.Item label="现病史">
<span>{{
Number(healthInfo.present_status) === 1 ? '有' : '无'
}}</span>
<p v-if="Number(healthInfo.present_status) === 1" class="mt-3">
<Tag
v-for="item in splitString(String(healthInfo.present_history || ''))"
:key="item"
color="#455cda"
>
{{ item }}
</Tag>
</p>
</Descriptions.Item>
<Descriptions.Item label="既往史">
<span>{{
healthInfo.person_status === 0 ? '无' : '有'
@@ -606,6 +641,71 @@ function getSexText(sex: number): string {
</Tag>
</p>
</Descriptions.Item>
<Descriptions.Item label="流行病学史">
<span>{{
Number(healthInfo.epidemic_status) === 1 ? '有' : '无'
}}</span>
<p v-if="Number(healthInfo.epidemic_status) === 1" class="mt-3">
<Tag
v-for="item in splitString(String(healthInfo.epidemic_history || ''))"
:key="item"
color="#455cda"
>
{{ item }}
</Tag>
</p>
</Descriptions.Item>
<Descriptions.Item label="个人史">
<span>{{
Number(healthInfo.personal_status) === 1 ? '有' : '无'
}}</span>
<p v-if="Number(healthInfo.personal_status) === 1" class="mt-3">
<Tag
v-for="item in splitString(String(healthInfo.personal_history || ''))"
:key="item"
color="#455cda"
>
{{ item }}
</Tag>
</p>
</Descriptions.Item>
<Descriptions.Item
v-if="Number(patientDetail?.patient?.sex) === 2"
label="月经史"
>
<span>{{
Number(healthInfo.menstrual_status) === 1 ? '有' : '无'
}}</span>
<p v-if="Number(healthInfo.menstrual_status) === 1" class="mt-3">
<Tag
v-for="item in splitString(String(healthInfo.menstrual_history || ''))"
:key="item"
color="#455cda"
>
{{ item }}
</Tag>
</p>
</Descriptions.Item>
<Descriptions.Item
v-if="
(Number(patientDetail?.patient?.sex) === 1 && Number(patientDetail?.age) > 22) ||
(Number(patientDetail?.patient?.sex) === 2 && Number(patientDetail?.age) > 20)
"
label="婚育史"
>
<span>{{
Number(healthInfo.marital_status) === 1 ? '有' : '无'
}}</span>
<p v-if="Number(healthInfo.marital_status) === 1" class="mt-3">
<Tag
v-for="item in splitString(String(healthInfo.marital_history || ''))"
:key="item"
color="#455cda"
>
{{ item }}
</Tag>
</p>
</Descriptions.Item>
</Descriptions>
<Empty v-else description="暂无健康信息" />
</Card>

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>