优化个人中心与界面交互

This commit is contained in:
Cheng Zhou
2026-06-11 09:09:17 +08:00
parent 84e55af8fe
commit b7484c8e01
15 changed files with 572 additions and 141 deletions
+301 -60
View File
@@ -1,70 +1,103 @@
<template>
<div class="page">
<el-card class="unified-shell">
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
<p class="subtitle">{{ TEXT.modules.profile.subtitle }}</p>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form" autocomplete="off">
<el-form-item :label="TEXT.common.fields.avatar">
<div class="avatar-row">
<el-avatar :size="64" :src="avatarPreview" :alt="form.full_name || form.email">
{{ form.full_name?.charAt(0)?.toUpperCase() || form.email?.charAt(0)?.toUpperCase() }}
</el-avatar>
<el-upload
class="avatar-uploader"
:show-file-list="false"
action="/api/v1/auth/me/avatar"
name="file"
:headers="uploadHeaders"
:on-success="onAvatarUploaded"
:on-error="onAvatarError"
>
<el-button>{{ TEXT.modules.profile.uploadAvatar }}</el-button>
</el-upload>
<div class="profile-layout">
<aside class="profile-aside">
<div class="avatar-panel">
<el-avatar :size="88" :src="avatarPreview" :alt="form.full_name || form.email" class="profile-avatar">
{{ profileInitial }}
</el-avatar>
<div class="avatar-meta">
<div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
<div class="avatar-email">{{ form.email }}</div>
</div>
</el-form-item>
<el-form-item :label="TEXT.common.fields.email">
<el-input v-model="form.email" disabled />
</el-form-item>
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
</el-form-item>
<el-divider />
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
<el-input
v-model="form.current_password"
type="password"
show-password
autocomplete="new-password"
name="ctms-profile-current-password"
:placeholder="TEXT.modules.profile.currentPasswordHint"
/>
</el-form-item>
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
</el-form-item>
<el-form-item :label="TEXT.modules.profile.confirmPassword" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password :placeholder="TEXT.modules.profile.confirmPasswordHint" />
</el-form-item>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
<el-upload
class="avatar-uploader"
:show-file-list="false"
accept="image/png,image/jpeg,image/gif,image/webp"
action="/api/v1/auth/me/avatar"
name="file"
:headers="uploadHeaders"
:before-upload="beforeAvatarUpload"
:on-success="onAvatarUploaded"
:on-error="onAvatarError"
>
<el-button :icon="Upload" class="upload-button">{{ TEXT.modules.profile.uploadAvatar }}</el-button>
</el-upload>
</div>
</el-form>
</el-card>
</aside>
<main class="profile-main">
<header class="profile-header">
<div>
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
</div>
<el-button class="dialog-close" text :icon="Close" aria-label="关闭个人中心" @click="emit('close-request')" />
</header>
<el-form ref="formRef" :model="form" :rules="rules" label-width="112px" class="form" autocomplete="off">
<section class="form-section">
<div class="section-heading">
<span class="section-kicker">Account</span>
<h4>基本信息</h4>
</div>
<el-form-item :label="TEXT.common.fields.email">
<el-input v-model="form.email" disabled />
</el-form-item>
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
</el-form-item>
</section>
<section class="form-section form-section--password">
<div class="section-heading">
<span class="section-kicker">Security</span>
<h4>修改密码</h4>
</div>
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
<el-input
v-model="form.current_password"
type="password"
show-password
autocomplete="new-password"
name="ctms-profile-current-password"
:placeholder="TEXT.modules.profile.currentPasswordHint"
/>
</el-form-item>
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
</el-form-item>
<el-form-item :label="TEXT.modules.profile.confirmPassword" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password :placeholder="TEXT.modules.profile.confirmPasswordHint" />
</el-form-item>
</section>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div>
</el-form>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { computed, onMounted, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules, UploadRawFile } from "element-plus";
import { ElMessage } from "element-plus";
import { Close, Upload } from "@element-plus/icons-vue";
import { updateProfile, fetchMe } from "../api/auth";
import { useAuthStore } from "../store/auth";
import { getToken } from "../utils/auth";
import { TEXT, requiredMessage } from "../locales";
const emit = defineEmits<{
"close-request": [];
"dirty-change": [dirty: boolean];
saved: [];
}>();
const auth = useAuthStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
@@ -76,10 +109,22 @@ const form = reactive({
password: "",
confirmPassword: "",
});
const savedProfile = ref({
full_name: "",
clinical_department: "",
});
const avatarPreview = ref<string | undefined>();
const uploadHeaders = computed<Record<string, string>>(() => ({
Authorization: `Bearer ${getToken() || ""}`,
}));
const profileInitial = computed(() => (form.full_name?.charAt(0) || form.email?.charAt(0) || "?").toUpperCase());
const avatarAcceptedTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
const hasUnsavedChanges = computed(
() =>
form.full_name !== savedProfile.value.full_name ||
form.clinical_department !== savedProfile.value.clinical_department ||
Boolean(form.current_password || form.password || form.confirmPassword)
);
const rules: FormRules<typeof form> = {
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
@@ -128,6 +173,10 @@ const loadProfile = async () => {
form.email = data.email;
form.full_name = data.full_name;
form.clinical_department = data.clinical_department;
savedProfile.value = {
full_name: data.full_name,
clinical_department: data.clinical_department,
};
avatarPreview.value = data.avatar_url || undefined;
};
@@ -148,7 +197,12 @@ const onSubmit = async () => {
form.current_password = "";
form.password = "";
form.confirmPassword = "";
savedProfile.value = {
full_name: form.full_name,
clinical_department: form.clinical_department,
};
avatarPreview.value = auth.user?.avatar_url || avatarPreview.value;
emit("saved");
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || TEXT.modules.profile.updateFailed);
} finally {
@@ -163,6 +217,12 @@ const onAvatarUploaded = async () => {
ElMessage.success(TEXT.modules.profile.avatarUpdated);
};
const beforeAvatarUpload = (file: UploadRawFile) => {
if (avatarAcceptedTypes.has(file.type)) return true;
ElMessage.error(TEXT.modules.profile.avatarTypeInvalid);
return false;
};
const onAvatarError = (err: any) => {
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
};
@@ -170,24 +230,205 @@ const onAvatarError = (err: any) => {
onMounted(() => {
loadProfile();
});
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
</script>
<style scoped>
.page {
padding: 16px;
background: #fff;
}
.profile-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
min-height: 620px;
}
.profile-aside {
padding: 48px 32px;
border-right: 1px solid #e5ebf2;
background: linear-gradient(180deg, #f8fbfd 0%, #f1f5f9 100%);
}
.avatar-panel {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.profile-avatar {
--el-avatar-bg-color: #64748b;
color: #fff;
font-size: 30px;
font-weight: 700;
border: 4px solid #fff;
box-shadow: 0 12px 30px rgba(51, 65, 85, 0.16);
}
.avatar-meta {
width: 100%;
margin-top: 18px;
}
.avatar-name {
overflow: hidden;
color: #172033;
font-size: 17px;
font-weight: 700;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.avatar-email {
overflow: hidden;
margin-top: 6px;
color: #718096;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.avatar-uploader {
margin-top: 22px;
}
.upload-button {
border-color: #cbd5e1;
color: #40566f;
font-weight: 600;
}
.profile-main {
padding: 42px 48px 36px;
background: #fff;
}
.profile-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 26px;
}
.dialog-close {
width: 32px;
height: 32px;
margin-top: -4px;
color: #718096;
}
.title {
margin: 0;
color: #172033;
font-size: 24px;
font-weight: 800;
line-height: 1.25;
}
.subtitle {
margin: 6px 0 16px;
color: #666;
}
.form {
max-width: 520px;
max-width: 620px;
}
.form-section {
padding-top: 2px;
}
.form-section--password {
margin-top: 26px;
padding-top: 28px;
border-top: 1px solid #e5ebf2;
}
.section-heading {
margin-bottom: 18px;
padding-left: 112px;
}
.section-kicker {
display: block;
color: #7f92ad;
font-size: 11px;
font-weight: 800;
letter-spacing: 0;
line-height: 1;
text-transform: uppercase;
}
.section-heading h4 {
margin: 6px 0 0;
color: #1f2a3d;
font-size: 15px;
font-weight: 800;
line-height: 1.4;
}
.form :deep(.el-form-item) {
margin-bottom: 20px;
}
.form :deep(.el-form-item__label) {
color: #6f83a3;
font-weight: 700;
}
.form :deep(.el-input__wrapper) {
min-height: 44px;
border-radius: 10px;
box-shadow: 0 0 0 1px #dfe6ee inset;
}
.form :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px #3f8f6b inset, 0 0 0 3px rgba(63, 143, 107, 0.12);
}
.actions {
margin-top: 12px;
margin-top: 28px;
padding-left: 112px;
text-align: right;
}
.actions :deep(.el-button) {
min-width: 96px;
min-height: 40px;
border-radius: 10px;
font-weight: 700;
}
@media (max-width: 900px) {
.profile-layout {
grid-template-columns: 1fr;
}
.profile-aside {
border-right: 0;
border-bottom: 1px solid #e5ebf2;
}
.profile-main {
padding: 32px 24px;
}
}
@media (max-width: 640px) {
.profile-main {
padding: 24px 18px;
}
.form {
max-width: none;
}
.section-heading,
.actions {
padding-left: 0;
}
.actions {
text-align: left;
}
}
</style>