530 lines
14 KiB
Vue
530 lines
14 KiB
Vue
<template>
|
|
<div class="page">
|
|
<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-button :icon="Upload" class="upload-button avatar-uploader" @click="selectAndUploadAvatar">
|
|
{{ TEXT.modules.profile.uploadAvatar }}
|
|
</el-button>
|
|
</div>
|
|
</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>
|
|
|
|
<section class="form-section form-section--desktop">
|
|
<div class="section-heading">
|
|
<span class="section-kicker">Desktop</span>
|
|
<h4>客户端与通知</h4>
|
|
</div>
|
|
<el-form-item v-if="isDesktop" label="系统通知">
|
|
<el-switch
|
|
v-model="desktopNotificationsEnabled"
|
|
:loading="desktopNotificationLoading"
|
|
@change="onDesktopNotificationChange"
|
|
/>
|
|
<span class="desktop-setting-hint">仅推送不含项目详情的文件更新提示</span>
|
|
</el-form-item>
|
|
<el-form-item label="客户端信息">
|
|
<div class="client-metadata">
|
|
<code>{{ clientMetadataText }}</code>
|
|
<el-button size="small" @click="copyClientMetadata">复制</el-button>
|
|
</div>
|
|
</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, watch } from "vue";
|
|
import type { FormInstance, FormRules } from "element-plus";
|
|
import { ElMessage } from "element-plus";
|
|
import { Close, Upload } from "@element-plus/icons-vue";
|
|
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
|
|
import {
|
|
getDesktopNotificationSubscription,
|
|
setDesktopNotificationSubscription,
|
|
} from "../api/desktopNotifications";
|
|
import { useAuthStore } from "../store/auth";
|
|
import {
|
|
getAppMetadata,
|
|
isTauriRuntime,
|
|
pickFiles,
|
|
requestNotificationPermission,
|
|
} from "../runtime";
|
|
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
|
|
import { TEXT, requiredMessage } from "../locales";
|
|
|
|
const emit = defineEmits<{
|
|
"close-request": [];
|
|
"dirty-change": [dirty: boolean];
|
|
saved: [];
|
|
}>();
|
|
const auth = useAuthStore();
|
|
const isDesktop = isTauriRuntime();
|
|
const clientMetadata = getAppMetadata();
|
|
const clientMetadataText = [
|
|
`${clientMetadata.clientType} ${clientMetadata.version}`,
|
|
clientMetadata.platform,
|
|
clientMetadata.channel,
|
|
clientMetadata.commit,
|
|
].join(" · ");
|
|
const desktopNotificationsEnabled = ref(false);
|
|
const desktopNotificationLoading = ref(false);
|
|
const formRef = ref<FormInstance>();
|
|
const submitting = ref(false);
|
|
const form = reactive({
|
|
email: "",
|
|
full_name: "",
|
|
clinical_department: "",
|
|
current_password: "",
|
|
password: "",
|
|
confirmPassword: "",
|
|
});
|
|
const savedProfile = ref({
|
|
full_name: "",
|
|
clinical_department: "",
|
|
});
|
|
const avatarPreview = ref<string | undefined>();
|
|
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" }],
|
|
clinical_department: [{ required: true, message: requiredMessage(TEXT.common.fields.clinicalDepartment), trigger: "blur" }],
|
|
current_password: [
|
|
{
|
|
validator: (_r, value, cb) => {
|
|
if (form.password && !value) {
|
|
cb(new Error(TEXT.modules.profile.passwordRequired));
|
|
return;
|
|
}
|
|
cb();
|
|
},
|
|
trigger: "blur",
|
|
},
|
|
],
|
|
password: [
|
|
{
|
|
validator: (_r, value, cb) => {
|
|
if (!value) return cb();
|
|
if (value.length < 8 || !/[A-Za-z]/.test(value) || !/[0-9]/.test(value)) {
|
|
cb(new Error(TEXT.modules.profile.passwordRule));
|
|
return;
|
|
}
|
|
cb();
|
|
},
|
|
trigger: "blur",
|
|
},
|
|
],
|
|
confirmPassword: [
|
|
{
|
|
validator: (_r, value, cb) => {
|
|
if (form.password && value !== form.password) {
|
|
cb(new Error(TEXT.modules.profile.passwordMismatch));
|
|
return;
|
|
}
|
|
cb();
|
|
},
|
|
trigger: "blur",
|
|
},
|
|
],
|
|
};
|
|
|
|
const loadProfile = async () => {
|
|
const { data } = await fetchMe();
|
|
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;
|
|
};
|
|
|
|
const loadDesktopNotificationSubscription = async () => {
|
|
if (!isDesktop) return;
|
|
const { data } = await getDesktopNotificationSubscription();
|
|
desktopNotificationsEnabled.value = data.enabled;
|
|
};
|
|
|
|
const onDesktopNotificationChange = async (value: string | number | boolean) => {
|
|
if (!isDesktop || desktopNotificationLoading.value) return;
|
|
desktopNotificationLoading.value = true;
|
|
try {
|
|
const enable = Boolean(value);
|
|
if (enable) {
|
|
const permission = await requestNotificationPermission();
|
|
if (permission !== "granted") {
|
|
desktopNotificationsEnabled.value = false;
|
|
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
|
|
return;
|
|
}
|
|
}
|
|
const { data } = await setDesktopNotificationSubscription(enable);
|
|
desktopNotificationsEnabled.value = data.enabled;
|
|
if (data.enabled) triggerDesktopNotificationPoll();
|
|
} catch (error: any) {
|
|
desktopNotificationsEnabled.value = !Boolean(value);
|
|
ElMessage.error(error?.response?.data?.detail || "通知设置保存失败");
|
|
} finally {
|
|
desktopNotificationLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const copyClientMetadata = async () => {
|
|
await navigator.clipboard.writeText(clientMetadataText);
|
|
ElMessage.success("客户端信息已复制");
|
|
};
|
|
|
|
const onSubmit = async () => {
|
|
if (!formRef.value) return;
|
|
await formRef.value.validate(async (valid) => {
|
|
if (!valid) return;
|
|
submitting.value = true;
|
|
try {
|
|
await updateProfile({
|
|
full_name: form.full_name,
|
|
clinical_department: form.clinical_department,
|
|
current_password: form.password ? form.current_password : undefined,
|
|
password: form.password || undefined,
|
|
});
|
|
await auth.fetchMe();
|
|
ElMessage.success(TEXT.modules.profile.updateSuccess);
|
|
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 {
|
|
submitting.value = false;
|
|
}
|
|
});
|
|
};
|
|
|
|
const selectAndUploadAvatar = async () => {
|
|
const [file] = await pickFiles({
|
|
multiple: false,
|
|
accept: ["png", "jpg", "jpeg", "gif", "webp"],
|
|
title: TEXT.modules.profile.uploadAvatar,
|
|
});
|
|
if (!file) return;
|
|
const extensionAllowed = /\.(png|jpe?g|gif|webp)$/i.test(file.name);
|
|
if (!avatarAcceptedTypes.has(file.type) && !extensionAllowed) {
|
|
ElMessage.error(TEXT.modules.profile.avatarTypeInvalid);
|
|
return;
|
|
}
|
|
try {
|
|
await uploadAvatar(file);
|
|
await auth.fetchMe();
|
|
avatarPreview.value = auth.user?.avatar_url || undefined;
|
|
ElMessage.success(TEXT.modules.profile.avatarUpdated);
|
|
} catch (err: any) {
|
|
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
loadProfile();
|
|
loadDesktopNotificationSubscription().catch(() => {});
|
|
});
|
|
|
|
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page {
|
|
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;
|
|
}
|
|
|
|
.form {
|
|
max-width: 620px;
|
|
}
|
|
|
|
.form-section {
|
|
padding-top: 2px;
|
|
}
|
|
|
|
.form-section--password {
|
|
margin-top: 26px;
|
|
padding-top: 28px;
|
|
border-top: 1px solid #e5ebf2;
|
|
}
|
|
|
|
.form-section--desktop {
|
|
margin-top: 26px;
|
|
padding-top: 28px;
|
|
border-top: 1px solid #e5ebf2;
|
|
}
|
|
|
|
.desktop-setting-hint {
|
|
margin-left: 12px;
|
|
color: #7f92ad;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.client-metadata {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.client-metadata code {
|
|
overflow-wrap: anywhere;
|
|
color: #40566f;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.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: 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>
|