496 lines
15 KiB
Vue
496 lines
15 KiB
Vue
<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>
|