feat: 新增主题切换、个人中心页面,优化界面样式与登录流程
1. 新增Pinia主题状态管理,支持明暗主题切换并持久化配置 2. 新增个人中心页面,支持用户信息编辑、密码修改与主题设置 3. 登录后缓存用户信息到localStorage,完善用户操作链路 4. 重构菜单命令注册逻辑,引入tauri-helper简化开发 5. 优化全局样式系统,使用CSS变量实现主题切换 6. 新增用户详情API接口,完善后端服务能力 7. 修复S3 SDK导入冗余依赖问题,优化菜单更新SQL执行逻辑 8. 调整侧边栏用户菜单,新增个人中心跳转入口
This commit is contained in:
parent
577b82291b
commit
faf181e67a
|
|
@ -17,6 +17,7 @@ dependencies = [
|
|||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-helper",
|
||||
"tauri-plugin-opener",
|
||||
]
|
||||
|
||||
|
|
@ -1204,6 +1205,25 @@ dependencies = [
|
|||
"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]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.13"
|
||||
|
|
@ -3827,6 +3847,26 @@ version = "0.6.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
|
|
@ -5044,6 +5084,21 @@ dependencies = [
|
|||
"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]]
|
||||
name = "tauri-macros"
|
||||
version = "2.6.3"
|
||||
|
|
@ -5196,6 +5251,33 @@ dependencies = [
|
|||
"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]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
|
|
|
|||
|
|
@ -14,8 +14,12 @@ edition = "2021"
|
|||
name = "DevBox_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[workspace]
|
||||
members = ["."]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
tauri-helper = "0.2.1"
|
||||
|
||||
[dependencies]
|
||||
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-credential-types = "1.3.0"
|
||||
aws-types = "1.5.0"
|
||||
tauri-helper = "0.2.1"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
// tauri_build::build()
|
||||
tauri_helper::generate_command_file(tauri_helper::TauriHelperOptions::default());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ pub async fn menu_update(
|
|||
pool: State<'_, MySqlPool>,
|
||||
params: MenuSaveReq,
|
||||
) -> 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.name)
|
||||
.bind(params.title)
|
||||
|
|
|
|||
|
|
@ -25,22 +25,23 @@ pub fn run() {
|
|||
app.manage(s3_client);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::user::login,
|
||||
commands::user::get_user_page,
|
||||
commands::user::register,
|
||||
commands::user::del_user,
|
||||
commands::user::update_user,
|
||||
commands::user::get_user_by_id,
|
||||
commands::role::role_save,
|
||||
commands::role::role_page,
|
||||
commands::role::role_update,
|
||||
commands::role::role_del,
|
||||
commands::menu::menu_list,
|
||||
commands::menu::menu_save,
|
||||
commands::menu::menu_update,
|
||||
commands::menu::menu_del,
|
||||
])
|
||||
.invoke_handler(tauri_helper::tauri_collect_commands!())
|
||||
// .invoke_handler(tauri::generate_handler![
|
||||
// commands::user::login,
|
||||
// commands::user::get_user_page,
|
||||
// commands::user::register,
|
||||
// commands::user::del_user,
|
||||
// commands::user::update_user,
|
||||
// commands::user::get_user_by_id,
|
||||
// commands::role::role_save,
|
||||
// commands::role::role_page,
|
||||
// commands::role::role_update,
|
||||
// commands::role::role_del,
|
||||
// commands::menu::menu_list,
|
||||
// commands::menu::menu_save,
|
||||
// commands::menu::menu_update,
|
||||
// commands::menu::menu_del,
|
||||
// ])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use aws_credential_types::Credentials as AwsCreds;
|
||||
use aws_sdk_s3::{
|
||||
config::{BehaviorVersion, Credentials, Region},
|
||||
config::{BehaviorVersion, Region},
|
||||
Client,
|
||||
};
|
||||
|
||||
|
|
|
|||
16
src/App.vue
16
src/App.vue
|
|
@ -1,4 +1,5 @@
|
|||
<template>
|
||||
<n-config-provider :theme="themeStore.naiveTheme" :theme-overrides="themeOverrides">
|
||||
<n-message-provider>
|
||||
<n-dialog-provider>
|
||||
<div class="container">
|
||||
|
|
@ -6,9 +7,24 @@
|
|||
</div>
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,10 @@ const UserApi = {
|
|||
update_user: async (params: UserSaveReq): Promise<ApiResponse<any>> => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="layout-shell">
|
||||
<div class="layout-shell" :style="shellVars">
|
||||
<n-layout has-sider class="layout-root">
|
||||
<!-- 侧边栏 -->
|
||||
<LayoutSider />
|
||||
|
|
@ -27,11 +27,12 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { watch, computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { GridOutline, PeopleOutline, ShieldCheckmarkOutline } from '@vicons/ionicons5'
|
||||
import { useLayoutStore } from '@/stores/layout'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import LayoutSider from './components/LayoutSider.vue'
|
||||
import LayoutHeader from './components/LayoutHeader.vue'
|
||||
import LayoutTabs from './components/LayoutTabs.vue'
|
||||
|
|
@ -39,6 +40,7 @@ import LayoutFooter from './components/LayoutFooter.vue'
|
|||
|
||||
const route = useRoute()
|
||||
const layoutStore = useLayoutStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
// 路由图标映射
|
||||
const routeIconMap: Record<string, Component> = {
|
||||
|
|
@ -58,6 +60,26 @@ watch(
|
|||
},
|
||||
{ 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>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -65,12 +87,6 @@ watch(
|
|||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
--color-bg: #F2F3F5;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-border: #E5E6EB;
|
||||
--color-text: #1D2129;
|
||||
--color-text-secondary: #86909C;
|
||||
--color-primary: #0891B2;
|
||||
--header-height: 50px;
|
||||
--tabs-height: 42px;
|
||||
--footer-height: 36px;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@
|
|||
</div>
|
||||
|
||||
<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">
|
||||
<template #icon><n-icon size="18" :component="ExpandOutline" /></template>
|
||||
</n-button>
|
||||
|
|
@ -56,13 +61,17 @@ import {
|
|||
PersonOutline,
|
||||
SettingsOutline,
|
||||
LogOutOutline,
|
||||
MoonOutline,
|
||||
SunnyOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import { useLayoutStore } from '@/stores/layout'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
const layoutStore = useLayoutStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const username = 'SparksFly'
|
||||
const userInitial = computed(() => username.charAt(0).toUpperCase())
|
||||
|
|
@ -81,7 +90,9 @@ const userDropdownOptions: DropdownOption[] = [
|
|||
]
|
||||
|
||||
function onUserAction(key: string) {
|
||||
if (key === 'logout') {
|
||||
if (key === 'profile') {
|
||||
router.push('/profile')
|
||||
} else if (key === 'logout') {
|
||||
message.success('已退出登录')
|
||||
router.push('/login')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,7 +134,9 @@ const userDropdownOptions: DropdownOption[] = [
|
|||
]
|
||||
|
||||
function onUserAction(key: string) {
|
||||
if (key === 'logout') {
|
||||
if (key === 'profile') {
|
||||
router.push('/profile')
|
||||
} else if (key === 'logout') {
|
||||
message.success('已退出登录')
|
||||
router.push('/login')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ const routes = [
|
|||
meta: { title: "菜单管理", icon: "MenuOutline", tabFixed: true },
|
||||
component: () => import("@/views/system/SysMenu.vue"),
|
||||
},
|
||||
{
|
||||
path: "/profile",
|
||||
name: "Profile",
|
||||
meta: { title: "个人中心", icon: "PersonOutline", tabFixed: true },
|
||||
component: () => import("@/views/system/Profile.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
|
|
@ -111,8 +111,8 @@ const repos = ref([
|
|||
<style scoped>
|
||||
.page-dashboard {
|
||||
padding: 28px 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
|
|
@ -124,14 +124,14 @@ const repos = ref([
|
|||
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #1D2129;
|
||||
color: var(--color-text, #1D2129);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
color: #86909C;
|
||||
color: var(--color-text-secondary, #86909C);
|
||||
}
|
||||
|
||||
/* ─── 统计卡片 ─── */
|
||||
|
|
@ -148,8 +148,8 @@ const repos = ref([
|
|||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #E5E6EB;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #E5E6EB);
|
||||
border-radius: 16px;
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
cursor: pointer;
|
||||
|
|
@ -179,14 +179,14 @@ const repos = ref([
|
|||
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1D2129;
|
||||
color: var(--color-text, #1D2129);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
color: #86909C;
|
||||
color: var(--color-text-secondary, #86909C);
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
|
|
@ -220,8 +220,8 @@ const repos = ref([
|
|||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #E5E6EB;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #E5E6EB);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -238,7 +238,7 @@ const repos = ref([
|
|||
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #1D2129;
|
||||
color: var(--color-text, #1D2129);
|
||||
}
|
||||
|
||||
.card-link {
|
||||
|
|
@ -268,7 +268,7 @@ const repos = ref([
|
|||
text-align: center;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #86909C;
|
||||
color: var(--color-text-secondary, #86909C);
|
||||
}
|
||||
|
||||
/* 仓库列表 */
|
||||
|
|
@ -281,7 +281,7 @@ const repos = ref([
|
|||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #E5E6EB;
|
||||
border-bottom: 1px solid var(--color-border, #E5E6EB);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
|
@ -305,12 +305,12 @@ const repos = ref([
|
|||
.repo-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1D2129;
|
||||
color: var(--color-text, #1D2129);
|
||||
}
|
||||
|
||||
.repo-desc {
|
||||
font-size: 12px;
|
||||
color: #86909C;
|
||||
color: var(--color-text-secondary, #86909C);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
|
|
@ -321,7 +321,7 @@ const repos = ref([
|
|||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: #86909C;
|
||||
color: var(--color-text-secondary, #86909C);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ const handleLogin = () => {
|
|||
try {
|
||||
const res = await UserApi.login(formData.username, formData.password)
|
||||
if (res.success === true) {
|
||||
localStorage.setItem('userInfo', JSON.stringify(res.data))
|
||||
message.success('登录成功')
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -344,21 +344,21 @@ function handleDelete(row: MenuInfo) {
|
|||
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1D2129;
|
||||
color: var(--color-text, #1D2129);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
background: #fff;
|
||||
border: 1px solid #E5E6EB;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #E5E6EB);
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
background: #fff;
|
||||
border: 1px solid #E5E6EB;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #E5E6EB);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -367,8 +367,6 @@ function handleDelete(row: MenuInfo) {
|
|||
--n-th-padding: 12px 16px;
|
||||
--n-td-padding: 10px 16px;
|
||||
--n-th-font-weight: 600;
|
||||
--n-th-color: #F7F8FA;
|
||||
--n-td-color-striped: #FAFBFC;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
|
|
|||
|
|
@ -230,21 +230,21 @@ function handleDelete(row: RoleInfo) {
|
|||
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1D2129;
|
||||
color: var(--color-text, #1D2129);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
background: #fff;
|
||||
border: 1px solid #E5E6EB;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #E5E6EB);
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
background: #fff;
|
||||
border: 1px solid #E5E6EB;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #E5E6EB);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -253,8 +253,6 @@ function handleDelete(row: RoleInfo) {
|
|||
--n-th-padding: 12px 16px;
|
||||
--n-td-padding: 10px 16px;
|
||||
--n-th-font-weight: 600;
|
||||
--n-th-color: #F7F8FA;
|
||||
--n-td-color-striped: #FAFBFC;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
|
|
@ -262,7 +260,7 @@ function handleDelete(row: RoleInfo) {
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid #E5E6EB;
|
||||
border-top: 1px solid var(--color-border, #E5E6EB);
|
||||
}
|
||||
|
||||
.total-hint {
|
||||
|
|
|
|||
|
|
@ -345,21 +345,21 @@ function handleDelete(row: UserInfo) {
|
|||
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1D2129;
|
||||
color: var(--color-text, #1D2129);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
background: #fff;
|
||||
border: 1px solid #E5E6EB;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #E5E6EB);
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
background: #fff;
|
||||
border: 1px solid #E5E6EB;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #E5E6EB);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -368,8 +368,6 @@ function handleDelete(row: UserInfo) {
|
|||
--n-th-padding: 12px 16px;
|
||||
--n-td-padding: 10px 16px;
|
||||
--n-th-font-weight: 600;
|
||||
--n-th-color: #F7F8FA;
|
||||
--n-td-color-striped: #FAFBFC;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
|
|
@ -377,12 +375,12 @@ function handleDelete(row: UserInfo) {
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid #E5E6EB;
|
||||
border-top: 1px solid var(--color-border, #E5E6EB);
|
||||
}
|
||||
|
||||
.total-hint {
|
||||
font-size: 13px;
|
||||
color: #86909C;
|
||||
color: var(--color-text-secondary, #86909C);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue