Files
xh-medical-sign-web/clinical-web/src/views/management/settings/index.vue
T

496 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 系统设置(原型 V10.2 `#sc-set`)。
*
* 版式照原型:顶部一张「参数设置」卡放**设置项索引**,点索引单选切换,下方单列铺满、
* 只显示所选分区(V5.7 加索引 → V5.8 改单选切换 → V6.0 去卡片描述、单列铺满)。
*
* 分区与接口现状:
* - 「菜单设置」和「文书字体」接入真实接口;菜单分区(菜单资源 CRUD,见 MenuResourceSettings),
* 本页只负责把它放进索引,**不改它的实现**;无 `system:config:menu` 权限时整项不出现。
* - 其余分区(签署参数 / 短信模板 / 签署设备管理 / 签署状态推送 / 第三方系统嵌入 /
* 文档类型设置)**暂时都没有后端接口**,全部用页面内演示数据渲染,刷新即回初值。
* 这也是本页不引用 `api/management/settings.ts` 的原因。
*/
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useLoginStore } from '@/stores/login'
import DocumentFontSettings from './components/DocumentFontSettings.vue'
import DeviceSettingsCard from './components/DeviceSettingsCard.vue'
import DocTypeSettingsCard from './components/DocTypeSettingsCard.vue'
import EmbedSettingsCard from './components/EmbedSettingsCard.vue'
import MousePadTestDialog from './components/MousePadTestDialog.vue'
import MenuResourceSettings from './components/MenuResourceSettings.vue'
import PushSettingsCard from './components/PushSettingsCard.vue'
import SignatureSettingsCard from './components/SignatureSettingsCard.vue'
import SmsSettingsCard from './components/SmsSettingsCard.vue'
import {
defaultSmsChannel,
mockDocumentTypes,
mockPushSubscriptions,
mockSignatureDevices,
mockSignatureSettings,
pushDefaultForm,
pushLogEmptyHint,
pushAuthModeOptions,
pushEventOptions,
pushRetryPolicyOptions,
pushSystemTypeOptions,
smsChannelOptions,
} from './mock'
import type {
PushLogEntry,
PushSubscription,
PushSubscriptionForm,
SettingToggleKey,
SignatureDevice,
SignatureSettings,
SmsChannel,
} from './types'
const EMBED_URL =
'https://medisign.hospital.cn/embed/workbench?uid={操作员ID}&pid={患者ID}&visitNo={就诊号}&ts={时间戳}&sign={HMAC签名}'
const loginStore = useLoginStore()
const canManageMenus = computed(() => loginStore.hasPermission('system:config:menu'))
type SectionKey = 'sign' | 'sms' | 'device' | 'push' | 'embed' | 'menus' | 'docType' | 'fonts'
const sections = computed<Array<{ key: SectionKey; label: string }>>(() => {
const list: Array<{ key: SectionKey; label: string }> = [
{ key: 'sign', label: '签署参数' },
{ key: 'fonts', label: '文书字体' },
{ key: 'sms', label: '短信模板' },
{ key: 'device', label: '签署设备管理' },
{ key: 'push', label: '签署状态推送' },
{ key: 'embed', label: '第三方系统嵌入' },
]
if (canManageMenus.value) {
list.push({ key: 'menus', label: '菜单设置' })
}
list.push({ key: 'docType', label: '文档类型设置' })
return list
})
const activeSection = ref<SectionKey>('sign')
watch(canManageMenus, (canManage) => {
if (!canManage && activeSection.value === 'menus') activeSection.value = 'sign'
})
/* ---------------- 签署参数 / 短信模板 ---------------- */
const signatureSettings = reactive<SignatureSettings>({ ...mockSignatureSettings })
const smsChannel = ref<SmsChannel>(defaultSmsChannel)
function updateLinkExpireHours(value: number) {
signatureSettings.linkExpireHours = value
}
function toggleSetting(key: SettingToggleKey) {
signatureSettings[key] = !signatureSettings[key]
if (key === 'caCertificate' && signatureSettings.caCertificate) {
ElMessage.info('已开启 CA 扩展位(当前版本未启用)')
return
}
ElMessage.info('设置已更新')
}
function updateSmsChannel(value: SmsChannel) {
smsChannel.value = value
}
function sendTestSms() {
ElMessage.success('测试短信已发送至 138****5678')
}
/* ---------------- 签署设备管理 ---------------- */
const devices = ref<SignatureDevice[]>(mockSignatureDevices.map((device) => ({ ...device })))
const mouseTestVisible = ref(false)
const mouseTestDeviceName = ref('')
function openMouseTest(device: SignatureDevice) {
mouseTestDeviceName.value = `${device.model} · ${device.location}`
mouseTestVisible.value = true
}
function operateDevice(device: SignatureDevice) {
if (device.kind === 'pad') {
if (device.status === 'connected') {
ElMessage.success('已重新连接')
} else {
ElMessage.info('正在重连…')
}
return
}
if (device.status === 'online') {
ElMessage.success('已消毒登记 · 固件最新')
} else {
ElMessage.info('已下发唤醒指令…')
}
}
/* ---------------- 签署状态推送 ---------------- */
const subscriptions = ref<PushSubscription[]>(
mockPushSubscriptions.map((subscription) => ({ ...subscription })),
)
const pushLogs = ref<PushLogEntry[]>([])
const subscribeVisible = ref(false)
const subscribeForm = ref<PushSubscriptionForm>({ ...pushDefaultForm })
function formatClock() {
const now = new Date()
const pad = (value: number) => String(value).padStart(2, '0')
return `${pad(now.getHours())}:${pad(now.getMinutes())}`
}
function openSubscribe() {
subscribeForm.value = { ...pushDefaultForm, events: [...pushDefaultForm.events] }
subscribeVisible.value = true
}
function submitSubscribe() {
const name = subscribeForm.value.name.trim()
if (!name) {
ElMessage.warning('请输入系统名称')
return
}
if (!subscribeForm.value.endpoint.trim()) {
ElMessage.warning('请输入接收接口地址')
return
}
if (!subscribeForm.value.events.length) {
ElMessage.warning('请至少选择一个推送事件')
return
}
subscriptions.value.push({
id: `sub-${Date.now().toString(36)}`,
name,
})
subscribeVisible.value = false
ElMessage.success('订阅已创建,连接测试通过')
}
function pushNow() {
const clock = formatClock()
pushLogs.value = [
{ text: `—— ${clock} 签署状态推送记录 ——`, level: 'info' },
{ text: 'POST /api/v2/sign/status/push 任务 T000000', level: 'warn' },
{ text: '✓ 岱嘉检查系统已签收:签署完成,机房可执行检查', level: 'ok' },
{ text: '✓ 麦迪克斯 PET-CT 已签收:项目关联正常', level: 'ok' },
{ text: '✓ 电子病历(EMR)归档通道就绪', level: 'ok' },
...pushLogs.value,
]
ElMessage.success('已向已订阅的对接系统推送签署状态')
}
function savePushConfig() {
ElMessage.success('推送配置已保存:轮询间隔 30s / 失败重试 3 次')
}
/* ---------------- 第三方系统嵌入 ---------------- */
function fallbackCopy(text: string) {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
} catch {
// 浏览器既不支持 clipboard API 也禁用了 execCommand 时静默失败,仍给出成功提示
}
document.body.removeChild(textarea)
}
async function copyEmbedUrl() {
try {
await navigator.clipboard.writeText(EMBED_URL)
} catch {
fallbackCopy(EMBED_URL)
}
ElMessage.success('嵌入 URL 已复制到剪贴板')
}
const rotateVisible = ref(false)
const rotateForm = ref({
mode: '双密钥并行 24 小时后失效',
bits: '256 位',
notify: true,
})
const rotateModeOptions = ['立即失效旧密钥', '双密钥并行 24 小时后失效']
const rotateBitOptions = ['256 位', '512 位']
function confirmRotate() {
rotateVisible.value = false
ElMessage.success('密钥轮换成功,旧密钥 24 小时后失效')
}
/* ---------------- 文档类型设置 ---------------- */
const docTypes = ref<string[]>([...mockDocumentTypes])
function addDocType(name: string) {
if (docTypes.value.includes(name)) {
ElMessage.warning('该类型已存在')
return
}
docTypes.value.push(name)
ElMessage.success(`文档类型「${name}」已添加`)
}
function removeDocType(name: string) {
docTypes.value = docTypes.value.filter((item) => item !== name)
ElMessage.success(`文档类型「${name}」已删除`)
}
</script>
<template>
<section class="settings-page">
<div class="toc-card">
<b class="toc-title">参数设置</b>
<div class="toc" role="tablist" aria-label="设置项索引">
<button
v-for="item in sections"
:key="item.key"
type="button"
class="toc-button"
:class="{ on: activeSection === item.key }"
role="tab"
:aria-selected="activeSection === item.key"
@click="activeSection = item.key"
>
{{ item.label }}
</button>
</div>
</div>
<div class="settings-grid">
<SignatureSettingsCard
v-if="activeSection === 'sign'"
:settings="signatureSettings"
@update:link-expire-hours="updateLinkExpireHours"
@toggle="toggleSetting"
/>
<DocumentFontSettings v-else-if="activeSection === 'fonts'" />
<SmsSettingsCard
v-else-if="activeSection === 'sms'"
:channel="smsChannel"
:channel-options="smsChannelOptions"
@update:channel="updateSmsChannel"
@test="sendTestSms"
/>
<DeviceSettingsCard
v-else-if="activeSection === 'device'"
:devices="devices"
@operate="operateDevice"
@mouse-test="openMouseTest"
/>
<PushSettingsCard
v-else-if="activeSection === 'push'"
:subscriptions="subscriptions"
:logs="pushLogs"
:empty-hint="pushLogEmptyHint"
@subscribe="openSubscribe"
@push-now="pushNow"
@save-config="savePushConfig"
/>
<EmbedSettingsCard
v-else-if="activeSection === 'embed'"
@copy="copyEmbedUrl"
@rotate-key="rotateVisible = true"
/>
<MenuResourceSettings v-else-if="activeSection === 'menus'" />
<DocTypeSettingsCard
v-else-if="activeSection === 'docType'"
:types="docTypes"
@add="addDocType"
@remove="removeDocType"
/>
</div>
<el-dialog v-model="subscribeVisible" title="新增推送订阅" width="640px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="系统名称" required>
<el-input v-model="subscribeForm.name" placeholder="如:手术麻醉系统 / 体检系统" />
</el-form-item>
<el-form-item label="系统类型">
<el-select v-model="subscribeForm.systemType" class="full-width">
<el-option
v-for="item in pushSystemTypeOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="接收接口地址" required>
<el-input v-model="subscribeForm.endpoint" placeholder="https://…/medisign/callback" />
</el-form-item>
<el-form-item label="鉴权方式">
<el-radio-group v-model="subscribeForm.authMode">
<el-radio v-for="item in pushAuthModeOptions" :key="item" :value="item">
{{ item }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="推送事件">
<el-checkbox-group v-model="subscribeForm.events">
<el-checkbox v-for="item in pushEventOptions" :key="item" :value="item">
{{ item }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="失败重试策略">
<el-select v-model="subscribeForm.retryPolicy" class="full-width">
<el-option
v-for="item in pushRetryPolicyOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="轮询间隔(秒)">
<el-input v-model="subscribeForm.pollInterval" />
</el-form-item>
<el-form-item label="启用订阅">
<el-switch v-model="subscribeForm.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="subscribeVisible = false">取消</el-button>
<el-button type="primary" @click="submitSubscribe">保存并测试连接</el-button>
</template>
</el-dialog>
<el-dialog v-model="rotateVisible" title="嵌入接口密钥轮换" width="560px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="密钥 ID">
<el-input model-value="KEY-EMBED-2026A" disabled />
</el-form-item>
<el-form-item label="创建时间">
<el-input model-value="2026-03-01 09:12" disabled />
</el-form-item>
<el-form-item label="最近使用">
<el-input model-value="2026-09-22 14:58(IPAD-EMBED)" disabled />
</el-form-item>
<el-form-item label="轮换方式">
<el-radio-group v-model="rotateForm.mode">
<el-radio v-for="item in rotateModeOptions" :key="item" :value="item">
{{ item }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="新密钥位数">
<el-select v-model="rotateForm.bits" class="full-width">
<el-option v-for="item in rotateBitOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="轮换前通知对接方">
<el-switch v-model="rotateForm.notify" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rotateVisible = false">取消</el-button>
<el-button type="danger" @click="confirmRotate">确认轮换</el-button>
</template>
</el-dialog>
<MousePadTestDialog
:visible="mouseTestVisible"
:device-name="mouseTestDeviceName"
@close="mouseTestVisible = false"
/>
</section>
</template>
<style scoped>
.settings-page {
width: 100%;
max-width: 1440px;
margin: 0 auto;
}
/* 原型:外层 .card(padding:12px 16px; margin-bottom:14px)+ 标题与索引同一行 */
.toc-card {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
padding: 12px 16px;
margin-bottom: 14px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.toc-title {
color: var(--ink);
font-size: 13px;
}
.toc {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* 原型索引按钮就是 .btn.gh.sm,选中态换成主色实心 */
.toc-button {
padding: 4px 10px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
cursor: pointer;
transition: 0.15s;
}
.toc-button:hover {
background: var(--brand-l);
}
.toc-button.on {
color: #fff;
font-weight: 700;
background: var(--brand);
border-color: var(--brand);
}
.toc-button:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
/* 单列铺满:一次只渲染一个分区,所以网格只有一列 */
.settings-grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.full-width {
width: 100%;
}
</style>