Files
his/openhis-ui-vue3/src/views/triageandqueuemanage/callnumbervoice/index.vue
chenqi 49b8a975a8 feat(invoice): 完善发票管理权限控制和检验申请功能
- 超级管理员可以编辑操作员字段,普通用户不可编辑
- 修改权限判断逻辑,只有用户名等于 'admin' 的用户才是超级管理员
- 非超级管理员用户只能查询自己的发票数据
- 添加根据员工ID更新操作员名称功能
- 新增行时根据用户权限填充信息
- 严格检查权限,超级管理员可以删除所有记录,普通用户只能删除自己维护的记录
- 在 bargain 组件中验证患者选择
- 添加检验申请单相关API接口
- 在医生工作站中添加检验申请tab页
- 实现检验申请单的增删改查功能
- 添加公告通知已读记录相关功能
- 实现用户未读公告数量统计和标记已读功能
2025-12-30 13:52:06 +08:00

754 lines
19 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="call-voice-settings">
<!-- 标题区域 -->
<div class="title-section">
<h1>叫号语音设置</h1>
</div>
<!-- 语音设置模块 -->
<div class="card">
<div class="card-header">
<h2 class="card-title">科室叫号语音设置</h2>
<div class="btn-group">
<button class="btn btn-primary" @click="saveSettings" :disabled="loading">
<span v-if="loading">保存中...</span>
<span v-else>保存设置</span>
</button>
<button class="btn btn-secondary" @click="cancelSettings" :disabled="loading">取消</button>
</div>
</div>
<div class="settings-section">
<!-- 播放次数设置 -->
<div class="setting-item">
<div class="setting-title">
<div class="icon">🔢</div>
<div>播放次数</div>
</div>
<div class="setting-content">
<div class="form-group">
<label class="form-label">播放次数</label>
<select v-model="settings.playCount" class="form-control" :disabled="loading">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
</div>
<div class="play-controls">
<div class="play-btn" @click="testPlay" :disabled="loading">
<svg v-if="!isPlaying" width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M8 5V19L19 12L8 5Z" fill="white"/>
</svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M14 19H18V5H14V19ZM6 19H10V5H6V19Z" fill="white"/>
</svg>
</div>
<span v-if="loading">加载中...</span>
<span v-else-if="isPlaying">正在播放...</span>
<span v-else>点击测试播放效果</span>
</div>
</div>
</div>
<!-- 语音内容设置 -->
<div class="setting-item">
<div class="setting-title">
<div class="icon">🗣</div>
<div>语音内容</div>
</div>
<div class="setting-content">
<div class="form-group">
<label class="form-label">叫号前缀</label>
<input
type="text"
class="form-control"
placeholder="例如:请"
v-model="settings.prefix"
:disabled="loading"
>
</div>
<div class="form-group">
<label class="form-label">叫号后缀</label>
<input
type="text"
class="form-control"
placeholder="例如:到诊室就诊"
v-model="settings.suffix"
:disabled="loading"
>
</div>
<div class="form-group">
<label class="form-label">语音速度</label>
<select v-model="settings.voiceSpeed" class="form-control" :disabled="loading">
<option value="slow">较慢</option>
<option value="normal">正常</option>
<option value="fast">较快</option>
</select>
</div>
</div>
</div>
<!-- 其他设置 -->
<div class="setting-item">
<div class="setting-title">
<div class="icon"></div>
<div>其他设置</div>
</div>
<div class="setting-content">
<div class="form-group">
<label class="form-label">音量设置</label>
<input
type="range"
min="0"
max="100"
v-model="settings.volume"
class="form-control"
@input="updateVolume"
:disabled="loading"
>
<div style="text-align: center; margin-top: 5px; color: var(--text-light);">
{{ settings.volume }}%
</div>
</div>
<div class="form-group">
<label class="form-label">播放间隔</label>
<select v-model="settings.playInterval" class="form-control" :disabled="loading">
<option value="3">3</option>
<option value="5">5</option>
<option value="10">10</option>
<option value="15">15</option>
<option value="20">20</option>
</select>
</div>
<div class="form-group">
<label class="switch-label">
<div class="switch">
<input type="checkbox" v-model="settings.repeatPlay" :disabled="loading">
<span class="slider"></span>
</div>
<span>开启重复播放</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElSelect, ElMessage } from 'element-plus'
import {
getCallNumberVoiceConfig,
addCallNumberVoiceConfig,
updateCallNumberVoiceConfig
} from '../api'
// 响应式数据
const isPlaying = ref(false)
const loading = ref(false)
const settings = reactive({
playCount: 2,
prefix: '请',
suffix: '到诊室就诊',
voiceSpeed: 'normal',
volume: 80,
playInterval: 10,
repeatPlay: true
})
// 存储原始设置,用于取消时恢复
const originalSettings = reactive({ ...settings })
// 速度值映射函数 - 后端期望中文值
const mapSpeedToDatabase = (frontendSpeed) => {
const speedMap = {
'slow': '较慢',
'normal': '正常',
'fast': '较快'
}
const result = speedMap[frontendSpeed] || '正常'
console.log(`🔄 mapSpeedToDatabase: ${frontendSpeed} -> ${result}`)
return result
}
const mapSpeedToFrontend = (databaseSpeed) => {
console.log(`🔍 mapSpeedToFrontend 输入: ${databaseSpeed} (类型: ${typeof databaseSpeed})`)
const speedMap = {
'较慢': 'slow',
'正常': 'normal',
'较快': 'fast'
}
const result = speedMap[databaseSpeed] || 'normal'
console.log(`✅ mapSpeedToFrontend 输出: ${databaseSpeed} -> ${result}`)
return result
}
// 方法
const saveSettings = async () => {
console.log('💾 开始保存设置...', settings)
loading.value = true
try {
// 验证必填数据
if (!settings.prefix || settings.prefix.trim() === '') {
throw new Error('请填写叫号前缀')
}
if (!settings.suffix || settings.suffix.trim() === '') {
throw new Error('请填写叫号后缀')
}
const configData = {
playCount: parseInt(settings.playCount),
callPrefix: settings.prefix.trim(),
callSuffix: settings.suffix.trim(),
speed: mapSpeedToDatabase(settings.voiceSpeed),
volume: parseInt(settings.volume),
intervalSeconds: parseInt(settings.playInterval),
cycleBroadcast: Boolean(settings.repeatPlay)
}
console.log('📤 准备保存的数据:', configData)
// 验证ID是否存在
if (!originalSettings.id) {
console.log('⚠️ 未找到配置ID尝试使用新增接口')
// 尝试新增配置
const response = await addCallNumberVoiceConfig(configData)
console.log('✅ 新增接口返回:', response)
if (response.data && response.data.id) {
configData.id = response.data.id
}
} else {
console.log('📝 使用更新接口配置ID:', originalSettings.id)
configData.id = originalSettings.id
// 使用更新接口保存设置
const response = await updateCallNumberVoiceConfig(configData)
console.log('✅ 更新接口返回:', response)
}
// 更新原始设置
Object.assign(originalSettings, {
id: configData.id,
playCount: configData.playCount,
callPrefix: configData.callPrefix,
callSuffix: configData.callSuffix,
speed: configData.speed,
volume: configData.volume,
intervalSeconds: configData.intervalSeconds,
cycleBroadcast: configData.cycleBroadcast
})
console.log('✅ 原始设置已更新:', originalSettings)
ElMessage.success('设置保存成功!')
} catch (error) {
console.error('❌ 保存失败:', error)
ElMessage.error('保存失败:' + (error.message || '请稍后重试'))
} finally {
loading.value = false
}
}
const cancelSettings = () => {
if (confirm('确定要取消所有更改吗?')) {
// 恢复到从服务器加载的原始数据
if (originalSettings.id) {
Object.assign(settings, {
playCount: originalSettings.playCount || 2,
prefix: originalSettings.callPrefix || '请',
suffix: originalSettings.callSuffix || '到诊室就诊',
voiceSpeed: originalSettings.speed || 'normal',
volume: originalSettings.volume || 80,
playInterval: originalSettings.intervalSeconds || 10,
repeatPlay: originalSettings.cycleBroadcast !== undefined ? originalSettings.cycleBroadcast : true
})
} else {
// 如果没有原始数据,恢复到默认值
Object.assign(settings, {
playCount: 2,
prefix: '请',
suffix: '到诊室就诊',
voiceSpeed: 'normal',
volume: 80,
playInterval: 10,
repeatPlay: true
})
}
ElMessage.info('已恢复到原始设置')
}
}
const loadSettings = async () => {
loading.value = true
try {
const response = await getCallNumberVoiceConfig()
// 处理后端返回的数据结构response.data.data
let data = response.data
if (response.data && response.data.data) {
data = response.data.data
}
// 验证数据有效性
if (data && data.id) {
// 播放次数处理 - 转换为字符串类型以匹配select选项
const playCountParsed = parseInt(data.playCount)
const playCountFinal = isNaN(playCountParsed) ? "2" : String(playCountParsed)
settings.playCount = playCountFinal
// 音量处理
const volumeParsed = parseInt(data.volume)
const volumeFinal = isNaN(volumeParsed) ? 80 : volumeParsed
settings.volume = volumeFinal
// 播放间隔处理 - 转换为字符串类型以匹配select选项
const intervalParsed = parseInt(data.intervalSeconds)
const intervalFinal = isNaN(intervalParsed) ? "10" : String(intervalParsed)
settings.playInterval = intervalFinal
// 其他字段处理
settings.prefix = data.callPrefix || '请'
settings.suffix = data.callSuffix || '到诊室就诊'
settings.voiceSpeed = mapSpeedToFrontend(data.speed) || 'normal'
settings.repeatPlay = data.cycleBroadcast !== undefined ? Boolean(data.cycleBroadcast) : true
// 存储原始设置
Object.assign(originalSettings, {
id: data.id,
playCount: data.playCount,
callPrefix: data.callPrefix,
callSuffix: data.callSuffix,
speed: data.speed,
volume: data.volume,
intervalSeconds: data.intervalSeconds,
cycleBroadcast: data.cycleBroadcast
})
ElMessage.success('配置加载成功')
} else {
// 如果没有有效配置数据,使用默认设置
if (data && Object.keys(data).length > 0) {
ElMessage.info('未找到现有配置,使用默认设置')
} else {
ElMessage.warning('未找到配置数据,将使用默认设置')
}
// 使用默认配置
Object.assign(settings, {
playCount: "2",
prefix: '请',
suffix: '到诊室就诊',
voiceSpeed: 'normal',
volume: 80,
playInterval: "10",
repeatPlay: true
})
}
} catch (error) {
ElMessage.error('获取设置失败:' + (error.message || '请稍后重试'))
// 错误时使用默认配置
Object.assign(settings, {
playCount: "2",
prefix: '请',
suffix: '到诊室就诊',
voiceSpeed: 'normal',
volume: 80,
playInterval: "10",
repeatPlay: true
})
} finally {
loading.value = false
}
}
// 组件挂载时加载设置
onMounted(() => {
loadSettings()
})
const testPlay = () => {
if (isPlaying.value) return
isPlaying.value = true
const playCount = parseInt(settings.playCount)
// 播放语音
for (let i = 0; i < playCount; i++) {
setTimeout(() => {
speak(`${settings.prefix}1001号${settings.suffix}`)
}, i * 1000)
}
// 恢复按钮状态
setTimeout(() => {
isPlaying.value = false
}, playCount * 1000)
}
const speak = (text) => {
// 使用Web Speech API进行语音合成
if ('speechSynthesis' in window) {
const utterance = new SpeechSynthesisUtterance()
utterance.text = text
utterance.lang = 'zh-CN'
utterance.volume = settings.volume / 100
// 设置语音速度
const speedMap = {
slow: 0.8,
normal: 1,
fast: 1.2
}
utterance.rate = speedMap[settings.voiceSpeed] || 1
window.speechSynthesis.speak(utterance)
} else {
// 当前浏览器不支持语音合成功能
}
}
const updateVolume = () => {
// 音量更新逻辑(显示在界面上)
}
</script>
<style scoped>
/* 基础样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
}
:host {
/* CSS变量定义适配项目主题 */
--primary-color: #409EFF; /* Element Plus 主色调 */
--secondary-color: #909399; /* 次要色 - 中性灰 */
--accent-color: #E6A23C; /* 强调色 - 警告色 */
--background-color: #f5f7fa; /* 背景色 - 浅灰 */
--card-color: #ffffff; /* 卡片背景色 */
--text-color: #303133; /* 主文本色 */
--text-light: #606266; /* 次要文本色 */
--border-color: #dcdfe6; /* 边框色 */
--success-color: #67C23A; /* 成功色 */
--shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); /* Element Plus 阴影 */
}
.call-voice-settings {
background-color: var(--background-color);
color: var(--text-color);
line-height: 1.6;
padding: 20px;
min-height: 100vh;
width: 100%;
}
/* 标题区域 */
.title-section {
background: white;
border-radius: 8px;
padding: 20px 30px;
margin-bottom: 20px;
box-shadow: 0 4px 15px rgba(64, 158, 255, 0.15);
width: 100%;
}
.title-section h1 {
color: #000000;
margin: 0;
text-align: left;
font-size: 24px;
font-weight: 600;
}
/* 按钮样式 */
.btn-group {
display: flex;
gap: 12px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
line-height: 1;
white-space: nowrap;
text-align: center;
background-image: none;
box-sizing: border-box;
outline: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-primary {
background-color: #5b8fb9;
color: white;
border: 1px solid #5b8fb9;
}
.btn-secondary {
background-color: #6c757d;
color: white;
border: 1px solid #6c757d;
}
.btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-primary:hover {
background-color: #4a7a9a;
border-color: #4a7a9a;
}
.btn-secondary:hover {
background-color: #5a6268;
border-color: #5a6268;
}
/* 语音设置卡片 */
.card {
background: #f8f9fa;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(230, 162, 60, 0.15);
padding: 25px;
margin-bottom: 25px;
overflow: hidden;
width: 100%;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 15px;
}
.card-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--primary-color);
display: flex;
align-items: center;
}
.card-title::before {
content: "①";
margin-right: 10px;
font-weight: bold;
}
/* 设置区域样式 */
.settings-section {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.setting-item {
background-color: rgba(64, 158, 255, 0.05);
border-radius: 8px;
padding: 20px;
flex: 1;
min-width: 300px;
}
.setting-title {
font-weight: 600;
margin-bottom: 15px;
display: flex;
align-items: center;
color: var(--text-color);
}
.setting-title .icon {
background-color: rgba(64, 158, 255, 0.1);
color: var(--primary-color);
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
font-size: 16px;
}
.setting-content {
padding-left: 44px;
}
/* 表单控件样式 */
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--text-color);
font-size: 14px;
}
.form-control {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 14px;
transition: border-color 0.3s, box-shadow 0.3s;
box-sizing: border-box;
outline: none;
}
.form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
select.form-control {
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236c757d' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
padding-right: 40px;
}
input[type="range"].form-control {
height: 32px;
padding: 0;
}
/* 开关控件 */
.switch-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 22px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 22px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #4caf50;
}
input:checked + .slider:before {
transform: translateX(22px);
}
/* 播放控制区域 */
.play-controls {
display: flex;
align-items: center;
gap: 15px;
margin-top: 15px;
}
.play-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #f8a978;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
border: none;
outline: none;
}
.play-btn:hover {
transform: scale(1.05);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
background-color: #e69965;
}
/* 响应式布局 */
@media (max-width: 768px) {
.title-section {
padding: 20px;
}
.title-section h1 {
font-size: 24px;
}
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.btn-group {
width: 100%;
justify-content: space-between;
}
.settings-section {
flex-direction: column;
}
.setting-item {
min-width: auto;
}
}
</style>