feat(系统设置): 新增签署设备与文档类型等设置分区并改为索引切换
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user