fix: 新增接口入参

This commit is contained in:
王天骄
2026-06-17 15:11:53 +08:00
parent b951f17658
commit 4a1fb3bc56
8 changed files with 1033 additions and 18 deletions
+53
View File
@@ -32,6 +32,7 @@ export interface InstitutionPayload {
level?: string
province?: string
city?: string
banner_url?: string
}
export interface CreateInstitutionPayload extends InstitutionPayload {
@@ -53,6 +54,18 @@ export interface DisableInstitutionParams {
id: number
}
export interface UploadInstitutionBannerParams {
token: string
id: number
file: File
}
export interface UploadInstitutionBannerResult {
message: string
bannerUrl: string
raw: unknown
}
export interface ImportInstitutionsParams {
token: string
file: File
@@ -102,6 +115,25 @@ function getString(record: Record<string, unknown>, key: string): string {
return ''
}
function getBannerUrlFromResponse(data: unknown): string {
if (!data || typeof data !== 'object') {
return ''
}
const record = data as Record<string, unknown>
const bannerUrl = record.banner_url || record.bannerUrl
if (typeof bannerUrl === 'string') {
return bannerUrl
}
const nested = record.data || record.result || record.payload
if (nested && typeof nested === 'object') {
return getBannerUrlFromResponse(nested)
}
return ''
}
function normalizeInstitution(item: unknown): InstitutionListItem {
const record = item && typeof item === 'object' ? (item as Record<string, unknown>) : {}
const id = record.id
@@ -291,6 +323,27 @@ export async function disableInstitution(params: DisableInstitutionParams): Prom
return parseMutationResponse(response, '停用机构失败')
}
export async function uploadInstitutionBanner(params: UploadInstitutionBannerParams): Promise<UploadInstitutionBannerResult> {
const formData = new FormData()
formData.append('file', params.file)
const response = await fetch(`/server/api/cms/institutions/${params.id}/banner/`, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: createAuthorization(params.token)
},
body: formData
})
const data = await parseMutationResponse(response, '上传机构背景图失败')
return {
message: getMessageFromResponse(data),
bannerUrl: getBannerUrlFromResponse(data),
raw: data
}
}
export async function importInstitutions(params: ImportInstitutionsParams): Promise<unknown> {
const formData = new FormData()
formData.append('file', params.file)
+4
View File
@@ -101,6 +101,10 @@ export interface HospitalOverview {
name?: string
logo?: string
level?: string
province?: string
city?: string
banner_url?: string
bannerUrl?: string
cooperation_days?: number | null
}
summary: {
+111 -4
View File
@@ -26,22 +26,29 @@ export interface TeacherStudentRelationListResult {
total: number
}
export interface TeacherStudentRelationUserOption {
id: string
name: string
phone: string
}
export interface TeacherStudentRelationPayload {
teacher_name?: string
teacher_phone?: string
student_name?: string
student_phone?: string
relation_type?: string
status?: 0 | 1
}
export interface CreateTeacherStudentRelationPayload extends TeacherStudentRelationPayload {
teacher_name: string
teacher_phone: string
student_name: string
student_phone: string
}
export interface UpdateTeacherStudentRelationPayload extends TeacherStudentRelationPayload {
teacher_phone: string
student_phone: string
}
export interface UpdateTeacherStudentRelationPayload extends TeacherStudentRelationPayload {}
export interface CreateTeacherStudentRelationParams {
token: string
@@ -188,6 +195,60 @@ function getResults(data: unknown): unknown[] {
return []
}
function getOptionResults(data: unknown, keys: string[]): unknown[] {
if (Array.isArray(data)) {
return data
}
if (!data || typeof data !== 'object') {
return []
}
const record = data as Record<string, unknown>
for (const key of keys) {
const value = record[key]
if (Array.isArray(value)) {
return value
}
}
if (record.data && typeof record.data === 'object') {
return getOptionResults(record.data, keys)
}
return []
}
function normalizeRelationUserOption(item: unknown): TeacherStudentRelationUserOption | null {
if (typeof item === 'string' || typeof item === 'number') {
const phone = String(item)
return { id: phone, name: '', phone }
}
if (!item || typeof item !== 'object') {
return null
}
const record = item as Record<string, unknown>
const phone = getString(record, [
'phone',
'mobile',
'username',
'account',
'teacher_phone',
'teacherPhone',
'student_phone',
'studentPhone',
'value'
])
if (!phone) {
return null
}
return {
id: getString(record, ['id', 'user_id', 'userId', 'teacher_id', 'teacherId', 'student_id', 'studentId'], phone),
name: getString(record, ['real_name', 'realName', 'name', 'label', 'nickname'], ''),
phone
}
}
function createRelationQuery(params: Partial<TeacherStudentRelationListParams>, includePage = true) {
const query = new URLSearchParams()
if (params.teacher?.trim()) {
@@ -209,6 +270,34 @@ function createRelationQuery(params: Partial<TeacherStudentRelationListParams>,
return query
}
async function fetchRelationUserOptions(url: string, token: string, listKeys: string[], fallbackMessage: string) {
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: createAuthorization(token)
}
})
const text = await response.text()
const data = parseResponseText(text)
if (!response.ok) {
const message = getMessageFromResponse(data) || fallbackMessage
throw new Error(message)
}
const seen = new Set<string>()
return getOptionResults(data, listKeys)
.map(normalizeRelationUserOption)
.filter((item): item is TeacherStudentRelationUserOption => {
if (!item || seen.has(item.phone)) {
return false
}
seen.add(item.phone)
return true
})
}
async function parseMutationResponse(response: Response, fallbackMessage: string): Promise<unknown> {
const text = await response.text()
const data = parseResponseText(text)
@@ -282,6 +371,24 @@ export async function fetchTeacherStudentRelations(
}
}
export async function fetchTeacherStudentRelationDoctors(token: string): Promise<TeacherStudentRelationUserOption[]> {
return fetchRelationUserOptions(
'/server/api/cms/teacher-student-relations/doctors/',
token,
['results', 'list', 'rows', 'items', 'records', 'doctors', 'teachers'],
'获取带教医生列表失败'
)
}
export async function fetchTeacherStudentRelationStudents(token: string): Promise<TeacherStudentRelationUserOption[]> {
return fetchRelationUserOptions(
'/server/api/cms/teacher-student-relations/students/',
token,
['results', 'list', 'rows', 'items', 'records', 'students'],
'获取学生列表失败'
)
}
export async function createTeacherStudentRelation(params: CreateTeacherStudentRelationParams): Promise<unknown> {
const response = await fetch('/server/api/cms/teacher-student-relations/', {
method: 'POST',
+7 -1
View File
@@ -78,7 +78,13 @@ export const roleMenus: Record<RoleKey, MenuSection[]> = {
},
],
'hospital-admin': [
{ title: '概览', items: [{ page: 'hospital-dashboard', icon: OfficeBuilding, title: '医院驾驶舱' }] },
{
title: '概览',
items: [
{ page: 'hospital-dashboard', icon: OfficeBuilding, title: '医院驾驶舱' },
{ page: 'hospital-settings', icon: Setting, title: '本院配置' }
]
},
{
title: '人员管理',
items: [
+1
View File
@@ -40,6 +40,7 @@ const routes: RouteRecordRaw[] = [
{ path: 'module/dept-analysis', name: 'HospitalDeptAnalysis', component: () => import('@/views/HospitalStatsModuleView.vue'), meta: { title: '科室分析' } },
{ path: 'module/ability-trend', name: 'HospitalAbilityTrend', component: () => import('@/views/HospitalStatsModuleView.vue'), meta: { title: '能力趋势' } },
{ path: 'module/student-ranking', name: 'HospitalStudentRanking', component: () => import('@/views/HospitalStatsModuleView.vue'), meta: { title: '学生排行' } },
{ path: 'module/hospital-settings', name: 'HospitalSettings', component: () => import('@/views/HospitalSettingsView.vue'), meta: { title: '本院配置' } },
{ path: 'module/content-dashboard', name: 'ContentDashboard', component: () => import('@/views/ContentDashboardView.vue'), meta: { title: '内容概览' } },
{ path: 'module/content-stats', name: 'ContentStats', component: () => import('@/views/ContentStatsView.vue'), meta: { title: '内容统计' } },
{ path: 'module/teacher-dashboard', name: 'TeacherDashboard', component: () => import('@/views/TeacherDashboardView.vue'), meta: { title: '教学概览' } },
+415
View File
@@ -0,0 +1,415 @@
<template>
<div v-loading="loading" class="page-stack">
<section class="page-toolbar">
<div>
<h1>本院配置</h1>
<p>维护本院基础资料和展示背景图</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadHospitalProfile">刷新</el-button>
<el-button :loading="saving" type="primary" @click="submitHospitalProfile">保存配置</el-button>
</div>
</section>
<section class="data-section hospital-settings-section">
<div class="hospital-settings-preview">
<div class="hospital-banner-preview">
<el-image v-if="hospitalBannerPreview" :src="hospitalBannerPreview" fit="contain" />
<span v-else>暂无背景图</span>
</div>
<div>
<h2>{{ profileForm.name || '本院名称' }}</h2>
<p>{{ profileSubtitle }}</p>
</div>
</div>
<el-form ref="profileFormRef" class="hospital-settings-form" :model="profileForm" :rules="profileRules" label-position="top">
<el-row :gutter="14">
<el-col :span="12">
<el-form-item label="医院名称" prop="name">
<el-input v-model="profileForm.name" placeholder="请输入医院名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="医院级别" prop="level">
<el-input v-model="profileForm.level" placeholder="例如:三甲、二甲" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="省" prop="province">
<el-input v-model="profileForm.province" placeholder="请输入省份" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="市" prop="city">
<el-input v-model="profileForm.city" placeholder="请输入城市" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="背景图" prop="bannerUrl">
<div class="hospital-banner-field">
<el-input v-model="profileForm.bannerUrl" clearable placeholder="完整 http(s) URL 或静态相对路径" @input="clearLocalBannerPreview" />
<div class="hospital-banner-actions">
<el-upload
ref="bannerUploadRef"
v-model:file-list="bannerFileList"
:auto-upload="false"
:limit="1"
:on-change="handleBannerFileChange"
:on-exceed="handleBannerExceed"
:on-remove="handleBannerFileRemove"
accept="image/png,image/jpeg,image/jpg,image/webp"
>
<el-button :disabled="!institutionId" :icon="Upload">选择图片</el-button>
</el-upload>
<el-button :icon="Delete" @click="clearHospitalBanner">清空背景图</el-button>
</div>
<p class="hospital-banner-tip">选择图片后保存配置会先上传背景图再更新本院信息支持 png/jpg/jpeg/webp最大 5MB</p>
</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules, UploadInstance, UploadProps, UploadRawFile, UploadUserFile } from 'element-plus'
import { Delete, Refresh, Upload } from '@element-plus/icons-vue'
import { fetchHospitalOverview } from '@/api/stats'
import { updateInstitution, uploadInstitutionBanner, type InstitutionPayload } from '@/api/institutions'
import { useAppStore } from '@/stores/app'
interface HospitalProfileSnapshot {
name: string
level: string
province: string
city: string
bannerUrl: string
}
const appStore = useAppStore()
const loading = ref(false)
const saving = ref(false)
const institutionId = ref<number | null>(null)
const profileFormRef = ref<FormInstance>()
const bannerUploadRef = ref<UploadInstance>()
const bannerFileList = ref<UploadUserFile[]>([])
const bannerFile = ref<File | null>(null)
const bannerPreviewUrl = ref('')
const originalProfile = ref<HospitalProfileSnapshot>({
name: '',
level: '',
province: '',
city: '',
bannerUrl: ''
})
const profileForm = reactive({
name: '',
level: '',
province: '',
city: '',
bannerUrl: ''
})
const profileRules: FormRules = {
name: [{ required: true, message: '请输入医院名称', trigger: 'blur' }]
}
const hospitalBannerPreview = computed(() => bannerPreviewUrl.value || profileForm.bannerUrl.trim())
const profileSubtitle = computed(() => {
const region = [profileForm.province, profileForm.city].filter(Boolean).join(' / ')
return [profileForm.level || '未设置级别', region || '未设置地区'].join(' | ')
})
function getProfileBannerUrl(profile: Record<string, unknown>) {
const value = profile.banner_url || profile.bannerUrl || profile.logo
return typeof value === 'string' ? value : ''
}
function getProfileString(profile: Record<string, unknown>, key: string) {
const value = profile[key]
if (typeof value === 'string') {
return value
}
if (typeof value === 'number') {
return String(value)
}
return ''
}
function getInstitutionId(profile: Record<string, unknown>) {
const id = profile.institution_id || profile.institutionId || profile.id
return typeof id === 'number' ? id : Number(id) || null
}
function applyProfile(profile: Record<string, unknown>) {
institutionId.value = getInstitutionId(profile)
profileForm.name = getProfileString(profile, 'name')
profileForm.level = getProfileString(profile, 'level')
profileForm.province = getProfileString(profile, 'province')
profileForm.city = getProfileString(profile, 'city')
profileForm.bannerUrl = getProfileBannerUrl(profile)
originalProfile.value = {
name: profileForm.name,
level: profileForm.level,
province: profileForm.province,
city: profileForm.city,
bannerUrl: profileForm.bannerUrl
}
}
async function loadHospitalProfile() {
if (!appStore.token) {
ElMessage.warning('缺少登录信息,请重新登录')
return
}
try {
loading.value = true
const overview = await fetchHospitalOverview(appStore.token)
applyProfile((overview.profile || {}) as Record<string, unknown>)
clearPendingBanner()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '获取本院配置失败')
} finally {
loading.value = false
}
}
function revokeBannerPreview() {
if (bannerPreviewUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(bannerPreviewUrl.value)
}
}
function clearLocalBannerPreview() {
revokeBannerPreview()
bannerPreviewUrl.value = ''
bannerFile.value = null
bannerFileList.value = []
bannerUploadRef.value?.clearFiles()
}
function clearPendingBanner() {
revokeBannerPreview()
bannerPreviewUrl.value = ''
bannerFile.value = null
bannerFileList.value = []
bannerUploadRef.value?.clearFiles()
}
function setHospitalBannerFile(file: UploadRawFile) {
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp']
if (!allowedTypes.includes(file.type)) {
clearPendingBanner()
ElMessage.warning('请选择 png、jpg、jpeg 或 webp 图片')
return
}
if (file.size > 5 * 1024 * 1024) {
clearPendingBanner()
ElMessage.warning('图片大小不能超过 5MB')
return
}
revokeBannerPreview()
profileForm.bannerUrl = ''
bannerFile.value = file
bannerPreviewUrl.value = URL.createObjectURL(file)
}
const handleBannerFileChange: UploadProps['onChange'] = uploadFile => {
if (!uploadFile.raw) {
return
}
setHospitalBannerFile(uploadFile.raw)
}
const handleBannerFileRemove: UploadProps['onRemove'] = () => {
clearPendingBanner()
}
const handleBannerExceed: UploadProps['onExceed'] = files => {
const file = files[0] as UploadRawFile | undefined
if (!file) {
return
}
bannerUploadRef.value?.clearFiles()
bannerUploadRef.value?.handleStart(file)
}
function clearHospitalBanner() {
clearPendingBanner()
profileForm.bannerUrl = ''
}
function buildUpdatePayload(): InstitutionPayload {
const current = {
name: profileForm.name.trim(),
level: profileForm.level.trim(),
province: profileForm.province.trim(),
city: profileForm.city.trim(),
bannerUrl: profileForm.bannerUrl.trim()
}
const original = originalProfile.value
const payload: InstitutionPayload = {}
if (current.name !== original.name) payload.name = current.name
if (current.level !== original.level) payload.level = current.level
if (current.province !== original.province) payload.province = current.province
if (current.city !== original.city) payload.city = current.city
if (current.bannerUrl !== original.bannerUrl) payload.banner_url = current.bannerUrl
return payload
}
async function uploadPendingBanner() {
if (!bannerFile.value || !institutionId.value || !appStore.token) {
return ''
}
const result = await uploadInstitutionBanner({
token: appStore.token,
id: institutionId.value,
file: bannerFile.value
})
if (!result.bannerUrl) {
throw new Error('上传背景图失败,未返回图片地址')
}
return result.bannerUrl
}
async function submitHospitalProfile() {
if (!appStore.token) {
ElMessage.warning('缺少登录信息,请重新登录')
return
}
if (!institutionId.value) {
ElMessage.warning('缺少本院机构 ID,无法保存配置')
return
}
const isValid = await profileFormRef.value?.validate().catch(() => false)
if (!isValid) {
return
}
try {
saving.value = true
const uploadedBannerUrl = await uploadPendingBanner()
if (uploadedBannerUrl) {
profileForm.bannerUrl = uploadedBannerUrl
clearPendingBanner()
}
const payload = buildUpdatePayload()
if (!Object.keys(payload).length) {
ElMessage.warning('没有需要保存的修改')
return
}
await updateInstitution({
token: appStore.token,
id: institutionId.value,
payload
})
ElMessage.success('本院配置已更新')
await loadHospitalProfile()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存本院配置失败')
} finally {
saving.value = false
}
}
onMounted(loadHospitalProfile)
</script>
<style scoped lang="scss">
.hospital-settings-section {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 22px;
}
.hospital-settings-preview {
display: grid;
align-content: start;
gap: 14px;
h2 {
margin: 0;
font-size: 20px;
}
p {
margin-top: 8px;
color: var(--muted);
}
}
.hospital-banner-preview {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 16 / 9;
padding: 12px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 8px;
color: var(--muted);
font-size: 13px;
background: var(--panel-soft);
.el-image {
width: 100%;
height: 100%;
}
:deep(img) {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.hospital-settings-form {
min-width: 0;
.el-select,
.el-input-number {
width: 100%;
}
}
.hospital-banner-field {
display: grid;
gap: 10px;
width: 100%;
}
.hospital-banner-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
:deep(.el-upload-list) {
display: none;
}
}
.hospital-banner-tip {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
</style>
+235 -4
View File
@@ -46,6 +46,12 @@
{{ formatRegion(row.province, row.city) }}
</template>
</el-table-column>
<el-table-column label="背景图" width="120">
<template #default="{ row }">
<el-image v-if="row.bannerUrl" class="institution-banner-thumb" :src="row.bannerUrl" fit="contain" />
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="更新时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.updatedAt) }}
@@ -105,6 +111,35 @@
<el-input v-model="institutionForm.city" placeholder="请输入城市" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="机构背景图" prop="bannerUrl">
<div class="institution-banner-field">
<div class="institution-banner-preview">
<el-image v-if="institutionBannerPreview" :src="institutionBannerPreview" fit="contain" />
<span v-else>暂无背景图</span>
</div>
<div class="institution-banner-control">
<el-input v-model="institutionForm.bannerUrl" clearable placeholder="完整 http(s) URL 或静态相对路径" @input="clearLocalBannerPreview" />
<div class="institution-banner-actions">
<el-upload
ref="bannerUploadRef"
v-model:file-list="bannerFileList"
:auto-upload="false"
:limit="1"
:on-change="handleBannerFileChange"
:on-exceed="handleBannerExceed"
:on-remove="handleBannerFileRemove"
accept="image/*"
>
<el-button :icon="Upload">选择图片</el-button>
</el-upload>
<el-button :icon="Delete" @click="clearInstitutionBanner">清空背景图</el-button>
</div>
<p class="institution-banner-tip">编辑时选择图片会先上传背景图再保存机构信息支持 png/jpg/jpeg/webp最大 5MB</p>
</div>
</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
@@ -143,7 +178,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules, UploadInstance, UploadProps, UploadRawFile, UploadUserFile } from 'element-plus'
import { Download, Plus, Refresh, Search, Upload, UploadFilled } from '@element-plus/icons-vue'
import { Delete, Download, Plus, Refresh, Search, Upload, UploadFilled } from '@element-plus/icons-vue'
import {
createInstitution,
disableInstitution,
@@ -152,6 +187,7 @@ import {
fetchInstitutions,
importInstitutions,
updateInstitution,
uploadInstitutionBanner,
type CreateInstitutionPayload,
type InstitutionListItem,
type InstitutionPayload
@@ -168,10 +204,14 @@ const institutionDialogVisible = ref(false)
const importDialogVisible = ref(false)
const institutionFormRef = ref<FormInstance>()
const importUploadRef = ref<UploadInstance>()
const bannerUploadRef = ref<UploadInstance>()
const institutionMode = ref<'create' | 'edit'>('create')
const editingInstitution = ref<InstitutionListItem | null>(null)
const importFile = ref<File | null>(null)
const importFileList = ref<UploadUserFile[]>([])
const bannerFileList = ref<UploadUserFile[]>([])
const bannerPreviewUrl = ref('')
const bannerFile = ref<File | null>(null)
const institutions = ref<InstitutionListItem[]>([])
const filters = reactive({
search: '',
@@ -189,7 +229,8 @@ const institutionForm = reactive({
type: 'hospital',
level: '',
province: '',
city: ''
city: '',
bannerUrl: ''
})
const institutionTypeOptions = [
@@ -209,6 +250,7 @@ const institutionRules = computed<FormRules>(() => {
}
})
const institutionDialogTitle = computed(() => (institutionMode.value === 'create' ? '新增机构' : '编辑机构'))
const institutionBannerPreview = computed(() => bannerPreviewUrl.value || institutionForm.bannerUrl.trim())
async function loadInstitutions() {
if (!appStore.token) {
@@ -263,22 +305,41 @@ function openEditDialog(row: InstitutionListItem) {
institutionForm.level = row.level
institutionForm.province = row.province
institutionForm.city = row.city
institutionForm.bannerUrl = row.bannerUrl
bannerPreviewUrl.value = ''
institutionDialogVisible.value = true
}
function revokeBannerPreview() {
if (bannerPreviewUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(bannerPreviewUrl.value)
}
}
function clearLocalBannerPreview() {
revokeBannerPreview()
bannerPreviewUrl.value = ''
}
function resetInstitutionForm() {
revokeBannerPreview()
institutionForm.code = ''
institutionForm.name = ''
institutionForm.type = 'hospital'
institutionForm.level = ''
institutionForm.province = ''
institutionForm.city = ''
institutionForm.bannerUrl = ''
bannerPreviewUrl.value = ''
bannerFile.value = null
bannerFileList.value = []
bannerUploadRef.value?.clearFiles()
editingInstitution.value = null
institutionFormRef.value?.clearValidate()
}
function buildCreatePayload(): CreateInstitutionPayload {
return {
const payload: CreateInstitutionPayload = {
code: institutionForm.code.trim(),
name: institutionForm.name.trim(),
type: institutionForm.type.trim() || 'hospital',
@@ -286,6 +347,12 @@ function buildCreatePayload(): CreateInstitutionPayload {
province: institutionForm.province.trim(),
city: institutionForm.city.trim()
}
const bannerUrl = institutionForm.bannerUrl.trim()
if (bannerUrl) {
payload.banner_url = bannerUrl
}
return payload
}
function buildUpdatePayload(): InstitutionPayload {
@@ -299,7 +366,8 @@ function buildUpdatePayload(): InstitutionPayload {
type: institutionForm.type.trim(),
level: institutionForm.level.trim(),
province: institutionForm.province.trim(),
city: institutionForm.city.trim()
city: institutionForm.city.trim(),
bannerUrl: institutionForm.bannerUrl.trim()
}
const original = editingInstitution.value
const payload: InstitutionPayload = {}
@@ -310,10 +378,93 @@ function buildUpdatePayload(): InstitutionPayload {
if (current.level !== original.level) payload.level = current.level
if (current.province !== original.province) payload.province = current.province
if (current.city !== original.city) payload.city = current.city
if (current.bannerUrl !== original.bannerUrl) payload.banner_url = current.bannerUrl
return payload
}
function setInstitutionBannerFile(file: UploadRawFile) {
if (institutionMode.value === 'create') {
bannerFileList.value = []
bannerFile.value = null
bannerUploadRef.value?.clearFiles()
ElMessage.warning('请先新增机构,保存后编辑时再上传背景图')
return
}
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp']
if (!allowedTypes.includes(file.type)) {
bannerFileList.value = []
bannerFile.value = null
bannerUploadRef.value?.clearFiles()
ElMessage.warning('请选择 png、jpg、jpeg 或 webp 图片')
return
}
if (file.size > 5 * 1024 * 1024) {
bannerFileList.value = []
bannerFile.value = null
bannerUploadRef.value?.clearFiles()
ElMessage.warning('图片大小不能超过 5MB')
return
}
revokeBannerPreview()
institutionForm.bannerUrl = ''
bannerFile.value = file
bannerPreviewUrl.value = URL.createObjectURL(file)
}
const handleBannerFileChange: UploadProps['onChange'] = uploadFile => {
if (!uploadFile.raw) {
return
}
setInstitutionBannerFile(uploadFile.raw)
}
const handleBannerFileRemove: UploadProps['onRemove'] = () => {
bannerFileList.value = []
bannerFile.value = null
clearLocalBannerPreview()
}
const handleBannerExceed: UploadProps['onExceed'] = files => {
const file = files[0] as UploadRawFile | undefined
if (!file) {
return
}
bannerUploadRef.value?.clearFiles()
bannerUploadRef.value?.handleStart(file)
}
function clearInstitutionBanner() {
revokeBannerPreview()
institutionForm.bannerUrl = ''
bannerPreviewUrl.value = ''
bannerFile.value = null
bannerFileList.value = []
bannerUploadRef.value?.clearFiles()
}
async function uploadPendingBanner() {
if (!bannerFile.value || !editingInstitution.value || !appStore.token) {
return ''
}
const result = await uploadInstitutionBanner({
token: appStore.token,
id: editingInstitution.value.id,
file: bannerFile.value
})
if (!result.bannerUrl) {
throw new Error('上传背景图失败,未返回图片地址')
}
return result.bannerUrl
}
async function submitInstitutionForm() {
if (!appStore.token) {
ElMessage.warning('缺少登录信息,请重新登录')
@@ -335,6 +486,13 @@ async function submitInstitutionForm() {
ElMessage.success('机构已新增')
pagination.page = 1
} else if (editingInstitution.value) {
const uploadedBannerUrl = await uploadPendingBanner()
if (uploadedBannerUrl) {
institutionForm.bannerUrl = uploadedBannerUrl
bannerFile.value = null
bannerFileList.value = []
clearLocalBannerPreview()
}
const payload = buildUpdatePayload()
if (!Object.keys(payload).length) {
ElMessage.warning('没有需要保存的修改')
@@ -503,3 +661,76 @@ function formatDateTime(value: string) {
onMounted(loadInstitutions)
</script>
<style scoped lang="scss">
.institution-banner-thumb {
width: 72px;
height: 40px;
padding: 3px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-soft);
:deep(img) {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.institution-banner-field {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 14px;
width: 100%;
}
.institution-banner-preview {
display: flex;
align-items: center;
justify-content: center;
width: 180px;
height: 104px;
padding: 8px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 8px;
color: var(--muted);
font-size: 13px;
background: var(--panel-soft);
.el-image {
width: 100%;
height: 100%;
}
:deep(img) {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.institution-banner-control {
display: grid;
gap: 10px;
min-width: 0;
}
.institution-banner-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
:deep(.el-upload-list) {
display: none;
}
}
.institution-banner-tip {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
</style>
+207 -9
View File
@@ -78,13 +78,37 @@
<el-form ref="relationFormRef" :model="relationForm" :rules="relationRules" label-position="top">
<el-row :gutter="14">
<el-col :span="12">
<el-form-item label="带教医生手机号" prop="teacher_phone">
<el-input v-model="relationForm.teacher_phone" maxlength="11" placeholder="请输入医生手机号" />
<el-form-item label="带教医生" prop="teacher_phone">
<el-select
v-model="relationForm.teacher_phone"
:loading="loadingDoctorOptions"
placeholder="请选择带教医生"
@change="handleTeacherChange"
>
<el-option
v-for="item in doctorOptions"
:key="item.phone"
:label="relationUserOptionLabel(item)"
:value="item.phone"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学生手机号" prop="student_phone">
<el-input v-model="relationForm.student_phone" maxlength="11" placeholder="请输入学生手机号" />
<el-form-item label="学生" prop="student_phone">
<el-select
v-model="relationForm.student_phone"
:loading="loadingStudentOptions"
placeholder="请选择学生"
@change="handleStudentChange"
>
<el-option
v-for="item in studentOptions"
:key="item.phone"
:label="relationUserOptionLabel(item)"
:value="item.phone"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
@@ -141,11 +165,14 @@ import {
disableTeacherStudentRelation,
downloadTeacherStudentRelationImportTemplate,
exportTeacherStudentRelations,
fetchTeacherStudentRelationDoctors,
fetchTeacherStudentRelationStudents,
fetchTeacherStudentRelations,
importTeacherStudentRelations,
updateTeacherStudentRelation,
type CreateTeacherStudentRelationPayload,
type TeacherStudentRelationItem,
type TeacherStudentRelationUserOption,
type UpdateTeacherStudentRelationPayload
} from '@/api/teacherStudentRelations'
import { useAppStore } from '@/stores/app'
@@ -156,6 +183,8 @@ const savingRelation = ref(false)
const importing = ref(false)
const exporting = ref(false)
const downloadingTemplate = ref(false)
const loadingDoctorOptions = ref(false)
const loadingStudentOptions = ref(false)
const relationDialogVisible = ref(false)
const importDialogVisible = ref(false)
const relationFormRef = ref<FormInstance>()
@@ -165,6 +194,8 @@ const editingRelation = ref<TeacherStudentRelationItem | null>(null)
const importFile = ref<File | null>(null)
const importFileList = ref<UploadUserFile[]>([])
const relations = ref<TeacherStudentRelationItem[]>([])
const doctorOptions = ref<TeacherStudentRelationUserOption[]>([])
const studentOptions = ref<TeacherStudentRelationUserOption[]>([])
const filters = reactive({
teacher: '',
student: '',
@@ -176,7 +207,9 @@ const pagination = reactive({
total: 0
})
const relationForm = reactive({
teacher_name: '',
teacher_phone: '',
student_name: '',
student_phone: '',
relation_type: '指导',
status: 1 as 0 | 1
@@ -184,12 +217,12 @@ const relationForm = reactive({
const relationRules: FormRules = {
teacher_phone: [
{ required: true, message: '请输入带教医生手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '请输入正确的带教医生手机号', trigger: 'blur' }
{ required: true, message: '请选择带教医生', trigger: 'change' },
{ pattern: /^1\d{10}$/, message: '请选择正确的带教医生手机号', trigger: 'change' }
],
student_phone: [
{ required: true, message: '请输入学生手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '请输入正确的学生手机号', trigger: 'blur' }
{ required: true, message: '请选择学生', trigger: 'change' },
{ pattern: /^1\d{10}$/, message: '请选择正确的学生手机号', trigger: 'change' }
]
}
const relationDialogTitle = computed(() => (relationMode.value === 'create' ? '新增师生关系' : '编辑师生关系'))
@@ -236,20 +269,27 @@ function resetFilters() {
function openCreateDialog() {
relationMode.value = 'create'
relationDialogVisible.value = true
loadRelationUserOptions()
}
function openEditDialog(row: TeacherStudentRelationItem) {
relationMode.value = 'edit'
editingRelation.value = row
relationForm.teacher_name = row.teacherName
relationForm.teacher_phone = row.teacherPhone
relationForm.student_name = row.studentName
relationForm.student_phone = row.studentPhone
relationForm.relation_type = row.relationType
relationForm.status = row.status
relationDialogVisible.value = true
ensureCurrentRelationOptions(row)
loadRelationUserOptions(true)
}
function resetRelationForm() {
relationForm.teacher_name = ''
relationForm.teacher_phone = ''
relationForm.student_name = ''
relationForm.student_phone = ''
relationForm.relation_type = '指导'
relationForm.status = 1
@@ -259,22 +299,162 @@ function resetRelationForm() {
function buildCreatePayload(): CreateTeacherStudentRelationPayload {
return {
teacher_name: relationForm.teacher_name.trim(),
teacher_phone: relationForm.teacher_phone.trim(),
student_name: relationForm.student_name.trim(),
student_phone: relationForm.student_phone.trim(),
relation_type: relationForm.relation_type.trim(),
status: relationForm.status
}
}
function ensureOption(options: TeacherStudentRelationUserOption[], option: TeacherStudentRelationUserOption) {
if (!option.phone) {
return
}
const existing = options.find(item => item.phone === option.phone)
if (existing) {
existing.id = option.id || existing.id
existing.name = option.name || existing.name
return
}
options.unshift(option)
}
function mergeRelationUserOptions(
currentOptions: TeacherStudentRelationUserOption[],
incomingOptions: TeacherStudentRelationUserOption[]
) {
const currentMap = new Map(currentOptions.map(item => [item.phone, item]))
const incomingPhones = new Set(incomingOptions.map(item => item.phone))
const missingCurrentOptions = currentOptions.filter(item => item.phone && !incomingPhones.has(item.phone))
const mergedIncomingOptions = incomingOptions.map(item => {
const current = currentMap.get(item.phone)
return {
id: item.id || current?.id || item.phone,
name: item.name || current?.name || '',
phone: item.phone
}
})
return [...missingCurrentOptions, ...mergedIncomingOptions]
}
function ensureCurrentRelationOptions(row: TeacherStudentRelationItem) {
ensureOption(doctorOptions.value, {
id: row.teacherId || row.teacherPhone,
name: row.teacherName,
phone: row.teacherPhone
})
ensureOption(studentOptions.value, {
id: row.studentId || row.studentPhone,
name: row.studentName,
phone: row.studentPhone
})
}
function buildUpdatePayload(): UpdateTeacherStudentRelationPayload {
if (!editingRelation.value) {
return {}
}
const current = {
teacher_name: relationForm.teacher_name.trim(),
teacher_phone: relationForm.teacher_phone.trim(),
student_name: relationForm.student_name.trim(),
student_phone: relationForm.student_phone.trim(),
relation_type: relationForm.relation_type.trim(),
status: relationForm.status
}
const original = editingRelation.value
const payload: UpdateTeacherStudentRelationPayload = {}
return current
if (current.teacher_phone !== original.teacherPhone || current.teacher_name !== original.teacherName) {
payload.teacher_name = current.teacher_name
payload.teacher_phone = current.teacher_phone
}
if (current.student_phone !== original.studentPhone || current.student_name !== original.studentName) {
payload.student_name = current.student_name
payload.student_phone = current.student_phone
}
if (current.relation_type !== original.relationType) payload.relation_type = current.relation_type
if (current.status !== original.status) payload.status = current.status
return payload
}
function handleTeacherChange(phone: string) {
const option = doctorOptions.value.find(item => item.phone === phone)
relationForm.teacher_phone = phone
relationForm.teacher_name = option?.name || relationForm.teacher_name
relationFormRef.value?.validateField('teacher_phone')
}
function handleStudentChange(phone: string) {
const option = studentOptions.value.find(item => item.phone === phone)
relationForm.student_phone = phone
relationForm.student_name = option?.name || relationForm.student_name
relationFormRef.value?.validateField('student_phone')
}
function syncSelectedRelationUsers() {
const teacherOption = doctorOptions.value.find(item => item.phone === relationForm.teacher_phone)
const studentOption = studentOptions.value.find(item => item.phone === relationForm.student_phone)
if (teacherOption?.name) {
relationForm.teacher_name = teacherOption.name
}
if (studentOption?.name) {
relationForm.student_name = studentOption.name
}
}
async function loadRelationUserOptions(force = false) {
if (!appStore.token) {
ElMessage.warning('缺少登录信息,请重新登录')
return
}
const tasks: Promise<void>[] = []
if (force || !doctorOptions.value.length) {
loadingDoctorOptions.value = true
tasks.push(
fetchTeacherStudentRelationDoctors(appStore.token)
.then(options => {
doctorOptions.value = mergeRelationUserOptions(doctorOptions.value, options)
syncSelectedRelationUsers()
})
.catch(error => {
ElMessage.error(error instanceof Error ? error.message : '获取带教医生列表失败')
})
.finally(() => {
loadingDoctorOptions.value = false
})
)
}
if (force || !studentOptions.value.length) {
loadingStudentOptions.value = true
tasks.push(
fetchTeacherStudentRelationStudents(appStore.token)
.then(options => {
studentOptions.value = mergeRelationUserOptions(studentOptions.value, options)
syncSelectedRelationUsers()
})
.catch(error => {
ElMessage.error(error instanceof Error ? error.message : '获取学生列表失败')
})
.finally(() => {
loadingStudentOptions.value = false
})
)
}
await Promise.all(tasks)
}
function relationUserOptionLabel(item: TeacherStudentRelationUserOption) {
return [item.name, item.phone].filter(Boolean).join(' / ')
}
async function submitRelationForm() {
@@ -291,6 +471,10 @@ async function submitRelationForm() {
try {
savingRelation.value = true
if (relationMode.value === 'create') {
if (!relationForm.teacher_name.trim() || !relationForm.student_name.trim()) {
ElMessage.warning('请选择包含姓名和手机号的带教医生与学生')
return
}
await createTeacherStudentRelation({
token: appStore.token,
payload: buildCreatePayload()
@@ -298,7 +482,15 @@ async function submitRelationForm() {
ElMessage.success('师生关系已新增')
pagination.page = 1
} else if (editingRelation.value) {
if (!relationForm.teacher_name.trim() || !relationForm.student_name.trim()) {
ElMessage.warning('请选择包含姓名和手机号的带教医生与学生')
return
}
const payload = buildUpdatePayload()
if (!Object.keys(payload).length) {
ElMessage.warning('没有需要保存的修改')
return
}
await updateTeacherStudentRelation({
token: appStore.token,
id: editingRelation.value.id,
@@ -447,3 +639,9 @@ function formatDateTime(value: string) {
onMounted(loadRelations)
</script>
<style scoped lang="scss">
:deep(.el-dialog .el-select) {
width: 100%;
}
</style>