feat: 初始化项目基础框架并完成用户管理模块开发
1. 集成 Pinia 状态管理并创建全局 store 2. 添加布局组件系统,包含侧边栏、头部、标签页、页脚 3. 实现路由系统与多页面框架 4. 完成后端用户 CRUD 与分页接口 5. 新增前端用户管理页面与 API 封装 6. 集成 AWS S3 服务支持 7. 优化类型定义与代码规范
This commit is contained in:
parent
68d08dbc23
commit
e0087b12a6
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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(¶ms.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(¶ms.nick_name)
|
||||||
|
.bind(¶ms.user_name)
|
||||||
|
.bind(¶ms.password)
|
||||||
|
.bind(¶ms.email)
|
||||||
|
.bind(¶ms.phone)
|
||||||
|
.bind(¶ms.age)
|
||||||
|
.bind(¶ms.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(¶ms.user_name)
|
||||||
|
.bind(¶ms.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(¶ms.nick_name)
|
||||||
|
.bind(¶ms.user_name)
|
||||||
|
.bind(¶ms.email)
|
||||||
|
.bind(¶ms.phone)
|
||||||
|
.bind(params.age)
|
||||||
|
.bind(params.sex)
|
||||||
|
.bind(params.id)
|
||||||
|
.execute(&*pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(ApiResponse::<()>::success_empty())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
pub mod s3;
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<n-message-provider>
|
<n-message-provider>
|
||||||
|
<n-dialog-provider>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
18
src/main.ts
18
src/main.ts
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { createPinia } from "pinia"
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
export default pinia
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -1,86 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="dashboard-layout">
|
<div class="page-dashboard">
|
||||||
<!-- 侧边栏遮罩(移动端) -->
|
|
||||||
<div
|
|
||||||
v-if="sidebarOpen"
|
|
||||||
class="sidebar-overlay"
|
|
||||||
@click="sidebarOpen = false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 侧边栏 -->
|
|
||||||
<aside class="sidebar" :class="{ open: sidebarOpen }">
|
|
||||||
<!-- 侧边栏 Logo -->
|
|
||||||
<div class="sidebar-brand">
|
|
||||||
<svg class="sidebar-logo" viewBox="0 0 40 40" fill="none">
|
|
||||||
<rect width="40" height="40" rx="12" fill="url(#sb-grad)" />
|
|
||||||
<path d="M12 26V14l8 8 8-8v12" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="sb-grad" x1="0" y1="0" x2="40" y2="40">
|
|
||||||
<stop stop-color="#0891B2" />
|
|
||||||
<stop offset="1" stop-color="#06B6D4" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</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>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- 主区域 -->
|
|
||||||
<div class="main-area">
|
|
||||||
<!-- 顶部栏 -->
|
|
||||||
<header class="topbar">
|
|
||||||
<button class="menu-trigger" @click="sidebarOpen = !sidebarOpen">
|
|
||||||
<n-icon size="22" :component="MenuOutline" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- 内容区域 -->
|
|
||||||
<main class="content">
|
|
||||||
<div class="content-inner">
|
|
||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2 class="page-title">概览</h2>
|
<h2 class="page-title">概览</h2>
|
||||||
<p class="page-subtitle">欢迎回来,来看看你的项目近况</p>
|
<p class="page-subtitle">欢迎回来,来看看你的项目近况</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 统计卡片行 -->
|
<!-- 统计卡片 -->
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
<div v-for="stat in stats" :key="stat.label" class="stat-card">
|
<div v-for="stat in stats" :key="stat.label" class="stat-card">
|
||||||
<div class="stat-icon" :style="{ backgroundColor: stat.bg }">
|
<div class="stat-icon" :style="{ backgroundColor: stat.bg }">
|
||||||
|
|
@ -97,26 +23,23 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 下半部分:图表占位 + 列表占位 -->
|
<!-- 双栏内容 -->
|
||||||
<div class="content-grid">
|
<div class="content-grid">
|
||||||
<div class="card card-chart">
|
<!-- 图表卡片 -->
|
||||||
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">项目活跃度</h3>
|
<h3 class="card-title">项目活跃度</h3>
|
||||||
<n-tag size="small" :bordered="false" type="info">最近 7 天</n-tag>
|
<n-tag size="small" :bordered="false" type="info">最近 7 天</n-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-placeholder">
|
<div class="chart-area">
|
||||||
<svg class="chart-dummy" viewBox="0 0 400 160" preserveAspectRatio="none">
|
<svg class="chart-svg" viewBox="0 0 400 160" preserveAspectRatio="none">
|
||||||
<polyline
|
<polyline
|
||||||
fill="none"
|
fill="none" stroke="#0891B2" stroke-width="2.5"
|
||||||
stroke="#0891B2"
|
stroke-linecap="round" stroke-linejoin="round"
|
||||||
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"
|
points="0,120 50,100 100,110 150,40 200,60 250,20 300,50 350,10 400,35"
|
||||||
/>
|
/>
|
||||||
<polyline
|
<polyline
|
||||||
fill="url(#area-grad)"
|
fill="url(#area-grad)" stroke="none"
|
||||||
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"
|
points="0,120 50,100 100,110 150,40 200,60 250,20 300,50 350,10 400,35 400,160 0,160"
|
||||||
/>
|
/>
|
||||||
<defs>
|
<defs>
|
||||||
|
|
@ -130,7 +53,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-list">
|
<!-- 仓库列表 -->
|
||||||
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">最近仓库</h3>
|
<h3 class="card-title">最近仓库</h3>
|
||||||
<a class="card-link" href="javascript:;">查看全部</a>
|
<a class="card-link" href="javascript:;">查看全部</a>
|
||||||
|
|
@ -149,7 +73,6 @@
|
||||||
</n-tag>
|
</n-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 空态骨架 -->
|
|
||||||
<div v-if="!repos.length" class="empty-state">
|
<div v-if="!repos.length" class="empty-state">
|
||||||
<n-icon size="40" :component="FolderOpenOutline" color="#CBD5E1" />
|
<n-icon size="40" :component="FolderOpenOutline" color="#CBD5E1" />
|
||||||
<p>暂无仓库数据</p>
|
<p>暂无仓库数据</p>
|
||||||
|
|
@ -157,81 +80,27 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
Loading…
Reference in New Issue