Files
2026-06-09 17:00:23 +08:00

819 lines
17 KiB
Vue

<template>
<view class="config-page">
<view class="phone-frame">
<view class="hero-section">
<image class="hospital-image" :src="hospitalBannerUrl" mode="aspectFill" @error="handleBannerError"></image>
<view class="hero-overlay"></view>
</view>
<view class="profile-section">
<view class="section-glow"></view>
<view class="profile-content">
<view class="intro-row">
<view class="doctor-card">
<image class="doctor-image" src="/static/config-doctor.png" mode="aspectFit"></image>
</view>
<view class="bubble">
<text class="bubble-text" :class="{ typing: typingActive }">{{ bubbleText }}</text>
<view class="bubble-arrow"></view>
</view>
</view>
<view class="form-area">
<view class="field-block">
<text class="field-label">执业科室</text>
<view class="glass-select" @click="openOptionPicker('department')">
<text class="select-value">{{ selectedDepartment.label }}</text>
<view class="chevron"></view>
</view>
</view>
<view class="field-grid">
<view class="field-block">
<text class="field-label">专业职称</text>
<view class="glass-select" @click="openOptionPicker('title')">
<text class="select-value">{{ selectedTitle.label }}</text>
<view class="chevron"></view>
</view>
</view>
<view class="field-block">
<text class="field-label">执业年限</text>
<view class="glass-select" @click="openOptionPicker('experience')">
<text class="select-value">{{ selectedExperience.label }}</text>
<view class="chevron"></view>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="action-section">
<button class="submit-button" :class="{ saved: saveState === 'saved' }" :disabled="saving" @click="handleSubmit">
<view v-if="saving" class="spinner"></view>
<view v-else-if="saveState === 'saved'" class="check-icon"></view>
<view v-else class="check-icon"></view>
<text>{{ submitText }}</text>
</button>
<view class="secure-tip">
<view class="lock-icon"></view>
<text>数据已进行安全加密处理</text>
</view>
</view>
<view class="home-indicator"></view>
</view>
<view class="toast" :class="{ visible: toastVisible }">{{ toastMessage }}</view>
<view v-if="pickerVisible" class="picker-mask" @click="closeOptionPicker">
<view class="picker-panel" @click.stop>
<view class="picker-header">
<text class="picker-title">{{ pickerTitle }}</text>
<text class="picker-close" @click="closeOptionPicker">关闭</text>
</view>
<scroll-view class="option-list" scroll-y>
<view
v-for="option in currentOptions"
:key="option.value"
class="option-item"
:class="{ active: option.value === form[activePicker] }"
@click="chooseOption(option)"
>
<view class="option-copy">
<text class="option-label">{{ option.label }}</text>
<text v-if="option.desc" class="option-desc">{{ option.desc }}</text>
</view>
<view v-if="option.value === form[activePicker]" class="selected-mark"></view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { createProfileOpener } from '../../api/navigation'
import { fetchInstitutionInfo, fetchMyDepartments, type DepartmentRecord } from '../../api/auth'
import {
MOCK_CONFIG_OPTIONS,
fetchConfigOptions,
saveClinicalConfig,
type ClinicalConfigPayload,
type ConfigOption,
type ConfigOptions
} from '../../api/config'
type PickerType = '' | 'department' | 'title' | 'experience'
type OptionGroup = keyof ConfigOptions
type SaveState = 'idle' | 'saved'
const DEFAULT_HOSPITAL_BANNER = '/static/config-hospital.png'
const form = reactive({
department: 'im',
title: 'resident',
experience: '1-3'
})
const options = ref<ConfigOptions>(MOCK_CONFIG_OPTIONS)
const mentorMessage = ref('欢迎回来!请配置执业信息,开始精准带教模拟。')
const bubbleText = ref('')
const typingActive = ref(false)
const activePicker = ref<PickerType>('')
const pickerVisible = ref(false)
const saving = ref(false)
const saveState = ref<SaveState>('idle')
const toastMessage = ref('')
const toastVisible = ref(false)
const hospitalBannerUrl = ref('')
const emit = defineEmits<{
(event: 'open-profile'): void
}>()
const openProfile = createProfileOpener(emit)
let typingTimer: ReturnType<typeof setTimeout> | null = null
let toastTimer: ReturnType<typeof setTimeout> | null = null
const selectedDepartment = computed(() => findOption('departments', form.department))
const selectedTitle = computed(() => findOption('titles', form.title))
const selectedExperience = computed(() => findOption('experiences', form.experience))
const currentOptions = computed(() => {
const optionMap: Record<Exclude<PickerType, ''>, ConfigOption[]> = {
department: options.value.departments,
title: options.value.titles,
experience: options.value.experiences
}
const picker = activePicker.value
return picker ? optionMap[picker] : []
})
const pickerTitle = computed(() => {
const titleMap: Record<Exclude<PickerType, ''>, string> = {
department: '选择执业科室',
title: '选择专业职称',
experience: '选择执业年限'
}
const picker = activePicker.value
return picker ? titleMap[picker] : '请选择'
})
const submitText = computed(() => {
if (saving.value) return '正在保存...'
if (saveState.value === 'saved') return '已就绪'
return '确认并继续'
})
function loadConfigOptions() {
fetchConfigOptions().then(({ options: remoteOptions, defaults, mentor }) => {
options.value = remoteOptions
Object.assign(form, defaults)
mentorMessage.value = mentor.message
startTypewriter()
})
}
function normalizeDepartment(item: DepartmentRecord): ConfigOption {
return {
value: String(item.id),
label: item.name,
desc: item.category
}
}
async function loadDepartments() {
try {
const departments = await fetchMyDepartments()
if (departments.length === 0) return
const departmentOptions = departments.map(normalizeDepartment)
options.value = {
...options.value,
departments: departmentOptions
}
form.department = departmentOptions[0].value
} catch {
// Keep mock departments when the user-specific list cannot be loaded.
}
}
async function loadInstitutionInfo() {
try {
const institution = await fetchInstitutionInfo()
hospitalBannerUrl.value = institution.banner_url || DEFAULT_HOSPITAL_BANNER
} catch {
hospitalBannerUrl.value = DEFAULT_HOSPITAL_BANNER
}
}
function findOption(group: OptionGroup, value: string) {
const groupOptions = options.value[group] || []
return groupOptions.find(item => item.value === value) || groupOptions[0] || { label: '请选择', value: '' }
}
function startTypewriter() {
if (typingTimer) clearTimeout(typingTimer)
bubbleText.value = ''
typingActive.value = true
let index = 0
const tick = () => {
if (index < mentorMessage.value.length) {
bubbleText.value += mentorMessage.value.charAt(index)
index += 1
typingTimer = setTimeout(tick, 48 + Math.floor(Math.random() * 42))
} else {
typingActive.value = false
}
}
tick()
}
function openOptionPicker(type: Exclude<PickerType, ''>) {
activePicker.value = type
pickerVisible.value = true
}
function closeOptionPicker() {
pickerVisible.value = false
activePicker.value = ''
}
function chooseOption(option: ConfigOption) {
const picker = activePicker.value
if (!picker) return
form[picker] = option.value
closeOptionPicker()
}
function handleSubmit() {
const departmentId = Number(form.department)
if (!Number.isInteger(departmentId) || departmentId <= 0) {
showToast('请先选择有效执业科室')
return
}
const payload: ClinicalConfigPayload = {
department: departmentId,
title_name: selectedTitle.value.label,
practice_years: selectedExperience.value.label
}
saving.value = true
saveState.value = 'idle'
saveClinicalConfig(payload).then(result => {
uni.setStorageSync('clinical-thinking-config', result)
saving.value = false
saveState.value = 'saved'
showToast('配置已保存')
setTimeout(() => {
uni.reLaunch({
url: '/pages/home/home'
})
}, 500)
}).catch(error => {
saving.value = false
showToast(error instanceof Error ? error.message : '保存失败,请稍后重试')
})
}
function handleBannerError() {
if (hospitalBannerUrl.value !== DEFAULT_HOSPITAL_BANNER) {
hospitalBannerUrl.value = DEFAULT_HOSPITAL_BANNER
}
}
function showToast(message: string) {
if (toastTimer) clearTimeout(toastTimer)
toastMessage.value = message
toastVisible.value = true
toastTimer = setTimeout(() => {
toastVisible.value = false
}, 2200)
}
onMounted(() => {
loadConfigOptions()
void loadDepartments()
void loadInstitutionInfo()
})
onUnmounted(() => {
if (typingTimer) clearTimeout(typingTimer)
if (toastTimer) clearTimeout(toastTimer)
})
</script>
<style>
page {
min-height: 100%;
background: #f9f9ff;
}
.config-page {
min-height: 100vh;
background: #f9f9ff;
color: #191c21;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
-webkit-tap-highlight-color: transparent;
}
.phone-frame {
position: relative;
width: 100%;
max-width: none;
height: 100vh;
min-height: 760px;
margin: 0 auto;
overflow: hidden;
background: #f9f9ff;
display: flex;
flex-direction: column;
}
.hero-section,
.profile-section,
.action-section {
height: 33.333vh;
min-height: 253px;
flex: 0 0 auto;
}
.hero-section {
position: relative;
overflow: hidden;
}
.hospital-image {
width: 100%;
height: 100%;
}
.hero-overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.2), rgba(249, 249, 255, 0) 58%, rgba(249, 249, 255, 0.16));
}
.profile-section {
position: relative;
padding: 16px 20px 0;
box-sizing: border-box;
}
.section-glow {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.12));
pointer-events: none;
}
.profile-content {
position: relative;
z-index: 1;
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
.intro-row {
display: flex;
align-items: flex-start;
gap: 16px;
}
.doctor-card {
width: 41.666%;
aspect-ratio: 1;
border: 2px solid rgba(0, 71, 141, 0.5);
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.4);
display: flex;
align-items: center;
justify-content: center;
animation: pulse-border 2s infinite ease-in-out;
}
.doctor-image {
width: 100%;
height: 100%;
}
.bubble {
position: relative;
flex: 1;
min-height: 80px;
margin-top: 4px;
padding: 12px;
border: 1px solid rgba(0, 71, 141, 0.1);
border-radius: 16px;
background: rgba(255, 255, 255, 0.4);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.03);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-sizing: border-box;
}
.bubble-text {
color: #191c21;
font-size: 14px;
line-height: 20px;
font-weight: 500;
}
.bubble-text.typing::after {
content: '|';
margin-left: 2px;
color: #005eb8;
font-weight: 700;
animation: blink 0.7s infinite;
}
.bubble-arrow {
position: absolute;
left: -8px;
top: 18px;
width: 16px;
height: 16px;
border-left: 1px solid rgba(0, 71, 141, 0.1);
border-top: 1px solid rgba(0, 71, 141, 0.1);
background: rgba(255, 255, 255, 0.4);
transform: rotate(-45deg);
}
.form-area {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 8px;
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.field-block {
min-width: 0;
}
.field-label {
display: block;
margin-bottom: 4px;
color: #424752;
font-size: 12px;
line-height: 16px;
font-weight: 600;
}
.glass-select {
box-sizing: border-box;
width: 100%;
height: 40px;
padding: 0 32px 0 12px;
border: 1px solid rgba(0, 94, 184, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
position: relative;
}
.glass-select:active {
background: rgba(255, 255, 255, 0.9);
border-color: #005db6;
box-shadow: 0 0 0 4px rgba(0, 93, 182, 0.1);
}
.select-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #191c21;
font-size: 14px;
line-height: 20px;
}
.chevron {
position: absolute;
right: 10px;
top: 50%;
width: 16px;
height: 16px;
transform: translateY(-50%);
}
.chevron::after {
content: '';
position: absolute;
left: 4px;
top: 3px;
width: 7px;
height: 7px;
border-right: 2px solid #424752;
border-bottom: 2px solid #424752;
transform: rotate(45deg);
}
.action-section {
box-sizing: border-box;
padding: 0 20px;
border-top: 1px solid rgba(194, 198, 212, 0.1);
background: rgba(255, 255, 255, 0.4);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
}
.submit-button {
width: 100%;
height: 56px;
border-radius: 16px;
background: #00478d;
box-shadow: 0 10px 24px rgba(0, 71, 141, 0.2);
color: #ffffff;
font-size: 20px;
line-height: 56px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: transform 0.2s ease, background 0.2s ease;
}
.submit-button.saved {
background: #006970;
}
.submit-button:active {
transform: scale(0.98);
}
.submit-button::after {
border: 0;
}
.check-icon {
position: relative;
width: 20px;
height: 20px;
border: 2px solid #ffffff;
border-radius: 50%;
box-sizing: border-box;
}
.check-icon::after {
content: '';
position: absolute;
left: 5px;
top: 3px;
width: 5px;
height: 9px;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
transform: rotate(45deg);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.36);
border-top-color: #ffffff;
border-radius: 50%;
box-sizing: border-box;
animation: spin 1s linear infinite;
}
.secure-tip {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: rgba(66, 71, 82, 0.6);
font-size: 12px;
line-height: 16px;
}
.lock-icon {
position: relative;
width: 14px;
height: 12px;
border: 2px solid rgba(66, 71, 82, 0.6);
border-radius: 3px;
box-sizing: border-box;
}
.lock-icon::before {
content: '';
position: absolute;
left: 2px;
top: -8px;
width: 6px;
height: 8px;
border: 2px solid rgba(66, 71, 82, 0.6);
border-bottom: 0;
border-radius: 8px 8px 0 0;
}
.home-indicator {
position: absolute;
left: 50%;
bottom: 8px;
width: 128px;
height: 6px;
border-radius: 999px;
background: rgba(25, 28, 33, 0.1);
transform: translateX(-50%);
}
.toast {
position: fixed;
left: 50%;
bottom: 96px;
z-index: 100;
max-width: 320px;
padding: 12px 24px;
border-radius: 999px;
background: #2e3037;
color: #eff0f8;
font-size: 14px;
line-height: 20px;
font-weight: 600;
text-align: center;
pointer-events: none;
opacity: 0;
transform: translate(-50%, 16px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast.visible {
opacity: 1;
transform: translate(-50%, 0);
}
.picker-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 90;
background: rgba(25, 28, 33, 0.38);
display: flex;
align-items: flex-end;
justify-content: center;
}
.picker-panel {
box-sizing: border-box;
width: 100%;
max-width: none;
max-height: 70vh;
padding: 16px 20px calc(16px + env(safe-area-inset-bottom));
border-radius: 16px 16px 0 0;
background: #ffffff;
box-shadow: 0 -12px 30px rgba(25, 28, 33, 0.16);
}
.picker-header {
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
}
.picker-title {
color: #191c21;
font-size: 18px;
line-height: 28px;
font-weight: 600;
}
.picker-close {
color: #00478d;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.option-list {
max-height: calc(70vh - 72px);
margin-top: 8px;
}
.option-item {
box-sizing: border-box;
min-height: 64px;
padding: 12px 4px;
border-bottom: 1px solid #ecedf6;
display: flex;
align-items: center;
justify-content: space-between;
}
.option-copy {
flex: 1;
min-width: 0;
padding-right: 16px;
display: flex;
flex-direction: column;
}
.option-label {
color: #191c21;
font-size: 16px;
line-height: 24px;
font-weight: 500;
}
.option-item.active .option-label {
color: #00478d;
font-weight: 600;
}
.option-desc {
margin-top: 2px;
color: #727783;
font-size: 12px;
line-height: 18px;
}
.selected-mark {
position: relative;
flex: 0 0 auto;
width: 18px;
height: 18px;
border-radius: 50%;
background: #00478d;
}
.selected-mark::after {
content: '';
position: absolute;
left: 6px;
top: 3px;
width: 5px;
height: 9px;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
transform: rotate(45deg);
}
@keyframes blink {
from,
to {
opacity: 1;
}
50% {
opacity: 0;
}
}
@keyframes pulse-border {
0% {
box-shadow: 0 0 0 0 rgba(0, 71, 141, 0.4);
border-color: rgba(0, 71, 141, 0.5);
}
50% {
box-shadow: 0 0 0 6px rgba(0, 71, 141, 0);
border-color: rgba(0, 71, 141, 0.8);
}
100% {
box-shadow: 0 0 0 0 rgba(0, 71, 141, 0.4);
border-color: rgba(0, 71, 141, 0.5);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>