feat: 修复部分功能(健康信息、VIP、医生端健康信息导入)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -121,6 +121,8 @@ export interface PrescriptionItem {
|
||||
type: number;
|
||||
/** 状态 */
|
||||
status: number;
|
||||
/** 是否支付 1已支付 0未支付 */
|
||||
is_pay: number;
|
||||
/** 临床诊断 */
|
||||
clinical_diagnose: string;
|
||||
/** 患者信息 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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