feat(settings): implement system settings

This commit is contained in:
yelan
2026-08-27 16:49:10 +08:00
parent ac8eff5286
commit 74df2ed741
7 changed files with 764 additions and 9 deletions

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
const emit = defineEmits<{
copy: []
rotateKey: []
}>()
</script>
<template>
<section class="setting-card">
<div class="card-heading">
<h2>第三方系统嵌入免登录接口页</h2>
</div>
<p class="description">
签署工作台可作为 iframe 嵌入 HIS / LIS / 体检等第三方系统URL 携带签名参数免登录直达
</p>
<div class="code-block embed-url">
https://medisign.hospital.cn<strong>/embed/workbench</strong>?uid=<strong>{操作员ID}</strong>&amp;pid=<strong>{患者ID}</strong>&amp;visitNo=<strong>{就诊号}</strong>&amp;ts=<strong>{时间戳}</strong>&amp;sign=<strong
>{HMAC签名}</strong
>
</div>
<p class="description rule-description">
生成规则HMAC-SHA256(密钥, uid+pid+visitNo+ts)有效期 5 分钟防篡改防重放
</p>
<div class="button-row">
<button type="button" class="outline-button" @click="emit('copy')">复制嵌入 URL</button>
<button type="button" class="outline-button" @click="emit('rotateKey')">密钥轮换</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;
}
.description {
margin: 0 0 10px;
color: var(--mut);
font-size: 12px;
line-height: 1.7;
}
.code-block {
padding: 10px 12px;
color: var(--ink);
font-family: Consolas, 'Microsoft YaHei', monospace;
font-size: 12px;
line-height: 1.8;
word-break: break-all;
background: #f4f8fa;
border: 1px solid var(--line);
border-radius: 8px;
}
.code-block strong {
color: var(--brand);
}
.rule-description {
margin: 10px 0;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.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>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import type { SettingToggleKey, SignatureSettings } from '../types'
defineProps<{
settings: SignatureSettings
}>()
const emit = defineEmits<{
'update:linkExpireHours': [value: number]
toggle: [key: SettingToggleKey]
}>()
function handleHoursInput(event: Event) {
emit('update:linkExpireHours', Number((event.target as HTMLInputElement).value))
}
</script>
<template>
<section class="setting-card">
<div class="card-heading">
<h2>签署参数</h2>
</div>
<div class="setting-row">
<span class="setting-label">线上链接有效期</span>
<input
:value="settings.linkExpireHours"
class="hours-input"
type="number"
min="1"
aria-label="线上链接有效期"
@input="handleHoursInput"
/>
<span class="setting-hint">小时</span>
</div>
<div class="setting-row">
<span class="setting-label">链接超时自动提醒</span>
<button
type="button"
class="toggle-button"
:class="{ active: settings.timeoutReminder }"
role="switch"
:aria-checked="settings.timeoutReminder"
aria-label="链接超时自动提醒"
@click="emit('toggle', 'timeoutReminder')"
>
<span aria-hidden="true" />
</button>
</div>
<div class="setting-row">
<span class="setting-label">签名原图双留存</span>
<button
type="button"
class="toggle-button"
:class="{ active: settings.dualRetention }"
role="switch"
:aria-checked="settings.dualRetention"
aria-label="签名原图双留存"
@click="emit('toggle', 'dualRetention')"
>
<span aria-hidden="true" />
</button>
<span class="setting-hint">PDF 原件 + 签名图片</span>
</div>
<div class="setting-row">
<span class="setting-label">预留 CA 数字证书</span>
<button
type="button"
class="toggle-button"
:class="{ active: settings.caCertificate }"
role="switch"
:aria-checked="settings.caCertificate"
aria-label="预留 CA 数字证书"
@click="emit('toggle', 'caCertificate')"
>
<span aria-hidden="true" />
</button>
<span class="setting-hint">扩展位本期未启用</span>
</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;
}
.setting-row {
display: flex;
gap: 10px;
align-items: center;
min-height: 34px;
margin-bottom: 12px;
}
.setting-row:last-child {
margin-bottom: 0;
}
.setting-label {
flex: 0 0 150px;
color: var(--ink);
font-size: 13px;
}
.hours-input {
width: 80px;
padding: 7px 10px;
color: var(--ink);
font-size: 13px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.hours-input:focus {
outline: none;
border-color: var(--brand);
}
.setting-hint {
color: var(--mut);
font-size: 12px;
}
.toggle-button {
position: relative;
width: 36px;
height: 20px;
padding: 0;
background: #cbd8de;
border-radius: 20px;
transition: background-color 0.2s ease;
}
.toggle-button::after {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
content: '';
background: #fff;
border-radius: 50%;
transition: left 0.2s ease;
}
.toggle-button.active {
background: var(--ok);
}
.toggle-button.active::after {
left: 18px;
}
.toggle-button:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
@media (max-width: 600px) {
.setting-row {
align-items: flex-start;
flex-wrap: wrap;
}
.setting-label {
flex-basis: 100%;
}
}
</style>

View File

@@ -0,0 +1,145 @@
<script setup lang="ts">
import type { SmsChannel, SmsChannelOption } from '../types'
defineProps<{
channel: SmsChannel
channelOptions: SmsChannelOption[]
}>()
const emit = defineEmits<{
'update:channel': [value: SmsChannel]
test: []
}>()
function handleChannelChange(event: Event) {
emit('update:channel', (event.target as HTMLSelectElement).value as SmsChannel)
}
</script>
<template>
<section class="setting-card">
<div class="card-heading">
<h2>短信模板</h2>
</div>
<div class="code-block sms-template">
xx医院尊敬的<strong>{患者姓名}</strong>您有一份<strong>{文档名称}</strong>待签署请点击
<strong>{签署链接}</strong> 完成签署链接 {有效期} 小时内有效
</div>
<hr class="divider" />
<label class="channel-row">
<span>短信通道</span>
<select :value="channel" aria-label="选择短信通道" @change="handleChannelChange">
<option v-for="option in channelOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<button type="button" class="outline-button" @click="emit('test')">发送测试短信</button>
</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;
}
.code-block {
padding: 10px 12px;
color: var(--ink);
font-family: Consolas, 'Microsoft YaHei', monospace;
font-size: 12px;
line-height: 1.8;
word-break: break-all;
background: #f4f8fa;
border: 1px solid var(--line);
border-radius: 8px;
}
.code-block strong {
color: var(--brand);
}
.divider {
margin: 12px 0;
border: 0;
border-top: 1px solid var(--line);
}
.channel-row {
display: flex;
gap: 10px;
align-items: center;
color: var(--ink);
font-size: 13px;
}
.channel-row > span {
flex: 0 0 110px;
}
.channel-row select {
width: 200px;
padding: 7px 10px;
color: var(--ink);
font-size: 13px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.channel-row select:focus {
outline: none;
border-color: var(--brand);
}
.outline-button {
padding: 4px 10px;
margin-top: 14px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
}
.outline-button:hover {
background: var(--brand-l);
}
@media (max-width: 600px) {
.channel-row {
align-items: flex-start;
flex-direction: column;
}
.channel-row > span {
flex-basis: auto;
}
.channel-row select {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,139 @@
<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>

View File

@@ -1,12 +1,136 @@
<script setup lang="ts"> <script setup lang="ts">
import PagePlaceholder from '@/components/PagePlaceholder.vue' import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import EmbedSettingsCard from './components/EmbedSettingsCard.vue'
import SignatureSettingsCard from './components/SignatureSettingsCard.vue'
import SmsSettingsCard from './components/SmsSettingsCard.vue'
import TabletDevicesCard from './components/TabletDevicesCard.vue'
import {
defaultSmsChannel,
mockSignatureSettings,
mockTabletDevices,
smsChannelOptions,
} from './mock'
import type { SettingToggleKey, SignatureSettings, SmsChannel, TabletDevice } from './types'
const loading = ref(true)
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
}
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')
}
function reconnectDevice(device: TabletDevice) {
if (device.status === 'connected') {
ElMessage.success('已重新连接')
return
}
ElMessage.info('正在重连…')
}
function copyEmbedUrl() {
ElMessage.success('原型演示:已复制嵌入 URL')
}
function rotateKey() {
ElMessage.success('原型演示:密钥轮换成功')
}
onMounted(loadSettings)
</script> </script>
<template> <template>
<PagePlaceholder <section class="settings-page">
group="管理" <div v-if="loading" class="loading-grid" aria-label="正在加载系统设置">
title="系统设置" <span v-for="index in 4" :key="index" />
icon="⚙" </div>
description="系统设置页面占位,待根据 Web 端需求实现。"
<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>
</section>
</template> </template>
<style scoped>
.settings-page {
width: 100%;
max-width: 1440px;
margin: 0 auto;
}
.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%;
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;
}
}
@media (max-width: 1200px) {
.settings-grid,
.loading-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,33 @@
import type { SignatureSettings, SmsChannel, SmsChannelOption, TabletDevice } from './types'
export const mockSignatureSettings: SignatureSettings = {
linkExpireHours: 48,
timeoutReminder: true,
dualRetention: true,
caCertificate: false,
}
export const smsChannelOptions: SmsChannelOption[] = [
{ value: 'hospital-primary', label: '医院短信网关(主)' },
{ value: 'third-party-backup', label: '第三方短信通道(备)' },
]
export const defaultSmsChannel: SmsChannel = 'hospital-primary'
export const mockTabletDevices: TabletDevice[] = [
{
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放射登记台',
status: 'disconnected',
},
]

View File

@@ -1,10 +1,32 @@
export type SettingValue = string | number | boolean export type SettingToggleKey = 'timeoutReminder' | 'dualRetention' | 'caCertificate'
export type DeviceStatus = 'connected' | 'disconnected'
export type SmsChannel = 'hospital-primary' | 'third-party-backup'
export interface SignatureSettings {
linkExpireHours: number
timeoutReminder: boolean
dualRetention: boolean
caCertificate: boolean
}
export interface TabletDevice {
id: string
name: string
status: DeviceStatus
}
export interface SmsChannelOption {
value: SmsChannel
label: string
}
export interface SettingFormItem { export interface SettingFormItem {
key: string key: string
label: string label: string
description: string description: string
value: SettingValue value: string | number | boolean
valueType: 'string' | 'number' | 'boolean' valueType: 'string' | 'number' | 'boolean'
} }