feat: 新增主题切换、个人中心页面,优化界面样式与登录流程

1.  新增Pinia主题状态管理,支持明暗主题切换并持久化配置
2.  新增个人中心页面,支持用户信息编辑、密码修改与主题设置
3.  登录后缓存用户信息到localStorage,完善用户操作链路
4.  重构菜单命令注册逻辑,引入tauri-helper简化开发
5.  优化全局样式系统,使用CSS变量实现主题切换
6.  新增用户详情API接口,完善后端服务能力
7.  修复S3 SDK导入冗余依赖问题,优化菜单更新SQL执行逻辑
8.  调整侧边栏用户菜单,新增个人中心跳转入口
This commit is contained in:
Yuhang Wu 2026-07-29 18:07:14 +08:00
parent 577b82291b
commit faf181e67a
19 changed files with 688 additions and 76 deletions

82
src-tauri/Cargo.lock generated
View File

@ -17,6 +17,7 @@ dependencies = [
"sysinfo", "sysinfo",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-helper",
"tauri-plugin-opener", "tauri-plugin-opener",
] ]
@ -1204,6 +1205,25 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-queue" name = "crossbeam-queue"
version = "0.3.13" version = "0.3.13"
@ -3827,6 +3847,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@ -5044,6 +5084,21 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-helper"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b6ed54c4a04e658f8f1c57513abbeb5e2493c9db1d31987c9cc6db6c19a245c"
dependencies = [
"rayon",
"serde",
"syn 2.0.119",
"tauri_helper_core",
"tauri_helper_macros",
"toml 0.9.12+spec-1.1.0",
"walkdir",
]
[[package]] [[package]]
name = "tauri-macros" name = "tauri-macros"
version = "2.6.3" version = "2.6.3"
@ -5196,6 +5251,33 @@ dependencies = [
"toml 1.1.3+spec-1.1.0", "toml 1.1.3+spec-1.1.0",
] ]
[[package]]
name = "tauri_helper_core"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6da0ac433082491b94f3ba7f5087ee888608917bd371bb213ac16bc722547541"
dependencies = [
"serde",
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "tauri_helper_macros"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6da533f4bd8a64d991a8ebca6abc2243d12eae7190c69ccdfa3483f82080b9"
dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"serde",
"syn 2.0.119",
"tauri_helper_core",
"thiserror 2.0.19",
"toml 0.9.12+spec-1.1.0",
"walkdir",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"

View File

@ -14,8 +14,12 @@ edition = "2021"
name = "DevBox_lib" name = "DevBox_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
[workspace]
members = ["."]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
tauri-helper = "0.2.1"
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
@ -34,3 +38,4 @@ aws-sdk-s3 = "1.140.0"
aws-config = {version = "1.10.1", features = ["behavior-version-latest"] } aws-config = {version = "1.10.1", features = ["behavior-version-latest"] }
aws-credential-types = "1.3.0" aws-credential-types = "1.3.0"
aws-types = "1.5.0" aws-types = "1.5.0"
tauri-helper = "0.2.1"

View File

@ -1,3 +1,4 @@
fn main() { fn main() {
tauri_build::build() // tauri_build::build()
tauri_helper::generate_command_file(tauri_helper::TauriHelperOptions::default());
} }

View File

@ -188,7 +188,7 @@ pub async fn menu_update(
pool: State<'_, MySqlPool>, pool: State<'_, MySqlPool>,
params: MenuSaveReq, params: MenuSaveReq,
) -> Result<ApiResponse<()>, String> { ) -> Result<ApiResponse<()>, String> {
let result = sqlx::query("UPDATE sys_menu SET parent_id = ?, name = ?, title = ?, path = ?, component = ?, icon = ?, perms = ?, menu_type = ?, sort = ?, visible = ?, status = ? WHERE id = ?") sqlx::query("UPDATE sys_menu SET parent_id = ?, name = ?, title = ?, path = ?, component = ?, icon = ?, perms = ?, menu_type = ?, sort = ?, visible = ?, status = ? WHERE id = ?")
.bind(params.parent_id) .bind(params.parent_id)
.bind(params.name) .bind(params.name)
.bind(params.title) .bind(params.title)

View File

@ -25,22 +25,23 @@ pub fn run() {
app.manage(s3_client); app.manage(s3_client);
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri_helper::tauri_collect_commands!())
commands::user::login, // .invoke_handler(tauri::generate_handler![
commands::user::get_user_page, // commands::user::login,
commands::user::register, // commands::user::get_user_page,
commands::user::del_user, // commands::user::register,
commands::user::update_user, // commands::user::del_user,
commands::user::get_user_by_id, // commands::user::update_user,
commands::role::role_save, // commands::user::get_user_by_id,
commands::role::role_page, // commands::role::role_save,
commands::role::role_update, // commands::role::role_page,
commands::role::role_del, // commands::role::role_update,
commands::menu::menu_list, // commands::role::role_del,
commands::menu::menu_save, // commands::menu::menu_list,
commands::menu::menu_update, // commands::menu::menu_save,
commands::menu::menu_del, // commands::menu::menu_update,
]) // commands::menu::menu_del,
// ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@ -1,6 +1,6 @@
use aws_credential_types::Credentials as AwsCreds; use aws_credential_types::Credentials as AwsCreds;
use aws_sdk_s3::{ use aws_sdk_s3::{
config::{BehaviorVersion, Credentials, Region}, config::{BehaviorVersion, Region},
Client, Client,
}; };

View File

@ -1,14 +1,30 @@
<template> <template>
<n-message-provider> <n-config-provider :theme="themeStore.naiveTheme" :theme-overrides="themeOverrides">
<n-dialog-provider> <n-message-provider>
<div class="container"> <n-dialog-provider>
<router-view /> <div class="container">
</div> <router-view />
</n-dialog-provider> </div>
</n-message-provider> </n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import type { GlobalThemeOverrides } from 'naive-ui'
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
const themeOverrides = computed<GlobalThemeOverrides>(() => ({
common: {
primaryColor: '#0891B2',
primaryColorHover: '#0E7490',
primaryColorPressed: '#155E75',
primaryColorSuppl: '#06B6D4',
},
}))
</script> </script>
<style scoped> <style scoped>

View File

@ -54,6 +54,10 @@ const UserApi = {
update_user: async (params: UserSaveReq): Promise<ApiResponse<any>> => { update_user: async (params: UserSaveReq): Promise<ApiResponse<any>> => {
return await invoke("update_user", { params }) return await invoke("update_user", { params })
}, },
// 根据ID获取用户信息
getUserById: async (id: number): Promise<ApiResponse<UserInfo>> => {
return await invoke("get_user_by_id", { id })
},
} }
export default UserApi export default UserApi

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="layout-shell"> <div class="layout-shell" :style="shellVars">
<n-layout has-sider class="layout-root"> <n-layout has-sider class="layout-root">
<!-- 侧边栏 --> <!-- 侧边栏 -->
<LayoutSider /> <LayoutSider />
@ -27,11 +27,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { watch } from 'vue' import { watch, computed } from 'vue'
import type { Component } from 'vue' import type { Component } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { GridOutline, PeopleOutline, ShieldCheckmarkOutline } from '@vicons/ionicons5' import { GridOutline, PeopleOutline, ShieldCheckmarkOutline } from '@vicons/ionicons5'
import { useLayoutStore } from '@/stores/layout' import { useLayoutStore } from '@/stores/layout'
import { useThemeStore } from '@/stores/theme'
import LayoutSider from './components/LayoutSider.vue' import LayoutSider from './components/LayoutSider.vue'
import LayoutHeader from './components/LayoutHeader.vue' import LayoutHeader from './components/LayoutHeader.vue'
import LayoutTabs from './components/LayoutTabs.vue' import LayoutTabs from './components/LayoutTabs.vue'
@ -39,6 +40,7 @@ import LayoutFooter from './components/LayoutFooter.vue'
const route = useRoute() const route = useRoute()
const layoutStore = useLayoutStore() const layoutStore = useLayoutStore()
const themeStore = useThemeStore()
// //
const routeIconMap: Record<string, Component> = { const routeIconMap: Record<string, Component> = {
@ -58,6 +60,26 @@ watch(
}, },
{ immediate: true }, { immediate: true },
) )
const shellVars = computed(() =>
themeStore.isDark
? {
'--color-bg': '#101014',
'--color-surface': '#18181c',
'--color-border': '#2a2a2f',
'--color-text': '#ffffffd9',
'--color-text-secondary': '#ffffff73',
'--color-primary': '#0891B2',
}
: {
'--color-bg': '#F2F3F5',
'--color-surface': '#FFFFFF',
'--color-border': '#E5E6EB',
'--color-text': '#1D2129',
'--color-text-secondary': '#86909C',
'--color-primary': '#0891B2',
}
)
</script> </script>
<style scoped> <style scoped>
@ -65,12 +87,6 @@ watch(
width: 100%; width: 100%;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
--color-bg: #F2F3F5;
--color-surface: #FFFFFF;
--color-border: #E5E6EB;
--color-text: #1D2129;
--color-text-secondary: #86909C;
--color-primary: #0891B2;
--header-height: 50px; --header-height: 50px;
--tabs-height: 42px; --tabs-height: 42px;
--footer-height: 36px; --footer-height: 36px;

View File

@ -15,6 +15,11 @@
</div> </div>
<div class="header-right"> <div class="header-right">
<n-button quaternary circle size="small" @click="themeStore.toggle()">
<template #icon>
<n-icon size="18" :component="themeStore.isDark ? SunnyOutline : MoonOutline" />
</template>
</n-button>
<n-button quaternary circle size="small" @click="toggleFullscreen"> <n-button quaternary circle size="small" @click="toggleFullscreen">
<template #icon><n-icon size="18" :component="ExpandOutline" /></template> <template #icon><n-icon size="18" :component="ExpandOutline" /></template>
</n-button> </n-button>
@ -56,13 +61,17 @@ import {
PersonOutline, PersonOutline,
SettingsOutline, SettingsOutline,
LogOutOutline, LogOutOutline,
MoonOutline,
SunnyOutline,
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { useLayoutStore } from '@/stores/layout' import { useLayoutStore } from '@/stores/layout'
import { useThemeStore } from '@/stores/theme'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const message = useMessage() const message = useMessage()
const layoutStore = useLayoutStore() const layoutStore = useLayoutStore()
const themeStore = useThemeStore()
const username = 'SparksFly' const username = 'SparksFly'
const userInitial = computed(() => username.charAt(0).toUpperCase()) const userInitial = computed(() => username.charAt(0).toUpperCase())
@ -81,7 +90,9 @@ const userDropdownOptions: DropdownOption[] = [
] ]
function onUserAction(key: string) { function onUserAction(key: string) {
if (key === 'logout') { if (key === 'profile') {
router.push('/profile')
} else if (key === 'logout') {
message.success('已退出登录') message.success('已退出登录')
router.push('/login') router.push('/login')
} }

View File

@ -134,7 +134,9 @@ const userDropdownOptions: DropdownOption[] = [
] ]
function onUserAction(key: string) { function onUserAction(key: string) {
if (key === 'logout') { if (key === 'profile') {
router.push('/profile')
} else if (key === 'logout') {
message.success('已退出登录') message.success('已退出登录')
router.push('/login') router.push('/login')
} }

View File

@ -40,6 +40,12 @@ const routes = [
meta: { title: "菜单管理", icon: "MenuOutline", tabFixed: true }, meta: { title: "菜单管理", icon: "MenuOutline", tabFixed: true },
component: () => import("@/views/system/SysMenu.vue"), component: () => import("@/views/system/SysMenu.vue"),
}, },
{
path: "/profile",
name: "Profile",
meta: { title: "个人中心", icon: "PersonOutline", tabFixed: true },
component: () => import("@/views/system/Profile.vue"),
},
], ],
}, },
] ]

21
src/stores/theme.ts Normal file
View File

@ -0,0 +1,21 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { darkTheme } from 'naive-ui'
import type { GlobalTheme } from 'naive-ui'
export const useThemeStore = defineStore('theme', () => {
const isDark = ref<boolean>(
localStorage.getItem('theme') === 'dark'
)
const naiveTheme = computed<GlobalTheme | null>(() =>
isDark.value ? darkTheme : null
)
function toggle() {
isDark.value = !isDark.value
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
}
return { isDark, naiveTheme, toggle }
})

View File

@ -111,8 +111,8 @@ const repos = ref([
<style scoped> <style scoped>
.page-dashboard { .page-dashboard {
padding: 28px 24px; padding: 28px 24px;
max-width: 1200px; width: 100%;
margin: 0 auto; box-sizing: border-box;
} }
.page-header { .page-header {
@ -124,14 +124,14 @@ const repos = ref([
font-family: 'Space Grotesk', system-ui, sans-serif; font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 26px; font-size: 26px;
font-weight: 700; font-weight: 700;
color: #1D2129; color: var(--color-text, #1D2129);
letter-spacing: -0.3px; letter-spacing: -0.3px;
} }
.page-subtitle { .page-subtitle {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 14px; font-size: 14px;
color: #86909C; color: var(--color-text-secondary, #86909C);
} }
/* ─── 统计卡片 ─── */ /* ─── 统计卡片 ─── */
@ -148,8 +148,8 @@ const repos = ref([
align-items: flex-start; align-items: flex-start;
gap: 16px; gap: 16px;
padding: 20px; padding: 20px;
background: #fff; background: var(--color-surface, #fff);
border: 1px solid #E5E6EB; border: 1px solid var(--color-border, #E5E6EB);
border-radius: 16px; border-radius: 16px;
transition: box-shadow 0.2s, transform 0.2s; transition: box-shadow 0.2s, transform 0.2s;
cursor: pointer; cursor: pointer;
@ -179,14 +179,14 @@ const repos = ref([
font-family: 'Space Grotesk', system-ui, sans-serif; font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: #1D2129; color: var(--color-text, #1D2129);
line-height: 1.1; line-height: 1.1;
} }
.stat-label { .stat-label {
margin-top: 2px; margin-top: 2px;
font-size: 13px; font-size: 13px;
color: #86909C; color: var(--color-text-secondary, #86909C);
} }
.stat-badge { .stat-badge {
@ -220,8 +220,8 @@ const repos = ref([
} }
.card { .card {
background: #fff; background: var(--color-surface, #fff);
border: 1px solid #E5E6EB; border: 1px solid var(--color-border, #E5E6EB);
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
} }
@ -238,7 +238,7 @@ const repos = ref([
font-family: 'Space Grotesk', system-ui, sans-serif; font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 17px; font-size: 17px;
font-weight: 600; font-weight: 600;
color: #1D2129; color: var(--color-text, #1D2129);
} }
.card-link { .card-link {
@ -268,7 +268,7 @@ const repos = ref([
text-align: center; text-align: center;
margin-top: 8px; margin-top: 8px;
font-size: 12px; font-size: 12px;
color: #86909C; color: var(--color-text-secondary, #86909C);
} }
/* 仓库列表 */ /* 仓库列表 */
@ -281,7 +281,7 @@ const repos = ref([
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 12px 0; padding: 12px 0;
border-bottom: 1px solid #E5E6EB; border-bottom: 1px solid var(--color-border, #E5E6EB);
cursor: pointer; cursor: pointer;
} }
@ -305,12 +305,12 @@ const repos = ref([
.repo-name { .repo-name {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #1D2129; color: var(--color-text, #1D2129);
} }
.repo-desc { .repo-desc {
font-size: 12px; font-size: 12px;
color: #86909C; color: var(--color-text-secondary, #86909C);
margin-top: 1px; margin-top: 1px;
} }
@ -321,7 +321,7 @@ const repos = ref([
justify-content: center; justify-content: center;
padding: 40px 20px; padding: 40px 20px;
text-align: center; text-align: center;
color: #86909C; color: var(--color-text-secondary, #86909C);
font-size: 14px; font-size: 14px;
} }

View File

@ -129,6 +129,7 @@ const handleLogin = () => {
try { try {
const res = await UserApi.login(formData.username, formData.password) const res = await UserApi.login(formData.username, formData.password)
if (res.success === true) { if (res.success === true) {
localStorage.setItem('userInfo', JSON.stringify(res.data))
message.success('登录成功') message.success('登录成功')
router.push('/dashboard') router.push('/dashboard')
} else { } else {

View File

@ -0,0 +1,452 @@
<template>
<div class="page-profile">
<div class="page-toolbar">
<h2 class="page-title">个人中心</h2>
</div>
<n-spin :show="loading">
<div class="profile-layout">
<!-- 左侧信息卡 -->
<div class="profile-card card">
<div class="avatar-wrap">
<n-avatar round :size="80" :style="{ backgroundColor: '#0891B2', fontSize: '32px' }">
{{ userInitial }}
</n-avatar>
<div class="avatar-info">
<span class="display-name">{{ form.nick_name || form.user_name }}</span>
<n-tag type="info" size="small" round>{{ genderLabel }}</n-tag>
</div>
</div>
<n-divider />
<div class="meta-list">
<div class="meta-item">
<n-icon :component="PersonOutline" size="16" class="meta-icon" />
<span class="meta-label">用户名</span>
<span class="meta-value">{{ form.user_name }}</span>
</div>
<div class="meta-item">
<n-icon :component="MailOutline" size="16" class="meta-icon" />
<span class="meta-label">邮箱</span>
<span class="meta-value">{{ form.email || '未设置' }}</span>
</div>
<div class="meta-item">
<n-icon :component="CallOutline" size="16" class="meta-icon" />
<span class="meta-label">手机</span>
<span class="meta-value">{{ form.phone || '未设置' }}</span>
</div>
<div class="meta-item">
<n-icon :component="CalendarOutline" size="16" class="meta-icon" />
<span class="meta-label">年龄</span>
<span class="meta-value">{{ form.age || '未设置' }}</span>
</div>
<div class="meta-item">
<n-icon :component="TimeOutline" size="16" class="meta-icon" />
<span class="meta-label">注册时间</span>
<span class="meta-value">{{ userInfo?.create_time || '-' }}</span>
</div>
</div>
</div>
<!-- 右侧编辑区 -->
<div class="profile-edit card">
<n-tabs type="line" animated>
<!-- 基本信息 -->
<n-tab-pane name="info" tab="基本信息">
<n-form
ref="infoFormRef"
:model="form"
:rules="infoRules"
label-placement="left"
label-width="80"
require-mark-placement="right-hanging"
class="edit-form"
>
<n-form-item label="昵称" path="nick_name">
<n-input v-model:value="form.nick_name" placeholder="请输入昵称" />
</n-form-item>
<n-form-item label="邮箱" path="email">
<n-input v-model:value="form.email" placeholder="请输入邮箱" />
</n-form-item>
<n-form-item label="手机" path="phone">
<n-input v-model:value="form.phone" placeholder="请输入手机号" />
</n-form-item>
<n-form-item label="年龄" path="age">
<n-input-number v-model:value="form.age" :min="0" :max="150" :style="{ width: '100%' }" />
</n-form-item>
<n-form-item label="性别" path="sex">
<n-radio-group v-model:value="form.sex">
<n-radio :value="0">保密</n-radio>
<n-radio :value="1"></n-radio>
<n-radio :value="2"></n-radio>
</n-radio-group>
</n-form-item>
<div class="form-actions">
<n-button type="primary" :loading="saving" @click="saveInfo">保存修改</n-button>
</div>
</n-form>
</n-tab-pane>
<!-- 修改密码 -->
<n-tab-pane name="password" tab="修改密码">
<n-form
ref="pwdFormRef"
:model="pwdForm"
:rules="pwdRules"
label-placement="left"
label-width="90"
require-mark-placement="right-hanging"
class="edit-form"
>
<n-form-item label="新密码" path="password">
<n-input
v-model:value="pwdForm.password"
type="password"
show-password-on="click"
placeholder="请输入新密码至少6位"
/>
</n-form-item>
<n-form-item label="确认密码" path="confirm">
<n-input
v-model:value="pwdForm.confirm"
type="password"
show-password-on="click"
placeholder="再次输入新密码"
/>
</n-form-item>
<div class="form-actions">
<n-button type="primary" :loading="pwdSaving" @click="savePassword">更新密码</n-button>
</div>
</n-form>
</n-tab-pane>
<!-- 外观设置 -->
<n-tab-pane name="appearance" tab="外观设置">
<div class="appearance-section">
<div class="appearance-item">
<div class="appearance-label">
<span class="appearance-title">深色模式</span>
<span class="appearance-desc">切换界面明暗主题</span>
</div>
<n-switch :value="themeStore.isDark" @update:value="themeStore.toggle()">
<template #checked-icon><n-icon :component="MoonOutline" /></template>
<template #unchecked-icon><n-icon :component="SunnyOutline" /></template>
</n-switch>
</div>
</div>
</n-tab-pane>
</n-tabs>
</div>
</div>
</n-spin>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import type { FormInst, FormRules } from 'naive-ui'
import {
PersonOutline, MailOutline, CallOutline, CalendarOutline,
TimeOutline, MoonOutline, SunnyOutline,
} from '@vicons/ionicons5'
import { useThemeStore } from '@/stores/theme'
import UserApi from '@/api/system/user'
import type { UserInfo } from '@/api/system/user'
const message = useMessage()
const themeStore = useThemeStore()
const userInfo = ref<UserInfo | null>(null)
const loading = ref(false)
const form = reactive({
nick_name: '',
email: '',
phone: '',
age: 0,
sex: 0,
user_name: '',
avatar: '',
})
function applyUserInfo(u: UserInfo) {
userInfo.value = u
form.nick_name = u.nick_name
form.email = u.email
form.phone = u.phone
form.age = u.age
form.sex = u.sex
form.user_name = u.user_name
form.avatar = u.avatar
}
onMounted(async () => {
const stored = localStorage.getItem('userInfo')
if (!stored) {
message.warning('未找到登录信息,请重新登录')
return
}
const cached: UserInfo = JSON.parse(stored)
loading.value = true
try {
const res = await UserApi.getUserById(cached.id)
if (res.success && res.data) {
applyUserInfo(res.data)
localStorage.setItem('userInfo', JSON.stringify(res.data))
} else {
message.error(res.message || '获取用户信息失败')
applyUserInfo(cached)
}
} catch (e: any) {
message.error(e?.toString() ?? '获取用户信息失败')
applyUserInfo(cached)
} finally {
loading.value = false
}
})
const userInitial = computed(() =>
(form.nick_name || form.user_name || 'U').charAt(0).toUpperCase()
)
const genderLabel = computed(() =>
({ 0: '保密', 1: '男', 2: '女' }[form.sex] ?? '保密')
)
const infoFormRef = ref<FormInst | null>(null)
const saving = ref(false)
const infoRules: FormRules = {
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
phone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
}
async function saveInfo() {
try { await infoFormRef.value?.validate() } catch { return }
if (!userInfo.value) { message.warning('未获取到用户信息'); return }
saving.value = true
try {
const res = await UserApi.update_user({
id: userInfo.value.id,
nick_name: form.nick_name,
avatar: form.avatar,
user_name: form.user_name,
password: '',
email: form.email,
phone: form.phone,
age: form.age,
sex: form.sex,
creater: 0,
})
if (res.success) {
const refreshed = await UserApi.getUserById(userInfo.value.id)
if (refreshed.success && refreshed.data) {
applyUserInfo(refreshed.data)
localStorage.setItem('userInfo', JSON.stringify(refreshed.data))
}
message.success('保存成功')
} else {
message.error(res.message)
}
} catch (e: any) {
message.error(e?.toString() ?? '保存失败')
} finally {
saving.value = false
}
}
const pwdFormRef = ref<FormInst | null>(null)
const pwdSaving = ref(false)
const pwdForm = reactive({ password: '', confirm: '' })
const pwdRules: FormRules = {
password: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
confirm: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{
validator: (_rule: any, value: string) =>
value === pwdForm.password ? true : new Error('两次密码不一致'),
trigger: 'blur',
},
],
}
async function savePassword() {
try { await pwdFormRef.value?.validate() } catch { return }
if (!userInfo.value) { message.warning('未获取到用户信息'); return }
pwdSaving.value = true
try {
const res = await UserApi.update_user({
id: userInfo.value.id,
nick_name: form.nick_name,
avatar: form.avatar,
user_name: form.user_name,
password: pwdForm.password,
email: form.email,
phone: form.phone,
age: form.age,
sex: form.sex,
creater: 0,
})
if (res.success) {
message.success('密码更新成功')
pwdForm.password = ''
pwdForm.confirm = ''
} else {
message.error(res.message)
}
} catch (e: any) {
message.error(e?.toString() ?? '密码更新失败')
} finally {
pwdSaving.value = false
}
}
</script>
<style scoped>
.page-profile {
padding: 24px;
width: 100%;
box-sizing: border-box;
}
.page-toolbar {
margin-bottom: 20px;
}
.page-title {
margin: 0;
font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 22px;
font-weight: 700;
color: var(--color-text, #1D2129);
letter-spacing: -0.3px;
}
.profile-layout {
display: flex;
gap: 20px;
align-items: flex-start;
}
.card {
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #E5E6EB);
border-radius: 10px;
padding: 24px;
}
.profile-card {
width: 260px;
flex-shrink: 0;
}
.profile-edit {
flex: 1;
min-width: 0;
}
.avatar-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding-bottom: 4px;
}
.avatar-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.display-name {
font-size: 16px;
font-weight: 600;
color: var(--color-text, #1D2129);
}
.meta-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.meta-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.meta-icon {
color: var(--color-text-secondary, #86909C);
flex-shrink: 0;
}
.meta-label {
color: var(--color-text-secondary, #86909C);
width: 48px;
flex-shrink: 0;
}
.meta-value {
color: var(--color-text, #1D2129);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.edit-form {
padding-top: 16px;
}
.form-actions {
display: flex;
justify-content: flex-end;
padding-top: 8px;
}
.appearance-section {
padding-top: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.appearance-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border: 1px solid var(--color-border, #E5E6EB);
border-radius: 8px;
}
.appearance-title {
font-size: 14px;
font-weight: 500;
color: var(--color-text, #1D2129);
display: block;
}
.appearance-desc {
font-size: 12px;
color: var(--color-text-secondary, #86909C);
display: block;
margin-top: 2px;
}
@media (max-width: 768px) {
.page-profile { padding: 16px; }
.profile-layout { flex-direction: column; }
.profile-card { width: 100%; }
}
</style>

View File

@ -344,21 +344,21 @@ function handleDelete(row: MenuInfo) {
font-family: 'Space Grotesk', system-ui, sans-serif; font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
color: #1D2129; color: var(--color-text, #1D2129);
letter-spacing: -0.3px; letter-spacing: -0.3px;
} }
.search-bar { .search-bar {
background: #fff; background: var(--color-surface, #fff);
border: 1px solid #E5E6EB; border: 1px solid var(--color-border, #E5E6EB);
border-radius: 10px; border-radius: 10px;
padding: 14px 18px; padding: 14px 18px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.table-card { .table-card {
background: #fff; background: var(--color-surface, #fff);
border: 1px solid #E5E6EB; border: 1px solid var(--color-border, #E5E6EB);
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
} }
@ -367,8 +367,6 @@ function handleDelete(row: MenuInfo) {
--n-th-padding: 12px 16px; --n-th-padding: 12px 16px;
--n-td-padding: 10px 16px; --n-td-padding: 10px 16px;
--n-th-font-weight: 600; --n-th-font-weight: 600;
--n-th-color: #F7F8FA;
--n-td-color-striped: #FAFBFC;
} }
@media (max-width: 768px) { @media (max-width: 768px) {

View File

@ -230,21 +230,21 @@ function handleDelete(row: RoleInfo) {
font-family: 'Space Grotesk', system-ui, sans-serif; font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
color: #1D2129; color: var(--color-text, #1D2129);
letter-spacing: -0.3px; letter-spacing: -0.3px;
} }
.search-bar { .search-bar {
background: #fff; background: var(--color-surface, #fff);
border: 1px solid #E5E6EB; border: 1px solid var(--color-border, #E5E6EB);
border-radius: 10px; border-radius: 10px;
padding: 14px 18px; padding: 14px 18px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.table-card { .table-card {
background: #fff; background: var(--color-surface, #fff);
border: 1px solid #E5E6EB; border: 1px solid var(--color-border, #E5E6EB);
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
} }
@ -253,8 +253,6 @@ function handleDelete(row: RoleInfo) {
--n-th-padding: 12px 16px; --n-th-padding: 12px 16px;
--n-td-padding: 10px 16px; --n-td-padding: 10px 16px;
--n-th-font-weight: 600; --n-th-font-weight: 600;
--n-th-color: #F7F8FA;
--n-td-color-striped: #FAFBFC;
} }
.table-footer { .table-footer {
@ -262,7 +260,7 @@ function handleDelete(row: RoleInfo) {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 14px 20px; padding: 14px 20px;
border-top: 1px solid #E5E6EB; border-top: 1px solid var(--color-border, #E5E6EB);
} }
.total-hint { .total-hint {

View File

@ -345,21 +345,21 @@ function handleDelete(row: UserInfo) {
font-family: 'Space Grotesk', system-ui, sans-serif; font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
color: #1D2129; color: var(--color-text, #1D2129);
letter-spacing: -0.3px; letter-spacing: -0.3px;
} }
.search-bar { .search-bar {
background: #fff; background: var(--color-surface, #fff);
border: 1px solid #E5E6EB; border: 1px solid var(--color-border, #E5E6EB);
border-radius: 10px; border-radius: 10px;
padding: 14px 18px; padding: 14px 18px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.table-card { .table-card {
background: #fff; background: var(--color-surface, #fff);
border: 1px solid #E5E6EB; border: 1px solid var(--color-border, #E5E6EB);
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
} }
@ -368,8 +368,6 @@ function handleDelete(row: UserInfo) {
--n-th-padding: 12px 16px; --n-th-padding: 12px 16px;
--n-td-padding: 10px 16px; --n-td-padding: 10px 16px;
--n-th-font-weight: 600; --n-th-font-weight: 600;
--n-th-color: #F7F8FA;
--n-td-color-striped: #FAFBFC;
} }
.table-footer { .table-footer {
@ -377,12 +375,12 @@ function handleDelete(row: UserInfo) {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 14px 20px; padding: 14px 20px;
border-top: 1px solid #E5E6EB; border-top: 1px solid var(--color-border, #E5E6EB);
} }
.total-hint { .total-hint {
font-size: 13px; font-size: 13px;
color: #86909C; color: var(--color-text-secondary, #86909C);
} }
@media (max-width: 768px) { @media (max-width: 768px) {