From d93e465d3c37fc13209c864437dfff8f288efde7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Thu, 9 Jul 2026 15:45:39 +0800 Subject: [PATCH] =?UTF-8?q?1.=20=E7=89=B9=E8=89=B2=E6=96=B9=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E8=BF=AD=E4=BB=A3(=E5=9B=BA=E5=AE=9A=E4=BB=B7?= =?UTF-8?q?=E6=A0=BC)=EF=BC=8C=E5=9B=BA=E5=AE=9A=E4=BB=B7=E6=A0=BC?= =?UTF-8?q?=E7=9B=AE=E5=89=8D=E7=BB=9F=E4=B8=80=E4=BD=BF=E7=94=A8=E8=8D=AF?= =?UTF-8?q?=E5=93=81id=EF=BC=88-102=EF=BC=89=EF=BC=8C=E5=90=8E=E6=9C=9F?= =?UTF-8?q?=E4=BC=9A=E8=80=83=E8=99=91=E6=96=B0=E5=A2=9E=E7=89=B9=E8=89=B2?= =?UTF-8?q?=E6=96=B9=E7=9A=84=E6=97=B6=E5=80=99=E8=87=AA=E5=8A=A8=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E4=B8=80=E4=B8=AA=E7=89=B9=E8=89=B2=E6=96=B9=E8=8D=AF?= =?UTF-8?q?=E5=93=81=202.=20=E9=80=80=E6=AC=BE=E7=9A=84=E6=97=B6=E5=80=99?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E9=80=80=E5=9B=9E=E5=88=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antd/src/api/request.ts | 112 ++++--------- .../DoctorTransferFloat.vue | 6 +- .../form/components/doctor-picker.vue | 13 +- .../form/components/promoter-picker.vue | 4 +- .../form/components/store-picker.vue | 13 +- .../promoter/PromoterDetailModal.vue | 5 +- .../components/promoter/PromoterInfoCard.vue | 3 +- .../sensitive-text/SensitiveText.vue | 158 ++++++++++++++++++ .../src/constants/sensitive-fields.ts | 133 +++++++++++++++ .../business/clinic-salesperson/earnings.vue | 3 +- .../business/clinic-salesperson/index.vue | 3 +- .../order/product-order/components/detail.vue | 7 +- .../business/order/product-order/index.vue | 7 +- .../components/DetailModal.vue | 6 +- .../index.vue | 7 +- .../settings/components/SalespersonCard.vue | 3 +- .../components/SalespersonInvitedDrawer.vue | 11 +- .../settings/components/SalespersonPicker.vue | 7 +- .../views/business/store/settings/index.vue | 5 +- .../admin/config/search.ts | 18 ++ .../components/SalespersonTransferDrawer.vue | 9 +- .../views/doctor/doctor-reception/index.vue | 5 +- .../doctor/components/DoctorCardModal.vue | 9 +- .../src/views/doctor/doctor/config/table.ts | 2 +- .../src/views/doctor/doctor/index.vue | 4 + .../components/PatientInfoPanel.vue | 8 +- .../components/PatientList.vue | 10 +- .../components/PatientInfoPanel.vue | 8 +- .../components/PatientList.vue | 10 +- .../components/PatientDetailModal.vue | 13 +- .../views/doctor/patient-management/index.vue | 3 +- .../doctor/pharmacist/components/detail.vue | 24 +-- .../views/doctor/pharmacist/config/table.ts | 2 +- .../src/views/doctor/pharmacist/index.vue | 4 + .../src/views/doctor/settings/index.vue | 3 +- .../views/system/admin/_shared/admin-page.vue | 6 +- .../system/admin/_shared/table-config.ts | 2 +- .../src/views/system/admin/config/table.ts | 2 +- .../doctor-input/components/DetailModal.vue | 10 +- .../views/system/doctor-input/config/table.ts | 4 +- .../src/views/system/doctor-input/index.vue | 4 + .../src/views/system/pharmacy/index.vue | 4 +- .../store-input/components/DetailModal.vue | 6 +- .../components/DoctorInputCardGrid.vue | 10 +- .../views/system/store-input/config/table.ts | 4 +- .../src/views/system/store-input/index.vue | 4 + .../components/cells/StoreBasicInfoCell.vue | 18 +- .../web-antd/src/views/system/store/index.vue | 4 +- 48 files changed, 556 insertions(+), 160 deletions(-) create mode 100644 apps/web-antd/src/components/sensitive-text/SensitiveText.vue create mode 100644 apps/web-antd/src/constants/sensitive-fields.ts diff --git a/apps/web-antd/src/api/request.ts b/apps/web-antd/src/api/request.ts index 5edad1c5..1e1071bb 100644 --- a/apps/web-antd/src/api/request.ts +++ b/apps/web-antd/src/api/request.ts @@ -16,6 +16,12 @@ import { message } from 'ant-design-vue'; // eslint-disable-next-line n/no-extraneous-import import { Base64 } from 'js-base64'; +import { + ENCRYPT_FIELDS, + isSensitiveField, + maskSensitiveField, + TM_TEXT_SUFFIX, +} from '#/constants/sensitive-fields'; import { useAuthStore } from '#/store'; import { refreshTokenApi } from './core'; @@ -23,33 +29,6 @@ import {downloadByData} from "#/util/tool"; const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); -const arr = new Set([ - 'account', - 'accept_tel', - 'doctor', - 'express_mobile', - 'id_card', - 'idcard', - 'mobile', - 'password', - 'patient_mobile', - 'phone', -]); - -// 敏感数据 -const sensitiveData = new Set([ - 'account', - 'accept_tel', - 'doctor', - 'express_mobile', - 'id_card', - 'idcard', - 'mobile', - 'password', - 'patient_mobile', - 'phone', -]); - function createRequestClient(baseURL: string) { const client = new RequestClient({ baseURL, @@ -189,61 +168,34 @@ export const baseRequestClient = new RequestClient({ baseURL: apiURL }); */ function getRes(obj: any, isDecode = true) { try { + if (obj == null || typeof obj !== 'object') { + return obj; + } for (const key in obj) { + // 跳过脱敏展示字段,避免重复处理 + if (key.endsWith(TM_TEXT_SUFFIX)) { + continue; + } if (Array.isArray(obj[key])) { - obj[key] = getRes(obj[key]); - } else if (typeof obj[key] === 'object') { - obj[key] = getRes(obj[key]); - } else { - // 判断obj[key]是否在arr中 - if (arr.has(key)) { - let aseFile = ''; - aseFile = isDecode - ? customBase64Decode(obj[key], key) - : customBase64Encode(obj[key]); - const isGarbled = - // eslint-disable-next-line no-control-regex - /[\u0000-\u0008\v\f\u000E-\u001F\u007F-\u00FF\u{E000}-\u{F8FF}]/u.test( - aseFile, - ); - if (isGarbled) { - aseFile = obj[key]; - } - if (isDecode && sensitiveData.has(key)) { - // 数据脱敏,自动匹配姓名、手机号、身份证 - // switch (key) { - // case 'accept_name': - // case 'express_name': - // case 'express_region': - // case 'patient': { - // // 保留前两个字符,其余用星号代替 - // aseFile = aseFile.slice(0, 1).padEnd(aseFile.length, '*'); - // break; - // } - // case 'express_mobile': - // case 'mobile': - // case 'phone': { - // // 保留前三位和后四位,中间用星号代替 - // aseFile = - // aseFile.slice(0, 3).padEnd(aseFile.length - 4, '*') + - // aseFile.slice(-4); - // break; - // } - // case 'id_card': - // case 'idcard': { - // // 保留前六位和后四位,中间用星号代替 - // aseFile = - // aseFile.slice(0, 6).padEnd(aseFile.length - 4, '*') + - // aseFile.slice(-4); - // break; - // } - // default: { - // // 默认情况下,全部用星号代替 - // break; - // } - // } - } - obj[key] = aseFile; + obj[key] = getRes(obj[key], isDecode); + } else if (obj[key] !== null && typeof obj[key] === 'object') { + obj[key] = getRes(obj[key], isDecode); + } else if (ENCRYPT_FIELDS.has(key)) { + let plainValue = isDecode + ? customBase64Decode(obj[key], key) + : customBase64Encode(obj[key]); + const isGarbled = + // eslint-disable-next-line no-control-regex + /[\u0000-\u0008\v\f\u000E-\u001F\u007F-\u00FF\u{E000}-\u{F8FF}]/u.test( + plainValue, + ); + if (isGarbled) { + plainValue = obj[key]; + } + obj[key] = plainValue; + // 解密后写入脱敏展示字段,明文保留在原字段 + if (isDecode && isSensitiveField(key)) { + obj[key + TM_TEXT_SUFFIX] = maskSensitiveField(key, plainValue); } } } diff --git a/apps/web-antd/src/components/doctor-transfer-float/DoctorTransferFloat.vue b/apps/web-antd/src/components/doctor-transfer-float/DoctorTransferFloat.vue index c5688cee..1392782f 100644 --- a/apps/web-antd/src/components/doctor-transfer-float/DoctorTransferFloat.vue +++ b/apps/web-antd/src/components/doctor-transfer-float/DoctorTransferFloat.vue @@ -33,6 +33,7 @@ import { } from 'ant-design-vue'; import FloatFab from '#/components/float-fab/FloatFab.vue'; +import SensitiveText from '#/components/sensitive-text/SensitiveText.vue'; import { DEFAULT_SUB_WINDOW_Z_INDEX, getSubWindowPopupZIndex, @@ -462,7 +463,10 @@ defineExpose({ loadList });
收货人 - {{ item.patient_name }} {{ item.patient_mobile }} + + {{ item.patient_name }} + +
地址 diff --git a/apps/web-antd/src/components/form/components/doctor-picker.vue b/apps/web-antd/src/components/form/components/doctor-picker.vue index b0e050b5..94ba5b68 100644 --- a/apps/web-antd/src/components/form/components/doctor-picker.vue +++ b/apps/web-antd/src/components/form/components/doctor-picker.vue @@ -5,6 +5,7 @@ import { useVModel } from '@vueuse/core'; import { Avatar, Empty, Input, Popover, Spin } from 'ant-design-vue'; import { getDoctorOptionApi } from '#/views/system/store/api'; +import SensitiveText from '#/components/sensitive-text/SensitiveText.vue'; import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser'; defineOptions({ @@ -156,7 +157,9 @@ watch(
{{ item.name || '-' }}
-
{{ item.mobile }}
+
+ +
{{ item.depart_name }}
@@ -174,7 +177,13 @@ watch(
{{ selectedOption.name }}
- {{ selectedOption.mobile || `su_id: ${selectedOption.id}` }} + + su_id: {{ selectedOption.id }}
{ {{ item.nick_name || '-' }}
{{ item.code }}
-
{{ item.phone }}
+
+ +
{{ item.role_name || '业务员' }} diff --git a/apps/web-antd/src/components/form/components/store-picker.vue b/apps/web-antd/src/components/form/components/store-picker.vue index 34a3dbe1..a413ec49 100644 --- a/apps/web-antd/src/components/form/components/store-picker.vue +++ b/apps/web-antd/src/components/form/components/store-picker.vue @@ -5,6 +5,7 @@ import { useVModel } from '@vueuse/core'; import { Avatar, Empty, Input, Popover, Spin } from 'ant-design-vue'; import { getStoreOption } from '#/views/system/store/api'; +import SensitiveText from '#/components/sensitive-text/SensitiveText.vue'; import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser'; defineOptions({ @@ -133,7 +134,9 @@ onMounted(() => {
{{ item.name || '-' }}
-
{{ item.mobile }}
+
+ +
ID: {{ item.id }}
@@ -149,7 +152,13 @@ onMounted(() => {
{{ selectedOption.name }}
- {{ selectedOption.mobile || `ID: ${selectedOption.id}` }} + + ID: {{ selectedOption.id }}
{{ detail.code || '-' }} - {{ detail.phone || '-' }} + + + {{ detail.clinic_input_count ?? 0 }} 家 diff --git a/apps/web-antd/src/components/promoter/PromoterInfoCard.vue b/apps/web-antd/src/components/promoter/PromoterInfoCard.vue index 207ed10f..96558145 100644 --- a/apps/web-antd/src/components/promoter/PromoterInfoCard.vue +++ b/apps/web-antd/src/components/promoter/PromoterInfoCard.vue @@ -6,6 +6,7 @@ import { useVbenModal } from '@vben/common-ui'; import { Avatar } from 'ant-design-vue'; import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser'; +import SensitiveText from '#/components/sensitive-text/SensitiveText.vue'; import PromoterDetailModal from './PromoterDetailModal.vue'; @@ -96,7 +97,7 @@ function onClear(e: Event) { 业务码:{{ admin.code }}
- {{ admin.phone }} +
+import type { SensitiveFieldSource } from '#/constants/sensitive-fields'; + +import { computed, ref } from 'vue'; + +import { useClipboard } from '@vueuse/core'; +import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons-vue'; +import { message } from 'ant-design-vue'; + +import { + getSensitiveMaskText, + getSensitivePlainText, +} from '#/constants/sensitive-fields'; + +const props = withDefaults( + defineProps<{ + /** 数据对象 */ + record?: Record | null; + /** 字段名 */ + field: string; + /** 主字段为空时的备选来源 */ + fallback?: SensitiveFieldSource; + /** 空值占位 */ + emptyText?: string; + /** 是否显示眼睛切换 */ + showEye?: boolean; + /** 点击文本是否复制明文 */ + copyable?: boolean; + }>(), + { + record: null, + emptyText: '—', + showEye: true, + copyable: true, + }, +); + +const revealed = ref(false); +const { copy } = useClipboard({ legacy: true }); + +/** 明文值,复制与展示切换时使用 */ +const plainText = computed(() => + getSensitivePlainText(props.record, props.field, props.fallback), +); + +/** 脱敏展示值 */ +const maskText = computed(() => + getSensitiveMaskText(props.record, props.field, props.fallback), +); + +/** 当前界面展示文本 */ +const displayText = computed(() => { + if (!plainText.value) { + return props.emptyText; + } + return revealed.value ? plainText.value : maskText.value; +}); + +/** 是否有有效值(非空占位) */ +const hasValue = computed(() => plainText.value !== ''); + +/** + * 点击文本复制明文(脱敏前的原始值) + */ +async function handleCopyText() { + if (!props.copyable || !hasValue.value) { + return; + } + try { + await copy(plainText.value); + message.success('复制成功'); + } catch { + message.error('复制失败'); + } +} + +/** + * 切换明文 / 脱敏展示 + */ +function toggleReveal() { + if (!hasValue.value) { + return; + } + revealed.value = !revealed.value; +} + + + + + diff --git a/apps/web-antd/src/constants/sensitive-fields.ts b/apps/web-antd/src/constants/sensitive-fields.ts new file mode 100644 index 00000000..c849d213 --- /dev/null +++ b/apps/web-antd/src/constants/sensitive-fields.ts @@ -0,0 +1,133 @@ +/** + * 敏感字段集中定义:拦截器脱敏与 SensitiveText 组件共用 + */ + +/** 脱敏展示后缀,明文保留在原字段 */ +export const TM_TEXT_SUFFIX = '_tm_text'; + +/** 需要加解密的字段(与 request 拦截器 arr 一致) */ +export const ENCRYPT_FIELDS = new Set([ + 'account', + 'accept_tel', + 'doctor', + 'express_mobile', + 'id_card', + 'idcard', + 'mobile', + 'password', + 'patient_mobile', + 'phone', +]); + +/** 需要生成 _tm_text 并默认脱敏展示的字段(encrypt_only) */ +export const MASK_DISPLAY_FIELDS = ENCRYPT_FIELDS; + +const PHONE_FIELDS = new Set([ + 'mobile', + 'phone', + 'express_mobile', + 'patient_mobile', + 'accept_tel', +]); + +const ID_CARD_FIELDS = new Set(['id_card', 'idcard']); + +export interface SensitiveFieldSource { + record?: Record | null; + field: string; +} + +/** + * 判断是否为敏感字段 + */ +export function isSensitiveField(field: string): boolean { + return MASK_DISPLAY_FIELDS.has(field); +} + +/** + * 从 record 读取明文,支持 fallback 链 + */ +export function getSensitivePlainText( + record: Record | null | undefined, + field: string, + fallback?: SensitiveFieldSource, +): string { + const primary = readFieldValue(record, field); + if (primary !== '') { + return primary; + } + if (fallback?.record) { + return readFieldValue(fallback.record, fallback.field); + } + return ''; +} + +/** + * 读取脱敏文本:优先 _tm_text,否则对明文现场脱敏 + */ +export function getSensitiveMaskText( + record: Record | null | undefined, + field: string, + fallback?: SensitiveFieldSource, +): string { + const tmKey = `${field}${TM_TEXT_SUFFIX}`; + const tmFromPrimary = readFieldValue(record, tmKey); + if (tmFromPrimary !== '') { + return tmFromPrimary; + } + if (fallback?.record) { + const tmFromFallback = readFieldValue(fallback.record, tmKey); + if (tmFromFallback !== '') { + return tmFromFallback; + } + } + const plain = getSensitivePlainText(record, field, fallback); + if (plain === '') { + return ''; + } + return maskSensitiveField(field, plain); +} + +/** + * 按字段类型生成脱敏文本 + */ +export function maskSensitiveField(field: string, plainText: unknown): string { + if (plainText == null || plainText === '') { + return ''; + } + const str = String(plainText); + if (field === 'password') { + return '*'.repeat(Math.max(str.length, 6)); + } + if (PHONE_FIELDS.has(field)) { + if (str.length <= 7) { + return str; + } + return str.slice(0, 3) + '*'.repeat(str.length - 7) + str.slice(-4); + } + if (ID_CARD_FIELDS.has(field)) { + if (str.length <= 10) { + return str; + } + return str.slice(0, 6) + '*'.repeat(str.length - 10) + str.slice(-4); + } + // account / doctor 等通用兜底:保留前 2 + 后 2 + if (str.length <= 4) { + return str; + } + return str.slice(0, 2) + '*'.repeat(str.length - 4) + str.slice(-2); +} + +function readFieldValue( + record: Record | null | undefined, + field: string, +): string { + if (!record) { + return ''; + } + const value = record[field]; + if (value == null || value === '') { + return ''; + } + return String(value); +} diff --git a/apps/web-antd/src/views/business/clinic-salesperson/earnings.vue b/apps/web-antd/src/views/business/clinic-salesperson/earnings.vue index 83f1cf00..1b032c0b 100644 --- a/apps/web-antd/src/views/business/clinic-salesperson/earnings.vue +++ b/apps/web-antd/src/views/business/clinic-salesperson/earnings.vue @@ -8,6 +8,7 @@ import { Card, Col, Image, Row, Spin } from 'ant-design-vue'; import { getSelfMyInfoApi } from '#/views/business/clinic-salesperson/api'; import QrCodePreview from '#/views/business/store/settings/components/SalespersonQrCodePreview.vue'; +import SensitiveText from '#/components/sensitive-text/SensitiveText.vue'; const [QrCodePreviewModal, QrCodePreviewApi] = useVbenModal({ connectedComponent: QrCodePreview, }); @@ -37,7 +38,7 @@ function openQrCode(item: any) {