financial_system/frontend/src/components/TopBar.vue
2026-06-18 13:21:20 +08:00

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>