feat: 初始化项目基础框架并完成用户管理模块开发

1.  集成 Pinia 状态管理并创建全局 store
2.  添加布局组件系统,包含侧边栏、头部、标签页、页脚
3.  实现路由系统与多页面框架
4.  完成后端用户 CRUD 与分页接口
5.  新增前端用户管理页面与 API 封装
6.  集成 AWS S3 服务支持
7.  优化类型定义与代码规范
This commit is contained in:
Yuhang Wu 2026-07-27 17:20:55 +08:00
parent 68d08dbc23
commit e0087b12a6
23 changed files with 2851 additions and 598 deletions

View File

@ -15,6 +15,7 @@
"@vicons/ionicons5": "^0.13.0", "@vicons/ionicons5": "^0.13.0",
"axios": "^1.18.1", "axios": "^1.18.1",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"pinia": "^4.0.2",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^5.2.0" "vue-router": "^5.2.0"
}, },

View File

@ -19,16 +19,19 @@ importers:
version: 0.13.0 version: 0.13.0
axios: axios:
specifier: ^1.18.1 specifier: ^1.18.1
version: 1.18.1 version: 1.18.1(debug@4.4.3)
naive-ui: naive-ui:
specifier: ^2.44.1 specifier: ^2.44.1
version: 2.44.1(vue@3.5.40(typescript@5.6.3)) version: 2.44.1(vue@3.5.40(typescript@5.6.3))
pinia:
specifier: ^4.0.2
version: 4.0.2(@vue/devtools-api@8.1.5)(typescript@5.6.3)(vue@3.5.40(typescript@5.6.3))
vue: vue:
specifier: ^3.5.13 specifier: ^3.5.13
version: 3.5.40(typescript@5.6.3) version: 3.5.40(typescript@5.6.3)
vue-router: vue-router:
specifier: ^5.2.0 specifier: ^5.2.0
version: 5.2.0(@vue/compiler-sfc@3.5.40)(esbuild@0.25.12)(rollup@4.62.2)(vite@6.4.3(@types/node@26.1.1)(yaml@2.9.0))(vue@3.5.40(typescript@5.6.3)) version: 5.2.0(@vue/compiler-sfc@3.5.40)(esbuild@0.25.12)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@5.6.3)(vue@3.5.40(typescript@5.6.3)))(rollup@4.62.2)(vite@6.4.3(@types/node@26.1.1)(yaml@2.9.0))(vue@3.5.40(typescript@5.6.3))
devDependencies: devDependencies:
'@tauri-apps/cli': '@tauri-apps/cli':
specifier: ^2 specifier: ^2
@ -885,6 +888,16 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'} engines: {node: '>=12'}
pinia@4.0.2:
resolution: {integrity: sha512-yKVVA7bSj5oRZFp/Ab9wLlmyb5gPUYEiIm4ryiWTe/xe7PtkRdMVOp1X1ggvq0c6Uj7Q0Du1HnV2mtAwM0Ks1g==}
peerDependencies:
'@vue/devtools-api': ^8.1.5
typescript: '>=5.6.0'
vue: ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
pkg-types@1.3.1: pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
@ -1534,9 +1547,9 @@ snapshots:
asynckit@0.4.0: {} asynckit@0.4.0: {}
axios@1.18.1: axios@1.18.1(debug@4.4.3):
dependencies: dependencies:
follow-redirects: 1.16.0 follow-redirects: 1.16.0(debug@4.4.3)
form-data: 4.0.6 form-data: 4.0.6
https-proxy-agent: 5.0.1 https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0 proxy-from-env: 2.1.0
@ -1660,7 +1673,9 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.5 picomatch: 4.0.5
follow-redirects@1.16.0: {} follow-redirects@1.16.0(debug@4.4.3):
optionalDependencies:
debug: 4.4.3
form-data@4.0.6: form-data@4.0.6:
dependencies: dependencies:
@ -1803,6 +1818,14 @@ snapshots:
picomatch@4.0.5: {} picomatch@4.0.5: {}
pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@5.6.3)(vue@3.5.40(typescript@5.6.3)):
dependencies:
'@vue/devtools-api': 8.1.5
nostics: 1.2.0
vue: 3.5.40(typescript@5.6.3)
optionalDependencies:
typescript: 5.6.3
pkg-types@1.3.1: pkg-types@1.3.1:
dependencies: dependencies:
confbox: 0.1.8 confbox: 0.1.8
@ -1977,7 +2000,7 @@ snapshots:
vscode-uri@3.1.0: {} vscode-uri@3.1.0: {}
vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(esbuild@0.25.12)(rollup@4.62.2)(vite@6.4.3(@types/node@26.1.1)(yaml@2.9.0))(vue@3.5.40(typescript@5.6.3)): vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(esbuild@0.25.12)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@5.6.3)(vue@3.5.40(typescript@5.6.3)))(rollup@4.62.2)(vite@6.4.3(@types/node@26.1.1)(yaml@2.9.0))(vue@3.5.40(typescript@5.6.3)):
dependencies: dependencies:
'@babel/generator': 8.0.0 '@babel/generator': 8.0.0
'@vue-macros/common': 3.1.4(vue@3.5.40(typescript@5.6.3)) '@vue-macros/common': 3.1.4(vue@3.5.40(typescript@5.6.3))
@ -2000,6 +2023,7 @@ snapshots:
yaml: 2.9.0 yaml: 2.9.0
optionalDependencies: optionalDependencies:
'@vue/compiler-sfc': 3.5.40 '@vue/compiler-sfc': 3.5.40
pinia: 4.0.2(@vue/devtools-api@8.1.5)(typescript@5.6.3)(vue@3.5.40(typescript@5.6.3))
vite: 6.4.3(@types/node@26.1.1)(yaml@2.9.0) vite: 6.4.3(@types/node@26.1.1)(yaml@2.9.0)
transitivePeerDependencies: transitivePeerDependencies:
- '@farmfe/core' - '@farmfe/core'

1167
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -30,3 +30,7 @@ sqlx = { version = "0.9.0", features = [
"tls-native-tls", "tls-native-tls",
"chrono", "chrono",
] } ] }
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"

View File

@ -1,33 +1,54 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::MySqlPool; use sqlx::{MySqlPool, QueryBuilder};
use tauri::State; use tauri::State;
use crate::models::ApiResponse; use crate::models::{ApiResponse, PageResult};
#[derive(Debug, Serialize, sqlx::FromRow)] #[derive(Debug, Serialize, sqlx::FromRow)]
struct SysUser { struct SysUser {
id: i32, id: i64,
nick_name: String, nick_name: String,
avatar: String, avatar: String,
user_name: String, user_name: String,
password: String, password: String,
email: String, email: String,
phone: String, phone: String,
age: i32, age: i64,
sex: i32, sex: i64,
creater: i32, creater: i64,
create_time: chrono::NaiveDateTime, create_time: chrono::NaiveDateTime,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct UserInfo { pub struct UserInfo {
id: i32, id: i64,
nick_name: String, nick_name: String,
avatar: String, avatar: String,
user_name: String, user_name: String,
email: String, email: String,
phone: String, phone: String,
age: i32, age: i64,
sex: i32, sex: i64,
create_time: chrono::NaiveDateTime,
}
#[derive(Debug, Deserialize)]
pub struct UserPageReq {
pub page: i64,
pub page_size: i64,
pub nick_name: Option<String>,
pub user_name: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UserSaveReq {
pub id: i64,
pub nick_name: String,
pub password: String,
pub user_name: String,
pub email: String,
pub phone: String,
pub age: i64,
pub sex: i64,
} }
impl From<SysUser> for UserInfo { impl From<SysUser> for UserInfo {
@ -41,6 +62,7 @@ impl From<SysUser> for UserInfo {
phone: user.phone, phone: user.phone,
age: user.age, age: user.age,
sex: user.sex, sex: user.sex,
create_time: user.create_time,
} }
} }
} }
@ -68,3 +90,146 @@ pub async fn login(
let user_info: UserInfo = UserInfo::from(user); let user_info: UserInfo = UserInfo::from(user);
Ok(ApiResponse::success(user_info)) Ok(ApiResponse::success(user_info))
} }
#[tauri::command]
pub async fn get_user_page(
pool: State<'_, MySqlPool>,
params: UserPageReq,
) -> Result<ApiResponse<PageResult<UserInfo>>, String> {
let page = params.page.max(1);
let page_size = params.page_size.min(10).max(1);
let offset = (page - 1) * page_size;
let mut count_builder = QueryBuilder::new("SELECT COUNT(*) FROM sys_user");
let has_nick_name = params
.nick_name
.as_ref()
.map_or(false, |nick_name| !nick_name.is_empty());
let has_user_name = params
.user_name
.as_ref()
.map_or(false, |user_name| !user_name.is_empty());
if has_nick_name || has_user_name {
count_builder.push(" WHERE 1=1");
if has_nick_name {
count_builder.push(" AND nick_name LIKE ");
count_builder.push_bind(format!("%{}%", params.nick_name.as_deref().unwrap()));
}
if has_user_name {
count_builder.push(" AND user_name LIKE ");
count_builder.push_bind(format!("%{}%", params.user_name.as_deref().unwrap()));
}
}
let total: i64 = count_builder
.build_query_scalar()
.fetch_one(&*pool)
.await
.map_err(|e| e.to_string())?;
// ===== 查分页数据 =====
let mut data_builder = QueryBuilder::new("SELECT * FROM sys_user");
if has_user_name || has_nick_name {
data_builder.push(" WHERE 1=1");
if has_user_name {
data_builder.push(" AND user_name LIKE ");
data_builder.push_bind(format!("%{}%", params.user_name.as_deref().unwrap()));
}
if has_nick_name {
data_builder.push(" AND nick_name LIKE ");
data_builder.push_bind(format!("%{}%", params.nick_name.as_deref().unwrap()));
}
}
data_builder
.push(" LIMIT ")
.push_bind(page_size)
.push(" OFFSET ")
.push_bind(offset);
let users = data_builder
.build_query_as::<SysUser>()
.fetch_all(&*pool)
.await
.map_err(|e| e.to_string())?;
let list: Vec<UserInfo> = users.into_iter().map(|user| UserInfo::from(user)).collect();
Ok(ApiResponse::success(PageResult {
total,
list,
page,
page_size,
}))
}
#[tauri::command]
pub async fn register(
pool: State<'_, MySqlPool>,
params: UserSaveReq,
) -> Result<ApiResponse<i64>, String> {
// 用户名是否存在
let exist: i64 = sqlx::query_scalar("SELECT COUNT(id) FROM sys_user WHERE user_name = ?")
.bind(&params.user_name)
.fetch_one(&*pool)
.await
.map_err(|e| e.to_string())?;
if exist > 0 {
return Ok(ApiResponse::error(1003, "用户名已存在!"));
}
// 插入新用户
let result = sqlx::query("INSERT INTO sys_user ( nick_name, user_name, password, email, phone, age, sex ) VALUES (?,?,?,?,?,?,?)")
.bind(&params.nick_name)
.bind(&params.user_name)
.bind(&params.password)
.bind(&params.email)
.bind(&params.phone)
.bind(&params.age)
.bind(&params.sex)
.execute(&*pool)
.await
.map_err(|e| e.to_string())?;
let id = result.last_insert_id() as i64;
Ok(ApiResponse::success(id))
}
#[tauri::command]
pub async fn del_user(pool: State<'_, MySqlPool>, id: i64) -> Result<ApiResponse<()>, String> {
sqlx::query("DELETE FROM sys_user WHERE id = ?")
.bind(id)
.execute(&*pool)
.await
.map_err(|e| e.to_string())?;
Ok(ApiResponse::<()>::success_empty())
}
#[tauri::command]
pub async fn update_user(
pool: State<'_, MySqlPool>,
params: UserSaveReq,
) -> Result<ApiResponse<()>, String> {
let exist: i64 =
sqlx::query_scalar("SELECT COUNT(id) FROM sys_user WHERE user_name=? AND id != ?")
.bind(&params.user_name)
.bind(&params.id)
.fetch_one(&*pool)
.await
.map_err(|e| e.to_string())?;
if exist > 0 {
return Ok(ApiResponse::error(1003, "用户名已存在!"));
}
sqlx::query("UPDATE sys_user SET nick_name = ?, user_name = ?, email = ?, phone = ?, age = ?, sex = ? WHERE id = ?")
.bind(&params.nick_name)
.bind(&params.user_name)
.bind(&params.email)
.bind(&params.phone)
.bind(params.age)
.bind(params.sex)
.bind(params.id)
.execute(&*pool)
.await
.map_err(|e| e.to_string())?;
Ok(ApiResponse::<()>::success_empty())
}

View File

@ -1,25 +1,36 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
pub mod commands; pub mod commands;
pub mod services;
pub mod models; pub mod models;
pub mod services;
use services::db::create_db_pool; use services::db::create_db_pool;
use services::s3::{S3Client, S3Config};
use tauri::Manager; use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
// 初始化数据库连接池
.setup(|app| { .setup(|app| {
// 初始化数据库连接池
let pool = tauri::async_runtime::block_on(async { let pool = tauri::async_runtime::block_on(async {
create_db_pool().await.expect("连接Mysql数据库出错!") create_db_pool().await.expect("连接Mysql数据库出错!")
}); });
app.manage(pool); app.manage(pool);
// 初始化S3客户端
let s3_cfg = S3Config::from().expect("读取S3配置失败!");
let s3_client = tauri::async_runtime::block_on(S3Client::new(&s3_cfg));
app.manage(s3_client);
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::user::login commands::user::login,
commands::user::get_user_page,
commands::user::register,
commands::user::del_user,
commands::user::update_user
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@ -1,4 +1,12 @@
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct PageResult<T> {
pub total: i64,
pub list: Vec<T>,
pub page: i64,
pub page_size: i64,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ApiResponse<T> { pub struct ApiResponse<T> {
@ -8,7 +16,7 @@ pub struct ApiResponse<T> {
data: Option<T>, data: Option<T>,
} }
impl<T: Serialize> ApiResponse<T> { impl<T> ApiResponse<T> {
// 成功响应 // 成功响应
pub fn success(data: T) -> Self { pub fn success(data: T) -> Self {
Self { Self {
@ -20,7 +28,12 @@ impl<T: Serialize> ApiResponse<T> {
} }
// 成功响应,数据为空 // 成功响应,数据为空
pub fn success_empty() -> ApiResponse<()> { pub fn success_empty() -> ApiResponse<()> {
ApiResponse { success: true, code: 200, message: "操作成功".to_string(), data: None } ApiResponse {
success: true,
code: 200,
message: "操作成功".to_string(),
data: None,
}
} }
// 错误响应 // 错误响应
pub fn error(code: i32, message: &str) -> Self { pub fn error(code: i32, message: &str) -> Self {

View File

@ -1 +1,2 @@
pub mod db; pub mod db;
pub mod s3;

View File

@ -0,0 +1,113 @@
use aws_credential_types::Credentials as AwsCreds;
use aws_sdk_s3::{
config::{BehaviorVersion, Credentials, Region},
Client,
};
pub struct S3Config {
pub region: String,
pub access_key: String,
pub secret_key: String,
pub bucket_name: String,
pub endpoint: String,
}
impl S3Config {
pub fn from() -> Result<Self, Box<dyn std::error::Error>> {
let region = String::from("US_EAST_1");
let access_key = String::from("admin");
let secret_key = String::from("123456");
let bucket_name = String::from("DevBox");
let endpoint = String::from("http://127.0.0.1:9000");
Ok(Self {
region,
access_key,
secret_key,
bucket_name,
endpoint,
})
}
}
pub struct S3Client {
pub client: Client,
pub bucket: String,
}
impl S3Client {
pub async fn new(cfg: &S3Config) -> Self {
let creds = AwsCreds::new(&cfg.access_key, &cfg.secret_key, None, None, "static");
let config = aws_sdk_s3::Config::builder()
.behavior_version(BehaviorVersion::latest())
.region(Region::new(cfg.region.clone()))
.endpoint_url(&cfg.endpoint)
.credentials_provider(creds)
.force_path_style(true)
.build();
Self {
client: Client::from_conf(config),
bucket: cfg.bucket_name.clone(),
}
}
pub async fn upload(&self, key: &str, data: Vec<u8>) -> Result<(), String> {
self.client
.put_object()
.bucket(&self.bucket)
.key(key)
.body(data.into())
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn download(&self, key: &str) -> Result<Vec<u8>, String> {
let res = self
.client
.get_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
.map_err(|e| e.to_string())?;
let data = res
.body
.collect()
.await
.map_err(|e| e.to_string())?
.into_bytes()
.to_vec();
Ok(data)
}
pub async fn list(&self, prefix: &str) -> Result<Vec<String>, String> {
let res = self
.client
.list_objects_v2()
.bucket(&self.bucket)
.prefix(prefix)
.send()
.await
.map_err(|e| e.to_string())?;
let keys = res
.contents()
.iter()
.filter_map(|obj| obj.key().map(|s| s.to_string()))
.collect();
Ok(keys)
}
pub async fn delete(&self, key: &str) -> Result<(), String> {
self.client
.delete_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
}

View File

@ -1,13 +1,14 @@
<template> <template>
<n-message-provider> <n-message-provider>
<div class="container"> <n-dialog-provider>
<router-view /> <div class="container">
</div> <router-view />
</div>
</n-dialog-provider>
</n-message-provider> </n-message-provider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
</script> </script>
<style scoped> <style scoped>

View File

@ -1,24 +1,59 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core"
import { ApiResponse } from "@/types"; import { ApiResponse, PageResult } from "@/types"
interface UserInfo { export interface UserInfo {
id: number; id: number
nickName: string; nick_name: string
avatar: string; avatar: string
userName: string; user_name: string
email: string; email: string
phone: string; phone: string
age: number; age: number
sex: number; sex: number
create_time?: string
}
export interface UserPageReq {
page: number
page_size: number
nick_name?: string
user_name?: string
}
export interface UserSaveReq {
id?: number
nick_name: string
avatar: string
user_name: string
password: string
email: string
phone: string
age: number
sex: number
creater: number
} }
const UserApi = { const UserApi = {
login: async ( // 登录
userName: string, login: async (userName: string, password: string): Promise<ApiResponse<UserInfo>> => {
password: string, return await invoke("login", { userName, password })
): Promise<ApiResponse<UserInfo>> => {
return await invoke("login", { userName, password });
}, },
}; // 获取用户分页列表
getUserPage: async (params: UserPageReq): Promise<ApiResponse<PageResult<UserInfo>>> => {
return await invoke("get_user_page", { params })
},
// 注册
register: async (params: UserSaveReq): Promise<ApiResponse<UserInfo>> => {
return await invoke("register", { params })
},
// 删除用户
delUser: async (id: number): Promise<ApiResponse<any>> => {
return await invoke("del_user", { id })
},
// 更新用户
update_user: async (params: UserSaveReq): Promise<ApiResponse<any>> => {
return await invoke("update_user", { params })
},
}
export default UserApi; export default UserApi

120
src/layout/Index.vue Normal file
View File

@ -0,0 +1,120 @@
<template>
<div class="layout-shell">
<n-layout has-sider class="layout-root">
<!-- 侧边栏 -->
<LayoutSider />
<!-- 右侧主区域 -->
<n-layout class="layout-main">
<LayoutHeader />
<LayoutTabs />
<!-- 主内容区 -->
<n-layout-content class="layout-content">
<router-view v-slot="{ Component }">
<transition name="page-fade" mode="out-in">
<keep-alive :include="layoutStore.keepAliveNames">
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
</n-layout-content>
<LayoutFooter />
</n-layout>
</n-layout>
</div>
</template>
<script setup lang="ts">
import { watch } from 'vue'
import type { Component } from 'vue'
import { useRoute } from 'vue-router'
import { GridOutline, PeopleOutline } from '@vicons/ionicons5'
import { useLayoutStore } from '@/stores/layout'
import LayoutSider from './components/LayoutSider.vue'
import LayoutHeader from './components/LayoutHeader.vue'
import LayoutTabs from './components/LayoutTabs.vue'
import LayoutFooter from './components/LayoutFooter.vue'
const route = useRoute()
const layoutStore = useLayoutStore()
//
const routeIconMap: Record<string, Component> = {
'/dashboard': GridOutline,
'/system/users': PeopleOutline,
}
// Tab
watch(
() => route.path,
(path) => {
const title = (route.meta?.title as string) || path
const icon = routeIconMap[path] || GridOutline
const fixed = (route.meta?.tabFixed as boolean) || false
layoutStore.addTab(path, title, icon, fixed)
},
{ immediate: true },
)
</script>
<style scoped>
.layout-shell {
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;
}
.layout-root {
height: 100%;
display: flex;
}
.layout-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
height: 100%;
overflow: hidden;
}
.layout-main :deep(.n-layout-scroll-container) {
display: flex;
flex-direction: column;
height: 100%;
}
.layout-content {
flex: 1;
background: var(--color-bg);
overflow: auto;
}
/* 页面切换动画 */
.page-fade-enter-active,
.page-fade-leave-active {
transition: opacity 0.15s ease;
}
.page-fade-enter-from,
.page-fade-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
}
}
</style>

View File

@ -0,0 +1,26 @@
<template>
<div class="layout-footer">
<span>DevBox 开发者一站式工作台</span>
<span class="footer-divider">|</span>
<span>Powered by Tauri + Vue3 + Naive UI</span>
</div>
</template>
<style scoped>
.layout-footer {
height: var(--footer-height, 36px);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-top: 1px solid var(--color-border, #E5E6EB);
background: var(--color-surface, #fff);
flex-shrink: 0;
font-size: 12px;
color: var(--color-text-secondary, #86909C);
}
.footer-divider {
color: var(--color-border, #E5E6EB);
}
</style>

View File

@ -0,0 +1,160 @@
<template>
<n-layout-header bordered class="layout-header">
<div class="header-left">
<n-button quaternary circle size="small" @click="layoutStore.toggleSider()">
<template #icon><n-icon size="20" :component="MenuOutline" /></template>
</n-button>
<n-breadcrumb class="header-breadcrumb">
<n-breadcrumb-item>
<n-icon :component="HomeOutline" size="16" />
</n-breadcrumb-item>
<n-breadcrumb-item v-if="currentRouteTitle">
{{ currentRouteTitle }}
</n-breadcrumb-item>
</n-breadcrumb>
</div>
<div class="header-right">
<n-button quaternary circle size="small" @click="toggleFullscreen">
<template #icon><n-icon size="18" :component="ExpandOutline" /></template>
</n-button>
<n-tooltip trigger="hover">
<template #trigger>
<n-badge dot processing>
<n-button quaternary circle size="small">
<template #icon><n-icon size="18" :component="NotificationsOutline" /></template>
</n-button>
</n-badge>
</template>
消息通知
</n-tooltip>
<n-dropdown trigger="click" :options="userDropdownOptions" @select="onUserAction">
<div class="header-user">
<n-avatar round size="small" :style="{ backgroundColor: '#0891B2' }">
{{ userInitial }}
</n-avatar>
<span class="header-username">{{ username }}</span>
<n-icon size="14" :component="ChevronDown" />
</div>
</n-dropdown>
</div>
</n-layout-header>
</template>
<script setup lang="ts">
import { computed, h } from 'vue'
import type { Component } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { NIcon, useMessage } from 'naive-ui'
import type { DropdownOption } from 'naive-ui'
import {
MenuOutline,
HomeOutline,
NotificationsOutline,
ExpandOutline,
ChevronDown,
PersonOutline,
SettingsOutline,
LogOutOutline,
} from '@vicons/ionicons5'
import { useLayoutStore } from '@/stores/layout'
const router = useRouter()
const route = useRoute()
const message = useMessage()
const layoutStore = useLayoutStore()
const username = 'SparksFly'
const userInitial = computed(() => username.charAt(0).toUpperCase())
const currentRouteTitle = computed(() => (route.meta?.title as string) || '')
function renderIcon(icon: Component) {
return () => h(NIcon, { size: 16 }, { default: () => h(icon) })
}
const userDropdownOptions: DropdownOption[] = [
{ key: 'profile', label: '个人中心', icon: renderIcon(PersonOutline) },
{ key: 'settings', label: '系统设置', icon: renderIcon(SettingsOutline) },
{ type: 'divider' },
{ key: 'logout', label: '退出登录', icon: renderIcon(LogOutOutline) },
]
function onUserAction(key: string) {
if (key === 'logout') {
message.success('已退出登录')
router.push('/login')
}
}
function toggleFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
document.documentElement.requestFullscreen()
}
}
</script>
<style scoped>
.layout-header {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--header-height, 50px);
padding: 0 16px;
--n-header-border-color: var(--color-border, #E5E6EB);
background: var(--color-surface, #fff);
flex-shrink: 0;
position: relative;
z-index: 10;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-breadcrumb {
font-size: 13px;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.header-user {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
transition: background 0.15s;
}
.header-user:hover {
background: var(--color-bg, #F2F3F5);
}
.header-username {
font-size: 13px;
font-weight: 500;
color: var(--color-text, #1D2129);
}
@media (max-width: 768px) {
.layout-header {
padding: 0 10px;
}
.header-username {
display: none;
}
.header-breadcrumb {
display: none;
}
}
</style>

View File

@ -0,0 +1,241 @@
<template>
<n-layout-sider
bordered
collapse-mode="transform"
:collapsed-width="64"
:width="240"
:collapsed="layoutStore.siderCollapsed"
:show-trigger="false"
class="layout-sider"
>
<div class="sider-wrapper">
<!-- Logo -->
<div class="sider-logo" @click="router.push('/dashboard')">
<div class="logo-icon">
<svg viewBox="0 0 36 36" fill="none">
<rect width="36" height="36" rx="10" fill="url(#logo-grad)" />
<path d="M10 24V12l8 7 8-7v12" stroke="#fff" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round" />
<defs>
<linearGradient id="logo-grad" x1="0" y1="0" x2="36" y2="36">
<stop stop-color="#0891B2" /><stop offset="1" stop-color="#06B6D4" />
</linearGradient>
</defs>
</svg>
</div>
<transition name="fade-text">
<span v-if="!layoutStore.siderCollapsed" class="logo-text">DevBox</span>
</transition>
</div>
<!-- 菜单 -->
<div class="sider-menu">
<n-menu
:value="activeMenuKey"
:collapsed="layoutStore.siderCollapsed"
:collapsed-width="64"
:collapsed-icon-size="22"
:options="menuOptions"
:default-expanded-keys="['system']"
@update:value="onMenuSelect"
/>
</div>
<!-- 用户区 -->
<transition name="fade-text">
<div v-if="!layoutStore.siderCollapsed" class="sider-user">
<n-avatar round size="small" :style="{ backgroundColor: '#0891B2' }">
{{ userInitial }}
</n-avatar>
<div class="user-meta">
<span class="user-name">{{ username }}</span>
<span class="user-role">管理员</span>
</div>
<n-dropdown trigger="click" :options="userDropdownOptions" @select="onUserAction">
<n-button text size="tiny" class="user-more-btn">
<template #icon><n-icon :component="ChevronForward" /></template>
</n-button>
</n-dropdown>
</div>
</transition>
</div>
</n-layout-sider>
</template>
<script setup lang="ts">
import { computed, h } from 'vue'
import type { Component } from 'vue'
import { NIcon } from 'naive-ui'
import { useRouter, useRoute } from 'vue-router'
import { useMessage } from 'naive-ui'
import type { MenuOption, DropdownOption } from 'naive-ui'
import {
ChevronForward,
GridOutline,
PeopleOutline,
GitBranchOutline,
ServerOutline,
ExtensionPuzzleOutline,
SettingsOutline,
LogOutOutline,
PersonOutline,
} from '@vicons/ionicons5'
import { useLayoutStore } from '@/stores/layout'
const router = useRouter()
const route = useRoute()
const message = useMessage()
const layoutStore = useLayoutStore()
//
const username = 'SparksFly'
const userInitial = computed(() => username.charAt(0).toUpperCase())
// Icon helper
function renderIcon(icon: Component) {
return () => h(NIcon, { size: 18 }, { default: () => h(icon) })
}
function renderDropdownIcon(icon: Component) {
return () => h(NIcon, { size: 16 }, { default: () => h(icon) })
}
//
const menuOptions: MenuOption[] = [
{ key: '/dashboard', label: '工作台', icon: renderIcon(GridOutline) },
{
key: 'system',
label: '系统管理',
icon: renderIcon(ServerOutline),
children: [
{ key: '/system/users', label: '用户管理', icon: renderIcon(PeopleOutline) },
],
},
{ key: 'repo', label: '仓库管理', icon: renderIcon(GitBranchOutline), disabled: true },
{ key: 'service', label: '服务管理', icon: renderIcon(ServerOutline), disabled: true },
{ key: 'plugin', label: '插件管理', icon: renderIcon(ExtensionPuzzleOutline), disabled: true },
{ key: 'settings', label: '系统设置', icon: renderIcon(SettingsOutline), disabled: true },
]
const activeMenuKey = computed(() => route.path)
function onMenuSelect(key: string) {
if (key && key.startsWith('/')) {
router.push(key)
}
}
//
const userDropdownOptions: DropdownOption[] = [
{ key: 'profile', label: '个人中心', icon: renderDropdownIcon(PersonOutline) },
{ key: 'settings', label: '系统设置', icon: renderDropdownIcon(SettingsOutline) },
{ type: 'divider' },
{ key: 'logout', label: '退出登录', icon: renderDropdownIcon(LogOutOutline) },
]
function onUserAction(key: string) {
if (key === 'logout') {
message.success('已退出登录')
router.push('/login')
}
}
</script>
<style scoped>
.layout-sider {
--n-sider-color: var(--color-surface, #fff);
--n-sider-border-color: var(--color-border, #E5E6EB);
}
.sider-wrapper {
display: flex;
flex-direction: column;
height: 100%;
overflow: visible;
}
.sider-logo {
display: flex;
align-items: center;
gap: 10px;
height: var(--header-height, 50px);
padding: 0 16px;
cursor: pointer;
border-bottom: 1px solid var(--color-border, #E5E6EB);
overflow: hidden;
flex-shrink: 0;
}
.logo-icon {
width: 32px;
height: 32px;
flex-shrink: 0;
}
.logo-text {
font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 16px;
font-weight: 700;
color: var(--color-text, #1D2129);
white-space: nowrap;
}
.sider-menu {
flex: 1;
overflow: visible;
padding: 8px;
min-height: 0;
}
.sider-menu :deep(.n-menu) {
--n-item-height: 40px;
--n-border-radius: 6px;
--n-font-size: 14px;
--n-item-icon-size: 18px;
}
.sider-user {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--color-border, #E5E6EB);
flex-shrink: 0;
}
.user-meta {
flex: 1;
min-width: 0;
line-height: 1.3;
}
.user-name {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--color-text, #1D2129);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-role {
display: block;
font-size: 11px;
color: var(--color-text-secondary, #86909C);
}
.user-more-btn {
flex-shrink: 0;
color: var(--color-text-secondary, #86909C);
}
.fade-text-enter-active,
.fade-text-leave-active {
transition: opacity 0.2s ease;
}
.fade-text-enter-from,
.fade-text-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,118 @@
<template>
<div class="layout-tabs">
<n-tabs
v-model:value="layoutStore.activeTabKey"
type="bar"
:closable="layoutStore.tabs.length > 1"
@close="onClose"
@update:value="onChange"
>
<n-tab
v-for="tab in layoutStore.tabs"
:key="tab.key"
:name="tab.key"
:closable="!tab.fixed"
>
<template #prefix>
<n-icon size="16" :component="tab.icon" />
</template>
{{ tab.label }}
</n-tab>
</n-tabs>
<n-dropdown trigger="click" :options="tabActions" @select="onAction">
<n-button quaternary size="small" class="tab-action-btn">
<template #icon><n-icon size="16" :component="ChevronDown" /></template>
</n-button>
</n-dropdown>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import type { DropdownOption } from 'naive-ui'
import { ChevronDown } from '@vicons/ionicons5'
import { useLayoutStore } from '@/stores/layout'
const router = useRouter()
const layoutStore = useLayoutStore()
function onChange(key: string) {
router.push(key)
}
function onClose(key: string) {
const redirect = layoutStore.removeTab(key)
if (redirect) router.push(redirect)
}
const tabActions: DropdownOption[] = [
{ key: 'close-current', label: '关闭当前' },
{ key: 'close-others', label: '关闭其他' },
{ key: 'close-all', label: '关闭全部' },
]
function onAction(key: string) {
switch (key) {
case 'close-current':
onClose(layoutStore.activeTabKey)
break
case 'close-others':
layoutStore.closeOtherTabs()
break
case 'close-all':
layoutStore.closeAllTabs()
router.push('/dashboard')
break
}
}
</script>
<style scoped>
.layout-tabs {
display: flex;
align-items: center;
height: var(--tabs-height, 42px);
background: var(--color-surface, #fff);
border-bottom: 1px solid var(--color-border, #E5E6EB);
padding: 0 8px;
flex-shrink: 0;
position: relative;
z-index: 10;
}
.layout-tabs :deep(.n-tabs) {
flex: 1;
min-width: 0;
--n-tab-font-size: 13px;
--n-bar-color: var(--color-primary, #0891B2);
--n-tab-gap: 2px;
--n-tab-padding: 6px 14px;
--n-tab-border-radius: 4px;
--n-tab-text-color-active-line: var(--color-primary, #0891B2);
--n-tab-text-color-hover-line: var(--color-primary, #0891B2);
--n-close-size: 14px;
}
.layout-tabs :deep(.n-tabs .n-tabs-tab) {
padding: 0 12px;
height: 30px;
line-height: 30px;
border-radius: 4px;
margin: 0 2px;
border: 1px solid transparent;
background: transparent;
}
.layout-tabs :deep(.n-tabs .n-tabs-tab--active) {
background: var(--color-bg, #F2F3F5);
}
.layout-tabs :deep(.n-tabs .n-tabs-tab:hover) {
background: var(--color-bg, #F2F3F5);
}
.tab-action-btn {
flex-shrink: 0;
margin-left: 4px;
}
</style>

View File

@ -1,9 +1,11 @@
import { createApp } from "vue"; import { createApp } from "vue"
import App from "./App.vue"; import App from "./App.vue"
import naive from "naive-ui"; import naive from "naive-ui"
import router from "@/router"; import router from "@/router"
import pinia from "@/stores"
const app = createApp(App); const app = createApp(App)
app.use(naive); app.use(naive)
app.use(router); app.use(router)
app.mount("#app"); app.use(pinia)
app.mount("#app")

View File

@ -1,25 +1,40 @@
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router"
const routes = [ const routes = [
{ {
path: "/", path: "/",
redirect: "/login" redirect: "/login",
}, },
{ {
path: "/login", path: "/login",
name: "Login", name: "Login",
component: () => import("@/views/home/Login.vue") component: () => import("@/views/home/Login.vue"),
}, },
{ {
path: "/home",
name: "Home",
component: () => import("@/layout/Index.vue"),
redirect: "/dashboard",
children: [
{
path: "/dashboard", path: "/dashboard",
name: "Dashboard", name: "Dashboard",
component: () => import("@/views/dashboard/Dashboard.vue") meta: { title: "工作台", icon: "GridOutline", tabFixed: true },
} component: () => import("@/views/dashboard/Dashboard.vue"),
},
{
path: "/system/users",
name: "SysUser",
meta: { title: "用户管理", icon: "PeopleOutline", tabFixed: true },
component: () => import("@/views/system/SysUser.vue"),
},
],
},
] ]
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes routes,
}) })
export default router; export default router

4
src/stores/index.ts Normal file
View File

@ -0,0 +1,4 @@
import { createPinia } from "pinia"
const pinia = createPinia()
export default pinia

66
src/stores/layout.ts Normal file
View File

@ -0,0 +1,66 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Component } from 'vue'
export interface TabItem {
key: string
label: string
icon: Component
fixed: boolean
}
export const useLayoutStore = defineStore('layout', () => {
// ─── 侧边栏 ───
const siderCollapsed = ref(false)
function toggleSider() {
siderCollapsed.value = !siderCollapsed.value
}
// ─── Tab 标签 ───
const tabs = ref<TabItem[]>([])
const activeTabKey = ref('')
const keepAliveNames = computed(() => tabs.value.map(t => t.key))
function addTab(path: string, label: string, icon: Component, fixed = false) {
const exists = tabs.value.find(t => t.key === path)
if (!exists) {
tabs.value.push({ key: path, label, icon, fixed })
}
activeTabKey.value = path
}
function removeTab(key: string): string | null {
const idx = tabs.value.findIndex(t => t.key === key)
if (idx === -1) return null
tabs.value.splice(idx, 1)
if (activeTabKey.value === key) {
const next = tabs.value[Math.min(idx, tabs.value.length - 1)]
return next ? next.key : '/dashboard'
}
return null
}
function closeOtherTabs() {
const current = tabs.value.find(t => t.key === activeTabKey.value)
tabs.value = current ? [current] : []
}
function closeAllTabs() {
tabs.value = []
activeTabKey.value = ''
}
return {
siderCollapsed,
toggleSider,
tabs,
activeTabKey,
keepAliveNames,
addTab,
removeTab,
closeOtherTabs,
closeAllTabs,
}
})

View File

@ -1,8 +1,15 @@
interface ApiResponse<T> { interface ApiResponse<T> {
success: boolean; success: boolean
code: number; code: number
message: string; message: string
data: T; data: T
} }
export type { ApiResponse }; interface PageResult<T> {
total: number
list: T[]
page: number
page_size: number
}
export type { ApiResponse, PageResult }

View File

@ -1,237 +1,106 @@
<template> <template>
<div class="dashboard-layout"> <div class="page-dashboard">
<!-- 侧边栏遮罩移动端 --> <!-- 页面标题 -->
<div <div class="page-header">
v-if="sidebarOpen" <h2 class="page-title">概览</h2>
class="sidebar-overlay" <p class="page-subtitle">欢迎回来来看看你的项目近况</p>
@click="sidebarOpen = false" </div>
/>
<!-- 侧边栏 --> <!-- 统计卡片 -->
<aside class="sidebar" :class="{ open: sidebarOpen }"> <div class="stat-grid">
<!-- 侧边栏 Logo --> <div v-for="stat in stats" :key="stat.label" class="stat-card">
<div class="sidebar-brand"> <div class="stat-icon" :style="{ backgroundColor: stat.bg }">
<svg class="sidebar-logo" viewBox="0 0 40 40" fill="none"> <n-icon size="22" :component="stat.icon" :color="stat.color" />
<rect width="40" height="40" rx="12" fill="url(#sb-grad)" /> </div>
<path d="M12 26V14l8 8 8-8v12" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" /> <div class="stat-info">
<defs> <span class="stat-value">{{ stat.value }}</span>
<linearGradient id="sb-grad" x1="0" y1="0" x2="40" y2="40"> <span class="stat-label">{{ stat.label }}</span>
<stop stop-color="#0891B2" /> </div>
<stop offset="1" stop-color="#06B6D4" /> <div class="stat-badge" :class="stat.trend > 0 ? 'up' : 'down'">
</linearGradient> <n-icon size="14" :component="stat.trend > 0 ? ArrowUpOutline : ArrowDownOutline" />
</defs> {{ Math.abs(stat.trend) }}%
</svg>
<span class="sidebar-title">DevBox</span>
</div>
<!-- 导航菜单 -->
<nav class="sidebar-nav">
<a
v-for="item in menuItems"
:key="item.key"
class="nav-item"
:class="{ active: activeMenu === item.key }"
href="javascript:;"
@click="activeMenu = item.key"
>
<n-icon size="20" :component="item.icon" />
<span class="nav-label">{{ item.label }}</span>
</a>
</nav>
<!-- 底部用户区 -->
<div class="sidebar-footer">
<div class="user-chip">
<n-avatar round size="small" :style="{ backgroundColor: '#0891B2' }">
{{ userInitial }}
</n-avatar>
<div class="user-meta">
<span class="user-name">{{ currentUser }}</span>
<span class="user-role">开发者</span>
</div>
</div> </div>
</div> </div>
</aside> </div>
<!-- 主区域 --> <!-- 双栏内容 -->
<div class="main-area"> <div class="content-grid">
<!-- 顶部栏 --> <!-- 图表卡片 -->
<header class="topbar"> <div class="card">
<button class="menu-trigger" @click="sidebarOpen = !sidebarOpen"> <div class="card-header">
<n-icon size="22" :component="MenuOutline" /> <h3 class="card-title">项目活跃度</h3>
</button> <n-tag size="small" :bordered="false" type="info">最近 7 </n-tag>
<div class="topbar-right">
<n-badge dot :show="true" processing>
<n-icon size="20" :component="NotificationsOutline" class="topbar-icon" />
</n-badge>
<n-avatar round size="small" :style="{ backgroundColor: '#0891B2' }" class="topbar-avatar">
{{ userInitial }}
</n-avatar>
</div> </div>
</header> <div class="chart-area">
<svg class="chart-svg" viewBox="0 0 400 160" preserveAspectRatio="none">
<polyline
fill="none" stroke="#0891B2" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round"
points="0,120 50,100 100,110 150,40 200,60 250,20 300,50 350,10 400,35"
/>
<polyline
fill="url(#area-grad)" stroke="none"
points="0,120 50,100 100,110 150,40 200,60 250,20 300,50 350,10 400,35 400,160 0,160"
/>
<defs>
<linearGradient id="area-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0891B2" stop-opacity="0.15" />
<stop offset="1" stop-color="#0891B2" stop-opacity="0.01" />
</linearGradient>
</defs>
</svg>
<span class="chart-note">连接数据源以查看完整图表</span>
</div>
</div>
<!-- 内容区域 --> <!-- 仓库列表 -->
<main class="content"> <div class="card">
<div class="content-inner"> <div class="card-header">
<!-- 页面标题 --> <h3 class="card-title">最近仓库</h3>
<div class="page-header"> <a class="card-link" href="javascript:;">查看全部</a>
<h2 class="page-title">概览</h2> </div>
<p class="page-subtitle">欢迎回来来看看你的项目近况</p> <div class="repo-list">
</div> <div v-for="repo in repos" :key="repo.name" class="repo-item">
<div class="repo-icon" :style="{ color: repo.color }">
<!-- 统计卡片行 --> <n-icon size="20" :component="GitBranchOutline" />
<div class="stat-grid">
<div v-for="stat in stats" :key="stat.label" class="stat-card">
<div class="stat-icon" :style="{ backgroundColor: stat.bg }">
<n-icon size="22" :component="stat.icon" :color="stat.color" />
</div>
<div class="stat-info">
<span class="stat-value">{{ stat.value }}</span>
<span class="stat-label">{{ stat.label }}</span>
</div>
<div class="stat-badge" :class="stat.trend > 0 ? 'up' : 'down'">
<n-icon size="14" :component="stat.trend > 0 ? ArrowUpOutline : ArrowDownOutline" />
{{ Math.abs(stat.trend) }}%
</div>
</div> </div>
</div> <div class="repo-info">
<span class="repo-name">{{ repo.name }}</span>
<!-- 下半部分图表占位 + 列表占位 --> <span class="repo-desc">{{ repo.desc }}</span>
<div class="content-grid">
<div class="card card-chart">
<div class="card-header">
<h3 class="card-title">项目活跃度</h3>
<n-tag size="small" :bordered="false" type="info">最近 7 </n-tag>
</div>
<div class="chart-placeholder">
<svg class="chart-dummy" viewBox="0 0 400 160" preserveAspectRatio="none">
<polyline
fill="none"
stroke="#0891B2"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
points="0,120 50,100 100,110 150,40 200,60 250,20 300,50 350,10 400,35"
/>
<polyline
fill="url(#area-grad)"
stroke="none"
points="0,120 50,100 100,110 150,40 200,60 250,20 300,50 350,10 400,35 400,160 0,160"
/>
<defs>
<linearGradient id="area-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0891B2" stop-opacity="0.15" />
<stop offset="1" stop-color="#0891B2" stop-opacity="0.01" />
</linearGradient>
</defs>
</svg>
<span class="chart-note">连接数据源以查看完整图表</span>
</div>
</div>
<div class="card card-list">
<div class="card-header">
<h3 class="card-title">最近仓库</h3>
<a class="card-link" href="javascript:;">查看全部</a>
</div>
<div class="repo-list">
<div v-for="repo in repos" :key="repo.name" class="repo-item">
<div class="repo-icon" :style="{ color: repo.color }">
<n-icon size="20" :component="GitBranchOutline" />
</div>
<div class="repo-info">
<span class="repo-name">{{ repo.name }}</span>
<span class="repo-desc">{{ repo.desc }}</span>
</div>
<n-tag size="small" :bordered="false" round :type="repo.statusType">
{{ repo.status }}
</n-tag>
</div>
</div>
<!-- 空态骨架 -->
<div v-if="!repos.length" class="empty-state">
<n-icon size="40" :component="FolderOpenOutline" color="#CBD5E1" />
<p>暂无仓库数据</p>
</div>
</div> </div>
<n-tag size="small" :bordered="false" round :type="repo.statusType">
{{ repo.status }}
</n-tag>
</div> </div>
</div> </div>
</main> <div v-if="!repos.length" class="empty-state">
<n-icon size="40" :component="FolderOpenOutline" color="#CBD5E1" />
<p>暂无仓库数据</p>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref } from 'vue'
import { import {
GridOutline,
GitBranchOutline, GitBranchOutline,
PeopleOutline, PeopleOutline,
ServerOutline, ServerOutline,
NotificationsOutline,
MenuOutline,
ArrowUpOutline, ArrowUpOutline,
ArrowDownOutline, ArrowDownOutline,
FolderOpenOutline, FolderOpenOutline,
ExtensionPuzzleOutline,
SettingsOutline,
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
//
const sidebarOpen = ref(false)
const activeMenu = ref('overview')
const menuItems = [
{ key: 'overview', label: '概览', icon: GridOutline },
{ key: 'repos', label: '仓库', icon: GitBranchOutline },
{ key: 'members', label: '成员', icon: PeopleOutline },
{ key: 'services', label: '服务', icon: ServerOutline },
{ key: 'plugins', label: '插件', icon: ExtensionPuzzleOutline },
{ key: 'settings', label: '设置', icon: SettingsOutline },
]
//
const currentUser = ref('SparksFly')
const userInitial = computed(() => currentUser.value.charAt(0).toUpperCase())
// //
const stats = [ const stats = [
{ { label: '仓库总数', value: 12, trend: 8, icon: GitBranchOutline, color: '#0891B2', bg: 'rgba(8,145,178,0.10)' },
label: '仓库总数', { label: '团队成员', value: 5, trend: 0, icon: PeopleOutline, color: '#22C55E', bg: 'rgba(34,197,94,0.10)' },
value: 12, { label: '活跃服务', value: 3, trend: -2, icon: ServerOutline, color: '#F59E0B', bg: 'rgba(245,158,11,0.10)' },
trend: 8, { label: '本周提交', value: 86, trend: 12, icon: ArrowUpOutline, color: '#8B5CF6', bg: 'rgba(139,92,246,0.10)' },
icon: GitBranchOutline,
color: '#0891B2',
bg: 'rgba(8, 145, 178, 0.10)',
},
{
label: '团队成员',
value: 5,
trend: 0,
icon: PeopleOutline,
color: '#22C55E',
bg: 'rgba(34, 197, 94, 0.10)',
},
{
label: '活跃服务',
value: 3,
trend: -2,
icon: ServerOutline,
color: '#F59E0B',
bg: 'rgba(245, 158, 11, 0.10)',
},
{
label: '本周提交',
value: 86,
trend: 12,
icon: ArrowUpOutline,
color: '#8B5CF6',
bg: 'rgba(139, 92, 246, 0.10)',
},
] ]
// //
const repos = ref([ const repos = ref([
{ name: 'DevBox', desc: '开发者工作台', status: 'Active', color: '#0891B2', statusType: 'success' as const }, { name: 'DevBox', desc: '开发者工作台', status: 'Active', color: '#0891B2', statusType: 'success' as const },
{ name: 'api-gateway', desc: 'API 网关服务', status: 'Idle', color: '#22C55E', statusType: 'default' as const }, { name: 'api-gateway', desc: 'API 网关服务', status: 'Idle', color: '#22C55E', statusType: 'default' as const },
@ -240,239 +109,29 @@ const repos = ref([
</script> </script>
<style scoped> <style scoped>
/* ─── 设计令牌 ─── */ .page-dashboard {
.dashboard-layout { padding: 28px 24px;
--color-primary: #0891B2;
--color-primary-light: #ECFEFF;
--color-secondary: #22D3EE;
--color-bg: #F8FAFC;
--color-surface: #FFFFFF;
--color-text: #164E63;
--color-text-muted: #64748B;
--color-border: #E2E8F0;
--sidebar-width: 250px;
--topbar-height: 60px;
--font-heading: 'Space Grotesk', system-ui, sans-serif;
--font-body: 'DM Sans', system-ui, sans-serif;
display: flex;
width: 100%;
height: 100vh;
font-family: var(--font-body);
color: var(--color-text);
background: var(--color-bg);
overflow: hidden;
}
/* ─── 侧边栏 ─── */
.sidebar {
position: fixed;
inset: 0 auto 0 0;
z-index: 100;
width: var(--sidebar-width);
display: flex;
flex-direction: column;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
transition: transform 0.25s ease;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 12px;
height: var(--topbar-height);
padding: 0 20px;
border-bottom: 1px solid var(--color-border);
}
.sidebar-logo {
display: block;
width: 36px;
height: 36px;
flex-shrink: 0;
}
.sidebar-title {
font-family: var(--font-heading);
font-size: 17px;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
}
/* 导航 */
.sidebar-nav {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
padding: 16px 12px;
overflow-y: auto;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
color: var(--color-text-muted);
text-decoration: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.nav-item:hover {
background: var(--color-primary-light);
color: var(--color-primary);
}
.nav-item.active {
background: rgba(8, 145, 178, 0.12);
color: var(--color-primary);
font-weight: 600;
}
.nav-label {
white-space: nowrap;
}
/* 底部用户 */
.sidebar-footer {
padding: 16px 16px;
border-top: 1px solid var(--color-border);
}
.user-chip {
display: flex;
align-items: center;
gap: 10px;
padding: 4px;
}
.user-meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-name {
font-size: 13px;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-role {
font-size: 12px;
color: var(--color-text-muted);
}
/* ─── 侧边栏遮罩(移动端) ─── */
.sidebar-overlay {
position: fixed;
inset: 0;
z-index: 99;
background: rgba(15, 23, 42, 0.3);
backdrop-filter: blur(2px);
}
/* ─── 主区域 ─── */
.main-area {
flex: 1;
margin-left: var(--sidebar-width);
display: flex;
flex-direction: column;
min-width: 0;
transition: margin-left 0.25s ease;
}
/* ─── 顶部栏 ─── */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--topbar-height);
padding: 0 24px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.menu-trigger {
display: none;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: background 0.15s ease;
}
.menu-trigger:hover {
background: var(--color-bg);
}
.topbar-right {
display: flex;
align-items: center;
gap: 16px;
margin-left: auto;
}
.topbar-icon {
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s ease;
}
.topbar-icon:hover {
color: var(--color-text);
}
.topbar-avatar {
cursor: pointer;
}
/* ─── 内容 ─── */
.content {
flex: 1;
overflow-y: auto;
}
.content-inner {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 28px 24px;
} }
/* 页面标题 */
.page-header { .page-header {
margin-bottom: 28px; margin-bottom: 28px;
} }
.page-title { .page-title {
margin: 0; margin: 0;
font-family: var(--font-heading); font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 26px; font-size: 26px;
font-weight: 700; font-weight: 700;
color: var(--color-text); color: #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: var(--color-text-muted); color: #86909C;
} }
/* ─── 统计卡片 ─── */ /* ─── 统计卡片 ─── */
@ -489,10 +148,10 @@ const repos = ref([
align-items: flex-start; align-items: flex-start;
gap: 16px; gap: 16px;
padding: 20px; padding: 20px;
background: var(--color-surface); background: #fff;
border: 1px solid var(--color-border); border: 1px solid #E5E6EB;
border-radius: 16px; border-radius: 16px;
transition: box-shadow 0.2s ease, transform 0.2s ease; transition: box-shadow 0.2s, transform 0.2s;
cursor: pointer; cursor: pointer;
} }
@ -510,7 +169,6 @@ const repos = ref([
border-radius: 12px; border-radius: 12px;
flex-shrink: 0; flex-shrink: 0;
} }
.stat-info { .stat-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -518,17 +176,17 @@ const repos = ref([
} }
.stat-value { .stat-value {
font-family: var(--font-heading); font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: var(--color-text); color: #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: var(--color-text-muted); color: #86909C;
} }
.stat-badge { .stat-badge {
@ -546,15 +204,15 @@ const repos = ref([
.stat-badge.up { .stat-badge.up {
color: #16A34A; color: #16A34A;
background: rgba(34, 197, 94, 0.10); background: rgba(34, 197, 94, 0.1);
} }
.stat-badge.down { .stat-badge.down {
color: #DC2626; color: #DC2626;
background: rgba(239, 68, 68, 0.10); background: rgba(239, 68, 68, 0.1);
} }
/* ─── 内容双栏 ─── */ /* ─── 双栏 ─── */
.content-grid { .content-grid {
display: grid; display: grid;
grid-template-columns: 1.5fr 1fr; grid-template-columns: 1.5fr 1fr;
@ -562,8 +220,8 @@ const repos = ref([
} }
.card { .card {
background: var(--color-surface); background: #fff;
border: 1px solid var(--color-border); border: 1px solid #E5E6EB;
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
} }
@ -577,16 +235,16 @@ const repos = ref([
.card-title { .card-title {
margin: 0; margin: 0;
font-family: var(--font-heading); font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 17px; font-size: 17px;
font-weight: 600; font-weight: 600;
color: var(--color-text); color: #1D2129;
} }
.card-link { .card-link {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--color-primary); color: #0891B2;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }
@ -595,16 +253,12 @@ const repos = ref([
text-decoration: underline; text-decoration: underline;
} }
/* 图表占位 */ /* 图表 */
.card-chart { .chart-area {
padding-bottom: 24px; padding: 20px 24px 24px;
} }
.chart-placeholder { .chart-svg {
padding: 20px 24px;
}
.chart-dummy {
width: 100%; width: 100%;
height: 160px; height: 160px;
} }
@ -614,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: var(--color-text-muted); color: #86909C;
} }
/* 仓库列表 */ /* 仓库列表 */
@ -627,9 +281,8 @@ const repos = ref([
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 12px 0; padding: 12px 0;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid #E5E6EB;
cursor: pointer; cursor: pointer;
transition: background 0.15s ease;
} }
.repo-item:last-child { .repo-item:last-child {
@ -652,16 +305,15 @@ const repos = ref([
.repo-name { .repo-name {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--color-text); color: #1D2129;
} }
.repo-desc { .repo-desc {
font-size: 12px; font-size: 12px;
color: var(--color-text-muted); color: #86909C;
margin-top: 1px; margin-top: 1px;
} }
/* 空态 */
.empty-state { .empty-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -669,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: var(--color-text-muted); color: #86909C;
font-size: 14px; font-size: 14px;
} }
@ -677,59 +329,20 @@ const repos = ref([
margin: 12px 0 0; margin: 12px 0 0;
} }
/* ─── 响应式(桌面端) ─── */ /* ─── 响应式 ─── */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.stat-grid { .stat-grid { grid-template-columns: repeat(2, 1fr); }
grid-template-columns: repeat(2, 1fr); .content-grid { grid-template-columns: 1fr; }
}
.content-grid {
grid-template-columns: 1fr;
}
} }
/* ─── 响应式(移动端) ─── */
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { .page-dashboard { padding: 20px 16px; }
transform: translateX(-100%); .stat-grid { grid-template-columns: 1fr; gap: 12px; }
} .page-title { font-size: 22px; }
.sidebar.open {
transform: translateX(0);
}
.main-area {
margin-left: 0 !important;
}
.menu-trigger {
display: flex;
}
.stat-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.content-inner {
padding: 20px 16px;
}
.page-title {
font-size: 22px;
}
} }
/* ─── 减少动画 ─── */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.sidebar, .stat-card { transition: none !important; }
.stat-card, .stat-card:hover { transform: none; }
.nav-item {
transition: none !important;
}
.stat-card:hover {
transform: none;
}
} }
</style> </style>

View File

@ -0,0 +1,400 @@
<template>
<div class="page-user">
<!-- 标题栏 -->
<div class="page-toolbar">
<h2 class="page-title">用户管理</h2>
<n-button type="primary" @click="showAddModal = true">
<template #icon><n-icon :component="PersonAddOutline" /></template>
新增用户
</n-button>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<n-space align="center" :wrap="true" :size="12">
<n-input
v-model:value="searchForm.nick_name"
placeholder="搜索昵称"
clearable
:style="{ width: '180px' }"
@keyup.enter="handleSearch"
/>
<n-input
v-model:value="searchForm.user_name"
placeholder="搜索用户名"
clearable
:style="{ width: '180px' }"
@keyup.enter="handleSearch"
/>
<n-button type="primary" size="small" @click="handleSearch">
<template #icon><n-icon :component="SearchOutline" /></template>
查询
</n-button>
<n-button size="small" @click="handleReset">重置</n-button>
</n-space>
</div>
<!-- 数据表格 -->
<div class="table-card">
<n-data-table
:columns="columns"
:data="tableData"
:loading="loading"
:row-key="rowKey"
:bordered="false"
:single-line="false"
remote
size="small"
striped
/>
<div v-if="total > 0" class="table-footer">
<span class="total-hint"> {{ total }} 条数据</span>
<n-pagination
v-model:page="pagination.page"
:page-size="pagination.page_size"
:item-count="total"
:page-sizes="[5, 10, 20, 50]"
show-size-picker
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
<div v-if="!loading && tableData.length === 0" class="empty-state">
<n-empty description="暂无用户数据">
<template #extra>
<n-button type="primary" size="small" @click="showAddModal = true">新增用户</n-button>
</template>
</n-empty>
</div>
</div>
<!-- 新增用户弹窗 -->
<n-modal v-model:show="showAddModal" preset="card" title="新增用户" :style="{ width: '520px' }" :mask-closable="false">
<n-form ref="formRef" :model="formData" :rules="formRules" label-placement="left" label-width="80" require-mark-placement="right-hanging">
<n-form-item label="用户名" path="user_name">
<n-input v-model:value="formData.user_name" placeholder="请输入用户名" />
</n-form-item>
<n-form-item label="密码" path="password">
<n-input v-model:value="formData.password" type="password" placeholder="请输入密码" show-password-on="click" />
</n-form-item>
<n-form-item label="昵称" path="nick_name">
<n-input v-model:value="formData.nick_name" placeholder="请输入昵称" />
</n-form-item>
<n-form-item label="邮箱" path="email">
<n-input v-model:value="formData.email" placeholder="请输入邮箱" />
</n-form-item>
<n-form-item label="手机号" path="phone">
<n-input v-model:value="formData.phone" placeholder="请输入手机号" />
</n-form-item>
<n-form-item label="年龄" path="age">
<n-input-number v-model:value="formData.age" :min="0" :max="150" :style="{ width: '100%' }" />
</n-form-item>
<n-form-item label="性别" path="sex">
<n-select v-model:value="formData.sex" :options="sexOptions" placeholder="请选择性别" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="showAddModal = false">取消</n-button>
<n-button type="primary" :loading="submitting" @click="handleSubmit">确定</n-button>
</n-space>
</template>
</n-modal>
<!-- 编辑用户弹窗 -->
<n-modal v-model:show="showEditModal" preset="card" title="编辑用户" :style="{ width: '520px' }" :mask-closable="false">
<n-form ref="editFormRef" :model="editFormData" :rules="editFormRules" label-placement="left" label-width="80" require-mark-placement="right-hanging">
<n-form-item label="用户名" path="user_name">
<n-input v-model:value="editFormData.user_name" placeholder="请输入用户名" />
</n-form-item>
<n-form-item label="昵称" path="nick_name">
<n-input v-model:value="editFormData.nick_name" placeholder="请输入昵称" />
</n-form-item>
<n-form-item label="邮箱" path="email">
<n-input v-model:value="editFormData.email" placeholder="请输入邮箱" />
</n-form-item>
<n-form-item label="手机号" path="phone">
<n-input v-model:value="editFormData.phone" placeholder="请输入手机号" />
</n-form-item>
<n-form-item label="年龄" path="age">
<n-input-number v-model:value="editFormData.age" :min="0" :max="150" :style="{ width: '100%' }" />
</n-form-item>
<n-form-item label="性别" path="sex">
<n-select v-model:value="editFormData.sex" :options="sexOptions" placeholder="请选择性别" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="showEditModal = false">取消</n-button>
<n-button type="primary" :loading="editSubmitting" @click="handleEditSubmit">保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, h } from 'vue'
import { NButton, NIcon, useMessage, useDialog } from 'naive-ui'
import type { DataTableColumns, FormInst, FormRules } from 'naive-ui'
import { SearchOutline, PersonAddOutline, CreateOutline, TrashOutline } from '@vicons/ionicons5'
import UserApi, { type UserInfo, type UserSaveReq } from '@/api/system/user'
const message = useMessage()
const dialog = useDialog()
//
const searchForm = reactive({ nick_name: '', user_name: '' })
//
const pagination = reactive({ page: 1, page_size: 10 })
//
const loading = ref(false)
const tableData = ref<UserInfo[]>([])
const total = ref(0)
const rowKey = (row: UserInfo) => row.id
const sexMap: Record<number, string> = { 0: '未知', 1: '男', 2: '女' }
const columns: DataTableColumns<UserInfo> = [
{ title: 'ID', key: 'id', width: 70, align: 'center' },
{ title: '用户名', key: 'user_name', width: 120, ellipsis: { tooltip: true } },
{ title: '昵称', key: 'nick_name', width: 120, ellipsis: { tooltip: true } },
{ title: '邮箱', key: 'email', width: 180, ellipsis: { tooltip: true } },
{ title: '手机号', key: 'phone', width: 130 },
{ title: '年龄', key: 'age', width: 60, align: 'center' },
{
title: '性别', key: 'sex', width: 70, align: 'center',
render(row) { return h('span', {}, sexMap[row.sex] ?? '未知') },
},
{
title: '创建时间', key: 'create_time', width: 170,
render(row) { return row.create_time ?? '-' },
},
{
title: '操作', key: 'actions', width: 150, align: 'center',
render(row) {
return h('div', { style: { display: 'flex', gap: '8px', justifyContent: 'center' } }, [
h(NButton, {
text: true, size: 'tiny', type: 'primary',
onClick: (e: Event) => { e.stopPropagation(); openEditModal(row) },
}, { icon: () => h(NIcon, null, { default: () => h(CreateOutline) }), default: () => '编辑' }),
h(NButton, {
text: true, size: 'tiny', type: 'error',
onClick: (e: Event) => { e.stopPropagation(); handleDelete(row) },
}, { icon: () => h(NIcon, null, { default: () => h(TrashOutline) }), default: () => '删除' }),
])
},
},
]
//
async function fetchData() {
loading.value = true
try {
const res = await UserApi.getUserPage({
page: pagination.page,
page_size: pagination.page_size,
nick_name: searchForm.nick_name || undefined,
user_name: searchForm.user_name || undefined,
})
if (res.success && res.data) {
tableData.value = res.data.list ?? []
total.value = res.data.total
}
} catch (e: any) {
message.error(e?.toString() ?? '获取用户列表失败')
} finally {
loading.value = false
}
}
fetchData()
function handleSearch() { pagination.page = 1; fetchData() }
function handleReset() { searchForm.nick_name = ''; searchForm.user_name = ''; pagination.page = 1; fetchData() }
function handlePageChange(page: number) { pagination.page = page; fetchData() }
function handlePageSizeChange(size: number) { pagination.page_size = size; pagination.page = 1; fetchData() }
//
const showAddModal = ref(false)
const submitting = ref(false)
const formRef = ref<FormInst | null>(null)
const formData = reactive<UserSaveReq>({
id: 0, nick_name: '', avatar: '', user_name: '', password: '',
email: '', phone: '', age: 0, sex: 0, creater: 0,
})
const formRules: FormRules = {
user_name: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, message: '密码至少6位', trigger: 'blur' }],
nick_name: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
}
const sexOptions = [
{ label: '未知', value: 0 },
{ label: '男', value: 1 },
{ label: '女', value: 2 },
]
async function handleSubmit() {
try { await formRef.value?.validate() } catch { return }
submitting.value = true
try {
const res = await UserApi.register({ ...formData })
if (res.success) {
message.success('新增用户成功')
showAddModal.value = false
Object.assign(formData, { id: 0, nick_name: '', avatar: '', user_name: '', password: '', email: '', phone: '', age: 0, sex: 0, creater: 0 })
fetchData()
} else {
message.error(res.message)
}
} catch (e: any) {
message.error(e?.toString() ?? '操作失败')
} finally {
submitting.value = false
}
}
//
const showEditModal = ref(false)
const editSubmitting = ref(false)
const editFormRef = ref<FormInst | null>(null)
const editingUserId = ref<number>(0)
const editFormData = reactive<UserSaveReq>({
id: 0, nick_name: '', avatar: '', user_name: '', password: '',
email: '', phone: '', age: 0, sex: 0, creater: 0,
})
const editFormRules: FormRules = {
user_name: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
nick_name: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
}
function openEditModal(row: UserInfo) {
editingUserId.value = row.id
Object.assign(editFormData, {
id: row.id, nick_name: row.nick_name, avatar: row.avatar,
user_name: row.user_name, password: '', email: row.email,
phone: row.phone, age: row.age, sex: row.sex, creater: 0,
})
showEditModal.value = true
}
async function handleEditSubmit() {
try { await editFormRef.value?.validate() } catch { return }
editSubmitting.value = true
try {
const res = await UserApi.update_user({ ...editFormData, id: editingUserId.value })
if (res.success) {
message.success('更新成功')
showEditModal.value = false
fetchData()
} else {
message.error(res.message)
}
} catch (e: any) {
message.error(e?.toString() ?? '更新失败')
} finally {
editSubmitting.value = false
}
}
//
function handleDelete(row: UserInfo) {
dialog.warning({
title: '确认删除',
content: `确定要删除用户「${row.user_name}」吗?此操作不可撤销。`,
positiveText: '确认删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
const res = await UserApi.delUser(row.id)
if (res.success) {
message.success('删除成功')
fetchData()
} else {
message.error(res.message)
}
} catch (e: any) {
message.error(e?.toString() ?? '删除失败')
}
},
})
}
</script>
<style scoped>
.page-user {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
margin: 0;
font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 22px;
font-weight: 700;
color: #1D2129;
letter-spacing: -0.3px;
}
.search-bar {
background: #fff;
border: 1px solid #E5E6EB;
border-radius: 10px;
padding: 14px 18px;
margin-bottom: 16px;
}
.table-card {
background: #fff;
border: 1px solid #E5E6EB;
border-radius: 10px;
overflow: hidden;
}
.table-card :deep(.n-data-table) {
--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 {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-top: 1px solid #E5E6EB;
}
.total-hint {
font-size: 13px;
color: #86909C;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 64px 20px;
}
@media (max-width: 768px) {
.page-user { padding: 16px; }
.page-toolbar { flex-direction: column; align-items: flex-start; gap: 12px; }
.search-bar :deep(.n-space) { flex-direction: column; align-items: stretch; }
}
</style>