feat(系统设置): 新增签署设备与文档类型等设置分区并改为索引切换

This commit is contained in:
yelan
2026-09-25 08:38:51 +08:00
parent 81febbdc8b
commit 4fbca4f1a3
8 changed files with 1084 additions and 249 deletions
@@ -1,39 +1,87 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
/**
* 系统设置(原型 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 DeviceSettingsCard from './components/DeviceSettingsCard.vue'
import DocTypeSettingsCard from './components/DocTypeSettingsCard.vue'
import EmbedSettingsCard from './components/EmbedSettingsCard.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 TabletDevicesCard from './components/TabletDevicesCard.vue'
import {
defaultSmsChannel,
mockDocumentTypes,
mockPushSubscriptions,
mockSignatureDevices,
mockSignatureSettings,
mockTabletDevices,
pushDefaultForm,
pushLogEmptyHint,
pushAuthModeOptions,
pushEventOptions,
pushRetryPolicyOptions,
pushSystemTypeOptions,
smsChannelOptions,
} from './mock'
import type { SettingToggleKey, SignatureSettings, SmsChannel, TabletDevice } from './types'
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 loading = ref(true)
const activeTab = ref('general')
const loginStore = useLoginStore()
const canManageMenus = computed(() => loginStore.hasPermission('system:config:menu'))
watch(canManageMenus, (canManage) => {
if (!canManage && activeTab.value === 'menus') activeTab.value = 'general'
type SectionKey = 'sign' | 'sms' | 'device' | 'push' | 'embed' | 'menus' | 'docType'
const sections = computed<Array<{ key: SectionKey; label: string }>>(() => {
const list: Array<{ key: SectionKey; label: string }> = [
{ key: 'sign', 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)
const tabletDevices = ref<TabletDevice[]>([])
async function loadSettings() {
loading.value = true
await Promise.resolve()
tabletDevices.value = mockTabletDevices.map((device) => ({ ...device }))
loading.value = false
}
function updateLinkExpireHours(value: number) {
signatureSettings.linkExpireHours = value
@@ -58,54 +106,301 @@ function sendTestSms() {
ElMessage.success('测试短信已发送至 138****5678')
}
function reconnectDevice(device: TabletDevice) {
if (device.status === 'connected') {
ElMessage.success('已重新连接')
/* ---------------- 签署设备管理 ---------------- */
const devices = ref<SignatureDevice[]>(mockSignatureDevices.map((device) => ({ ...device })))
function operateDevice(device: SignatureDevice) {
if (device.kind === 'pad') {
if (device.status === 'connected') {
ElMessage.success('已重新连接')
} else {
ElMessage.info('正在重连…')
}
return
}
ElMessage.info('正在重连…')
if (device.status === 'online') {
ElMessage.success('已消毒登记 · 固件最新')
} else {
ElMessage.info('已下发唤醒指令…')
}
}
function copyEmbedUrl() {
ElMessage.success('原型演示:已复制嵌入 URL')
/* ---------------- 签署状态推送 ---------------- */
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 rotateKey() {
ElMessage.success('原型演示:密钥轮换成功')
function openSubscribe() {
subscribeForm.value = { ...pushDefaultForm, events: [...pushDefaultForm.events] }
subscribeVisible.value = true
}
onMounted(loadSettings)
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">
<el-tabs v-model="activeTab" class="settings-tabs">
<el-tab-pane label="系统参数" name="general">
<div v-if="loading" class="loading-grid" aria-label="正在加载系统设置">
<span v-for="index in 4" :key="index" />
</div>
<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 v-else class="settings-grid">
<SignatureSettingsCard
:settings="signatureSettings"
@update:link-expire-hours="updateLinkExpireHours"
@toggle="toggleSetting"
/>
<SmsSettingsCard
:channel="smsChannel"
:channel-options="smsChannelOptions"
@update:channel="updateSmsChannel"
@test="sendTestSms"
/>
<TabletDevicesCard :devices="tabletDevices" @reconnect="reconnectDevice" />
<EmbedSettingsCard @copy="copyEmbedUrl" @rotate-key="rotateKey" />
</div>
</el-tab-pane>
<el-tab-pane v-if="canManageMenus" label="菜单设置" name="menus">
<MenuResourceSettings />
</el-tab-pane>
</el-tabs>
<div class="settings-grid">
<SignatureSettingsCard
v-if="activeSection === 'sign'"
:settings="signatureSettings"
@update:link-expire-hours="updateLinkExpireHours"
@toggle="toggleSetting"
/>
<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"
/>
<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>
</section>
</template>
@@ -116,41 +411,66 @@ onMounted(loadSettings)
margin: 0 auto;
}
.settings-tabs {
background: transparent;
}
.settings-grid,
.loading-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.loading-grid span {
display: block;
height: 240px;
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
background-size: 400% 100%;
/* 原型:外层 .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);
animation: skeleton-shimmer 1.4s ease infinite;
}
@keyframes skeleton-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
.toc-title {
color: var(--ink);
font-size: 13px;
}
@media (max-width: 1200px) {
.settings-grid,
.loading-grid {
grid-template-columns: 1fr;
}
.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>