forked from jiaoly/financial_system
293 lines
9.9 KiB
Vue
293 lines
9.9 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
Bell,
|
|
CircleHelp,
|
|
KeyRound,
|
|
Loader2,
|
|
Menu,
|
|
Palette,
|
|
Save,
|
|
Search,
|
|
Upload,
|
|
X,
|
|
} from "@lucide/vue";
|
|
import { computed, reactive, ref, watch } from "vue";
|
|
|
|
import { ApiError, buildApiUrl } from "../api/http";
|
|
import AppearancePanel from "./AppearancePanel.vue";
|
|
import { useAuthStore } from "../stores/auth";
|
|
|
|
defineEmits<{
|
|
"toggle-sidebar": [];
|
|
}>();
|
|
|
|
const auth = useAuthStore();
|
|
const showThemePanel = ref(false);
|
|
const showProfileModal = ref(false);
|
|
const savingProfile = ref(false);
|
|
const savingPassword = ref(false);
|
|
const uploadingAvatar = ref(false);
|
|
const profileError = ref("");
|
|
const profileSuccess = ref("");
|
|
const passwordError = ref("");
|
|
const passwordSuccess = ref("");
|
|
|
|
const profileForm = reactive({
|
|
display_name: "",
|
|
email: "",
|
|
phone: "",
|
|
department: "",
|
|
position_title: "",
|
|
});
|
|
|
|
const passwordForm = reactive({
|
|
old_password: "",
|
|
new_password: "",
|
|
confirm_password: "",
|
|
});
|
|
|
|
const initials = computed(() => {
|
|
const source = auth.displayName || auth.user?.username || "U";
|
|
return source.slice(0, 2).toUpperCase();
|
|
});
|
|
|
|
const avatarSrc = computed(() => {
|
|
const avatarUrl = auth.user?.avatar_url || "";
|
|
if (!avatarUrl) {
|
|
return "";
|
|
}
|
|
return /^https?:\/\//.test(avatarUrl) || avatarUrl.startsWith("data:") ? avatarUrl : buildApiUrl(avatarUrl);
|
|
});
|
|
|
|
watch(
|
|
() => auth.user,
|
|
(user) => {
|
|
profileForm.display_name = user?.display_name || "";
|
|
profileForm.email = user?.email || "";
|
|
profileForm.phone = user?.phone || "";
|
|
profileForm.department = user?.department || "";
|
|
profileForm.position_title = user?.position_title || "";
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
async function saveProfile(): Promise<void> {
|
|
savingProfile.value = true;
|
|
profileError.value = "";
|
|
profileSuccess.value = "";
|
|
try {
|
|
await auth.updateProfile({ ...profileForm });
|
|
profileSuccess.value = "个人资料已保存";
|
|
} catch (error) {
|
|
profileError.value = error instanceof ApiError ? error.message : "保存失败,请稍后重试";
|
|
} finally {
|
|
savingProfile.value = false;
|
|
}
|
|
}
|
|
|
|
async function savePassword(): Promise<void> {
|
|
savingPassword.value = true;
|
|
passwordError.value = "";
|
|
passwordSuccess.value = "";
|
|
|
|
if (passwordForm.new_password !== passwordForm.confirm_password) {
|
|
passwordError.value = "两次输入的新密码不一致";
|
|
savingPassword.value = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const message = await auth.changePassword({ ...passwordForm });
|
|
passwordSuccess.value = message || "密码修改成功";
|
|
clearPasswordForm(false);
|
|
} catch (error) {
|
|
passwordError.value = error instanceof ApiError ? error.message : "密码修改失败,请稍后重试";
|
|
} finally {
|
|
savingPassword.value = false;
|
|
}
|
|
}
|
|
|
|
function clearPasswordForm(clearMessages = true): void {
|
|
passwordForm.old_password = "";
|
|
passwordForm.new_password = "";
|
|
passwordForm.confirm_password = "";
|
|
if (clearMessages) {
|
|
passwordError.value = "";
|
|
passwordSuccess.value = "";
|
|
}
|
|
}
|
|
|
|
async function handleAvatarChange(event: Event): Promise<void> {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
uploadingAvatar.value = true;
|
|
profileError.value = "";
|
|
profileSuccess.value = "";
|
|
try {
|
|
await auth.uploadAvatar(file);
|
|
profileSuccess.value = "头像已更新";
|
|
} catch (error) {
|
|
profileError.value = error instanceof ApiError ? error.message : "头像上传失败";
|
|
} finally {
|
|
uploadingAvatar.value = false;
|
|
input.value = "";
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<header class="topbar">
|
|
<div class="topbar-left">
|
|
<button class="icon-button mobile-menu" type="button" title="打开菜单" @click="$emit('toggle-sidebar')">
|
|
<Menu :size="22" aria-hidden="true" />
|
|
</button>
|
|
<div class="global-search">
|
|
<Search :size="20" aria-hidden="true" />
|
|
<input type="search" placeholder="搜索员工、任务或部门..." />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="topbar-actions">
|
|
<div class="theme-switcher">
|
|
<button class="icon-button" type="button" title="切换肤色" @click="showThemePanel = !showThemePanel">
|
|
<Palette :size="21" aria-hidden="true" />
|
|
</button>
|
|
<Teleport to="body">
|
|
<Transition name="drawer-slide">
|
|
<AppearancePanel v-if="showThemePanel" @close="showThemePanel = false" />
|
|
</Transition>
|
|
</Teleport>
|
|
</div>
|
|
<button class="icon-button" type="button" title="通知">
|
|
<Bell :size="21" aria-hidden="true" />
|
|
<span class="notice-dot" aria-hidden="true" />
|
|
</button>
|
|
<button class="icon-button" type="button" title="帮助">
|
|
<CircleHelp :size="21" aria-hidden="true" />
|
|
</button>
|
|
<button class="user-summary profile-trigger" type="button" title="编辑个人资料" @click="showProfileModal = true">
|
|
<div class="user-text">
|
|
<strong>{{ auth.displayName }}</strong>
|
|
<span>{{ auth.user?.position_title || auth.roleLabel }}</span>
|
|
</div>
|
|
<div class="avatar" aria-hidden="true">
|
|
<img v-if="avatarSrc" :src="avatarSrc" alt="" />
|
|
<span v-else>{{ initials }}</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<Teleport to="body">
|
|
<div v-if="showProfileModal" class="modal-backdrop" @click.self="showProfileModal = false">
|
|
<section class="profile-modal" aria-label="个人资料">
|
|
<div class="modal-header">
|
|
<div>
|
|
<h2>个人资料</h2>
|
|
<p>修改头像、联系方式、岗位信息和登录密码</p>
|
|
</div>
|
|
<button class="icon-button" type="button" title="关闭" @click="showProfileModal = false">
|
|
<X :size="21" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="profile-avatar-row">
|
|
<div class="avatar large" aria-hidden="true">
|
|
<img v-if="avatarSrc" :src="avatarSrc" alt="" />
|
|
<span v-else>{{ initials }}</span>
|
|
</div>
|
|
<div>
|
|
<strong>{{ auth.user?.username }}</strong>
|
|
<p>{{ auth.roleLabel }}</p>
|
|
<label class="secondary-button avatar-upload">
|
|
<Loader2 v-if="uploadingAvatar" class="spin" :size="17" aria-hidden="true" />
|
|
<Upload v-else :size="17" aria-hidden="true" />
|
|
<span>{{ uploadingAvatar ? "上传中" : "上传头像" }}</span>
|
|
<input accept=".jpg,.jpeg,.png,.webp,.gif" type="file" @change="handleAvatarChange" />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<form class="profile-form" @submit.prevent="saveProfile">
|
|
<label class="field">
|
|
<span>显示名称</span>
|
|
<input v-model="profileForm.display_name" type="text" />
|
|
</label>
|
|
<label class="field">
|
|
<span>邮箱</span>
|
|
<input v-model="profileForm.email" type="email" />
|
|
</label>
|
|
<label class="field">
|
|
<span>手机号</span>
|
|
<input v-model="profileForm.phone" type="tel" />
|
|
</label>
|
|
<label class="field">
|
|
<span>部门</span>
|
|
<input v-model="profileForm.department" type="text" />
|
|
</label>
|
|
<label class="field span-row">
|
|
<span>职位</span>
|
|
<input v-model="profileForm.position_title" type="text" />
|
|
</label>
|
|
|
|
<p v-if="profileError" class="form-error span-row">{{ profileError }}</p>
|
|
<p v-if="profileSuccess" class="form-success span-row">{{ profileSuccess }}</p>
|
|
|
|
<div class="form-actions align-right span-row">
|
|
<button class="secondary-button" type="button" @click="showProfileModal = false">取消</button>
|
|
<button class="primary-button" type="submit" :disabled="savingProfile">
|
|
<Loader2 v-if="savingProfile" class="spin" :size="18" aria-hidden="true" />
|
|
<Save v-else :size="18" aria-hidden="true" />
|
|
<span>{{ savingProfile ? "保存中" : "保存资料" }}</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<form class="password-form" @submit.prevent="savePassword">
|
|
<div class="form-section-head span-row">
|
|
<div>
|
|
<h3>修改密码</h3>
|
|
<p>需要输入当前密码后才能更新登录密码</p>
|
|
</div>
|
|
</div>
|
|
|
|
<label class="field">
|
|
<span>当前密码</span>
|
|
<input v-model="passwordForm.old_password" autocomplete="current-password" required type="password" />
|
|
</label>
|
|
<label class="field">
|
|
<span>新密码</span>
|
|
<input v-model="passwordForm.new_password" autocomplete="new-password" minlength="6" required type="password" />
|
|
</label>
|
|
<label class="field">
|
|
<span>确认新密码</span>
|
|
<input
|
|
v-model="passwordForm.confirm_password"
|
|
autocomplete="new-password"
|
|
minlength="6"
|
|
required
|
|
type="password"
|
|
/>
|
|
</label>
|
|
|
|
<p v-if="passwordError" class="form-error span-row">{{ passwordError }}</p>
|
|
<p v-if="passwordSuccess" class="form-success span-row">{{ passwordSuccess }}</p>
|
|
|
|
<div class="form-actions align-right span-row">
|
|
<button class="secondary-button" type="button" @click="() => clearPasswordForm()">清空</button>
|
|
<button class="primary-button" type="submit" :disabled="savingPassword">
|
|
<Loader2 v-if="savingPassword" class="spin" :size="18" aria-hidden="true" />
|
|
<KeyRound v-else :size="18" aria-hidden="true" />
|
|
<span>{{ savingPassword ? "修改中" : "修改密码" }}</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
</Teleport>
|
|
</header>
|
|
</template>
|