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
@@ -0,0 +1,179 @@
<script setup lang="ts">
/**
* 签署设备管理(原型 V10.2 `#setSecDev`)。
*
* 手写签名板与平板终端共用一张表,列固定为「设备类型 / 设备 / 点位 / 状态 / 操作」。
* 操作列按设备类型分流:手写板重连;平板在线自检、离线唤醒(原型 `onclick` 的文案)。
* 设备状态是本地演示数据,没有后端接口。
*/
import type { DeviceKind, SignatureDevice } from '../types'
defineProps<{
devices: SignatureDevice[]
}>()
const emit = defineEmits<{
operate: [device: SignatureDevice]
}>()
const kindLabels: Record<DeviceKind, string> = {
pad: '手写签名板',
tablet: '平板终端',
}
/** 平板在线要带电量(原型「在线 · 电量86%」) */
function statusText(device: SignatureDevice) {
if (device.status === 'connected') return '已连接'
if (device.status === 'disconnected') return '未连接'
if (device.status === 'online') {
return device.battery === undefined ? '在线' : `在线 · 电量${device.battery}%`
}
return '离线'
}
function statusClass(device: SignatureDevice) {
if (device.kind === 'tablet' && device.status === 'online') return 'status-ipad'
if (device.status === 'connected') return 'status-connected'
return 'status-idle'
}
/** 手写板只能重连;平板在线自检、离线唤醒 */
function actionLabel(device: SignatureDevice) {
if (device.kind === 'pad') return '重连'
return device.status === 'online' ? '自检' : '唤醒'
}
</script>
<template>
<section class="setting-card">
<div class="card-heading">
<h2>签署设备管理</h2>
</div>
<div class="table-scroll">
<table class="device-table">
<thead>
<tr>
<th>设备类型</th>
<th>设备</th>
<th>点位</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="device in devices" :key="device.id">
<td>{{ kindLabels[device.kind] }}</td>
<td>{{ device.model }}</td>
<td>{{ device.location }}</td>
<td>
<span class="status-tag" :class="statusClass(device)">{{ statusText(device) }}</span>
</td>
<td>
<button type="button" class="outline-button" @click="emit('operate', device)">
{{ actionLabel(device) }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
.setting-card {
min-width: 0;
padding: 16px 18px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.card-heading {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 14px;
}
.card-heading h2 {
margin: 0;
color: var(--ink);
font-size: 14.5px;
line-height: 1.4;
}
.table-scroll {
width: 100%;
overflow-x: auto;
}
.device-table {
width: 100%;
min-width: 620px;
font-size: 13px;
border-collapse: collapse;
}
.device-table th {
padding: 9px 12px;
color: var(--mut);
font-size: 12.5px;
font-weight: 700;
text-align: left;
white-space: nowrap;
background: #f5f9fa;
border-bottom: 1px solid var(--line);
}
.device-table td {
padding: 9px 12px;
vertical-align: middle;
border-bottom: 1px solid #eef4f6;
}
.device-table tbody tr:hover td {
background: #f7fbfc;
}
.status-tag {
display: inline-block;
padding: 2px 10px;
font-size: 11.5px;
font-weight: 700;
white-space: nowrap;
border-radius: 20px;
}
/* 已连接 → 原型 .tg-done */
.status-connected {
color: var(--ok);
background: var(--ok-l);
}
/* 平板在线 → 原型 .tg-ipad */
.status-ipad {
color: #2b6cb0;
background: #e8f1fb;
}
/* 未连接 / 离线 → 原型 .tg-wait */
.status-idle {
color: var(--info);
background: var(--info-l);
}
.outline-button {
padding: 4px 10px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
}
.outline-button:hover {
background: var(--brand-l);
}
</style>
@@ -0,0 +1,162 @@
<script setup lang="ts">
/**
* 文档类型设置(原型 V10.2 `#setDocTypeCard`)。
*
* 一行标签 + 「新类型名称 / + 添加」,标签右侧的 × 直接删除。
* 原型底部提示:文档库 / 新增文书 / 报表筛选的「文档类型」下拉都读这里 —— 目前
* 那三处的下拉还是各自的演示词表,本页维护的清单**只在页面内存里**(无后端接口)。
*/
import { ref } from 'vue'
defineProps<{
types: string[]
}>()
const emit = defineEmits<{
add: [name: string]
remove: [name: string]
}>()
const draft = ref('')
function submit() {
const name = draft.value.trim()
if (!name) return
emit('add', name)
draft.value = ''
}
</script>
<template>
<section class="setting-card">
<div class="card-heading">
<h2>文档类型设置</h2>
</div>
<div class="type-row">
<span v-for="type in types" :key="type" class="type-tag">
{{ type }}
<button
type="button"
class="tag-remove"
:aria-label="`删除文档类型 ${type}`"
@click="emit('remove', type)"
>
×
</button>
</span>
<span class="type-add">
<input
v-model="draft"
class="type-input"
placeholder="新类型名称"
aria-label="新类型名称"
@keyup.enter="submit"
/>
<button type="button" class="outline-button" @click="submit">+ 添加</button>
</span>
</div>
<p class="type-hint">文档库 / 新增文书 / 报表筛选中的「文档类型」下拉即读取此处配置</p>
</section>
</template>
<style scoped>
.setting-card {
min-width: 0;
padding: 16px 18px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.card-heading {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 14px;
}
.card-heading h2 {
margin: 0;
color: var(--ink);
font-size: 14.5px;
line-height: 1.4;
}
.type-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
/* 原型 .exam-tag 的配色,字号/内边距按设置页放大一档 */
.type-tag {
display: inline-flex;
gap: 6px;
align-items: center;
padding: 6px 10px;
color: #2b6cb0;
font-size: 12.5px;
font-weight: 700;
background: #eef6fb;
border-radius: 10px;
}
.tag-remove {
padding: 0;
color: inherit;
font-weight: 700;
line-height: 1;
cursor: pointer;
background: none;
border: 0;
opacity: 0.55;
}
.tag-remove:hover {
opacity: 1;
}
.type-add {
display: inline-flex;
gap: 6px;
align-items: center;
}
.type-input {
width: 130px;
padding: 5px 8px;
color: var(--ink);
font-size: 12.5px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.type-input:focus {
outline: none;
border-color: var(--brand);
}
.type-hint {
margin: 12px 0 0;
color: var(--mut);
font-size: 12px;
}
.outline-button {
padding: 4px 10px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
}
.outline-button:hover {
background: var(--brand-l);
}
</style>
@@ -0,0 +1,165 @@
<script setup lang="ts">
/**
* 签署状态推送(原型 V10.2 `#setSecPush`)。
*
* 三段结构:已订阅系统标签行(右侧「+ 订阅」)→ 推送日志框 → 底部两个动作按钮。
* 推送日志在原型里就是前端拼字符串(`pushSignLog()` 把新记录 unshift 到框顶),
* 这里沿用同样的做法,没有后端接口。
*/
import type { PushLogEntry, PushSubscription } from '../types'
defineProps<{
subscriptions: PushSubscription[]
logs: PushLogEntry[]
/** 日志框为空时显示的提示文案 */
emptyHint: string
}>()
const emit = defineEmits<{
subscribe: []
pushNow: []
saveConfig: []
}>()
</script>
<template>
<section class="setting-card">
<div class="card-heading">
<h2>签署状态推送(对接系统联动)</h2>
</div>
<div class="subscription-row">
<span class="subscription-label">已订阅系统:</span>
<span v-for="item in subscriptions" :key="item.id" class="subscription-tag">
{{ item.name }}
</span>
<button type="button" class="outline-button subscribe-button" @click="emit('subscribe')">
+ 订阅
</button>
</div>
<div class="push-log">
<template v-if="logs.length">
<div v-for="(entry, index) in logs" :key="index" :class="`log-${entry.level}`">
{{ entry.text }}
</div>
</template>
<template v-else>
<div class="log-info">—— 等待签署事件推送 ——</div>
<div class="log-hint">{{ emptyHint }}</div>
</template>
</div>
<div class="button-row">
<button type="button" class="outline-button" @click="emit('pushNow')">
立即推送当前任务状态
</button>
<button type="button" class="outline-button" @click="emit('saveConfig')">保存配置</button>
</div>
</section>
</template>
<style scoped>
.setting-card {
min-width: 0;
padding: 16px 18px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.card-heading {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 14px;
}
.card-heading h2 {
margin: 0;
color: var(--ink);
font-size: 14.5px;
line-height: 1.4;
}
.subscription-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 10px;
font-size: 12.5px;
}
.subscription-label {
color: var(--ink);
}
/* 已订阅系统标签沿用原型 .tag.tg-done 的绿 */
.subscription-tag {
display: inline-block;
padding: 2px 10px;
color: var(--ok);
font-size: 11.5px;
font-weight: 700;
background: var(--ok-l);
border-radius: 20px;
}
.subscribe-button {
margin-left: auto;
}
/* 原型 .pushlog:等宽小字 + 最高 150px 内滚动,新记录插在最前 */
.push-log {
max-height: 150px;
padding: 8px 10px;
overflow-y: auto;
color: var(--ink);
font-family: Consolas, 'Microsoft YaHei', monospace;
font-size: 11px;
line-height: 1.9;
word-break: break-all;
background: #f4f8fa;
border: 1px solid var(--line);
border-radius: 8px;
}
.log-ok {
color: var(--ok);
}
.log-warn {
color: var(--warn);
}
.log-info {
color: #8a9aa3;
}
.log-hint {
color: var(--mut);
font-family: var(--font-sans);
font-size: 11.5px;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.outline-button {
padding: 4px 10px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
}
.outline-button:hover {
background: var(--brand-l);
}
</style>
@@ -65,6 +65,22 @@ function handleHoursInput(event: Event) {
<span class="setting-hint">PDF 原件 + 签名图片</span>
</div>
<div class="setting-row">
<span class="setting-label">短信签署身份校验</span>
<button
type="button"
class="toggle-button"
:class="{ active: settings.smsIdentityVerify }"
role="switch"
:aria-checked="settings.smsIdentityVerify"
aria-label="短信签署身份校验"
@click="emit('toggle', 'smsIdentityVerify')"
>
<span aria-hidden="true" />
</button>
<span class="setting-hint">就诊号 + 姓名 + 证件后四位</span>
</div>
<div class="setting-row">
<span class="setting-label">预留 CA 数字证书</span>
<button
@@ -1,139 +0,0 @@
<script setup lang="ts">
import type { DeviceStatus, TabletDevice } from '../types'
defineProps<{
devices: TabletDevice[]
}>()
const emit = defineEmits<{
reconnect: [device: TabletDevice]
}>()
const statusLabels: Record<DeviceStatus, string> = {
connected: '已连接',
disconnected: '未连接',
}
</script>
<template>
<section class="setting-card">
<div class="card-heading">
<h2>手写板设备</h2>
</div>
<div class="table-scroll">
<table class="device-table">
<thead>
<tr>
<th>设备</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="device in devices" :key="device.id">
<td>{{ device.name }}</td>
<td>
<span class="status-tag" :class="'status-' + device.status">
{{ statusLabels[device.status] }}
</span>
</td>
<td>
<button type="button" class="outline-button" @click="emit('reconnect', device)">
重连
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
.setting-card {
min-width: 0;
padding: 16px 18px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.card-heading {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 14px;
}
.card-heading h2 {
margin: 0;
color: var(--ink);
font-size: 14.5px;
line-height: 1.4;
}
.table-scroll {
width: 100%;
overflow-x: auto;
}
.device-table {
width: 100%;
min-width: 430px;
font-size: 13px;
border-collapse: collapse;
}
.device-table th {
padding: 9px 12px;
color: var(--mut);
font-size: 12.5px;
font-weight: 700;
text-align: left;
white-space: nowrap;
background: #f5f9fa;
border-bottom: 1px solid var(--line);
}
.device-table td {
padding: 9px 12px;
vertical-align: middle;
border-bottom: 1px solid #eef4f6;
}
.device-table tbody tr:hover td {
background: #f7fbfc;
}
.status-tag {
display: inline-block;
padding: 2px 10px;
font-size: 11.5px;
font-weight: 700;
border-radius: 20px;
}
.status-connected {
color: var(--ok);
background: var(--ok-l);
}
.status-disconnected {
color: var(--info);
background: var(--info-l);
}
.outline-button {
padding: 4px 10px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
}
.outline-button:hover {
background: var(--brand-l);
}
</style>
@@ -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>
@@ -1,9 +1,25 @@
import type { SignatureSettings, SmsChannel, SmsChannelOption, TabletDevice } from './types'
/**
* 系统设置页的演示数据(原型 V10.2 · `#sc-set`)。
*
* 这些分区**都没有后端接口**,数据只活在页面内存里,刷新即回到初值 —— 与原型一致。
* 取值口径照抄原型:签署参数 5 项、签署设备 6 台(3 手写签名板 + 3 平板终端)、
* 已订阅系统 3 个、文档类型 5 个。
*/
import type {
DocumentType,
PushSubscription,
PushSubscriptionForm,
SignatureDevice,
SignatureSettings,
SmsChannel,
SmsChannelOption,
} from './types'
export const mockSignatureSettings: SignatureSettings = {
linkExpireHours: 48,
timeoutReminder: true,
dualRetention: true,
smsIdentityVerify: true,
caCertificate: false,
}
@@ -14,20 +30,90 @@ export const smsChannelOptions: SmsChannelOption[] = [
export const defaultSmsChannel: SmsChannel = 'hospital-primary'
export const mockTabletDevices: TabletDevice[] = [
export const mockSignatureDevices: SignatureDevice[] = [
{ id: 'dev-01', kind: 'pad', model: '汉王 GP-1108U', location: '护士站01', status: 'connected' },
{ id: 'dev-02', kind: 'pad', model: '汉王 GP-1108U', location: '护士站02', status: 'connected' },
{
id: 'tablet-01',
name: '汉王 GP-1108U(护士站01)',
status: 'connected',
},
{
id: 'tablet-02',
name: '汉王 GP-1108U(护士站02)',
status: 'connected',
},
{
id: 'tablet-03',
name: 'Wacom STU-430(放射登记台)',
id: 'dev-03',
kind: 'pad',
model: 'Wacom STU-430',
location: '放射登记台',
status: 'disconnected',
},
{
id: 'dev-04',
kind: 'tablet',
model: 'Pad Pro 11″',
location: '儿科急诊 · 移动签署车01',
status: 'online',
battery: 86,
},
{
id: 'dev-05',
kind: 'tablet',
model: 'Pad Pro 11″',
location: '儿科急诊 · 诊间02',
status: 'online',
battery: 72,
},
{
id: 'dev-06',
kind: 'tablet',
model: 'Pad Air',
location: '核医学科 · PET护士台',
status: 'offline',
},
]
export const mockPushSubscriptions: PushSubscription[] = [
{ id: 'sub-01', name: '岱嘉检查系统' },
{ id: 'sub-02', name: '麦迪克斯 PET-CT' },
{ id: 'sub-03', name: '电子病历 EMR(归档)' },
]
/** 推送日志为空时的提示(原型 `#pushLogBox` 初值) */
export const pushLogEmptyHint =
'签署完成后将自动推送「签署状态 + 文档URL」至已订阅系统,未签署/异常任务在岱嘉侧醒目提醒。'
export const pushSystemTypeOptions = [
'HIS',
'EMR 电子病历',
'LIS',
'PACS',
'体检系统',
'手术麻醉',
'第三方归档',
]
export const pushAuthModeOptions = ['HMAC-SHA256 签名', 'OAuth2 客户端凭据', '固定 Token']
export const pushEventOptions = [
'任务发起',
'签署完成',
'患者拒签',
'任务超时',
'任务作废',
'科室复核签章',
]
export const pushRetryPolicyOptions = ['3 次,间隔 30s', '5 次,间隔 60s', '不重试']
export const pushDefaultForm: PushSubscriptionForm = {
name: '',
systemType: 'HIS',
endpoint: '',
authMode: 'HMAC-SHA256 签名',
events: ['签署完成', '患者拒签', '任务超时'],
retryPolicy: '3 次,间隔 30s',
pollInterval: '30',
enabled: true,
}
/** 原型 `docTypes` 初值 */
export const mockDocumentTypes: DocumentType[] = [
'知情同意书',
'风险告知书',
'入院告知',
'检查预约单',
'授权委托',
]
@@ -1,37 +1,83 @@
export type SettingToggleKey = 'timeoutReminder' | 'dualRetention' | 'caCertificate'
/**
* 系统设置页(原型 V10.2 · `#sc-set`)的页面模型。
*
* 除「菜单设置」外,本页各分区目前都**没有后端接口**,页面只用本地演示数据渲染,
* 因此这里的类型描述的是「页面要展示什么」,不是接口契约。菜单设置走真实接口,
* 其类型在 `@/api/management/types`(PermissionResourceNodeDto 等)。
*/
export type DeviceStatus = 'connected' | 'disconnected'
export type SmsChannel = 'hospital-primary' | 'third-party-backup'
/** 签署参数里可开关的项(对应原型 `.switch` 行) */
export type SettingToggleKey =
'timeoutReminder' | 'dualRetention' | 'smsIdentityVerify' | 'caCertificate'
export interface SignatureSettings {
/** 线上链接有效期(小时),原型默认 48 */
linkExpireHours: number
/** 链接超时自动提醒 */
timeoutReminder: boolean
/** 签名原图双留存:PDF 原件 + 签名图片 */
dualRetention: boolean
/** 短信签署身份校验:就诊号 + 姓名 + 证件后四位 */
smsIdentityVerify: boolean
/** 预留 CA 数字证书(扩展位,本期未启用) */
caCertificate: boolean
}
export interface TabletDevice {
/**
* 设备类型决定操作列按钮:
* - `pad` 手写签名板 → 重连
* - `tablet` 平板终端 → 在线自检 / 离线唤醒
*/
export type DeviceKind = 'pad' | 'tablet'
/**
* 手写签名板只有「已连接 / 未连接」,平板终端只有「在线 / 离线」;
* 原型里它们共用一张表,所以状态放在同一个联合类型里。
*/
export type DeviceStatus = 'connected' | 'disconnected' | 'online' | 'offline'
export interface SignatureDevice {
id: string
name: string
kind: DeviceKind
/** 设备型号,如「汉王 GP-1108U」 */
model: string
/** 点位,如「护士站01」「儿科急诊 · 移动签署车01」 */
location: string
status: DeviceStatus
/** 平板终端在线时的电量百分比;手写签名板与离线平板没有该值 */
battery?: number
}
export type SmsChannel = 'hospital-primary' | 'third-party-backup'
export interface SmsChannelOption {
value: SmsChannel
label: string
}
export interface SettingFormItem {
key: string
label: string
description: string
value: string | number | boolean
valueType: 'string' | 'number' | 'boolean'
/** 签署状态推送的订阅目标(对接系统) */
export interface PushSubscription {
id: string
name: string
}
export interface SettingSection {
key: string
label: string
items: SettingFormItem[]
/** 新增订阅弹窗的表单值(原型 `openSub()` 的字段集) */
export interface PushSubscriptionForm {
name: string
systemType: string
endpoint: string
authMode: string
events: string[]
retryPolicy: string
pollInterval: string
enabled: boolean
}
/** 推送日志的一条记录;`level` 决定配色(ok 绿 / warn 橙 / info 灰) */
export interface PushLogEntry {
text: string
level: 'ok' | 'warn' | 'info'
}
/** 文档类型设置里的一项;文档库 / 新增文书 / 报表筛选的下拉都读这里 */
export type DocumentType = string