feat: 优化字典管理页面布局,修复部门人员操作列,完善LDAP集成
- 字典管理页面改为左右分栏 a-menu 布局,对齐系统配置页面风格 - 左侧菜单项显示字典名称、编码(hover展开)及数据数量徽标 - 修复部门人员操作列因 ProTable dataIndex='action' 拦截导致按钮不显示 - 字典类型编辑/删除移至右侧工具栏,操作列增加序号列 - 新增 LDAP 配置管理与同步功能(UnboundID LDAP SDK) - 清理废弃的 fantastic-admin 目录
This commit is contained in:
parent
567aa74833
commit
a5176016b3
@ -8,8 +8,7 @@
|
|||||||
tansci/
|
tansci/
|
||||||
├── antdv-next-admin/ # 前端管理后台 (Vue 3 + TypeScript + Ant Design Vue)
|
├── antdv-next-admin/ # 前端管理后台 (Vue 3 + TypeScript + Ant Design Vue)
|
||||||
├── our-itam/ # 后端服务 (Java Spring Boot + magic-api)
|
├── our-itam/ # 后端服务 (Java Spring Boot + magic-api)
|
||||||
├── fantastic-admin/ # 前端底层框架 (pnpm monorepo)
|
├── ciyo-itasset/ # CIYO IT资产模块 (功能参考)
|
||||||
├── ciyo-itasset/ # CIYO IT资产模块
|
|
||||||
└── magic-script-skill/ # Claude Code 自定义 Skill
|
└── magic-script-skill/ # Claude Code 自定义 Skill
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -81,12 +80,12 @@ magic-api 管理平台: http://localhost:9999/magic/web/index.html
|
|||||||
### 3. 启动前端服务
|
### 3. 启动前端服务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd fantastic-admin
|
cd antdv-next-admin
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后选择 `core-antdv-next` 进入 antdv-next-admin 前端,默认地址 http://localhost:9000/
|
默认地址 http://localhost:3000/
|
||||||
|
|
||||||
### 4. 测试账号
|
### 4. 测试账号
|
||||||
|
|
||||||
|
|||||||
2
antdv-next-admin/pnpm-workspace.yaml
Normal file
2
antdv-next-admin/pnpm-workspace.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@parcel/watcher': set this to true or false
|
||||||
160
antdv-next-admin/src/api/ldap.ts
Normal file
160
antdv-next-admin/src/api/ldap.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import { request } from "@/utils/request";
|
||||||
|
|
||||||
|
const isMock = import.meta.env.VITE_USE_MOCK === "true";
|
||||||
|
|
||||||
|
const ok = <T>(data: T, message = "success"): ApiResponse<T> => ({
|
||||||
|
code: 200,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface LdapConfig {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
use_ssl: string;
|
||||||
|
base_dn: string;
|
||||||
|
admin_dn: string;
|
||||||
|
admin_password: string;
|
||||||
|
user_base_dn?: string;
|
||||||
|
dept_base_dn?: string;
|
||||||
|
status: string;
|
||||||
|
last_sync_time?: string;
|
||||||
|
sync_status?: string;
|
||||||
|
create_time?: string;
|
||||||
|
update_time?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LdapSyncResult {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
created?: number;
|
||||||
|
updated?: number;
|
||||||
|
deleted?: number;
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LdapFullSyncResult {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
deptResult?: LdapSyncResult;
|
||||||
|
userResult?: LdapSyncResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 LDAP 配置分页列表
|
||||||
|
*/
|
||||||
|
export async function getLdapConfigList(params: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}): Promise<ApiResponse<{ list: LdapConfig[]; total: number }>> {
|
||||||
|
if (isMock) {
|
||||||
|
return ok({ list: [], total: 0 });
|
||||||
|
}
|
||||||
|
const res: any = await request.post("/sys/ldap/page", params);
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: res.message || "success",
|
||||||
|
data: { list: (res.data || res).list || [], total: (res.data || res).total || 0 },
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 LDAP 配置详情
|
||||||
|
*/
|
||||||
|
export async function getLdapConfigDetail(id: string): Promise<ApiResponse<LdapConfig>> {
|
||||||
|
if (isMock) {
|
||||||
|
return ok({} as LdapConfig);
|
||||||
|
}
|
||||||
|
const res: any = await request.get(`/sys/ldap/detail?id=${id}`);
|
||||||
|
return { code: 200, message: res.message || "success", data: res.data || res, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存 LDAP 配置(新增或更新)
|
||||||
|
*/
|
||||||
|
export async function saveLdapConfig(data: Partial<LdapConfig>): Promise<ApiResponse<{ id: string }>> {
|
||||||
|
if (isMock) {
|
||||||
|
return ok({ id: String(Date.now()) }, "保存成功");
|
||||||
|
}
|
||||||
|
const res: any = await request.post("/sys/ldap/save", {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
host: data.host,
|
||||||
|
port: data.port,
|
||||||
|
use_ssl: data.use_ssl,
|
||||||
|
base_dn: data.base_dn,
|
||||||
|
admin_dn: data.admin_dn,
|
||||||
|
admin_password: data.admin_password,
|
||||||
|
user_base_dn: data.user_base_dn,
|
||||||
|
dept_base_dn: data.dept_base_dn,
|
||||||
|
status: data.status,
|
||||||
|
});
|
||||||
|
return { code: 200, message: res.message || "保存成功", data: res.data || res, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 LDAP 配置
|
||||||
|
*/
|
||||||
|
export async function deleteLdapConfig(id: string): Promise<ApiResponse<void>> {
|
||||||
|
if (isMock) {
|
||||||
|
return ok(undefined as unknown as void, "删除成功");
|
||||||
|
}
|
||||||
|
await request.post("/sys/ldap/delete", { id });
|
||||||
|
return { code: 200, message: "删除成功", data: null, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 LDAP 连接
|
||||||
|
*/
|
||||||
|
export async function testLdapConnection(config: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
use_ssl: string;
|
||||||
|
admin_dn: string;
|
||||||
|
admin_password: string;
|
||||||
|
base_dn: string;
|
||||||
|
}): Promise<ApiResponse<{ success: boolean; message?: string }>> {
|
||||||
|
if (isMock) {
|
||||||
|
return ok({ success: true, message: "连接成功" });
|
||||||
|
}
|
||||||
|
const res: any = await request.post("/sys/ldap/test_connection", config);
|
||||||
|
return { code: 200, message: res.message || "success", data: res.data || res, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步部门到 LDAP
|
||||||
|
*/
|
||||||
|
export async function syncDepts(configId: string): Promise<ApiResponse<LdapSyncResult>> {
|
||||||
|
if (isMock) {
|
||||||
|
return ok({ success: true, created: 0, updated: 0, deleted: 0 });
|
||||||
|
}
|
||||||
|
const res: any = await request.post("/sys/ldap/sync_depts", { config_id: configId });
|
||||||
|
return { code: 200, message: res.message || "success", data: res.data || res, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步用户到 LDAP
|
||||||
|
*/
|
||||||
|
export async function syncUsers(configId: string): Promise<ApiResponse<LdapSyncResult>> {
|
||||||
|
if (isMock) {
|
||||||
|
return ok({ success: true, created: 0, updated: 0, deleted: 0 });
|
||||||
|
}
|
||||||
|
const res: any = await request.post("/sys/ldap/sync_users", { config_id: configId });
|
||||||
|
return { code: 200, message: res.message || "success", data: res.data || res, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全量同步(部门 + 用户)
|
||||||
|
*/
|
||||||
|
export async function fullSync(configId: string): Promise<ApiResponse<LdapFullSyncResult>> {
|
||||||
|
if (isMock) {
|
||||||
|
return ok({ success: true });
|
||||||
|
}
|
||||||
|
const res: any = await request.post("/sys/ldap/full_sync", { config_id: configId });
|
||||||
|
return { code: 200, message: res.message || "success", data: res.data || res, success: true };
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ function mapRoleToFrontend(r: any) {
|
|||||||
name: r.role_name,
|
name: r.role_name,
|
||||||
code: r.role_key,
|
code: r.role_key,
|
||||||
description: r.remark || "",
|
description: r.remark || "",
|
||||||
|
permissionCount: r.menu_count ?? 0,
|
||||||
createdAt: r.create_time,
|
createdAt: r.create_time,
|
||||||
updatedAt: r.update_time,
|
updatedAt: r.update_time,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -26,7 +26,7 @@ function mapUserToFrontend(u: any) {
|
|||||||
updatedAt: u.update_time,
|
updatedAt: u.update_time,
|
||||||
deptName: u.dept_name || "",
|
deptName: u.dept_name || "",
|
||||||
roles: (u.roles || []).map((r: any) => ({
|
roles: (u.roles || []).map((r: any) => ({
|
||||||
id: r.role_key,
|
id: r.id || r.role_key,
|
||||||
name: r.role_name,
|
name: r.role_name,
|
||||||
code: r.role_key,
|
code: r.role_key,
|
||||||
})),
|
})),
|
||||||
@ -37,10 +37,15 @@ function mapUserToBackend(data: Record<string, unknown>) {
|
|||||||
const result: Record<string, unknown> = { ...data };
|
const result: Record<string, unknown> = { ...data };
|
||||||
if (result.username !== undefined) { result.user_name = result.username; delete result.username; }
|
if (result.username !== undefined) { result.user_name = result.username; delete result.username; }
|
||||||
if (result.realName !== undefined) { result.nick_name = result.realName; delete result.realName; }
|
if (result.realName !== undefined) { result.nick_name = result.realName; delete result.realName; }
|
||||||
|
if (result.phone !== undefined) { result.phonenumber = result.phone; delete result.phone; }
|
||||||
|
if (result.bio !== undefined) { result.remark = result.bio; delete result.bio; }
|
||||||
if (result.gender === "male") { result.sex = "1"; delete result.gender; }
|
if (result.gender === "male") { result.sex = "1"; delete result.gender; }
|
||||||
if (result.gender === "female") { result.sex = "0"; delete result.gender; }
|
if (result.gender === "female") { result.sex = "0"; delete result.gender; }
|
||||||
if (result.status === "active") { result.status = "0"; }
|
if (result.status === "active") { result.status = "0"; }
|
||||||
if (result.status === "inactive") { result.status = "1"; }
|
if (result.status === "inactive") { result.status = "1"; }
|
||||||
|
// roles 由 assignRoles 单独处理
|
||||||
|
delete result.roles;
|
||||||
|
delete result.roleIds;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -92,6 +92,7 @@ export default {
|
|||||||
permission: "Permission",
|
permission: "Permission",
|
||||||
menu: "Menu Management",
|
menu: "Menu Management",
|
||||||
dict: "Dictionary",
|
dict: "Dictionary",
|
||||||
|
ldap: "LDAP Config",
|
||||||
log: "System Log",
|
log: "System Log",
|
||||||
asset: "Asset Management",
|
asset: "Asset Management",
|
||||||
assetDevice: "Asset",
|
assetDevice: "Asset",
|
||||||
@ -471,6 +472,7 @@ export default {
|
|||||||
loadDataFailed: "Failed to load dictionary data",
|
loadDataFailed: "Failed to load dictionary data",
|
||||||
dictDataTitle: "Dictionary Data - {name}",
|
dictDataTitle: "Dictionary Data - {name}",
|
||||||
confirmDelete: "Confirm Delete",
|
confirmDelete: "Confirm Delete",
|
||||||
|
serial: "#",
|
||||||
},
|
},
|
||||||
|
|
||||||
table: {
|
table: {
|
||||||
@ -1641,4 +1643,58 @@ export default {
|
|||||||
outputPreview: "Output Preview",
|
outputPreview: "Output Preview",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
ldap: {
|
||||||
|
title: "LDAP Config",
|
||||||
|
name: "Config Name",
|
||||||
|
host: "Server Host",
|
||||||
|
port: "Port",
|
||||||
|
useSsl: "Use SSL",
|
||||||
|
baseDn: "Base DN",
|
||||||
|
adminDn: "Admin DN",
|
||||||
|
adminPassword: "Admin Password",
|
||||||
|
userBaseDn: "User Base DN",
|
||||||
|
deptBaseDn: "Dept Base DN",
|
||||||
|
status: "Status",
|
||||||
|
lastSyncTime: "Last Sync Time",
|
||||||
|
syncStatus: "Sync Status",
|
||||||
|
enabled: "Enabled",
|
||||||
|
disabled: "Disabled",
|
||||||
|
createConfig: "Create Config",
|
||||||
|
editConfig: "Edit Config",
|
||||||
|
deleteConfig: "Delete Config",
|
||||||
|
confirmDelete: "Are you sure you want to delete this LDAP config?",
|
||||||
|
saveSuccess: "Saved successfully",
|
||||||
|
deleteSuccess: "Deleted successfully",
|
||||||
|
testConnection: "Test Connection",
|
||||||
|
testing: "Testing...",
|
||||||
|
testSuccess: "Connection successful",
|
||||||
|
testFailed: "Connection failed",
|
||||||
|
testConnectionFirst: "Please test connection first",
|
||||||
|
syncDepts: "Sync Departments",
|
||||||
|
syncUsers: "Sync Users",
|
||||||
|
fullSync: "Full Sync",
|
||||||
|
syncing: "Syncing...",
|
||||||
|
syncSuccess: "Sync successful",
|
||||||
|
syncFailed: "Sync failed",
|
||||||
|
confirmSyncDepts: "Are you sure you want to sync departments to LDAP server?",
|
||||||
|
confirmSyncUsers: "Are you sure you want to sync users to LDAP server?",
|
||||||
|
confirmFullSync: "Are you sure you want to perform a full sync? This will overwrite existing data on LDAP server.",
|
||||||
|
idle: "Idle",
|
||||||
|
running: "Running",
|
||||||
|
success: "Success",
|
||||||
|
failed: "Failed",
|
||||||
|
namePlaceholder: "Please enter config name",
|
||||||
|
hostPlaceholder: "e.g. 192.168.2.1",
|
||||||
|
portPlaceholder: "e.g. 389",
|
||||||
|
baseDnPlaceholder: "e.g. dc=koteladt,dc=com",
|
||||||
|
adminDnPlaceholder: "e.g. cn=admin,dc=koteladt,dc=com",
|
||||||
|
adminPasswordPlaceholder: "Please enter admin password",
|
||||||
|
requiredFields: "Please fill in required fields",
|
||||||
|
loadFailed: "Failed to load LDAP config",
|
||||||
|
operateFailed: "Operation failed",
|
||||||
|
syncDeptsResult: "Dept sync completed: {success} success, {failed} failed",
|
||||||
|
syncUsersResult: "User sync completed: {success} success, {failed} failed",
|
||||||
|
fullSyncResult: "Full sync completed: dept {deptSuccess} success {deptFailed} failed, user {userSuccess} success {userFailed} failed",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -92,6 +92,7 @@ export default {
|
|||||||
permission: "权限管理",
|
permission: "权限管理",
|
||||||
menu: "菜单管理",
|
menu: "菜单管理",
|
||||||
dict: "数据字典",
|
dict: "数据字典",
|
||||||
|
ldap: "LDAP配置",
|
||||||
log: "系统日志",
|
log: "系统日志",
|
||||||
asset: "资产管理",
|
asset: "资产管理",
|
||||||
assetDevice: "资产管理",
|
assetDevice: "资产管理",
|
||||||
@ -463,6 +464,7 @@ export default {
|
|||||||
loadDataFailed: "加载字典数据失败",
|
loadDataFailed: "加载字典数据失败",
|
||||||
dictDataTitle: "字典数据 - {name}",
|
dictDataTitle: "字典数据 - {name}",
|
||||||
confirmDelete: "确认删除",
|
confirmDelete: "确认删除",
|
||||||
|
serial: "序号",
|
||||||
},
|
},
|
||||||
|
|
||||||
table: {
|
table: {
|
||||||
@ -1590,4 +1592,58 @@ export default {
|
|||||||
outputPreview: "输出预览",
|
outputPreview: "输出预览",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
ldap: {
|
||||||
|
title: "LDAP配置",
|
||||||
|
name: "配置名称",
|
||||||
|
host: "服务器地址",
|
||||||
|
port: "端口",
|
||||||
|
useSsl: "启用SSL",
|
||||||
|
baseDn: "Base DN",
|
||||||
|
adminDn: "管理员DN",
|
||||||
|
adminPassword: "管理员密码",
|
||||||
|
userBaseDn: "用户Base DN",
|
||||||
|
deptBaseDn: "部门Base DN",
|
||||||
|
status: "状态",
|
||||||
|
lastSyncTime: "最后同步时间",
|
||||||
|
syncStatus: "同步状态",
|
||||||
|
enabled: "启用",
|
||||||
|
disabled: "停用",
|
||||||
|
createConfig: "新增配置",
|
||||||
|
editConfig: "编辑配置",
|
||||||
|
deleteConfig: "删除配置",
|
||||||
|
confirmDelete: "确定要删除该LDAP配置吗?",
|
||||||
|
saveSuccess: "保存成功",
|
||||||
|
deleteSuccess: "删除成功",
|
||||||
|
testConnection: "测试连接",
|
||||||
|
testing: "测试中...",
|
||||||
|
testSuccess: "连接成功",
|
||||||
|
testFailed: "连接失败",
|
||||||
|
testConnectionFirst: "请先测试连接",
|
||||||
|
syncDepts: "同步部门",
|
||||||
|
syncUsers: "同步用户",
|
||||||
|
fullSync: "全量同步",
|
||||||
|
syncing: "同步中...",
|
||||||
|
syncSuccess: "同步成功",
|
||||||
|
syncFailed: "同步失败",
|
||||||
|
confirmSyncDepts: "确定要同步部门到LDAP服务器吗?",
|
||||||
|
confirmSyncUsers: "确定要同步用户到LDAP服务器吗?",
|
||||||
|
confirmFullSync: "确定要执行全量同步吗?这将覆盖LDAP服务器上已有的数据。",
|
||||||
|
idle: "空闲",
|
||||||
|
running: "同步中",
|
||||||
|
success: "成功",
|
||||||
|
failed: "失败",
|
||||||
|
namePlaceholder: "请输入配置名称",
|
||||||
|
hostPlaceholder: "例:192.168.2.1",
|
||||||
|
portPlaceholder: "例:389",
|
||||||
|
baseDnPlaceholder: "例:dc=koteladt,dc=com",
|
||||||
|
adminDnPlaceholder: "例:cn=admin,dc=koteladt,dc=com",
|
||||||
|
adminPasswordPlaceholder: "请输入管理员密码",
|
||||||
|
requiredFields: "请填写必填项",
|
||||||
|
loadFailed: "加载LDAP配置失败",
|
||||||
|
operateFailed: "操作失败",
|
||||||
|
syncDeptsResult: "同步部门完成:成功 {success},失败 {failed}",
|
||||||
|
syncUsersResult: "同步用户完成:成功 {success},失败 {failed}",
|
||||||
|
fullSyncResult: "全量同步完成:部门成功 {deptSuccess} 失败 {deptFailed},用户成功 {userSuccess} 失败 {userFailed}",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import { basicRoutes, notFoundRoute } from "./routes";
|
|||||||
const MENU_HISTORY_KEY = "app-menu-history";
|
const MENU_HISTORY_KEY = "app-menu-history";
|
||||||
const MAX_HISTORY_ITEMS = 10;
|
const MAX_HISTORY_ITEMS = 10;
|
||||||
|
|
||||||
|
let tokenVerified = false; // 标记 token 是否已在本次会话中校验通过
|
||||||
|
|
||||||
interface MenuHistoryItem {
|
interface MenuHistoryItem {
|
||||||
path: string;
|
path: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -154,6 +156,24 @@ export function setupRouterGuards(router: Router) {
|
|||||||
return { path: "/login", query: { redirect: to.fullPath } };
|
return { path: "/login", query: { redirect: to.fullPath } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验 token 是否有效(首次进入需认证页面时校验一次)
|
||||||
|
if (!tokenVerified) {
|
||||||
|
try {
|
||||||
|
const { request } = await import("@/utils/request");
|
||||||
|
const checkRes: any = await request.get("/auth/check_token");
|
||||||
|
if (!checkRes?.data?.valid) {
|
||||||
|
authStore.logout();
|
||||||
|
tokenVerified = false;
|
||||||
|
return { path: "/login", query: { redirect: to.fullPath } };
|
||||||
|
}
|
||||||
|
tokenVerified = true;
|
||||||
|
} catch {
|
||||||
|
authStore.logout();
|
||||||
|
tokenVerified = false;
|
||||||
|
return { path: "/login", query: { redirect: to.fullPath } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate dynamic routes if not already generated
|
// Generate dynamic routes if not already generated
|
||||||
if (!permissionStore.isRoutesGenerated) {
|
if (!permissionStore.isRoutesGenerated) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -174,16 +174,6 @@ export const asyncRoutes: AppRouteRecordRaw[] = [
|
|||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "permission",
|
|
||||||
name: "SystemPermission",
|
|
||||||
component: () => import("@/views/system/permission/index.vue"),
|
|
||||||
meta: {
|
|
||||||
title: "menu.permission",
|
|
||||||
icon: "SafetyCertificateOutlined",
|
|
||||||
requiresAuth: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "dict",
|
path: "dict",
|
||||||
name: "SystemDict",
|
name: "SystemDict",
|
||||||
@ -194,6 +184,16 @@ export const asyncRoutes: AppRouteRecordRaw[] = [
|
|||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "ldap",
|
||||||
|
name: "SystemLdap",
|
||||||
|
component: () => import("@/views/system/ldap/index.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "menu.ldap",
|
||||||
|
icon: "ClusterOutlined",
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -47,6 +47,20 @@ service.interceptors.response.use(
|
|||||||
router.push("/login");
|
router.push("/login");
|
||||||
return Promise.reject(new Error(res.message || "Unauthorized"));
|
return Promise.reject(new Error(res.message || "Unauthorized"));
|
||||||
}
|
}
|
||||||
|
// magic-api 返回 -1 可能由 token 失效引起,校验并跳转登录
|
||||||
|
if (res.code === -1) {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
if (authStore.token && response.config.url !== "/auth/check_token") {
|
||||||
|
// 异步校验 token 是否失效
|
||||||
|
service.get("/auth/check_token").then((checkRes: any) => {
|
||||||
|
if (!checkRes?.data?.valid) {
|
||||||
|
authStore.logout();
|
||||||
|
message.error("登录已过期,请重新登录");
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
message.error(res.message || "请求失败");
|
message.error(res.message || "请求失败");
|
||||||
return Promise.reject(new Error(res.message || "Error"));
|
return Promise.reject(new Error(res.message || "Error"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,124 +1,257 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<ProTable ref="tableRef" :columns="columns" :request="loadData" :toolbar="{ title: '资产分类' }" :custom-row="(record: any) => ({ onClick: () => showChangeLog(record), style: { cursor: 'pointer' } })">
|
<a-card :bordered="false">
|
||||||
<template #toolbar-actions>
|
<!-- 工具栏 -->
|
||||||
<a-button v-permission="'base:category:add'" type="primary" @click="handleAdd"><PlusOutlined /> 新增分类</a-button>
|
<div class="category-toolbar">
|
||||||
</template>
|
<a-input-search
|
||||||
<template #bodyCell="{ column, record }">
|
v-model:value="searchKeyword"
|
||||||
<template v-if="column.key === 'category_type'">
|
placeholder="搜索分类名称或编码"
|
||||||
<a-tag>{{ record.category_type }}</a-tag>
|
style="width: 280px"
|
||||||
</template>
|
@search="handleSearch"
|
||||||
<template v-if="column.key === 'operations'">
|
/>
|
||||||
<a-space :size="4" @click.stop>
|
<a-button type="primary" @click="handleAdd(null)">
|
||||||
<a-button v-permission="'base:category:edit'" type="link" size="small" @click.stop="handleEdit(record)"><EditOutlined /> 编辑</a-button>
|
<PlusOutlined /> 新增顶级分类
|
||||||
<a-popconfirm v-permission="'base:category:delete'" title="确认删除?" @confirm="handleDelete(record.id)">
|
</a-button>
|
||||||
<a-button type="link" size="small" danger @click.stop><DeleteOutlined /> 删除</a-button>
|
</div>
|
||||||
</a-popconfirm>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</ProTable>
|
|
||||||
|
|
||||||
<a-modal v-model:open="visible" :title="isEdit ? '编辑分类' : '新增分类'" @ok="handleSubmit" width="600px">
|
<!-- 树形表格 -->
|
||||||
<a-form :model="form" :label-col="{ span: 6 }">
|
<a-table
|
||||||
<a-form-item label="分类名称" required><a-input v-model:value="form.name" /></a-form-item>
|
:columns="columns"
|
||||||
<a-form-item label="分类编码" required><a-input v-model:value="form.code" /></a-form-item>
|
:data-source="treeData"
|
||||||
<a-form-item label="分类大类" required>
|
:pagination="false"
|
||||||
<a-select v-model:value="form.category_type" :options="categoryTypeOptions" />
|
:default-expand-all-rows="true"
|
||||||
</a-form-item>
|
row-key="id"
|
||||||
<a-form-item label="父级分类">
|
size="middle"
|
||||||
<a-select v-model:value="form.parent_id" show-search placeholder="留空为顶级分类" option-filter-prop="label" :options="parentOptions" allow-clear />
|
class="category-table"
|
||||||
</a-form-item>
|
>
|
||||||
</a-form>
|
<template #bodyCell="{ column, record }">
|
||||||
</a-modal>
|
<template v-if="column.key === 'name'">
|
||||||
|
<span :class="{ 'is-leaf': !record.children || !record.children.length }">
|
||||||
<a-modal v-model:open="logVisible" title="变更记录" width="800px" :footer="null">
|
{{ record.name }}
|
||||||
<a-table :columns="logColumns" :data-source="logData" :pagination="false" size="small" row-key="id">
|
</span>
|
||||||
<template #bodyCell="{ column, record: row }">
|
</template>
|
||||||
<template v-if="column.key === 'change_type'">
|
<template v-if="column.key === 'operations'">
|
||||||
<a-tag :color="row.change_type === 'create' ? 'green' : row.change_type === 'delete' ? 'red' : 'blue'">{{ row.change_type === 'create' ? '新增' : row.change_type === 'delete' ? '删除' : '修改' }}</a-tag>
|
<a-space :size="4">
|
||||||
|
<a-button type="link" size="small" @click="handleAdd(record)">
|
||||||
|
<PlusOutlined /> 子分类
|
||||||
|
</a-button>
|
||||||
|
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||||
|
<EditOutlined /> 编辑
|
||||||
|
</a-button>
|
||||||
|
<a-popconfirm title="确认删除?子分类将一并删除。" @confirm="handleDelete(record.id)">
|
||||||
|
<a-button type="link" size="small" danger><DeleteOutlined /> 删除</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</a-modal>
|
|
||||||
|
<!-- 新增/编辑弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="visible"
|
||||||
|
:title="isEdit ? '编辑分类' : '新增分类'"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
width="500px"
|
||||||
|
>
|
||||||
|
<a-form :model="form" :label-col="{ span: 6 }">
|
||||||
|
<a-form-item label="分类名称" required>
|
||||||
|
<a-input v-model:value="form.name" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="分类编码" required>
|
||||||
|
<a-input v-model:value="form.code" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="父级分类">
|
||||||
|
<a-tree-select
|
||||||
|
v-model:value="form.parent_id"
|
||||||
|
:tree-data="treeSelectData"
|
||||||
|
:field-names="{ label: 'name', value: 'id', children: 'children' }"
|
||||||
|
tree-default-expand-all
|
||||||
|
placeholder="留空为顶级分类"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</a-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from "@antdv-next/icons";
|
import { PlusOutlined, EditOutlined, DeleteOutlined } from "@antdv-next/icons";
|
||||||
import { message } from "antdv-next";
|
import { message } from "antdv-next";
|
||||||
import { ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { categoryApi, changeRecordApi, getCategoryList } from "@/api/asset";
|
import { categoryApi } from "@/api/asset";
|
||||||
import ProTable from "@/components/Pro/ProTable/index.vue";
|
|
||||||
import type { ProTableColumn } from "@/types/pro";
|
|
||||||
|
|
||||||
const tableRef = ref();
|
interface Category {
|
||||||
const visible = ref(false); const isEdit = ref(false);
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
category_type: string;
|
||||||
|
children?: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const isEdit = ref(false);
|
||||||
const form = ref<Record<string, unknown>>({});
|
const form = ref<Record<string, unknown>>({});
|
||||||
|
const flatList = ref<Category[]>([]);
|
||||||
|
const searchKeyword = ref("");
|
||||||
|
|
||||||
const parentOptions = ref<{ label: string; value: string }[]>([]);
|
const columns = [
|
||||||
const categoryTypeOptions = [
|
{ title: "分类名称", dataIndex: "name", key: "name", width: 260 },
|
||||||
{ label: '设备', value: 'device' },
|
{ title: "分类编码", dataIndex: "code", key: "code", width: 180 },
|
||||||
{ label: '许可证', value: 'license' },
|
{ title: "操作", dataIndex: "operations", key: "operations", width: 260 },
|
||||||
{ label: '配件', value: 'accessory' },
|
|
||||||
{ label: '耗材', value: 'consumable' },
|
|
||||||
{ label: '服务', value: 'service' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const loadOptions = async () => {
|
function buildTree(list: Category[]): Category[] {
|
||||||
|
const map = new Map<string, Category>();
|
||||||
|
const roots: Category[] = [];
|
||||||
|
for (const cat of list) {
|
||||||
|
map.set(cat.id, { ...cat, children: [] });
|
||||||
|
}
|
||||||
|
for (const cat of list) {
|
||||||
|
const node = map.get(cat.id)!;
|
||||||
|
if (cat.parent_id && map.has(cat.parent_id)) {
|
||||||
|
map.get(cat.parent_id)!.children!.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 清理空 children
|
||||||
|
const cleanEmpty = (nodes: Category[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
cleanEmpty(node.children);
|
||||||
|
} else {
|
||||||
|
delete node.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cleanEmpty(roots);
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeData = computed(() => {
|
||||||
|
const keyword = searchKeyword.value.trim().toLowerCase();
|
||||||
|
if (!keyword) return buildTree(flatList.value);
|
||||||
|
|
||||||
|
const matched = flatList.value.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(keyword) ||
|
||||||
|
c.code.toLowerCase().includes(keyword),
|
||||||
|
);
|
||||||
|
// 包含匹配项的祖先
|
||||||
|
const matchIds = new Set(matched.map((c) => c.id));
|
||||||
|
const withAncestors = [...matched];
|
||||||
|
for (const cat of flatList.value) {
|
||||||
|
if (!matchIds.has(cat.id) && cat.children?.length) {
|
||||||
|
// 递归检查是否有子孙匹配
|
||||||
|
const hasMatch = (node: Category): boolean => {
|
||||||
|
if (matchIds.has(node.id)) return true;
|
||||||
|
return (node.children || []).some(hasMatch);
|
||||||
|
};
|
||||||
|
if (cat.children && cat.children.some(hasMatch)) {
|
||||||
|
withAncestors.push(cat);
|
||||||
|
matchIds.add(cat.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buildTree(withAncestors);
|
||||||
|
});
|
||||||
|
|
||||||
|
const treeSelectData = computed(() => {
|
||||||
|
// 始终用完整树(不受搜索影响),但排除自身
|
||||||
|
const fullTree = buildTree(flatList.value);
|
||||||
|
const excludeSelf = (nodes: Category[]): any[] => {
|
||||||
|
return nodes
|
||||||
|
.filter((c) => c.id !== form.value.id)
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name + " [" + c.code + "]",
|
||||||
|
children: c.children?.length ? excludeSelf(c.children!) : undefined,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
return excludeSelf(fullTree);
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const res: any = await getCategoryList();
|
const res: any = await categoryApi.page({});
|
||||||
const data = (res.data || res) as any[];
|
const data = Array.isArray(res.data) ? res.data : Array.isArray(res) ? res : (res.data || res).list || [];
|
||||||
parentOptions.value = (data || []).map((c: any) => ({ label: c.name + ' [' + c.code + ']', value: c.id }));
|
flatList.value = data;
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
flatList.value = [];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logVisible = ref(false);
|
const handleSearch = () => {
|
||||||
const logData = ref<Record<string, unknown>[]>([]);
|
// treeData computed 会自动响应 searchKeyword
|
||||||
const logColumns = [
|
|
||||||
{ title: "时间", dataIndex: "create_time", key: "create_time", width: 170 },
|
|
||||||
{ title: "变更类型", dataIndex: "change_type", key: "change_type", width: 80 },
|
|
||||||
{ title: "变更字段", dataIndex: "field_name", key: "field_name", width: 120 },
|
|
||||||
{ title: "旧值", dataIndex: "old_value", key: "old_value", ellipsis: true },
|
|
||||||
{ title: "新值", dataIndex: "new_value", key: "new_value", ellipsis: true },
|
|
||||||
{ title: "操作人", dataIndex: "operator_name", key: "operator_name", width: 100 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const columns: ProTableColumn[] = [
|
|
||||||
{ title: "分类名称", dataIndex: "name", key: "name", search: true, searchType: "input" },
|
|
||||||
{ title: "分类编码", dataIndex: "code", key: "code" },
|
|
||||||
{ title: "分类大类", dataIndex: "category_type", key: "category_type" },
|
|
||||||
{ title: "父级ID", dataIndex: "parent_id", key: "parent_id", ellipsis: true },
|
|
||||||
{ title: "操作", dataIndex: "operations", key: "operations", width: 200, fixed: "right" as const },
|
|
||||||
];
|
|
||||||
|
|
||||||
const loadData = async (params: Record<string, unknown>) => {
|
|
||||||
try {
|
|
||||||
const res: any = await categoryApi.page({ page: params.current, pageSize: params.pageSize, keyword: params.keyword });
|
|
||||||
const pageData = res.data || res;
|
|
||||||
return { data: pageData.list || [], total: pageData.total || 0, success: true };
|
|
||||||
} catch { return { data: [], total: 0, success: false }; }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const showChangeLog = async (record: Record<string, unknown>) => {
|
const handleAdd = (parent: Category | null) => {
|
||||||
try {
|
isEdit.value = false;
|
||||||
const res: any = await changeRecordApi.page({ asset_type: "category", asset_id: record.id, page: 1, pageSize: 200 });
|
form.value = {
|
||||||
logData.value = (res.data || res).list || [];
|
name: "",
|
||||||
logVisible.value = true;
|
code: "",
|
||||||
} catch { message.error("获取变更记录失败"); }
|
parent_id: parent?.id || undefined,
|
||||||
|
category_type: "",
|
||||||
|
};
|
||||||
|
visible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (record: Category) => {
|
||||||
|
isEdit.value = true;
|
||||||
|
form.value = { ...record };
|
||||||
|
visible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = async () => { isEdit.value = false; form.value = {}; await loadOptions(); visible.value = true; };
|
|
||||||
const handleEdit = async (r: Record<string, unknown>) => { isEdit.value = true; form.value = { ...r }; await loadOptions(); visible.value = true; };
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
await categoryApi.del(id);
|
try {
|
||||||
message.success("删除成功");
|
await categoryApi.del(id);
|
||||||
tableRef.value?.reload();
|
message.success("删除成功");
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
message.error("删除失败");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
await categoryApi.save(form.value);
|
if (!form.value.name?.trim() || !form.value.code?.trim()) {
|
||||||
message.success(isEdit.value ? "更新成功" : "创建成功");
|
message.warning("名称和编码不能为空");
|
||||||
visible.value = false;
|
return;
|
||||||
tableRef.value?.reload();
|
}
|
||||||
|
try {
|
||||||
|
await categoryApi.save({
|
||||||
|
...form.value,
|
||||||
|
name: form.value.name.trim(),
|
||||||
|
code: form.value.code.trim(),
|
||||||
|
parent_id: form.value.parent_id || null,
|
||||||
|
});
|
||||||
|
message.success(isEdit.value ? "更新成功" : "创建成功");
|
||||||
|
visible.value = false;
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
message.error("保存失败");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.category-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-table {
|
||||||
|
.is-leaf {
|
||||||
|
color: var(--ant-text-color, #333);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -284,7 +284,7 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
await updateUser(editingId.value, values);
|
await updateUser({ ...values, id: editingId.value });
|
||||||
message.success($t("exampleTable.updateSuccess"));
|
message.success($t("exampleTable.updateSuccess"));
|
||||||
} else {
|
} else {
|
||||||
await createUser(values);
|
await createUser(values);
|
||||||
@ -299,7 +299,7 @@ const handleSubmit = async () => {
|
|||||||
const handleStatusChange = async (record: User, checked: boolean) => {
|
const handleStatusChange = async (record: User, checked: boolean) => {
|
||||||
try {
|
try {
|
||||||
const newStatus = checked ? "active" : "inactive";
|
const newStatus = checked ? "active" : "inactive";
|
||||||
await updateUser(record.id, { ...record, status: newStatus });
|
await updateUser({ ...record, id: record.id, status: newStatus });
|
||||||
record.status = newStatus;
|
record.status = newStatus;
|
||||||
message.success($t("exampleTable.updateSuccess"));
|
message.success($t("exampleTable.updateSuccess"));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@ -110,8 +110,9 @@
|
|||||||
{{ r.name }}
|
{{ r.name }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'action'">
|
<template v-else-if="column.key === 'operate'">
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
|
v-if="canRemoveMember"
|
||||||
title="确认将该成员移出此部门?"
|
title="确认将该成员移出此部门?"
|
||||||
@confirm="handleRemoveUser(record)"
|
@confirm="handleRemoveUser(record)"
|
||||||
>
|
>
|
||||||
@ -235,6 +236,7 @@ import { PlusOutlined, EditOutlined, DeleteOutlined } from "@antdv-next/icons";
|
|||||||
import { message, Modal } from "antdv-next";
|
import { message, Modal } from "antdv-next";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { usePermission } from "@/composables/usePermission";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getDeptTree,
|
getDeptTree,
|
||||||
@ -250,6 +252,8 @@ import ProStatus from "@/components/Pro/ProStatus/index.vue";
|
|||||||
import ProTable from "@/components/Pro/ProTable/index.vue";
|
import ProTable from "@/components/Pro/ProTable/index.vue";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { hasAnyRole } = usePermission();
|
||||||
|
const canRemoveMember = computed(() => hasAnyRole(["admin"]));
|
||||||
|
|
||||||
const deptStatusMap = computed<ProStatusMap>(() => ({
|
const deptStatusMap = computed<ProStatusMap>(() => ({
|
||||||
enabled: { text: t("dept.enabled"), color: "#52c41a" },
|
enabled: { text: t("dept.enabled"), color: "#52c41a" },
|
||||||
@ -328,7 +332,7 @@ const userColumns = computed<ProTableColumn[]>(() => [
|
|||||||
{ title: "邮箱", dataIndex: "email", key: "email", width: 150, ellipsis: true },
|
{ title: "邮箱", dataIndex: "email", key: "email", width: 150, ellipsis: true },
|
||||||
{ title: "角色", dataIndex: "roles", key: "roles", width: 140 },
|
{ title: "角色", dataIndex: "roles", key: "roles", width: 140 },
|
||||||
{ title: "状态", dataIndex: "status", key: "status", width: 70 },
|
{ title: "状态", dataIndex: "status", key: "status", width: 70 },
|
||||||
{ title: "操作", dataIndex: "action", key: "action", width: 90 },
|
{ title: "操作", dataIndex: "operate", key: "operate", width: 90 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const deptDescColumns = computed<ProDescriptionItem[]>(() => [
|
const deptDescColumns = computed<ProDescriptionItem[]>(() => [
|
||||||
|
|||||||
@ -1,72 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<ProSplitLayout :side-width="260">
|
<ProSplitLayout :side-width="200">
|
||||||
<template #side>
|
<template #side>
|
||||||
<!-- dict type list -->
|
<!-- dict type nav -->
|
||||||
<div class="dict-types-header">
|
<div class="dict-side-header">
|
||||||
<h3>{{ t("dict.dictType") }}</h3>
|
<h3>{{ t("dict.dictType") }}</h3>
|
||||||
<a-button type="primary" size="small" @click="handleAddType">
|
<a-button type="primary" size="small" @click="handleAddType">
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
{{ t("common.add") }}
|
{{ t("common.add") }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="dict-types-list">
|
<a-menu
|
||||||
<div
|
v-model:selectedKeys="selectedMenuKeys"
|
||||||
v-for="type in dictTypes"
|
mode="inline"
|
||||||
:key="type.id"
|
:items="menuItems"
|
||||||
class="dict-type-item"
|
class="dict-types-menu"
|
||||||
:class="{ active: selectedTypeCode === type.code }"
|
@click="handleMenuClick"
|
||||||
@click="handleSelectType(type)"
|
/>
|
||||||
>
|
|
||||||
<div class="type-info">
|
|
||||||
<div class="type-name">{{ type.name }}</div>
|
|
||||||
<div class="type-code">{{ type.code }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="type-actions" @click.stop>
|
|
||||||
<a-tooltip :title="t('common.edit')">
|
|
||||||
<a-button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
@click="handleEditType(type)"
|
|
||||||
>
|
|
||||||
<template #icon><EditOutlined /></template>
|
|
||||||
</a-button>
|
|
||||||
</a-tooltip>
|
|
||||||
<a-tooltip :title="t('common.delete')">
|
|
||||||
<a-button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
@click="handleDeleteType(type)"
|
|
||||||
>
|
|
||||||
<template #icon><DeleteOutlined /></template>
|
|
||||||
</a-button>
|
|
||||||
</a-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #main>
|
<template #main>
|
||||||
<!-- dict data list -->
|
<!-- dict data table -->
|
||||||
<ProTable
|
<ProTable
|
||||||
v-if="selectedTypeCode"
|
v-if="selectedTypeCode"
|
||||||
:key="selectedTypeCode"
|
:key="selectedTypeCode + refreshKey"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:request="loadData"
|
:request="loadData"
|
||||||
:search="false"
|
:search="false"
|
||||||
:toolbar="{
|
:toolbar="{
|
||||||
title: t('dict.dictDataTitle', { name: selectedTypeName }),
|
title: selectedTypeName || t('dict.dictData'),
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #toolbar-actions>
|
<template #toolbar-actions>
|
||||||
<a-button type="primary" @click="handleAdd">
|
<a-button type="primary" @click="handleAdd">
|
||||||
<PlusOutlined /> {{ t("dict.createData") }}
|
<PlusOutlined /> {{ t("dict.createData") }}
|
||||||
</a-button>
|
</a-button>
|
||||||
|
<a-button @click="handleEditCurrentType">
|
||||||
|
<template #icon><EditOutlined /></template>
|
||||||
|
{{ t("common.edit") }}
|
||||||
|
</a-button>
|
||||||
|
<a-button danger @click="handleDeleteCurrentType">
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
{{ t("common.delete") }}
|
||||||
|
</a-button>
|
||||||
</template>
|
</template>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'status'">
|
<template v-if="column.key === 'status'">
|
||||||
<ProStatus :value="record.status" :status-map="dictStatusMap" />
|
<a-tag :color="record.status === 'enabled' ? 'green' : 'default'">
|
||||||
|
{{ record.status === "enabled" ? t("dict.enabled") : t("dict.disabled") }}
|
||||||
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="column.key === 'action'">
|
<template v-if="column.key === 'action'">
|
||||||
<a-space :size="4">
|
<a-space :size="4">
|
||||||
@ -74,12 +56,7 @@
|
|||||||
<template #icon><EditOutlined /></template>
|
<template #icon><EditOutlined /></template>
|
||||||
{{ t("common.edit") }}
|
{{ t("common.edit") }}
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button type="link" size="small" danger @click="handleDelete(record)">
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
@click="handleDelete(record)"
|
|
||||||
>
|
|
||||||
<template #icon><DeleteOutlined /></template>
|
<template #icon><DeleteOutlined /></template>
|
||||||
{{ t("common.delete") }}
|
{{ t("common.delete") }}
|
||||||
</a-button>
|
</a-button>
|
||||||
@ -175,11 +152,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DictType, DictData } from "@/types/dict";
|
import type { DictType, DictData } from "@/types/dict";
|
||||||
import type { ProTableColumn, ProStatusMap } from "@/types/pro";
|
import type { ProTableColumn } from "@/types/pro";
|
||||||
|
import type { MenuItemType } from "antdv-next";
|
||||||
|
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from "@antdv-next/icons";
|
import { PlusOutlined, EditOutlined, DeleteOutlined } from "@antdv-next/icons";
|
||||||
import { message, Modal } from "antdv-next";
|
import { message, Modal } from "antdv-next";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed, h } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -188,33 +166,61 @@ import {
|
|||||||
updateDictType,
|
updateDictType,
|
||||||
deleteDictType,
|
deleteDictType,
|
||||||
getDictDataList,
|
getDictDataList,
|
||||||
|
getAllDictData,
|
||||||
createDictData,
|
createDictData,
|
||||||
updateDictData,
|
updateDictData,
|
||||||
deleteDictData,
|
deleteDictData,
|
||||||
} from "@/api/dict";
|
} from "@/api/dict";
|
||||||
import ProSplitLayout from "@/components/Pro/ProSplitLayout/index.vue";
|
import ProSplitLayout from "@/components/Pro/ProSplitLayout/index.vue";
|
||||||
import ProStatus from "@/components/Pro/ProStatus/index.vue";
|
|
||||||
import ProTable from "@/components/Pro/ProTable/index.vue";
|
import ProTable from "@/components/Pro/ProTable/index.vue";
|
||||||
import { useDictStore } from "@/stores/dict";
|
import { useDictStore } from "@/stores/dict";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const dictStore = useDictStore();
|
const dictStore = useDictStore();
|
||||||
|
|
||||||
const dictStatusMap = computed<ProStatusMap>(() => ({
|
|
||||||
enabled: { text: t("dict.enabled"), color: "#52c41a" },
|
|
||||||
disabled: { text: t("dict.disabled"), color: "#bfbfbf" },
|
|
||||||
}));
|
|
||||||
|
|
||||||
// dict type list
|
// dict type list
|
||||||
const dictTypes = ref<DictType[]>([]);
|
const dictTypes = ref<DictType[]>([]);
|
||||||
const selectedTypeCode = ref<string>("");
|
const selectedTypeCode = ref<string>("");
|
||||||
|
const refreshKey = ref(0);
|
||||||
|
const allDictData = ref<DictData[]>([]);
|
||||||
|
|
||||||
|
const selectedMenuKeys = computed({
|
||||||
|
get: () => (selectedTypeCode.value ? [selectedTypeCode.value] : []),
|
||||||
|
set: (keys: string[]) => {
|
||||||
|
if (keys.length > 0) selectedTypeCode.value = keys[0];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const selectedTypeName = computed(() => {
|
const selectedTypeName = computed(() => {
|
||||||
const type = dictTypes.value.find(
|
const type = dictTypes.value.find((item) => item.code === selectedTypeCode.value);
|
||||||
(item) => item.code === selectedTypeCode.value,
|
|
||||||
);
|
|
||||||
return type?.name || "";
|
return type?.name || "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentType = computed(() =>
|
||||||
|
dictTypes.value.find((item) => item.code === selectedTypeCode.value) || null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const getTypeCount = (typeCode: string) =>
|
||||||
|
allDictData.value.filter((d) => d.typeCode === typeCode).length;
|
||||||
|
|
||||||
|
const menuItems = computed<MenuItemType[]>(() =>
|
||||||
|
dictTypes.value.map((type) => ({
|
||||||
|
key: type.code,
|
||||||
|
label: h("div", { class: "menu-item-label" }, [
|
||||||
|
h("div", { class: "item-main" }, [
|
||||||
|
h("span", { class: "dict-name" }, type.name),
|
||||||
|
h("span", { class: "dict-code" }, type.code),
|
||||||
|
]),
|
||||||
|
h("span", { class: "dict-count" }, String(getTypeCount(type.code))),
|
||||||
|
]),
|
||||||
|
title: `${type.name} (${type.code})`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMenuClick = ({ key }: { key: string }) => {
|
||||||
|
selectedTypeCode.value = key;
|
||||||
|
};
|
||||||
|
|
||||||
// dict type modal
|
// dict type modal
|
||||||
const typeModalVisible = ref(false);
|
const typeModalVisible = ref(false);
|
||||||
const typeModalTitle = computed(() =>
|
const typeModalTitle = computed(() =>
|
||||||
@ -242,6 +248,12 @@ const dataForm = ref<Partial<DictData>>({
|
|||||||
|
|
||||||
// table columns
|
// table columns
|
||||||
const columns: ProTableColumn[] = [
|
const columns: ProTableColumn[] = [
|
||||||
|
{
|
||||||
|
title: t("dict.serial"),
|
||||||
|
dataIndex: "__index",
|
||||||
|
key: "__index",
|
||||||
|
width: 70,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t("dict.dictLabel"),
|
title: t("dict.dictLabel"),
|
||||||
dataIndex: "label",
|
dataIndex: "label",
|
||||||
@ -262,7 +274,7 @@ const columns: ProTableColumn[] = [
|
|||||||
title: t("common.status"),
|
title: t("common.status"),
|
||||||
dataIndex: "status",
|
dataIndex: "status",
|
||||||
key: "status",
|
key: "status",
|
||||||
width: 80,
|
width: 90,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("dict.remark"),
|
title: t("dict.remark"),
|
||||||
@ -293,6 +305,18 @@ const loadDictTypes = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// load all dict data for count
|
||||||
|
const loadAllDictData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getAllDictData();
|
||||||
|
if (response.code === 200) {
|
||||||
|
allDictData.value = response.data;
|
||||||
|
}
|
||||||
|
} catch (_error: unknown) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// load dict data list
|
// load dict data list
|
||||||
const loadData = async (params: Record<string, unknown>) => {
|
const loadData = async (params: Record<string, unknown>) => {
|
||||||
try {
|
try {
|
||||||
@ -318,11 +342,6 @@ const loadData = async (params: Record<string, unknown>) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// select dict type
|
|
||||||
const handleSelectType = (type: DictType) => {
|
|
||||||
selectedTypeCode.value = type.code;
|
|
||||||
};
|
|
||||||
|
|
||||||
// add dict type
|
// add dict type
|
||||||
const handleAddType = () => {
|
const handleAddType = () => {
|
||||||
typeForm.value = {
|
typeForm.value = {
|
||||||
@ -334,26 +353,33 @@ const handleAddType = () => {
|
|||||||
typeModalVisible.value = true;
|
typeModalVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// edit dict type
|
// edit current selected type
|
||||||
const handleEditType = (type: DictType) => {
|
const handleEditCurrentType = () => {
|
||||||
typeForm.value = { ...type };
|
if (!currentType.value) {
|
||||||
|
message.warning(t("dict.selectTypeHint"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
typeForm.value = { ...currentType.value };
|
||||||
typeModalVisible.value = true;
|
typeModalVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// delete dict type
|
// delete current selected type
|
||||||
const handleDeleteType = (type: DictType) => {
|
const handleDeleteCurrentType = () => {
|
||||||
|
if (!currentType.value) {
|
||||||
|
message.warning(t("dict.selectTypeHint"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: t("dict.confirmDelete"),
|
title: t("dict.confirmDelete"),
|
||||||
content: t("dict.confirmDeleteType", { name: type.name }),
|
content: t("dict.confirmDeleteType", { name: currentType.value.name }),
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
const response = await deleteDictType(type.id);
|
const response = await deleteDictType(currentType.value!.id);
|
||||||
if (response.code === 200) {
|
if (response.code === 200) {
|
||||||
message.success(t("dict.deleteSuccess"));
|
message.success(t("dict.deleteSuccess"));
|
||||||
|
selectedTypeCode.value = "";
|
||||||
loadDictTypes();
|
loadDictTypes();
|
||||||
if (selectedTypeCode.value === type.code) {
|
loadAllDictData();
|
||||||
selectedTypeCode.value = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (_error: unknown) {
|
} catch (_error: unknown) {
|
||||||
message.error(t("dict.deleteFailed"));
|
message.error(t("dict.deleteFailed"));
|
||||||
@ -376,6 +402,7 @@ const handleTypeSubmit = async () => {
|
|||||||
message.success(t("dict.updateSuccess"));
|
message.success(t("dict.updateSuccess"));
|
||||||
typeModalVisible.value = false;
|
typeModalVisible.value = false;
|
||||||
loadDictTypes();
|
loadDictTypes();
|
||||||
|
loadAllDictData();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const response = await createDictType(typeForm.value);
|
const response = await createDictType(typeForm.value);
|
||||||
@ -383,6 +410,7 @@ const handleTypeSubmit = async () => {
|
|||||||
message.success(t("dict.createSuccess"));
|
message.success(t("dict.createSuccess"));
|
||||||
typeModalVisible.value = false;
|
typeModalVisible.value = false;
|
||||||
loadDictTypes();
|
loadDictTypes();
|
||||||
|
loadAllDictData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_error: unknown) {
|
} catch (_error: unknown) {
|
||||||
@ -419,6 +447,8 @@ const handleDelete = (record: DictData) => {
|
|||||||
const response = await deleteDictData(record.id);
|
const response = await deleteDictData(record.id);
|
||||||
if (response.code === 200) {
|
if (response.code === 200) {
|
||||||
message.success(t("dict.deleteSuccess"));
|
message.success(t("dict.deleteSuccess"));
|
||||||
|
refreshKey.value++;
|
||||||
|
loadAllDictData();
|
||||||
dictStore.refreshDictData();
|
dictStore.refreshDictData();
|
||||||
}
|
}
|
||||||
} catch (_error: unknown) {
|
} catch (_error: unknown) {
|
||||||
@ -441,6 +471,8 @@ const handleDataSubmit = async () => {
|
|||||||
if (response.code === 200) {
|
if (response.code === 200) {
|
||||||
message.success(t("dict.updateSuccess"));
|
message.success(t("dict.updateSuccess"));
|
||||||
dataModalVisible.value = false;
|
dataModalVisible.value = false;
|
||||||
|
refreshKey.value++;
|
||||||
|
loadAllDictData();
|
||||||
dictStore.refreshDictData();
|
dictStore.refreshDictData();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -448,6 +480,8 @@ const handleDataSubmit = async () => {
|
|||||||
if (response.code === 200) {
|
if (response.code === 200) {
|
||||||
message.success(t("dict.createSuccess"));
|
message.success(t("dict.createSuccess"));
|
||||||
dataModalVisible.value = false;
|
dataModalVisible.value = false;
|
||||||
|
refreshKey.value++;
|
||||||
|
loadAllDictData();
|
||||||
dictStore.refreshDictData();
|
dictStore.refreshDictData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -458,136 +492,117 @@ const handleDataSubmit = async () => {
|
|||||||
|
|
||||||
// init
|
// init
|
||||||
loadDictTypes();
|
loadDictTypes();
|
||||||
|
loadAllDictData();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
// dict types content
|
.page-container {
|
||||||
.dict-types-header {
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// left sidebar
|
||||||
|
.dict-side-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 16px;
|
padding: 0 16px;
|
||||||
padding-bottom: 12px;
|
margin-bottom: 8px;
|
||||||
border-bottom: 1px solid var(--color-border-secondary, #f0f0f0);
|
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: var(--color-text-heading);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dict-types-list {
|
.dict-types-menu {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow: auto;
|
||||||
margin: 0 -8px;
|
background: transparent;
|
||||||
padding: 0 8px;
|
border: none;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
:deep(.ant-menu-item) {
|
||||||
width: 4px;
|
margin: 4px 8px;
|
||||||
}
|
padding: 10px 12px !important;
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dict-type-item {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 12px 12px 16px;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
height: auto;
|
||||||
margin-bottom: 4px;
|
transition: all 0.2s;
|
||||||
border: none;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: "";
|
display: none;
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 3px;
|
|
||||||
height: 0;
|
|
||||||
background: var(--ant-primary-color);
|
|
||||||
border-radius: 0 3px 3px 0;
|
|
||||||
transition: height 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-fill-quaternary, #fafafa);
|
background: var(--color-fill-quaternary, #fafafa);
|
||||||
|
|
||||||
.type-actions {
|
.dict-code {
|
||||||
opacity: 1;
|
white-space: normal;
|
||||||
transform: translateX(0);
|
overflow: visible;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.ant-menu-item-selected {
|
||||||
background: var(
|
background: var(
|
||||||
--ant-primary-color-deprecated-l-50,
|
--ant-primary-color-deprecated-l-50,
|
||||||
rgba(22, 119, 255, 0.06)
|
rgba(22, 119, 255, 0.06)
|
||||||
);
|
);
|
||||||
|
|
||||||
&::before {
|
.dict-name {
|
||||||
height: 60%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-name {
|
|
||||||
color: var(--ant-primary-color);
|
color: var(--ant-primary-color);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.type-info {
|
.menu-item-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.item-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
.type-name {
|
.dict-name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
display: block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-code {
|
.dict-code {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-text-quaternary, #bfbfbf);
|
color: var(--color-text-quaternary, #bfbfbf);
|
||||||
font-family: "SF Mono", "Monaco", "Menlo", monospace;
|
font-family: "SF Mono", "Monaco", "Menlo", monospace;
|
||||||
|
display: block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-actions {
|
.dict-count {
|
||||||
display: flex;
|
font-size: 12px;
|
||||||
gap: 2px;
|
color: var(--color-text-quaternary);
|
||||||
opacity: 0;
|
background: var(--color-fill-quaternary);
|
||||||
transform: translateX(8px);
|
padding: 0 8px;
|
||||||
transition: all 0.2s ease;
|
border-radius: 10px;
|
||||||
|
line-height: 20px;
|
||||||
|
margin-left: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// dict data content
|
// right side empty state
|
||||||
:deep(.ant-table-thead > tr > th),
|
|
||||||
:deep(.ant-table-thead > tr > td) {
|
|
||||||
background: var(--color-fill-quaternary, #fafafa);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dict-data-empty {
|
.dict-data-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
404
antdv-next-admin/src/views/system/ldap/index.vue
Normal file
404
antdv-next-admin/src/views/system/ldap/index.vue
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<ProTable :key="refreshKey" :columns="columns" :request="loadList" :toolbar="{ title: $t('ldap.title') }">
|
||||||
|
<template #toolbar-actions>
|
||||||
|
<a-button type="primary" @click="handleAdd">
|
||||||
|
<PlusOutlined /> {{ $t("ldap.createConfig") }}
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'status'">
|
||||||
|
<a-tag :color="record.status === '0' ? 'green' : 'default'">
|
||||||
|
{{ record.status === "0" ? $t("ldap.enabled") : $t("ldap.disabled") }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'sync_status'">
|
||||||
|
<a-tag :color="syncStatusColor(record.sync_status)">
|
||||||
|
{{ $t(`ldap.${record.sync_status || "idle"}`) }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'last_sync_time'">
|
||||||
|
<span>{{ record.last_sync_time || "-" }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'operate'">
|
||||||
|
<a-space :size="4">
|
||||||
|
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||||
|
<template #icon><EditOutlined /></template>
|
||||||
|
{{ $t("common.edit") }}
|
||||||
|
</a-button>
|
||||||
|
<a-button type="link" size="small" @click="handleTestConnection(record)">
|
||||||
|
<template #icon><LinkOutlined /></template>
|
||||||
|
{{ $t("ldap.testConnection") }}
|
||||||
|
</a-button>
|
||||||
|
<a-dropdown :trigger="['click']" :menu="syncMenuProps(record)">
|
||||||
|
<a-button type="link" size="small">
|
||||||
|
<SyncOutlined /> 同步
|
||||||
|
</a-button>
|
||||||
|
</a-dropdown>
|
||||||
|
<a-button type="link" size="small" danger @click="handleDelete(record)">
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
{{ $t("common.delete") }}
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</ProTable>
|
||||||
|
|
||||||
|
<!-- 新增/编辑弹窗 -->
|
||||||
|
<a-modal v-model:open="modalVisible" :title="modalTitle" @ok="handleSubmit" :width="560" :confirm-loading="submitting">
|
||||||
|
<a-form :model="form" :label-col="{ span: 6 }" style="margin-top: 16px">
|
||||||
|
<a-form-item :label="$t('ldap.name')" required>
|
||||||
|
<a-input v-model:value="form.name" :placeholder="$t('ldap.namePlaceholder')" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label="$t('ldap.host')" required>
|
||||||
|
<a-input v-model:value="form.host" :placeholder="$t('ldap.hostPlaceholder')" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label="$t('ldap.port')" required>
|
||||||
|
<a-input-number v-model:value="form.port" :min="1" :max="65535" style="width: 100%" :placeholder="$t('ldap.portPlaceholder')" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label="$t('ldap.useSsl')">
|
||||||
|
<a-switch v-model:checked="sslChecked" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :wrapper-col="{ offset: 6 }">
|
||||||
|
<a-button @click="handleTestFormConnection" :loading="testing">
|
||||||
|
<LinkOutlined /> {{ $t("ldap.testConnection") }}
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label="$t('ldap.baseDn')" required>
|
||||||
|
<a-input v-model:value="form.base_dn" :placeholder="$t('ldap.baseDnPlaceholder')" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label="$t('ldap.adminDn')" required>
|
||||||
|
<a-input v-model:value="form.admin_dn" :placeholder="$t('ldap.adminDnPlaceholder')" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label="$t('ldap.adminPassword')" required>
|
||||||
|
<a-input-password v-model:value="form.admin_password" :placeholder="$t('ldap.adminPasswordPlaceholder')" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label="$t('ldap.userBaseDn')">
|
||||||
|
<a-input v-model:value="form.user_base_dn" placeholder="如 ou=users,dc=koteladt,dc=com" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label="$t('ldap.deptBaseDn')">
|
||||||
|
<a-input v-model:value="form.dept_base_dn" placeholder="如 ou=departments,dc=koteladt,dc=com" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label="$t('ldap.status')">
|
||||||
|
<a-switch :checked="form.status === '0'" @change="(v: boolean) => (form.status = v ? '0' : '1')" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { LdapConfig } from "@/api/ldap";
|
||||||
|
import type { ProTableColumn } from "@/types/pro";
|
||||||
|
|
||||||
|
import { EditOutlined, DeleteOutlined, PlusOutlined, LinkOutlined, SyncOutlined } from "@antdv-next/icons";
|
||||||
|
import { message, Modal } from "antdv-next";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getLdapConfigList,
|
||||||
|
saveLdapConfig,
|
||||||
|
deleteLdapConfig,
|
||||||
|
testLdapConnection,
|
||||||
|
syncDepts,
|
||||||
|
syncUsers,
|
||||||
|
fullSync,
|
||||||
|
} from "@/api/ldap";
|
||||||
|
import ProTable from "@/components/Pro/ProTable/index.vue";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const syncStatusColor = (status?: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
idle: "default",
|
||||||
|
running: "blue",
|
||||||
|
success: "green",
|
||||||
|
failed: "red",
|
||||||
|
};
|
||||||
|
return map[status || "idle"] || "default";
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ProTableColumn[] = [
|
||||||
|
{ title: t("ldap.name"), dataIndex: "name", key: "name", width: 180 },
|
||||||
|
{ title: t("ldap.host"), dataIndex: "host", key: "host", width: 150 },
|
||||||
|
{
|
||||||
|
title: t("ldap.status"),
|
||||||
|
dataIndex: "status",
|
||||||
|
key: "status",
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("ldap.syncStatus"),
|
||||||
|
dataIndex: "sync_status",
|
||||||
|
key: "sync_status",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("ldap.lastSyncTime"),
|
||||||
|
dataIndex: "last_sync_time",
|
||||||
|
key: "last_sync_time",
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("common.actions"),
|
||||||
|
dataIndex: "operate",
|
||||||
|
key: "operate",
|
||||||
|
width: 320,
|
||||||
|
fixed: "right",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const loadList = async (params: Record<string, unknown>) => {
|
||||||
|
try {
|
||||||
|
const res = await getLdapConfigList({
|
||||||
|
page: params.current as number,
|
||||||
|
pageSize: params.pageSize as number,
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
return { data: res.data.list, total: res.data.total, success: true };
|
||||||
|
}
|
||||||
|
} catch (_e: unknown) {
|
||||||
|
message.error(t("ldap.loadFailed"));
|
||||||
|
}
|
||||||
|
return { data: [], total: 0, success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
const modalVisible = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const modalTitle = computed(() =>
|
||||||
|
form.value.id ? t("ldap.editConfig") : t("ldap.createConfig"),
|
||||||
|
);
|
||||||
|
const form = ref<Partial<LdapConfig>>({
|
||||||
|
name: "",
|
||||||
|
host: "",
|
||||||
|
port: 389,
|
||||||
|
use_ssl: "0",
|
||||||
|
base_dn: "",
|
||||||
|
admin_dn: "",
|
||||||
|
admin_password: "",
|
||||||
|
user_base_dn: "",
|
||||||
|
dept_base_dn: "",
|
||||||
|
status: "0",
|
||||||
|
});
|
||||||
|
const sslChecked = computed({
|
||||||
|
get: () => form.value.use_ssl === "1",
|
||||||
|
set: (v: boolean) => {
|
||||||
|
form.value.use_ssl = v ? "1" : "0";
|
||||||
|
form.value.port = v ? 636 : 389;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
form.value = {
|
||||||
|
name: "",
|
||||||
|
host: "",
|
||||||
|
port: 389,
|
||||||
|
use_ssl: "0",
|
||||||
|
base_dn: "",
|
||||||
|
admin_dn: "",
|
||||||
|
admin_password: "",
|
||||||
|
user_base_dn: "",
|
||||||
|
dept_base_dn: "",
|
||||||
|
status: "0",
|
||||||
|
};
|
||||||
|
modalVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (record: LdapConfig) => {
|
||||||
|
form.value = { ...record };
|
||||||
|
modalVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (record: LdapConfig) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t("ldap.confirmDelete"),
|
||||||
|
content: t("ldap.confirmDelete", { name: record.name }),
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const res = await deleteLdapConfig(record.id!);
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success(t("ldap.deleteSuccess"));
|
||||||
|
refreshKey.value++;
|
||||||
|
} else {
|
||||||
|
message.error(res.message || t("ldap.operateFailed"));
|
||||||
|
}
|
||||||
|
} catch (_e: unknown) {
|
||||||
|
message.error(t("ldap.operateFailed"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!form.value.name || !form.value.host || !form.value.base_dn || !form.value.admin_dn || !form.value.admin_password) {
|
||||||
|
message.warning(t("ldap.requiredFields"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const res = await saveLdapConfig(form.value);
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success(t("ldap.saveSuccess"));
|
||||||
|
modalVisible.value = false;
|
||||||
|
refreshKey.value++;
|
||||||
|
} else {
|
||||||
|
message.error(res.message || t("ldap.operateFailed"));
|
||||||
|
}
|
||||||
|
} catch (_e: unknown) {
|
||||||
|
message.error(t("ldap.operateFailed"));
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testing = ref(false);
|
||||||
|
|
||||||
|
const handleTestFormConnection = async () => {
|
||||||
|
if (!form.value.host || !form.value.base_dn || !form.value.admin_dn || !form.value.admin_password) {
|
||||||
|
message.warning("请先填写主机、Base DN、管理员 DN 和密码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
testing.value = true;
|
||||||
|
try {
|
||||||
|
const res = await testLdapConnection({
|
||||||
|
host: form.value.host!,
|
||||||
|
port: form.value.port!,
|
||||||
|
use_ssl: form.value.use_ssl!,
|
||||||
|
admin_dn: form.value.admin_dn!,
|
||||||
|
admin_password: form.value.admin_password!,
|
||||||
|
base_dn: form.value.base_dn!,
|
||||||
|
});
|
||||||
|
if (res.code === 200 && res.data?.success) {
|
||||||
|
message.success(t("ldap.testSuccess"));
|
||||||
|
} else {
|
||||||
|
message.error(res.data?.message || t("ldap.testFailed"));
|
||||||
|
}
|
||||||
|
} catch (_e: unknown) {
|
||||||
|
message.error(t("ldap.testFailed"));
|
||||||
|
} finally {
|
||||||
|
testing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestConnection = async (record: LdapConfig) => {
|
||||||
|
const hide = message.loading(t("ldap.testing"), 0);
|
||||||
|
try {
|
||||||
|
const res = await testLdapConnection({
|
||||||
|
host: record.host,
|
||||||
|
port: record.port,
|
||||||
|
use_ssl: record.use_ssl,
|
||||||
|
admin_dn: record.admin_dn,
|
||||||
|
admin_password: record.admin_password,
|
||||||
|
base_dn: record.base_dn,
|
||||||
|
});
|
||||||
|
hide();
|
||||||
|
if (res.code === 200 && res.data?.success) {
|
||||||
|
message.success(t("ldap.testSuccess"));
|
||||||
|
} else {
|
||||||
|
message.error(res.data?.message || res.message || t("ldap.testFailed"));
|
||||||
|
}
|
||||||
|
} catch (_e: unknown) {
|
||||||
|
hide();
|
||||||
|
message.error(t("ldap.testFailed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshKey = ref(0);
|
||||||
|
|
||||||
|
const doSyncDepts = async (record: LdapConfig) => {
|
||||||
|
const hide = message.loading(t("ldap.syncing"), 0);
|
||||||
|
try {
|
||||||
|
const res = await syncDepts(record.id!);
|
||||||
|
hide();
|
||||||
|
if (res.code === 200 && res.data?.success) {
|
||||||
|
message.success(t("ldap.syncDeptsResult", { success: res.data.created || 0, failed: 0 }));
|
||||||
|
refreshKey.value++;
|
||||||
|
} else {
|
||||||
|
message.error(res.data?.message || t("ldap.syncFailed"));
|
||||||
|
}
|
||||||
|
} catch (_e: unknown) {
|
||||||
|
hide();
|
||||||
|
message.error(t("ldap.syncFailed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doSyncUsers = async (record: LdapConfig) => {
|
||||||
|
const hide = message.loading(t("ldap.syncing"), 0);
|
||||||
|
try {
|
||||||
|
const res = await syncUsers(record.id!);
|
||||||
|
hide();
|
||||||
|
if (res.code === 200 && res.data?.success) {
|
||||||
|
message.success(t("ldap.syncUsersResult", { success: res.data.created || 0, failed: 0 }));
|
||||||
|
refreshKey.value++;
|
||||||
|
} else {
|
||||||
|
message.error(res.data?.message || t("ldap.syncFailed"));
|
||||||
|
}
|
||||||
|
} catch (_e: unknown) {
|
||||||
|
hide();
|
||||||
|
message.error(t("ldap.syncFailed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doFullSync = async (record: LdapConfig) => {
|
||||||
|
const hide = message.loading(t("ldap.syncing"), 0);
|
||||||
|
try {
|
||||||
|
const res = await fullSync(record.id!);
|
||||||
|
hide();
|
||||||
|
if (res.code === 200 && res.data?.success) {
|
||||||
|
const dr = (res.data as any)?.dept || {};
|
||||||
|
const ur = (res.data as any)?.user || {};
|
||||||
|
message.success(t("ldap.fullSyncResult", {
|
||||||
|
deptSuccess: dr.created || 0,
|
||||||
|
deptFailed: 0,
|
||||||
|
userSuccess: ur.created || 0,
|
||||||
|
userFailed: 0,
|
||||||
|
}));
|
||||||
|
refreshKey.value++;
|
||||||
|
} else {
|
||||||
|
message.error(res.data?.message || t("ldap.syncFailed"));
|
||||||
|
}
|
||||||
|
} catch (_e: unknown) {
|
||||||
|
hide();
|
||||||
|
message.error(t("ldap.syncFailed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncMenuProps = (record: LdapConfig) => ({
|
||||||
|
items: [
|
||||||
|
{ key: "syncDepts", label: t("ldap.syncDepts") },
|
||||||
|
{ key: "syncUsers", label: t("ldap.syncUsers") },
|
||||||
|
{ type: "divider" },
|
||||||
|
{ key: "fullSync", label: t("ldap.fullSync") },
|
||||||
|
],
|
||||||
|
onClick: ({ key }: { key: string }) => {
|
||||||
|
if (key === "syncDepts") {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t("ldap.syncDepts"),
|
||||||
|
content: t("ldap.confirmSyncDepts"),
|
||||||
|
onOk: () => doSyncDepts(record),
|
||||||
|
});
|
||||||
|
} else if (key === "syncUsers") {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t("ldap.syncUsers"),
|
||||||
|
content: t("ldap.confirmSyncUsers"),
|
||||||
|
onOk: () => doSyncUsers(record),
|
||||||
|
});
|
||||||
|
} else if (key === "fullSync") {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t("ldap.fullSync"),
|
||||||
|
content: t("ldap.confirmFullSync"),
|
||||||
|
onOk: () => doFullSync(record),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.page-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,410 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="page-container perm-page-container">
|
|
||||||
<ProSplitLayout :side-width="260">
|
|
||||||
<template #side>
|
|
||||||
<div class="role-side">
|
|
||||||
<div class="role-side-header">选择角色</div>
|
|
||||||
<a-input-search
|
|
||||||
v-model:value="roleSearch"
|
|
||||||
placeholder="搜索角色"
|
|
||||||
allow-clear
|
|
||||||
class="role-search"
|
|
||||||
/>
|
|
||||||
<div class="role-list">
|
|
||||||
<div
|
|
||||||
v-for="role in filteredRoles"
|
|
||||||
:key="role.id"
|
|
||||||
:class="['role-item', { active: selectedRole?.id === role.id }]"
|
|
||||||
@click="selectRole(role)"
|
|
||||||
>
|
|
||||||
<div class="role-item-name">{{ role.role_name }}</div>
|
|
||||||
<div class="role-item-code">{{ role.role_key }}</div>
|
|
||||||
</div>
|
|
||||||
<a-empty
|
|
||||||
v-if="!filteredRoles.length"
|
|
||||||
:image="null"
|
|
||||||
description="无匹配角色"
|
|
||||||
class="role-empty"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #main>
|
|
||||||
<div v-if="selectedRole" class="perm-main">
|
|
||||||
<div class="perm-header">
|
|
||||||
<div class="perm-header-left">
|
|
||||||
<h3>{{ selectedRole.role_name }}</h3>
|
|
||||||
<a-tag color="blue">{{ selectedRole.role_key }}</a-tag>
|
|
||||||
<span class="perm-count">已选 {{ checkedKeys.length }} 项权限</span>
|
|
||||||
</div>
|
|
||||||
<div class="perm-header-right">
|
|
||||||
<span class="perm-legend">
|
|
||||||
<FolderOutlined /><span>目录</span>
|
|
||||||
<FileOutlined /><span>菜单</span>
|
|
||||||
<CheckSquareOutlined /><span>按钮</span>
|
|
||||||
</span>
|
|
||||||
<a-button
|
|
||||||
type="primary"
|
|
||||||
:loading="saving"
|
|
||||||
:disabled="!dirty"
|
|
||||||
@click="handleSave"
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="perm-tree-wrapper">
|
|
||||||
<a-spin :spinning="loadingDetail" class="perm-tree-spin">
|
|
||||||
<a-tree
|
|
||||||
v-if="treeData.length"
|
|
||||||
v-model:checkedKeys="checkedKeys"
|
|
||||||
:tree-data="treeData"
|
|
||||||
:field-names="{ title: 'title', key: 'key', children: 'children' }"
|
|
||||||
checkable
|
|
||||||
default-expand-all
|
|
||||||
block-node
|
|
||||||
class="perm-tree"
|
|
||||||
>
|
|
||||||
<template #title="{ title, menu_type, perms }">
|
|
||||||
<span :class="['tree-node', `tree-node--${menu_type}`]">
|
|
||||||
<span class="tree-node-icon">
|
|
||||||
<FolderOutlined v-if="menu_type === 'M'" />
|
|
||||||
<FileOutlined v-else-if="menu_type === 'C'" />
|
|
||||||
<CheckSquareOutlined v-else />
|
|
||||||
</span>
|
|
||||||
<span class="tree-node-name">{{ title }}</span>
|
|
||||||
<span v-if="perms" class="tree-node-perms">{{ perms }}</span>
|
|
||||||
<span class="tree-node-type">{{ menu_type === 'M' ? '目录' : menu_type === 'C' ? '菜单' : '按钮' }}</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</a-tree>
|
|
||||||
<a-empty v-else description="暂无菜单数据" />
|
|
||||||
</a-spin>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="perm-placeholder">
|
|
||||||
<a-empty description="请从左侧选择角色" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ProSplitLayout>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
CheckSquareOutlined,
|
|
||||||
FileOutlined,
|
|
||||||
FolderOutlined,
|
|
||||||
} from "@antdv-next/icons";
|
|
||||||
import { message } from "antdv-next";
|
|
||||||
import { computed, onMounted, ref } from "vue";
|
|
||||||
|
|
||||||
import { getRoleAll } from "@/api/role";
|
|
||||||
import { request } from "@/utils/request";
|
|
||||||
import { getMenuTree } from "@/api/menu";
|
|
||||||
import ProSplitLayout from "@/components/Pro/ProSplitLayout/index.vue";
|
|
||||||
|
|
||||||
interface MenuItem {
|
|
||||||
id: string;
|
|
||||||
menu_name: string;
|
|
||||||
parent_id: string | null;
|
|
||||||
menu_type: "M" | "C" | "F";
|
|
||||||
order_num: number;
|
|
||||||
perms?: string;
|
|
||||||
children?: MenuItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoleItem {
|
|
||||||
id: string;
|
|
||||||
role_name: string;
|
|
||||||
role_key: string;
|
|
||||||
menu_ids: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TreeNode {
|
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
menu_type: string;
|
|
||||||
perms?: string;
|
|
||||||
children?: TreeNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadingDetail = ref(false);
|
|
||||||
const saving = ref(false);
|
|
||||||
const dirty = ref(false);
|
|
||||||
const roleSearch = ref("");
|
|
||||||
|
|
||||||
const roleList = ref<RoleItem[]>([]);
|
|
||||||
const selectedRole = ref<RoleItem | null>(null);
|
|
||||||
const checkedKeys = ref<string[]>([]);
|
|
||||||
const treeData = ref<TreeNode[]>([]);
|
|
||||||
|
|
||||||
const filteredRoles = computed(() => {
|
|
||||||
const kw = roleSearch.value.toLowerCase();
|
|
||||||
if (!kw) return roleList.value;
|
|
||||||
return roleList.value.filter(
|
|
||||||
(r) =>
|
|
||||||
r.role_name.toLowerCase().includes(kw) ||
|
|
||||||
r.role_key.toLowerCase().includes(kw),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function transformTree(items: MenuItem[]): TreeNode[] {
|
|
||||||
return items
|
|
||||||
.sort((a, b) => a.order_num - b.order_num)
|
|
||||||
.map((item) => ({
|
|
||||||
key: item.id,
|
|
||||||
title: item.menu_name,
|
|
||||||
menu_type: item.menu_type,
|
|
||||||
perms: item.perms || undefined,
|
|
||||||
children: item.children ? transformTree(item.children) : undefined,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRoles() {
|
|
||||||
const res: any = await getRoleAll();
|
|
||||||
if (res.code === 1) {
|
|
||||||
roleList.value = res.data || [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectRole(role: RoleItem) {
|
|
||||||
if (dirty.value && selectedRole.value && selectedRole.value.id !== role.id) {
|
|
||||||
message.warning("当前角色有未保存的更改,请先保存");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectedRole.value = role;
|
|
||||||
dirty.value = false;
|
|
||||||
loadingDetail.value = true;
|
|
||||||
try {
|
|
||||||
const res: any = await request.get("/sys/role/detail", {
|
|
||||||
params: { id: role.id },
|
|
||||||
});
|
|
||||||
if (res.code === 1 && res.data) {
|
|
||||||
checkedKeys.value = res.data.menu_ids || [];
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error("加载角色权限失败");
|
|
||||||
} finally {
|
|
||||||
loadingDetail.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (!selectedRole.value) return;
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
await request.post("/sys/role/save", {
|
|
||||||
id: selectedRole.value.id,
|
|
||||||
role_name: selectedRole.value.role_name,
|
|
||||||
role_key: selectedRole.value.role_key,
|
|
||||||
menu_ids: checkedKeys.value,
|
|
||||||
});
|
|
||||||
dirty.value = false;
|
|
||||||
message.success("保存成功");
|
|
||||||
} catch {
|
|
||||||
message.error("保存失败");
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const [treeRes] = await Promise.all([getMenuTree(), loadRoles()]);
|
|
||||||
if ((treeRes as any).code === 1) {
|
|
||||||
treeData.value = transformTree((treeRes as any).data || []);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
/* 权限管理页面专用:修复ProSplitLayout overflow:hidden导致滚动条被裁剪 */
|
|
||||||
.perm-page-container .pro-split-main {
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.role-side {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
.role-side-header {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 12px 16px 8px;
|
|
||||||
color: var(--ant-text-color, #333);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-search {
|
|
||||||
padding: 0 12px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 10px 12px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--ant-primary-1, #e6f7ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: var(--ant-primary-color, #1890ff);
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
.role-item-code {
|
|
||||||
color: rgba(255, 255, 255, 0.75);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-item-name {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-item-code {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--ant-text-color-secondary, #999);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-empty {
|
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.perm-main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
.perm-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 0 12px;
|
|
||||||
border-bottom: 1px solid var(--ant-border-color, #f0f0f0);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perm-header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perm-count {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ant-text-color-secondary, #999);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.perm-header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perm-legend {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ant-text-color-secondary, #999);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.anticon {
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.perm-tree-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perm-tree-spin {
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
:deep(.ant-spin-container),
|
|
||||||
:deep(.ant-spin-nested-loading) {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.perm-tree {
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-node {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
|
|
||||||
.tree-node-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-node--M & {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-node--C & {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-node-name {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-node-perms {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--ant-text-color-secondary, #999);
|
|
||||||
font-family: monospace;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-node-type {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--ant-text-color-tertiary, #bbb);
|
|
||||||
margin-left: 4px;
|
|
||||||
padding: 0 4px;
|
|
||||||
border: 1px solid var(--ant-border-color, #f0f0f0);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.perm-placeholder {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -46,7 +46,7 @@ import { message, Modal } from "antdv-next";
|
|||||||
import { computed, markRaw, onMounted, ref } from "vue";
|
import { computed, markRaw, onMounted, ref } from "vue";
|
||||||
|
|
||||||
import { getPermissionTree } from "@/api/permission";
|
import { getPermissionTree } from "@/api/permission";
|
||||||
import { createRole, deleteRole, getRoleList, updateRole } from "@/api/role";
|
import { createRole, deleteRole, getRoleById, getRoleList, updateRole } from "@/api/role";
|
||||||
import ProForm from "@/components/Pro/ProForm/index.vue";
|
import ProForm from "@/components/Pro/ProForm/index.vue";
|
||||||
import ProTable from "@/components/Pro/ProTable/index.vue";
|
import ProTable from "@/components/Pro/ProTable/index.vue";
|
||||||
import { $t } from "@/locales";
|
import { $t } from "@/locales";
|
||||||
@ -101,23 +101,6 @@ const permissionOptions = computed<PermissionOption[]>(() => {
|
|||||||
return buildOptions(permissionTree.value);
|
return buildOptions(permissionTree.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const permissionMap = computed(() => {
|
|
||||||
const map = new Map<string, Permission>();
|
|
||||||
const traverse = (nodes: Permission[]) => {
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
map.set(node.id, {
|
|
||||||
...node,
|
|
||||||
children: node.children ? [...node.children] : undefined,
|
|
||||||
});
|
|
||||||
if (node.children && node.children.length > 0) {
|
|
||||||
traverse(node.children);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
traverse(permissionTree.value);
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns = computed<ProTableColumn[]>(() => [
|
const columns = computed<ProTableColumn[]>(() => [
|
||||||
{
|
{
|
||||||
title: $t("role.name"),
|
title: $t("role.name"),
|
||||||
@ -208,7 +191,7 @@ const formItems = computed<ProFormItem[]>(() => [
|
|||||||
props: {
|
props: {
|
||||||
treeCheckable: true,
|
treeCheckable: true,
|
||||||
allowClear: true,
|
allowClear: true,
|
||||||
treeDefaultExpandAll: true,
|
treeDefaultExpandAll: false,
|
||||||
showCheckedStrategy: "SHOW_PARENT",
|
showCheckedStrategy: "SHOW_PARENT",
|
||||||
maxTagCount: 2,
|
maxTagCount: 2,
|
||||||
},
|
},
|
||||||
@ -240,13 +223,8 @@ const fetchTableData = async (params: Record<string, unknown>) => {
|
|||||||
code: (params.code as string)?.trim() || undefined,
|
code: (params.code as string)?.trim() || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const list = response.data.list.map((item) => ({
|
|
||||||
...item,
|
|
||||||
permissionCount: item.permissions?.length || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: list,
|
data: response.data.list,
|
||||||
total: response.data.total,
|
total: response.data.total,
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
@ -266,15 +244,15 @@ const handleCreate = () => {
|
|||||||
modalVisible.value = true;
|
modalVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (record: Role) => {
|
const handleEdit = async (record: Role) => {
|
||||||
editingRoleId.value = record.id;
|
editingRoleId.value = record.id;
|
||||||
|
const detailRes = await getRoleById(record.id);
|
||||||
|
const menuIds: string[] = (detailRes.data?.menu_ids || []) as string[];
|
||||||
formData.value = {
|
formData.value = {
|
||||||
name: record.name,
|
name: record.name,
|
||||||
code: record.code,
|
code: record.code,
|
||||||
description: record.description || "",
|
description: record.description || "",
|
||||||
permissionIds: (record.permissions || []).map(
|
permissionIds: menuIds,
|
||||||
(permission) => permission.id,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
modalVisible.value = true;
|
modalVisible.value = true;
|
||||||
};
|
};
|
||||||
@ -308,21 +286,17 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
const permissionIds: string[] = values.permissionIds || [];
|
const permissionIds: string[] = values.permissionIds || [];
|
||||||
|
|
||||||
const selectedPermissions = permissionIds
|
|
||||||
.map((id) => permissionMap.value.get(id))
|
|
||||||
.filter((permission): permission is Permission => Boolean(permission));
|
|
||||||
|
|
||||||
const payload: Partial<Role> = {
|
const payload: Partial<Role> = {
|
||||||
name: values.name?.trim(),
|
name: values.name?.trim(),
|
||||||
code: values.code?.trim(),
|
code: values.code?.trim(),
|
||||||
description: values.description?.trim(),
|
description: values.description?.trim(),
|
||||||
permissions: selectedPermissions,
|
menu_ids: permissionIds,
|
||||||
};
|
};
|
||||||
|
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
if (editingRoleId.value) {
|
if (editingRoleId.value) {
|
||||||
await updateRole(editingRoleId.value, payload);
|
await updateRole({ ...payload, id: editingRoleId.value });
|
||||||
message.success($t("role.updateSuccess"));
|
message.success($t("role.updateSuccess"));
|
||||||
refreshTable();
|
refreshTable();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -68,7 +68,7 @@ import { message, Modal } from "antdv-next";
|
|||||||
import { computed, markRaw, onMounted, ref } from "vue";
|
import { computed, markRaw, onMounted, ref } from "vue";
|
||||||
|
|
||||||
import { getRoleList } from "@/api/role";
|
import { getRoleList } from "@/api/role";
|
||||||
import { createUser, deleteUser, getUserList, updateUser } from "@/api/user";
|
import { assignRoles, createUser, deleteUser, getUserList, updateUser } from "@/api/user";
|
||||||
import ProForm from "@/components/Pro/ProForm/index.vue";
|
import ProForm from "@/components/Pro/ProForm/index.vue";
|
||||||
import ProTable from "@/components/Pro/ProTable/index.vue";
|
import ProTable from "@/components/Pro/ProTable/index.vue";
|
||||||
import { $t } from "@/locales";
|
import { $t } from "@/locales";
|
||||||
@ -407,12 +407,21 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
|
// 提取角色 ID,保存用户信息时由 mapUserToBackend 自动过滤掉 roles
|
||||||
|
const roleIds = (values.roleIds || []) as string[];
|
||||||
|
const savePayload = { ...payload, roles: selectedRoles };
|
||||||
|
|
||||||
if (editingUserId.value) {
|
if (editingUserId.value) {
|
||||||
await updateUser(editingUserId.value, payload);
|
await updateUser({ ...savePayload, id: editingUserId.value });
|
||||||
|
await assignRoles({ user_id: editingUserId.value, role_ids: roleIds });
|
||||||
message.success($t("user.updateSuccess"));
|
message.success($t("user.updateSuccess"));
|
||||||
refreshTable();
|
refreshTable();
|
||||||
} else {
|
} else {
|
||||||
await createUser(payload);
|
const res: any = await createUser({ ...savePayload, password: "123456" });
|
||||||
|
const newUserId = res?.data?.id;
|
||||||
|
if (newUserId && roleIds.length > 0) {
|
||||||
|
await assignRoles({ user_id: newUserId, role_ids: roleIds });
|
||||||
|
}
|
||||||
message.success($t("user.createSuccess"));
|
message.success($t("user.createSuccess"));
|
||||||
reloadTable();
|
reloadTable();
|
||||||
}
|
}
|
||||||
@ -504,6 +513,7 @@ const handleImport = async (file: File) => {
|
|||||||
realName: row[realNameIdx] || "",
|
realName: row[realNameIdx] || "",
|
||||||
email: row[emailIdx] || "",
|
email: row[emailIdx] || "",
|
||||||
status: "active",
|
status: "active",
|
||||||
|
password: "123456",
|
||||||
});
|
});
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
/** @type {import('cz-git').UserConfig} */
|
|
||||||
export default {
|
|
||||||
rules: {
|
|
||||||
// @see: https://commitlint.js.org/#/reference-rules
|
|
||||||
},
|
|
||||||
prompt: {
|
|
||||||
alias: { fd: 'docs: fix typos' },
|
|
||||||
messages: {
|
|
||||||
type: '选择你要提交的类型 :',
|
|
||||||
scope: '选择一个提交范围(可选):',
|
|
||||||
customScope: '请输入自定义的提交范围 :',
|
|
||||||
subject: '填写简短精炼的变更描述 :\n',
|
|
||||||
body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n',
|
|
||||||
breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n',
|
|
||||||
footerPrefixsSelect: '选择关联issue前缀(可选):',
|
|
||||||
customFooterPrefixs: '输入自定义issue前缀 :',
|
|
||||||
footer: '列举关联issue (可选) 例如: #31, #I3244 :\n',
|
|
||||||
confirmCommit: '是否提交或修改commit ?',
|
|
||||||
},
|
|
||||||
types: [
|
|
||||||
{ value: 'feat', name: 'feat: ✨ 新增功能 | A new feature', emoji: ':sparkles:' },
|
|
||||||
{ value: 'fix', name: 'fix: 🐛 修复缺陷 | A bug fix', emoji: ':bug:' },
|
|
||||||
{ value: 'docs', name: 'docs: 📝 文档更新 | Documentation only changes', emoji: ':memo:' },
|
|
||||||
{ value: 'style', name: 'style: 💄 代码格式 | Changes that do not affect the meaning of the code', emoji: ':lipstick:' },
|
|
||||||
{ value: 'refactor', name: 'refactor: ♻️ 代码重构 | A code change that neither fixes a bug nor adds a feature', emoji: ':recycle:' },
|
|
||||||
{ value: 'perf', name: 'perf: ⚡️ 性能提升 | A code change that improves performance', emoji: ':zap:' },
|
|
||||||
{ value: 'test', name: 'test: ✅ 测试相关 | Adding missing tests or correcting existing tests', emoji: ':white_check_mark:' },
|
|
||||||
{ value: 'build', name: 'build: 📦️ 构建相关 | Changes that affect the build system or external dependencies', emoji: ':package:' },
|
|
||||||
{ value: 'ci', name: 'ci: 🎡 持续集成 | Changes to our CI configuration files and scripts', emoji: ':ferris_wheel:' },
|
|
||||||
{ value: 'revert', name: 'revert: ⏪️ 回退代码 | Revert to a commit', emoji: ':rewind:' },
|
|
||||||
{ value: 'chore', name: 'chore: 🔨 其他修改 | Other changes that do not modify src or test files', emoji: ':hammer:' },
|
|
||||||
],
|
|
||||||
useEmoji: false,
|
|
||||||
emojiAlign: 'center',
|
|
||||||
themeColorCode: '',
|
|
||||||
scopes: [],
|
|
||||||
allowCustomScopes: true,
|
|
||||||
allowEmptyScopes: true,
|
|
||||||
customScopesAlign: 'bottom',
|
|
||||||
customScopesAlias: 'custom',
|
|
||||||
emptyScopesAlias: 'empty',
|
|
||||||
upperCaseSubject: false,
|
|
||||||
markBreakingChangeMode: true,
|
|
||||||
allowBreakingChanges: ['feat', 'fix'],
|
|
||||||
breaklineNumber: 100,
|
|
||||||
breaklineChar: '|',
|
|
||||||
skipQuestions: [],
|
|
||||||
issuePrefixs: [
|
|
||||||
// 如果使用 gitee 作为开发管理
|
|
||||||
{ value: 'link', name: 'link: 链接 ISSUES 进行中' },
|
|
||||||
{ value: 'closed', name: 'closed: 标记 ISSUES 已完成' },
|
|
||||||
],
|
|
||||||
customIssuePrefixsAlign: 'top',
|
|
||||||
emptyIssuePrefixsAlias: 'skip',
|
|
||||||
customIssuePrefixsAlias: 'custom',
|
|
||||||
allowCustomIssuePrefixs: true,
|
|
||||||
allowEmptyIssuePrefixs: true,
|
|
||||||
confirmColorize: true,
|
|
||||||
maxHeaderLength: Number.POSITIVE_INFINITY,
|
|
||||||
maxSubjectLength: Number.POSITIVE_INFINITY,
|
|
||||||
minSubjectLength: 0,
|
|
||||||
scopeOverrides: undefined,
|
|
||||||
defaultBody: '',
|
|
||||||
defaultIssues: '',
|
|
||||||
defaultScope: '',
|
|
||||||
defaultSubject: '',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
7
fantastic-admin/.gitignore
vendored
7
fantastic-admin/.gitignore
vendored
@ -1,7 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.DS_Store
|
|
||||||
dist*
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
.eslintcache
|
|
||||||
.stylelintcache
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"*.{ts,tsx,vue}": "eslint --cache --fix",
|
|
||||||
"*.{css,scss,vue}": "stylelint --cache --fix"
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
24
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
# Fantastic-admin
|
|
||||||
|
|
||||||
## 项目概述
|
|
||||||
|
|
||||||
Fantastic-admin 是一个基于 Vue 3 的后台管理框架。
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- **框架**: Vue 3.6 + TypeScript + Vite 8
|
|
||||||
- **UI 组件**: Element Plus(默认,可替换) + Reka UI + UnoCSS(原子化 CSS)
|
|
||||||
- **状态管理**: Pinia + pinia-plugin-persistedstate
|
|
||||||
- **路由**: Vue Router 5
|
|
||||||
- **包管理器**: pnpm(必须使用 pnpm,禁止使用 npm/yarn)
|
|
||||||
- **HTTP**: Axios
|
|
||||||
- **Mock**: vite-plugin-fake-server
|
|
||||||
|
|
||||||
## 目录结构
|
|
||||||
|
|
||||||
采用 pnpm monorepo 架构:
|
|
||||||
|
|
||||||
```
|
|
||||||
├── apps/
|
|
||||||
│ └── <app>/ # 应用
|
|
||||||
│ └── src/
|
|
||||||
│ ├── api/ # API 请求模块
|
|
||||||
│ ├── assets/ # 静态资源
|
|
||||||
│ ├── components/ # 全局业务组件
|
|
||||||
│ ├── composables/ # 组合式函数
|
|
||||||
│ ├── layouts/ # 布局组件
|
|
||||||
│ ├── router/ # 路由配置
|
|
||||||
│ ├── slots/ # 插槽组件
|
|
||||||
│ ├── store/ # Pinia store
|
|
||||||
│ ├── types/ # TypeScript 类型定义
|
|
||||||
│ ├── ui/ # 框架内建 UI 组件(本地)
|
|
||||||
│ ├── utils/ # 工具函数
|
|
||||||
│ └── views/ # 页面视图
|
|
||||||
├── packages/
|
|
||||||
│ ├── components/ # 框架内建 UI 组件库(子包,跨应用共享)
|
|
||||||
│ ├── settings/ # 框架设置(子包,跨应用共享)
|
|
||||||
│ └── themes/ # 主题配置(子包,跨应用共享)
|
|
||||||
├── scripts/ # 工程脚本
|
|
||||||
└── pnpm-workspace.yaml # monorepo workspace 配置
|
|
||||||
```
|
|
||||||
|
|
||||||
## 常用命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm dev # 启动开发服务器
|
|
||||||
pnpm build # 构建
|
|
||||||
pnpm lint # 运行全量 lint(tsc + eslint + stylelint)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 开发规范
|
|
||||||
|
|
||||||
- 使用 `<script setup lang="ts">` 语法
|
|
||||||
- 样式优先使用 UnoCSS 原子类,复杂样式用 SCSS
|
|
||||||
- 组件命名使用 PascalCase,文件名与组件名一致
|
|
||||||
- API 模块放在 `apps/<app>/src/api/modules/`,按业务模块拆分
|
|
||||||
- Store 模块放在 `apps/<app>/src/store/modules/`,使用 Pinia composition API 风格
|
|
||||||
- 路由配置在 `apps/<app>/src/router/`,meta 属性可借助 fa-route-generator skill
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
- 框架内建组件在 `packages/components/` 子包中,优先使用内建组件而非第三方组件或自定义实现
|
|
||||||
- 在任何情况下都请勿直接修改内建组件,确定修改前需要和用户进行确认
|
|
||||||
- Mock 数据使用 `vite-plugin-fake-server`,文件放在 `apps/<app>/src/api/modules/` 对应模块旁
|
|
||||||
- 代码提交前会自动运行 lint-staged,确保代码符合规范
|
|
||||||
- Node.js 版本要求以根目录下 `package.json` 中定义的为准
|
|
||||||
|
|
||||||
## 反复修改检测
|
|
||||||
|
|
||||||
在使用任何 fa-* 系列技能时,如果用户针对同一功能点已经要求修改 3 次及以上仍未达到预期(例如连续说"不对"、"再改改"、"还是不行"),必须触发 fa-feedback 技能,询问用户是否将问题反馈给框架作者。
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2020 fantastic-admin
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
[中文](./readme.md) | **English**
|
|
||||||
<a href="https://fantastic-admin.hurui.me" target="_blank"><img src="https://fantastic-admin.hurui.me/logo.svg" align="right" height="80" alt="logo" /></a>
|
|
||||||
|
|
||||||
# Fantastic-admin
|
|
||||||
|
|
||||||
An **AI-oriented** admin system framework
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://fantastic-admin.hurui.me" target="_blank">Official Website</a>
|
|
||||||
<span> | </span>
|
|
||||||
<a href="https://fantastic-admin.pages.dev" target="_blank">Backup URL</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="###"><img src="https://img.shields.io/github/license/fantastic-admin/basic?label=License&style=flat-square" alt="" /></a>
|
|
||||||
<a href="https://github.com/fantastic-admin/basic/releases" target="_blank"><img src="https://img.shields.io/github/v/release/fantastic-admin/basic?label=Current%20Version&style=flat-square" alt="" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
> Some features are available in the Professional Edition
|
|
||||||
|
|
||||||
- AI-friendly engineering foundation with multiple built-in Skills
|
|
||||||
- First-class tech stack: Vue 3.6 / Vite 8 / Pinia / UnoCSS / VueUse / TypeScript / ESLint / Stylelint / ...
|
|
||||||
- Freely choose your preferred UI library, with Element Plus by default
|
|
||||||
- 8 built-in theme schemes with extensibility for distinct industry brand styles
|
|
||||||
- 7 navigation menu modes to match products at different growth stages
|
|
||||||
- Fine-grained and controllable page keep-alive strategy
|
|
||||||
- Comprehensive permission validation
|
|
||||||
- Internationalization and RTL support
|
|
||||||
- 19 reserved slots for flexible product extensions
|
|
||||||
|
|
||||||
## Download
|
|
||||||
|
|
||||||
> This repository is the Basic Edition
|
|
||||||
|
|
||||||
Pulling the source code directly may include unreleased content. It is recommended to download the stable release package from the [Github Releases](https://github.com/fantastic-admin/basic/releases) page.
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
If you find Fantastic-admin useful, or you are already using it, please consider giving it a star on **Github** / **Gitee** / **GitCode**. This is a significant help for the project's visibility.
|
|
||||||
|
|
||||||
[](https://github.com/fantastic-admin/basic)
|
|
||||||
|
|
||||||
[](https://gitee.com/fantastic-admin/basic)
|
|
||||||
|
|
||||||
[](https://atomgit.com/fantastic-admin/basic)
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Github Stars Chart</summary>
|
|
||||||
|
|
||||||
[](https://starchart.cc/fantastic-admin/basic)
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Ecosystem
|
|
||||||
|
|
||||||
- [`Fantastic-startkit`](https://hooray.github.io/fantastic-startkit/) - A simple and easy-to-use Vue 3 project starter kit
|
|
||||||
- [`Fantastic-mobile`](https://fantastic-mobile.hurui.me/) - Let your H5 project have a solid engineering base
|
|
||||||
- [`One-step-admin`](https://one-step-admin.hurui.me) - A Vue admin framework built to help you move faster than the rest
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
**中文** | [English](./README.EN.md)
|
|
||||||
<a href="https://fantastic-admin.hurui.me" target="_blank"><img src="https://fantastic-admin.hurui.me/logo.svg" align="right" height="80" alt="logo" /></a>
|
|
||||||
|
|
||||||
# Fantastic-admin
|
|
||||||
|
|
||||||
面向 **AI** 的管理系统框架
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://fantastic-admin.hurui.me" target="_blank">官网</a>
|
|
||||||
<span> | </span>
|
|
||||||
<a href="https://fantastic-admin.pages.dev" target="_blank">备用地址</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="###"><img src="https://img.shields.io/github/license/fantastic-admin/basic?label=%E5%BC%80%E6%BA%90%E5%8D%8F%E8%AE%AE&style=flat-square" alt="" /></a>
|
|
||||||
<a href="https://github.com/fantastic-admin/basic/releases" target="_blank"><img src="https://img.shields.io/github/v/release/fantastic-admin/basic?label=%E5%BD%93%E5%89%8D%E7%89%88%E6%9C%AC&style=flat-square" alt="" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 特点
|
|
||||||
|
|
||||||
> 部分为专业版能力
|
|
||||||
|
|
||||||
- AI 友好的工程底座,内置多个 Skills
|
|
||||||
- 一流的技术栈:Vue 3.6 / Vite 8 / Pinia / UnoCSS / VueUse / TypeScript / ESLint / Stylelint / ...
|
|
||||||
- 自由选择喜爱的 UI 库,默认 Element Plus
|
|
||||||
- 8 套默认主题方案且可扩展,给不同行业提供专属品牌气质
|
|
||||||
- 7 款导航菜单模式,匹配产品发展的各个阶段
|
|
||||||
- 精细可控的页面保活策略
|
|
||||||
- 全方位权限验证
|
|
||||||
- 国际化、RTL支持
|
|
||||||
- 19 处预留插槽,灵活扩展产品内容
|
|
||||||
|
|
||||||
## 下载
|
|
||||||
|
|
||||||
> 本仓库为基础版
|
|
||||||
|
|
||||||
直接拉取源码可能会包含未发布的内容,推荐去 [Github Releases](https://github.com/fantastic-admin/basic/releases) 页面下载稳定版本的压缩包。
|
|
||||||
|
|
||||||
## 支持
|
|
||||||
|
|
||||||
如果觉得 Fantastic-admin 这个框架不错,或者已经在使用了,希望你可以在 **Github** / **Gitee** / **GitCode** 帮我点个 ⭐ ,这将对本产品的推广有极大帮助。
|
|
||||||
|
|
||||||
[](https://github.com/fantastic-admin/basic)
|
|
||||||
|
|
||||||
[](https://gitee.com/fantastic-admin/basic)
|
|
||||||
|
|
||||||
[](https://atomgit.com/fantastic-admin/basic)
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Github Stars 曲线</summary>
|
|
||||||
|
|
||||||
[](https://starchart.cc/fantastic-admin/basic)
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## 生态
|
|
||||||
|
|
||||||
- [`Fantastic-startkit`](https://hooray.github.io/fantastic-startkit/) - 简单好用的 Vue3 项目启动套件
|
|
||||||
- [`Fantastic-mobile`](https://fantastic-mobile.hurui.me/) - 让你的 H5 项目拥有稳固的工程底座
|
|
||||||
- [`One-step-admin`](https://one-step-admin.hurui.me) - 干啥都快人一步的 Vue 中后台管理系统框架
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
# 应用配置面板
|
|
||||||
# Application configuration panel
|
|
||||||
VITE_APP_SETTING = true
|
|
||||||
|
|
||||||
# 网站标题
|
|
||||||
# Website title
|
|
||||||
VITE_APP_TITLE = Fantastic-admin
|
|
||||||
|
|
||||||
# 网络请求地址,应用于 axios 的 baseURL
|
|
||||||
# Network request address, applied to axios's baseURL
|
|
||||||
VITE_APP_API_BASEURL = /
|
|
||||||
|
|
||||||
# localStorage/sessionStorage 前缀
|
|
||||||
# localStorage/sessionStorage prefix
|
|
||||||
VITE_APP_STORAGE_PREFIX = fa_dev_
|
|
||||||
|
|
||||||
# 调试工具,可设置 eruda 或 vconsole
|
|
||||||
# Debugging tool, can set eruda or vconsole
|
|
||||||
VITE_APP_DEBUG_TOOL =
|
|
||||||
|
|
||||||
# ===== 以下配置仅在开发环境生效 =====
|
|
||||||
# ===== The following configuration is only effective in the development environment. =====
|
|
||||||
|
|
||||||
# 启用代理
|
|
||||||
# Enable proxy
|
|
||||||
VITE_ENABLE_PROXY = false
|
|
||||||
|
|
||||||
# 启用 Vue 开发工具
|
|
||||||
# Enable Vue DevTools
|
|
||||||
VITE_ENABLE_VUE_DEVTOOLS = false
|
|
||||||
|
|
||||||
# 启用 turbo console
|
|
||||||
# Enable turbo console
|
|
||||||
VITE_ENABLE_TURBO_CONSOLE = false
|
|
||||||
|
|
||||||
# 启动编辑器,用于 vite-plugin-vue-devtools 和 unplugin-turbo-console
|
|
||||||
# 支持的编辑器 https://github.com/yyx990803/launch-editor#supported-editors
|
|
||||||
# Launch the editor for vite-plugin-vue-devtools and unplugin-turbo-console
|
|
||||||
# Supported editors https://github.com/yyx990803/launch-editor#supported-editors
|
|
||||||
VITE_LAUNCH_EDITOR = code
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
# 应用配置面板
|
|
||||||
# Application configuration panel
|
|
||||||
VITE_APP_SETTING = false
|
|
||||||
|
|
||||||
# 网站标题
|
|
||||||
# Website title
|
|
||||||
VITE_APP_TITLE = Fantastic-admin
|
|
||||||
|
|
||||||
# 网络请求地址,应用于 axios 的 baseURL
|
|
||||||
# Network request address, applied to axios's baseURL
|
|
||||||
VITE_APP_API_BASEURL = /
|
|
||||||
|
|
||||||
# localStorage/sessionStorage 前缀
|
|
||||||
# localStorage/sessionStorage prefix
|
|
||||||
VITE_APP_STORAGE_PREFIX = fa_
|
|
||||||
|
|
||||||
# 调试工具,可设置 eruda 或 vconsole
|
|
||||||
# Debugging tool, can set eruda or vconsole
|
|
||||||
VITE_APP_DEBUG_TOOL =
|
|
||||||
|
|
||||||
# ===== 以下配置仅在生产环境生效 =====
|
|
||||||
# ===== The following configuration is only effective in the production environment. =====
|
|
||||||
|
|
||||||
# 启用假数据
|
|
||||||
# Enable build fake data
|
|
||||||
VITE_BUILD_FAKE = false
|
|
||||||
|
|
||||||
# 启用 sourcemap
|
|
||||||
# Enable build sourcemap
|
|
||||||
VITE_BUILD_SOURCEMAP = false
|
|
||||||
|
|
||||||
# 压缩方式,支持 gzip 和 brotli
|
|
||||||
# Build compression method, supports gzip and brotli
|
|
||||||
VITE_BUILD_COMPRESS = gzip,brotli
|
|
||||||
|
|
||||||
# 构建后生成存档,支持 zip 和 tar
|
|
||||||
# Generate archive after build, supports zip and tar
|
|
||||||
VITE_BUILD_ARCHIVE =
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
# 应用配置面板
|
|
||||||
# Application configuration panel
|
|
||||||
VITE_APP_SETTING = false
|
|
||||||
|
|
||||||
# 网站标题
|
|
||||||
# Website title
|
|
||||||
VITE_APP_TITLE = Fantastic-admin
|
|
||||||
|
|
||||||
# 网络请求地址,应用于 axios 的 baseURL
|
|
||||||
# Network request address, applied to axios's baseURL
|
|
||||||
VITE_APP_API_BASEURL = /
|
|
||||||
|
|
||||||
# localStorage/sessionStorage 前缀
|
|
||||||
# localStorage/sessionStorage prefix
|
|
||||||
VITE_APP_STORAGE_PREFIX = fa_test_
|
|
||||||
|
|
||||||
# 调试工具,可设置 eruda 或 vconsole
|
|
||||||
# Debugging tool, can set eruda or vconsole
|
|
||||||
VITE_APP_DEBUG_TOOL =
|
|
||||||
|
|
||||||
# ===== 以下配置仅在测试环境生效 =====
|
|
||||||
# ===== The following configuration is only effective in the test environment. =====
|
|
||||||
|
|
||||||
# 启用假数据
|
|
||||||
# Enable build fake data
|
|
||||||
VITE_BUILD_FAKE = true
|
|
||||||
|
|
||||||
# 启用 sourcemap
|
|
||||||
# Enable build sourcemap
|
|
||||||
VITE_BUILD_SOURCEMAP = true
|
|
||||||
|
|
||||||
# 压缩方式,支持 gzip 和 brotli
|
|
||||||
# Build compression method, supports gzip and brotli
|
|
||||||
VITE_BUILD_COMPRESS =
|
|
||||||
|
|
||||||
# 构建后生成存档,支持 zip 和 tar
|
|
||||||
# Generate archive after build, supports zip and tar
|
|
||||||
VITE_BUILD_ARCHIVE =
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://shadcn-vue.com/schema.json",
|
|
||||||
"style": "new-york-v4",
|
|
||||||
"typescript": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "tailwind.config.js",
|
|
||||||
"css": "src/assets/index.css",
|
|
||||||
"baseColor": "neutral",
|
|
||||||
"cssVariables": true
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/ui/shadcn",
|
|
||||||
"utils": "@/utils",
|
|
||||||
"lib": "@/utils"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" href="/favicon.svg" />
|
|
||||||
<link rel="stylesheet" href="/browser_upgrade/index.css" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0, viewport-fit=cover"/>
|
|
||||||
<meta http-equiv="Expires" content="0">
|
|
||||||
<meta http-equiv="Pragma" content="no-cache">
|
|
||||||
<meta http-equiv="Cache-control" content="no-cache">
|
|
||||||
<meta http-equiv="Cache" content="no-cache">
|
|
||||||
<title>%VITE_APP_TITLE%</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app">
|
|
||||||
<div id="browser-upgrade">
|
|
||||||
<div class="title">为了您的体验,推荐使用以下浏览器</div>
|
|
||||||
<div class="browsers">
|
|
||||||
<a href="https://www.microsoft.com/edge" target="_blank" class="browser">
|
|
||||||
<img class="browser-icon" src="/browser_upgrade/edge.png" />
|
|
||||||
<div class="browser-name">Microsoft Edge</div>
|
|
||||||
</a>
|
|
||||||
<a href="https://www.google.cn/chrome/" target="_blank" class="browser">
|
|
||||||
<img class="browser-icon" src="/browser_upgrade/chrome.png" />
|
|
||||||
<div class="browser-name">Google Chrome</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
if (!!window.ActiveXObject || 'ActiveXObject' in window) {
|
|
||||||
document.getElementById('browser-upgrade').style.display = 'block'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,216 +0,0 @@
|
|||||||
<style>
|
|
||||||
@keyframes rainbow {
|
|
||||||
0% { --rainbow-color: #00a98e; }
|
|
||||||
1.25% { --rainbow-color: #00a996; }
|
|
||||||
2.5% { --rainbow-color: #00a99f; }
|
|
||||||
3.75% { --rainbow-color: #00a9a7; }
|
|
||||||
5% { --rainbow-color: #00a9b0; }
|
|
||||||
6.25% { --rainbow-color: #00a9b8; }
|
|
||||||
7.5% { --rainbow-color: #00a9c0; }
|
|
||||||
8.75% { --rainbow-color: #00a8c7; }
|
|
||||||
10% { --rainbow-color: #00a8cf; }
|
|
||||||
11.25% { --rainbow-color: #00a7d5; }
|
|
||||||
12.5% { --rainbow-color: #00a6dc; }
|
|
||||||
13.75% { --rainbow-color: #00a6e2; }
|
|
||||||
15% { --rainbow-color: #00a4e7; }
|
|
||||||
16.25% { --rainbow-color: #00a3ec; }
|
|
||||||
17.5% { --rainbow-color: #00a2f1; }
|
|
||||||
18.75% { --rainbow-color: #00a0f4; }
|
|
||||||
20% { --rainbow-color: #009ff7; }
|
|
||||||
21.25% { --rainbow-color: #009dfa; }
|
|
||||||
22.5% { --rainbow-color: #009bfc; }
|
|
||||||
23.75% { --rainbow-color: #0098fd; }
|
|
||||||
25% { --rainbow-color: #0096fd; }
|
|
||||||
26.25% { --rainbow-color: #0093fd; }
|
|
||||||
27.5% { --rainbow-color: #2e90fc; }
|
|
||||||
28.75% { --rainbow-color: #4d8dfa; }
|
|
||||||
30% { --rainbow-color: #638af8; }
|
|
||||||
31.25% { --rainbow-color: #7587f5; }
|
|
||||||
32.5% { --rainbow-color: #8583f1; }
|
|
||||||
33.75% { --rainbow-color: #9280ed; }
|
|
||||||
35% { --rainbow-color: #9f7ce9; }
|
|
||||||
36.25% { --rainbow-color: #aa78e3; }
|
|
||||||
37.5% { --rainbow-color: #b574dd; }
|
|
||||||
38.75% { --rainbow-color: #be71d7; }
|
|
||||||
40% { --rainbow-color: #c76dd1; }
|
|
||||||
41.25% { --rainbow-color: #cf69c9; }
|
|
||||||
42.5% { --rainbow-color: #d566c2; }
|
|
||||||
43.75% { --rainbow-color: #dc63ba; }
|
|
||||||
45% { --rainbow-color: #e160b3; }
|
|
||||||
46.25% { --rainbow-color: #e65eab; }
|
|
||||||
47.5% { --rainbow-color: #e95ca2; }
|
|
||||||
48.75% { --rainbow-color: #ed5a9a; }
|
|
||||||
50% { --rainbow-color: #ef5992; }
|
|
||||||
51.25% { --rainbow-color: #f15989; }
|
|
||||||
52.5% { --rainbow-color: #f25981; }
|
|
||||||
53.75% { --rainbow-color: #f25a79; }
|
|
||||||
55% { --rainbow-color: #f25c71; }
|
|
||||||
56.25% { --rainbow-color: #f15e69; }
|
|
||||||
57.5% { --rainbow-color: #ef6061; }
|
|
||||||
58.75% { --rainbow-color: #ed635a; }
|
|
||||||
60% { --rainbow-color: #eb6552; }
|
|
||||||
61.25% { --rainbow-color: #e8694b; }
|
|
||||||
62.5% { --rainbow-color: #e46c44; }
|
|
||||||
63.75% { --rainbow-color: #e06f3d; }
|
|
||||||
65% { --rainbow-color: #db7336; }
|
|
||||||
66.25% { --rainbow-color: #d77630; }
|
|
||||||
67.5% { --rainbow-color: #d17a2a; }
|
|
||||||
68.75% { --rainbow-color: #cc7d24; }
|
|
||||||
70% { --rainbow-color: #c6811e; }
|
|
||||||
71.25% { --rainbow-color: #bf8418; }
|
|
||||||
72.5% { --rainbow-color: #b98713; }
|
|
||||||
73.75% { --rainbow-color: #b28a0f; }
|
|
||||||
75% { --rainbow-color: #ab8d0c; }
|
|
||||||
76.25% { --rainbow-color: #a3900b; }
|
|
||||||
77.5% { --rainbow-color: #9c920d; }
|
|
||||||
78.75% { --rainbow-color: #949510; }
|
|
||||||
80% { --rainbow-color: #8b9715; }
|
|
||||||
81.25% { --rainbow-color: #83991b; }
|
|
||||||
82.5% { --rainbow-color: #7a9b21; }
|
|
||||||
83.75% { --rainbow-color: #719d27; }
|
|
||||||
85% { --rainbow-color: #679e2e; }
|
|
||||||
86.25% { --rainbow-color: #5da035; }
|
|
||||||
87.5% { --rainbow-color: #51a13c; }
|
|
||||||
88.75% { --rainbow-color: #44a244; }
|
|
||||||
90% { --rainbow-color: #34a44b; }
|
|
||||||
91.25% { --rainbow-color: #1ba553; }
|
|
||||||
92.5% { --rainbow-color: #00a65b; }
|
|
||||||
93.75% { --rainbow-color: #00a663; }
|
|
||||||
95% { --rainbow-color: #00a76c; }
|
|
||||||
96.25% { --rainbow-color: #00a874; }
|
|
||||||
97.5% { --rainbow-color: #00a87d; }
|
|
||||||
98.75% { --rainbow-color: #00a985; }
|
|
||||||
100% { --rainbow-color: #00a98e; }
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--rainbow-color: #00a98e;
|
|
||||||
animation: rainbow 20s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 10000;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
color: var(--rainbow-color);
|
|
||||||
user-select: none;
|
|
||||||
background-color: oklch(var(--background));
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container *::before,
|
|
||||||
.loading-container *::after {
|
|
||||||
box-sizing: content-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container .loading {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container .loading .square {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container .loading .square::before {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
content: "";
|
|
||||||
border: 3px solid var(--rainbow-color);
|
|
||||||
border-radius: 15%;
|
|
||||||
animation: square-to-dot-animation 2s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container .loading .square:nth-child(1)::before {
|
|
||||||
animation-delay: calc(150ms * 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container .loading .square:nth-child(2)::before {
|
|
||||||
animation-delay: calc(150ms * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container .loading .square:nth-child(3)::before {
|
|
||||||
animation-delay: calc(150ms * 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container .loading .square:nth-child(4)::before {
|
|
||||||
animation-delay: calc(150ms * 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes square-to-dot-animation {
|
|
||||||
0%,
|
|
||||||
33.33% {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
margin: initial;
|
|
||||||
border-width: 3px;
|
|
||||||
border-radius: 15%;
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
50%,
|
|
||||||
83.33% {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
margin: 5px;
|
|
||||||
border-width: 5px;
|
|
||||||
border-radius: 100%;
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
margin: initial;
|
|
||||||
border-width: 3px;
|
|
||||||
border-radius: 15%;
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container .name {
|
|
||||||
position: relative;
|
|
||||||
margin-top: 20px;
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container .tips {
|
|
||||||
position: relative;
|
|
||||||
margin-top: 10px;
|
|
||||||
font-size: 16px;
|
|
||||||
opacity: 0.5;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container .tips::after {
|
|
||||||
position: absolute;
|
|
||||||
padding-left: 5px;
|
|
||||||
content: "…";
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="loading-container">
|
|
||||||
<div class="loading">
|
|
||||||
<div class="square"></div>
|
|
||||||
<div class="square"></div>
|
|
||||||
<div class="square"></div>
|
|
||||||
<div class="square"></div>
|
|
||||||
</div>
|
|
||||||
<div class="name">%VITE_APP_TITLE%</div>
|
|
||||||
<div class="tips">载入中</div>
|
|
||||||
</div>
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@fantastic-admin/core-ant-design-vue",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vue-tsc -b && vite build",
|
|
||||||
"build:test": "vue-tsc -b && vite build --mode test",
|
|
||||||
"serve": "http-server ./dist -o",
|
|
||||||
"serve:test": "http-server ./dist-test -o",
|
|
||||||
"svgo": "svgo -f src/assets/icons",
|
|
||||||
"generate:icons": "fa-iconify-tools",
|
|
||||||
"lint": "vue-tsc -b"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@fantastic-admin/components": "workspace:*",
|
|
||||||
"@fantastic-admin/composables": "workspace:*",
|
|
||||||
"@fantastic-admin/settings": "workspace:*",
|
|
||||||
"@fantastic-admin/types": "workspace:*",
|
|
||||||
"@vee-validate/zod": "catalog:",
|
|
||||||
"@vueuse/core": "catalog:",
|
|
||||||
"@vueuse/integrations": "catalog:",
|
|
||||||
"@zumer/snapdom": "catalog:",
|
|
||||||
"ant-design-vue": "catalog:ant-design-vue",
|
|
||||||
"axios": "catalog:",
|
|
||||||
"class-variance-authority": "catalog:",
|
|
||||||
"clsx": "catalog:",
|
|
||||||
"dayjs": "catalog:",
|
|
||||||
"eruda": "catalog:",
|
|
||||||
"es-toolkit": "catalog:",
|
|
||||||
"hotkeys-js": "catalog:",
|
|
||||||
"lucide-vue-next": "catalog:",
|
|
||||||
"mitt": "catalog:",
|
|
||||||
"nprogress": "catalog:",
|
|
||||||
"path-browserify": "catalog:",
|
|
||||||
"path-to-regexp": "catalog:",
|
|
||||||
"pinia": "catalog:",
|
|
||||||
"pinia-plugin-persistedstate": "catalog:",
|
|
||||||
"pinyin-pro": "catalog:",
|
|
||||||
"qs": "catalog:",
|
|
||||||
"reka-ui": "catalog:",
|
|
||||||
"scule": "catalog:",
|
|
||||||
"tailwind-merge": "catalog:",
|
|
||||||
"type-fest": "catalog:",
|
|
||||||
"ua-parser-js": "catalog:",
|
|
||||||
"vconsole": "catalog:",
|
|
||||||
"vee-validate": "catalog:",
|
|
||||||
"vue": "catalog:",
|
|
||||||
"vue-router": "catalog:",
|
|
||||||
"zod": "catalog:"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@faker-js/faker": "catalog:",
|
|
||||||
"@fantastic-admin/copyright": "workspace:*",
|
|
||||||
"@fantastic-admin/iconify-tools": "workspace:*",
|
|
||||||
"@iconify/vue": "catalog:",
|
|
||||||
"@spiriit/vite-plugin-svg-spritemap": "catalog:",
|
|
||||||
"@types/nprogress": "catalog:",
|
|
||||||
"@types/path-browserify": "catalog:",
|
|
||||||
"@types/qs": "catalog:",
|
|
||||||
"@vitejs/plugin-legacy": "catalog:",
|
|
||||||
"@vitejs/plugin-vue": "catalog:",
|
|
||||||
"@vitejs/plugin-vue-jsx": "catalog:",
|
|
||||||
"@vue/tsconfig": "catalog:",
|
|
||||||
"http-server": "catalog:",
|
|
||||||
"postcss": "catalog:",
|
|
||||||
"postcss-nested": "catalog:",
|
|
||||||
"sass-embedded": "catalog:",
|
|
||||||
"svgo": "catalog:",
|
|
||||||
"unplugin-auto-import": "catalog:",
|
|
||||||
"unplugin-turbo-console": "catalog:",
|
|
||||||
"unplugin-vue-components": "catalog:",
|
|
||||||
"vite": "catalog:",
|
|
||||||
"vite-plugin-app-loading": "catalog:",
|
|
||||||
"vite-plugin-archiver": "catalog:",
|
|
||||||
"vite-plugin-compression2": "catalog:",
|
|
||||||
"vite-plugin-env-parse": "catalog:",
|
|
||||||
"vite-plugin-fake-server": "catalog:",
|
|
||||||
"vite-plugin-vue-devtools": "catalog:",
|
|
||||||
"vue-tsc": "catalog:"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
'autoprefixer': {},
|
|
||||||
'postcss-nested': {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 KiB |
@ -1,49 +0,0 @@
|
|||||||
#browser-upgrade {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 10001;
|
|
||||||
display: none;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
color: #736477;
|
|
||||||
user-select: none;
|
|
||||||
background-color: snow;
|
|
||||||
}
|
|
||||||
|
|
||||||
#browser-upgrade .title {
|
|
||||||
margin: 40px 0;
|
|
||||||
font-size: 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#browser-upgrade .browsers {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#browser-upgrade .browsers .browser {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0 20px;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
#browser-upgrade .browsers .browser .browser-icon {
|
|
||||||
display: block;
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
margin: 0 auto;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#browser-upgrade .browsers .browser .browser-name {
|
|
||||||
padding-bottom: 2px;
|
|
||||||
margin-top: 10px;
|
|
||||||
color: #736477;
|
|
||||||
text-align: center;
|
|
||||||
border-bottom: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
#browser-upgrade .browsers .browser:hover .browser-name {
|
|
||||||
border-bottom: 1px solid #736477;
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -21 206 206" xml:space="preserve">
|
|
||||||
<path d="M0,82.57C0,58.4,0,34.23,0,10.06C0,1.35,1.35,0,10.03,0C45.36,0,80.7-0.01,116.03,0.03c1.77,0,3.59,0.26,5.29,0.74
|
|
||||||
c3.07,0.87,3.57,3.21,2.45,5.8c-4.37,10.1-8.83,20.15-13.31,30.2c-1.44,3.23-4.06,4.26-7.54,4.25c-18.5-0.09-37,0.1-55.5-0.14
|
|
||||||
c-4.95-0.06-6.49,2.12-6.46,6.47c0.02,3,0.11,6.01-0.02,9c-0.3,6.8,0.34,9.29,9.51,9.62c5.36,0.19,10.73,0.04,16.09,0.04
|
|
||||||
c7.57,0,15.15-0.04,22.72,0.01c5.13,0.03,6.6,2.12,4.59,6.89C90.43,81.09,86.27,89,83.4,97.35c-2.59,7.55-8.57,7.27-14.28,7.56
|
|
||||||
c-7.48,0.38-15,0.17-22.5,0.06c-3.69-0.05-4.96,1.04-4.86,5.18c0.36,15.83,0.26,31.67,0.12,47.5c-0.04,4.2-2.26,6.53-6.94,6.42
|
|
||||||
c-9-0.21-18-0.06-27-0.06c-6.05,0-7.91-1.8-7.92-7.94C-0.02,131.57,0,107.07,0,82.57z" fill="#42b883" />
|
|
||||||
<path d="M132.98,94.13c-8.21,18.88-16.33,37.1-24.06,55.48c-6.02,14.31-5.81,14.4-21.31,14.4c-6.67,0-13.33,0.05-20-0.01
|
|
||||||
c-5.91-0.05-7.71-2.44-5.46-7.61c6.86-15.8,13.86-31.55,20.82-47.31c9.41-21.32,18.81-42.63,28.24-63.94
|
|
||||||
c5.59-12.62,11.14-25.25,16.94-37.77c0.82-1.78,2.95-4.09,4.47-4.09c1.63,0,4,2.11,4.78,3.86c13.4,29.78,26.56,59.66,39.87,89.49
|
|
||||||
c6.48,14.53,13.16,28.97,19.72,43.46c2.57,5.68,5.11,11.38,7.61,17.1c1.61,3.68-0.13,6.75-4.12,6.77
|
|
||||||
c-23.83,0.08-47.66,0.09-71.49-0.01c-4.03-0.02-5.46-2.47-3.98-6.17c2.26-5.63,4.77-11.16,6.95-16.82c1.74-4.54,4.92-6.06,9.6-5.98
|
|
||||||
c9.65,0.16,9.89-0.55,5.89-9.13c-4.57-9.81-8.76-19.8-13.14-29.7C134.08,95.62,133.68,95.17,132.98,94.13z" fill="#42b883" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@ -1,60 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import dayjs from '@/utils/dayjs'
|
|
||||||
import { ua } from '@/utils/ua'
|
|
||||||
import Provider from './ui/provider/index.vue'
|
|
||||||
import 'dayjs/locale/zh-cn'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
|
|
||||||
const { auth } = useAppAuth()
|
|
||||||
const { generateTitle } = useAppMenu()
|
|
||||||
|
|
||||||
document.body.setAttribute('data-os', ua.getOS().name || '')
|
|
||||||
|
|
||||||
const isAuth = computed(() => {
|
|
||||||
return route.matched.every((item) => {
|
|
||||||
return auth(item.meta.auth ?? '')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 设置网页 title
|
|
||||||
watch([
|
|
||||||
() => appSettingsStore.settings.app.dynamicTitle,
|
|
||||||
() => appSettingsStore.title,
|
|
||||||
], () => {
|
|
||||||
nextTick(() => {
|
|
||||||
if (appSettingsStore.settings.app.dynamicTitle && appSettingsStore.title) {
|
|
||||||
document.title = `${generateTitle(appSettingsStore.title)} - ${import.meta.env.VITE_APP_TITLE}`
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
document.title = import.meta.env.VITE_APP_TITLE
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, {
|
|
||||||
immediate: true,
|
|
||||||
deep: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
appSettingsStore.setMode(document.documentElement.clientWidth)
|
|
||||||
dayjs.locale('zh-cn')
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
appSettingsStore.setMode(document.documentElement.clientWidth)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Provider>
|
|
||||||
<RouterView v-slot="{ Component }">
|
|
||||||
<AppNotSupportedMobile v-if="!appSettingsStore.settings.app.mobile && appSettingsStore.mode === 'mobile'" />
|
|
||||||
<Component :is="Component" v-else-if="isAuth" />
|
|
||||||
<AppNotAllowed v-else />
|
|
||||||
</RouterView>
|
|
||||||
<AppBackToTop />
|
|
||||||
<FaToast :theme="appSettingsStore.currentColorScheme" />
|
|
||||||
<AppSystemInfo />
|
|
||||||
</Provider>
|
|
||||||
</template>
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
import { faker } from '@faker-js/faker'
|
|
||||||
import { defineFakeRoute } from 'vite-plugin-fake-server/client'
|
|
||||||
|
|
||||||
export default defineFakeRoute([
|
|
||||||
{
|
|
||||||
url: '/fake/app/route/list',
|
|
||||||
method: 'get',
|
|
||||||
response: () => {
|
|
||||||
return {
|
|
||||||
error: '',
|
|
||||||
status: 1,
|
|
||||||
data: [
|
|
||||||
// 主导航
|
|
||||||
{
|
|
||||||
meta: {
|
|
||||||
title: '演示',
|
|
||||||
icon: 'uim:box',
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
// 次导航(一级路由)
|
|
||||||
{
|
|
||||||
path: '/multilevel_menu_example',
|
|
||||||
component: 'Layout',
|
|
||||||
name: 'multilevelMenuExample',
|
|
||||||
meta: {
|
|
||||||
title: '多级导航',
|
|
||||||
icon: 'heroicons-solid:menu-alt-3',
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
// 次导航(二级路由)
|
|
||||||
{
|
|
||||||
path: 'page',
|
|
||||||
name: 'multilevelMenuExample1',
|
|
||||||
component: 'multilevel_menu_example/page.vue',
|
|
||||||
meta: {
|
|
||||||
title: '导航1',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'level2',
|
|
||||||
name: 'multilevelMenuExample2',
|
|
||||||
meta: {
|
|
||||||
title: '导航2',
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
// 次导航(三级路由)
|
|
||||||
{
|
|
||||||
path: 'page',
|
|
||||||
name: 'multilevelMenuExample2-1',
|
|
||||||
component: 'multilevel_menu_example/level2/page.vue',
|
|
||||||
meta: {
|
|
||||||
title: '导航2-1',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'level3',
|
|
||||||
name: 'multilevelMenuExample2-2',
|
|
||||||
meta: {
|
|
||||||
title: '导航2-2',
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
// 次导航(四级路由)
|
|
||||||
{
|
|
||||||
path: 'page1',
|
|
||||||
name: 'multilevelMenuExample2-2-1',
|
|
||||||
component: 'multilevel_menu_example/level2/level3/page1.vue',
|
|
||||||
meta: {
|
|
||||||
title: '导航2-2-1',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'page2',
|
|
||||||
name: 'multilevelMenuExample2-2-2',
|
|
||||||
component: 'multilevel_menu_example/level2/level3/page2.vue',
|
|
||||||
meta: {
|
|
||||||
title: '导航2-2-2',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/fake/app/account/login',
|
|
||||||
method: 'post',
|
|
||||||
response: ({ body }) => {
|
|
||||||
return {
|
|
||||||
error: '',
|
|
||||||
status: 1,
|
|
||||||
data: {
|
|
||||||
account: body.account,
|
|
||||||
token: `${body.account}:${faker.internet.jwt()}`,
|
|
||||||
avatar: `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${body.account}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/fake/app/account/permission',
|
|
||||||
method: 'get',
|
|
||||||
response: ({ headers }) => {
|
|
||||||
let permissions: string[] = []
|
|
||||||
if (headers.token?.indexOf('admin') === 0) {
|
|
||||||
permissions = [
|
|
||||||
'pages.general:browse',
|
|
||||||
'pages.form:browse',
|
|
||||||
'pages.list:browse',
|
|
||||||
'pages.shop:browse',
|
|
||||||
]
|
|
||||||
}
|
|
||||||
else if (headers.token?.indexOf('test') === 0) {
|
|
||||||
permissions = [
|
|
||||||
'pages.general:browse',
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
error: '',
|
|
||||||
status: 1,
|
|
||||||
data: {
|
|
||||||
permissions,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/fake/app/account/password/edit',
|
|
||||||
method: 'post',
|
|
||||||
response: () => {
|
|
||||||
return {
|
|
||||||
error: '',
|
|
||||||
status: 1,
|
|
||||||
data: {
|
|
||||||
isSuccess: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
import axios from 'axios'
|
|
||||||
// import qs from 'qs'
|
|
||||||
|
|
||||||
// 请求重试配置
|
|
||||||
const MAX_RETRY_COUNT = 3 // 最大重试次数
|
|
||||||
const RETRY_DELAY = 1000 // 重试延迟时间(毫秒)
|
|
||||||
|
|
||||||
// 扩展 AxiosRequestConfig 类型
|
|
||||||
declare module 'axios' {
|
|
||||||
export interface AxiosRequestConfig {
|
|
||||||
retry?: boolean
|
|
||||||
retryCount?: number
|
|
||||||
fake?: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = axios.create({
|
|
||||||
baseURL: (import.meta.env.DEV && import.meta.env.VITE_ENABLE_PROXY) ? '/proxy/' : import.meta.env.VITE_APP_API_BASEURL,
|
|
||||||
timeout: 1000 * 60,
|
|
||||||
responseType: 'json',
|
|
||||||
})
|
|
||||||
|
|
||||||
api.interceptors.request.use(
|
|
||||||
(request) => {
|
|
||||||
// 如果设置了 fake 属性,强制使用 fake 的 baseURL
|
|
||||||
if (request.fake) {
|
|
||||||
request.baseURL = '/fake/'
|
|
||||||
}
|
|
||||||
// 全局拦截请求发送前提交的参数
|
|
||||||
const appAccountStore = useAppAccountStore()
|
|
||||||
// 设置请求头
|
|
||||||
if (request.headers) {
|
|
||||||
request.headers['Accept-Language'] = 'zh-CN'
|
|
||||||
if (appAccountStore.isLogin) {
|
|
||||||
request.headers.Token = appAccountStore.token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 是否将 POST 请求参数进行字符串化处理
|
|
||||||
if (request.method === 'post') {
|
|
||||||
// request.data = qs.stringify(request.data, {
|
|
||||||
// arrayFormat: 'brackets',
|
|
||||||
// })
|
|
||||||
}
|
|
||||||
return request
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// 处理错误信息的函数
|
|
||||||
function handleError(error: any) {
|
|
||||||
if (error.status === 401) {
|
|
||||||
useAppAccountStore().requestLogout()
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
faToast.error('Error', {
|
|
||||||
description: error.message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
api.interceptors.response.use(
|
|
||||||
(response) => {
|
|
||||||
/**
|
|
||||||
* 全局拦截请求发送后返回的数据,如果数据有报错则在这做全局的错误提示
|
|
||||||
* 约定的数据格式:{ status: 1 | 0, error: string, data: object }
|
|
||||||
* status 只有两种状态,1 表示请求成功,0 表示接口需要登录或者登录状态失效,需要重新登录
|
|
||||||
* error 只有在请求出错时才会有值,表示错误信息
|
|
||||||
* data 只有在请求成功时才会有值,表示请求返回的数据
|
|
||||||
*/
|
|
||||||
if (typeof response.data === 'object') {
|
|
||||||
if (response.data.status === 1) {
|
|
||||||
if (response.data.error) {
|
|
||||||
faToast.warning('Warning', {
|
|
||||||
description: response.data.error,
|
|
||||||
})
|
|
||||||
return Promise.reject(response.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
useAppAccountStore().requestLogout()
|
|
||||||
}
|
|
||||||
return Promise.resolve(response.data)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return Promise.reject(response.data)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async (error) => {
|
|
||||||
// 获取请求配置
|
|
||||||
const config = error.config
|
|
||||||
// 如果配置不存在或未启用重试,则直接处理错误
|
|
||||||
if (!config || !config.retry) {
|
|
||||||
return handleError(error)
|
|
||||||
}
|
|
||||||
// 设置重试次数
|
|
||||||
config.retryCount = config.retryCount || 0
|
|
||||||
// 判断是否超过重试次数
|
|
||||||
if (config.retryCount >= MAX_RETRY_COUNT) {
|
|
||||||
return handleError(error)
|
|
||||||
}
|
|
||||||
// 重试次数自增
|
|
||||||
config.retryCount += 1
|
|
||||||
// 延迟重试
|
|
||||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY))
|
|
||||||
// 重新发起请求
|
|
||||||
return api(config)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export default api
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import api from '../index'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
// 后端获取路由数据
|
|
||||||
routeList: () => api.get('app/route/list', {
|
|
||||||
fake: true,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// 登录
|
|
||||||
login: (data: {
|
|
||||||
account: string
|
|
||||||
password: string
|
|
||||||
}) => api.post('app/account/login', data, {
|
|
||||||
fake: true,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// 获取权限
|
|
||||||
permission: () => api.get('app/account/permission', {
|
|
||||||
fake: true,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// 修改密码
|
|
||||||
passwordEdit: (data: {
|
|
||||||
password: string
|
|
||||||
newPassword: string
|
|
||||||
}) => api.post('app/account/password/edit', data, {
|
|
||||||
fake: true,
|
|
||||||
}),
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="200.781" height="200" class="icon" viewBox="0 0 1028 1024"><path d="M989.867 234.667H499.2c-17.067 0-34.133-21.334-34.133-42.667 0-25.6 12.8-42.667 34.133-42.667h490.667c17.066 0 34.133 17.067 34.133 42.667 0 21.333-12.8 42.667-34.133 42.667m-473.6 128h465.066c25.6 0 46.934 21.333 46.934 42.666 0 25.6-21.334 42.667-46.934 42.667H516.267c-25.6 0-46.934-17.067-46.934-42.667s21.334-42.666 46.934-42.666m0 298.666c-25.6 0-46.934-21.333-46.934-42.666 0-25.6 21.334-42.667 46.934-42.667h465.066c25.6 0 46.934 17.067 46.934 42.667s-21.334 42.666-46.934 42.666zm4.266 128H972.8c29.867 0 51.2 17.067 51.2 42.667s-21.333 42.667-51.2 42.667H520.533c-29.866 0-51.2-17.067-51.2-42.667s21.334-42.667 51.2-42.667m-192 25.6c-17.066 17.067-46.933 17.067-64 0L12.8 541.867c-17.067-17.067-17.067-51.2 0-68.267l251.733-273.067c17.067-17.066 46.934-17.066 64 0s17.067 51.2 0 68.267L106.667 507.733l221.866 238.934c17.067 21.333 17.067 51.2 0 68.266"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 986 B |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
@ -1,14 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-25 -46 256 256" xml:space="preserve">
|
|
||||||
<path d="M0,82.57C0,58.4,0,34.23,0,10.06C0,1.35,1.35,0,10.03,0C45.36,0,80.7-0.01,116.03,0.03c1.77,0,3.59,0.26,5.29,0.74
|
|
||||||
c3.07,0.87,3.57,3.21,2.45,5.8c-4.37,10.1-8.83,20.15-13.31,30.2c-1.44,3.23-4.06,4.26-7.54,4.25c-18.5-0.09-37,0.1-55.5-0.14
|
|
||||||
c-4.95-0.06-6.49,2.12-6.46,6.47c0.02,3,0.11,6.01-0.02,9c-0.3,6.8,0.34,9.29,9.51,9.62c5.36,0.19,10.73,0.04,16.09,0.04
|
|
||||||
c7.57,0,15.15-0.04,22.72,0.01c5.13,0.03,6.6,2.12,4.59,6.89C90.43,81.09,86.27,89,83.4,97.35c-2.59,7.55-8.57,7.27-14.28,7.56
|
|
||||||
c-7.48,0.38-15,0.17-22.5,0.06c-3.69-0.05-4.96,1.04-4.86,5.18c0.36,15.83,0.26,31.67,0.12,47.5c-0.04,4.2-2.26,6.53-6.94,6.42
|
|
||||||
c-9-0.21-18-0.06-27-0.06c-6.05,0-7.91-1.8-7.92-7.94C-0.02,131.57,0,107.07,0,82.57z" fill="#35495e" />
|
|
||||||
<path d="M132.98,94.13c-8.21,18.88-16.33,37.1-24.06,55.48c-6.02,14.31-5.81,14.4-21.31,14.4c-6.67,0-13.33,0.05-20-0.01
|
|
||||||
c-5.91-0.05-7.71-2.44-5.46-7.61c6.86-15.8,13.86-31.55,20.82-47.31c9.41-21.32,18.81-42.63,28.24-63.94
|
|
||||||
c5.59-12.62,11.14-25.25,16.94-37.77c0.82-1.78,2.95-4.09,4.47-4.09c1.63,0,4,2.11,4.78,3.86c13.4,29.78,26.56,59.66,39.87,89.49
|
|
||||||
c6.48,14.53,13.16,28.97,19.72,43.46c2.57,5.68,5.11,11.38,7.61,17.1c1.61,3.68-0.13,6.75-4.12,6.77
|
|
||||||
c-23.83,0.08-47.66,0.09-71.49-0.01c-4.03-0.02-5.46-2.47-3.98-6.17c2.26-5.63,4.77-11.16,6.95-16.82c1.74-4.54,4.92-6.06,9.6-5.98
|
|
||||||
c9.65,0.16,9.89-0.55,5.89-9.13c-4.57-9.81-8.76-19.8-13.14-29.7C134.08,95.62,133.68,95.17,132.98,94.13z" fill="#42b883" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@ -1,80 +0,0 @@
|
|||||||
/* 页面布局 CSS 变量 */
|
|
||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
|
|
||||||
/* 头部宽度(默认自适应宽度,可固定宽度,固定宽度后为居中显示) */
|
|
||||||
--g-header-width: 100%;
|
|
||||||
|
|
||||||
/* 头部高度 */
|
|
||||||
--g-header-height: 60px;
|
|
||||||
|
|
||||||
/* 侧边栏宽度 */
|
|
||||||
--g-main-sidebar-width: 80px;
|
|
||||||
--g-sub-sidebar-width: 220px;
|
|
||||||
--g-sub-sidebar-collapse-width: 64px;
|
|
||||||
|
|
||||||
/* 侧边栏 Logo 区域高度 */
|
|
||||||
--g-sidebar-logo-height: 50px;
|
|
||||||
|
|
||||||
/* 标签栏高度 */
|
|
||||||
--g-tabbar-height: 50px;
|
|
||||||
|
|
||||||
/* 工具栏高度 */
|
|
||||||
--g-toolbar-height: 50px;
|
|
||||||
|
|
||||||
&.dark {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 明暗模式过渡动画 */
|
|
||||||
::view-transition-old(root),
|
|
||||||
::view-transition-new(root) {
|
|
||||||
mix-blend-mode: normal;
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
::view-transition-old(root) {
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
::view-transition-new(root) {
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
&::view-transition-old(root) {
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::view-transition-new(root) {
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.disable-color-scheme-transition-duration *,
|
|
||||||
.disable-color-scheme-transition-duration *::before,
|
|
||||||
.disable-color-scheme-transition-duration *::after {
|
|
||||||
transition-duration: 0ms !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
height: 100%;
|
|
||||||
text-autospace: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* textarea 字体跟随系统 */
|
|
||||||
textarea {
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
#nprogress {
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 3000;
|
|
||||||
width: 100%;
|
|
||||||
height: 2px;
|
|
||||||
background: oklch(var(--primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.peg {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
display: block;
|
|
||||||
width: 100px;
|
|
||||||
height: 100%;
|
|
||||||
box-shadow: 0 0 10px oklch(var(--primary)), 0 0 5px oklch(var(--primary));
|
|
||||||
opacity: 1;
|
|
||||||
transform: rotate(3deg) translate(0, -4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
position: fixed;
|
|
||||||
top: 11px;
|
|
||||||
right: 14px;
|
|
||||||
z-index: 2000;
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
.spinner-icon {
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border: solid 2px transparent;
|
|
||||||
border-top-color: oklch(var(--primary));
|
|
||||||
border-left-color: oklch(var(--primary));
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: nprogress-spinner 400ms linear infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nprogress-custom-parent {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
#nprogress .spinner,
|
|
||||||
#nprogress .bar {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes nprogress-spinner {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes nprogress-spinner {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
// 文字超出隐藏,默认为单行超出隐藏,可设置多行
|
|
||||||
@mixin text-overflow($line: 1, $fixed-width: true) {
|
|
||||||
@if $line == 1 and $fixed-width == true {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
} @else {
|
|
||||||
/* stylelint-disable-next-line value-no-vendor-prefix */
|
|
||||||
display: -webkit-box;
|
|
||||||
overflow: hidden;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: $line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定位居中,默认水平居中,可选择垂直居中,或者水平垂直都居中
|
|
||||||
@mixin position-center($type: x) {
|
|
||||||
position: absolute;
|
|
||||||
|
|
||||||
@if $type == x {
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@if $type == y {
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@if $type == xy {
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%) translateY(-50%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文字两端对齐
|
|
||||||
%justify-align {
|
|
||||||
text-align: justify;
|
|
||||||
text-align-last: justify;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除浮动
|
|
||||||
%clearfix {
|
|
||||||
zoom: 1;
|
|
||||||
|
|
||||||
&::before,
|
|
||||||
&::after {
|
|
||||||
clear: both;
|
|
||||||
display: block;
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
// 全局变量
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { HTMLAttributes } from 'vue'
|
|
||||||
import { cn } from '@/utils'
|
|
||||||
import eventBus from '@/utils/eventBus'
|
|
||||||
import Profile from './profile.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'AppAccountButton',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
onlyAvatar?: boolean
|
|
||||||
dropdownAlign?: 'start' | 'center' | 'end'
|
|
||||||
dropdownSide?: 'left' | 'right' | 'top' | 'bottom'
|
|
||||||
buttonVariant?: 'secondary' | 'ghost'
|
|
||||||
class?: HTMLAttributes['class']
|
|
||||||
}>(), {
|
|
||||||
dropdownAlign: 'end',
|
|
||||||
dropdownSide: 'right',
|
|
||||||
buttonVariant: 'ghost',
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
const appAccountStore = useAppAccountStore()
|
|
||||||
|
|
||||||
const { generateTitle } = useAppMenu()
|
|
||||||
|
|
||||||
const profileModal = useFaModal().create({
|
|
||||||
alignCenter: true,
|
|
||||||
header: false,
|
|
||||||
footer: false,
|
|
||||||
closeOnClickOverlay: false,
|
|
||||||
closeOnPressEscape: false,
|
|
||||||
class: 'h-[500px] sm:max-w-xl overflow-hidden',
|
|
||||||
contentClass: 'min-h-full p-0 flex',
|
|
||||||
content: () => h(Profile),
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<FaDropdown
|
|
||||||
:align="dropdownAlign" :side="dropdownSide" :items="[
|
|
||||||
[
|
|
||||||
...(appSettingsStore.settings.app.home.enable
|
|
||||||
? [{ label: generateTitle(appSettingsStore.settings.app.home.title), icon: 'i-mdi:home', handle: () => router.push({ path: appSettingsStore.settings.app.home.fullPath }) }]
|
|
||||||
: []),
|
|
||||||
{ label: '个人设置', icon: 'i-mdi:account', handle: () => profileModal.open() },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
...(appSettingsStore.mode === 'pc'
|
|
||||||
? [{ label: '快捷键', icon: 'i-mdi:keyboard', handle: () => eventBus.emit('global-hotkeys-intro-toggle') }]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
label: '退出登录',
|
|
||||||
icon: 'i-mdi:logout',
|
|
||||||
handle: () => appAccountStore.logout(appSettingsStore.settings.app.home.fullPath),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]" class="flex-center"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex-center-start gap-2">
|
|
||||||
<FaAvatar :src="appAccountStore.avatar" :fallback="appAccountStore.account.slice(0, 2)" shape="square" />
|
|
||||||
<div class="min-w-0 space-y-1">
|
|
||||||
<div class="text-base lh-none truncate">
|
|
||||||
{{ appAccountStore.account }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-secondary-foreground/50 font-normal">
|
|
||||||
[ xyz@xyz.com ]
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<FaButton
|
|
||||||
:variant="buttonVariant" size="icon-sm" :class="cn('flex-center gap-1 p-2', {
|
|
||||||
'p-1': onlyAvatar,
|
|
||||||
}, props.class)"
|
|
||||||
>
|
|
||||||
<FaAvatar :src="appAccountStore.avatar" :class="cn('size-6', { 'size-full': onlyAvatar })">
|
|
||||||
<FaIcon name="i-carbon:user-avatar-filled" class="text-secondary-foreground/50 size-6" />
|
|
||||||
</FaAvatar>
|
|
||||||
<div v-if="!onlyAvatar" class="flex-center-between flex-1 gap-2 min-w-0">
|
|
||||||
<div class="text-start flex-1 truncate">
|
|
||||||
{{ appAccountStore.account }}
|
|
||||||
</div>
|
|
||||||
<FaIcon name="i-material-symbols:expand-all-rounded" />
|
|
||||||
</div>
|
|
||||||
</FaButton>
|
|
||||||
</FaDropdown>
|
|
||||||
</template>
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import EditPassword from '@/components/AppAccountForm/edit-password.vue'
|
|
||||||
|
|
||||||
const active = ref(0)
|
|
||||||
const tabs = ref([
|
|
||||||
{
|
|
||||||
title: '基本设置',
|
|
||||||
description: '账号的基本信息,头像、昵称等',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '安全设置',
|
|
||||||
description: '定期修改密码可以提高帐号安全性',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-full w-full">
|
|
||||||
<div class="border-b border-e bg-background flex flex-row right-0 top-0 fixed z-1 overflow-auto md:(flex-col h-full w-40 inset-s-0 bottom-0)">
|
|
||||||
<div v-for="(tab, index) in tabs" :key="index" class="px-4 py-3 flex-shrink-0 cursor-pointer transition-background-color space-y-2 hover-bg-accent/50" :class="{ 'bg-accent hover-bg-accent!': active === index }" @click="active = index">
|
|
||||||
<div class="text-base text-accent-foreground leading-tight">
|
|
||||||
{{ tab.title }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-accent-foreground/50">
|
|
||||||
{{ tab.description }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-10 pt-20 flex-col-center min-h-full md:(ms-40 pt-10)">
|
|
||||||
<div v-if="active === 0">
|
|
||||||
请开发者自行扩展
|
|
||||||
</div>
|
|
||||||
<EditPassword v-if="active === 1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
|
||||||
import { useForm } from 'vee-validate'
|
|
||||||
import * as z from 'zod'
|
|
||||||
import { FormControl, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'EditPasswordForm',
|
|
||||||
})
|
|
||||||
|
|
||||||
const appAccountStore = useAppAccountStore()
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
validationSchema: toTypedSchema(
|
|
||||||
z.object({
|
|
||||||
password: z.string().min(1, '请输入原密码'),
|
|
||||||
newPassword: z.string().min(1, '请输入新密码').min(6, '密码长度为6到18位').max(18, '密码长度为6到18位'),
|
|
||||||
checkPassword: z.string().min(1, '请确认新密码'),
|
|
||||||
}).refine(data => data.newPassword === data.checkPassword, {
|
|
||||||
message: '两次输入的密码不一致',
|
|
||||||
path: ['checkPassword'],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
initialValues: {
|
|
||||||
password: '',
|
|
||||||
newPassword: '',
|
|
||||||
checkPassword: '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const onSubmit = form.handleSubmit((values) => {
|
|
||||||
loading.value = true
|
|
||||||
appAccountStore.editPassword(values).then(async () => {
|
|
||||||
faToast.success('模拟修改成功,请重新登录')
|
|
||||||
appAccountStore.logout()
|
|
||||||
}).finally(() => {
|
|
||||||
loading.value = false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex-col-stretch-center w-full">
|
|
||||||
<div class="mb-6 space-y-2">
|
|
||||||
<h3 class="text-4xl font-bold">
|
|
||||||
修改密码
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-muted-foreground lg:text-base">
|
|
||||||
请输入原密码、新密码和确认密码
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<form @submit="onSubmit">
|
|
||||||
<FormField v-slot="{ componentField, errors }" name="password">
|
|
||||||
<FormItem class="pb-6 relative space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<FaInput type="password" placeholder="原密码" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
|
|
||||||
<template #start>
|
|
||||||
<FaIcon name="i-lucide:lock" />
|
|
||||||
</template>
|
|
||||||
</FaInput>
|
|
||||||
</FormControl>
|
|
||||||
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
|
|
||||||
<FormMessage class="text-xs bottom-1 absolute" />
|
|
||||||
</Transition>
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
<FormField v-slot="{ componentField, errors }" name="newPassword">
|
|
||||||
<FormItem class="pb-6 relative space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<FaInput type="password" placeholder="新密码" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
|
|
||||||
<template #start>
|
|
||||||
<FaIcon name="i-lucide:lock" />
|
|
||||||
</template>
|
|
||||||
</FaInput>
|
|
||||||
</FormControl>
|
|
||||||
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
|
|
||||||
<FormMessage class="text-xs bottom-1 absolute" />
|
|
||||||
</Transition>
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
<FormField v-slot="{ componentField, errors }" name="checkPassword">
|
|
||||||
<FormItem class="pb-6 relative space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<FaInput type="password" placeholder="确认密码" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
|
|
||||||
<template #start>
|
|
||||||
<FaIcon name="i-lucide:lock" />
|
|
||||||
</template>
|
|
||||||
</FaInput>
|
|
||||||
</FormControl>
|
|
||||||
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
|
|
||||||
<FormMessage class="text-xs bottom-1 absolute" />
|
|
||||||
</Transition>
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
<FaButton :loading="loading" size="lg" class="mt-8 w-full" type="submit">
|
|
||||||
保存
|
|
||||||
</FaButton>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
|
||||||
import { useForm } from 'vee-validate'
|
|
||||||
import * as z from 'zod'
|
|
||||||
import { FormControl, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'LoginForm',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
account?: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emits = defineEmits<{
|
|
||||||
onLogin: [account?: string]
|
|
||||||
onRegister: [account?: string]
|
|
||||||
onResetPassword: [account?: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const appAccountStore = useAppAccountStore()
|
|
||||||
|
|
||||||
const title = import.meta.env.VITE_APP_TITLE
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
// 登录方式,default 账号密码登录,qrcode 扫码登录
|
|
||||||
const type = ref<'default' | 'qrcode'>('default')
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
validationSchema: toTypedSchema(z.object({
|
|
||||||
account: z.string().min(1, '请输入用户名'),
|
|
||||||
password: z.string().min(1, '请输入密码'),
|
|
||||||
remember: z.boolean(),
|
|
||||||
})),
|
|
||||||
initialValues: {
|
|
||||||
account: props.account ?? localStorage.getItem('login_account') ?? '',
|
|
||||||
password: '',
|
|
||||||
remember: localStorage.getItem('login_account') !== null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const onSubmit = form.handleSubmit((values) => {
|
|
||||||
loading.value = true
|
|
||||||
appAccountStore.login(values).then(() => {
|
|
||||||
if (values.remember) {
|
|
||||||
localStorage.setItem('login_account', values.account)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
localStorage.removeItem('login_account')
|
|
||||||
}
|
|
||||||
emits('onLogin', values.account)
|
|
||||||
}).finally(() => {
|
|
||||||
loading.value = false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function testAccount(account: string) {
|
|
||||||
form.setFieldValue('account', account)
|
|
||||||
form.setFieldValue('password', '123456')
|
|
||||||
onSubmit()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="p-12 flex-col-stretch-center min-h-500px w-full">
|
|
||||||
<div class="mb-6 space-y-2">
|
|
||||||
<h3 class="text-4xl font-bold">
|
|
||||||
欢迎使用 👋🏻
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-muted-foreground lg:text-base">
|
|
||||||
{{ title }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<FaTabs
|
|
||||||
v-model="type" :list="[
|
|
||||||
{ label: '账号密码登录', value: 'default' },
|
|
||||||
{ label: '扫码登录', value: 'qrcode' },
|
|
||||||
]" class="inline-flex"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-show="type === 'default'">
|
|
||||||
<form @submit="onSubmit">
|
|
||||||
<FormField v-slot="{ componentField, errors }" name="account">
|
|
||||||
<FormItem class="pb-6 relative space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<FaInput type="text" placeholder="用户名" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
|
|
||||||
<template #start>
|
|
||||||
<FaIcon name="i-lucide:user" />
|
|
||||||
</template>
|
|
||||||
</FaInput>
|
|
||||||
</FormControl>
|
|
||||||
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
|
|
||||||
<FormMessage class="text-xs bottom-1 absolute" />
|
|
||||||
</Transition>
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
<FormField v-slot="{ componentField, errors }" name="password">
|
|
||||||
<FormItem class="pb-6 relative space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<FaInput type="password" placeholder="密码" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
|
|
||||||
<template #start>
|
|
||||||
<FaIcon name="i-lucide:lock" />
|
|
||||||
</template>
|
|
||||||
</FaInput>
|
|
||||||
</FormControl>
|
|
||||||
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
|
|
||||||
<FormMessage class="text-xs bottom-1 absolute" />
|
|
||||||
</Transition>
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
<div class="mb-4 flex-center-between">
|
|
||||||
<div class="flex-center-start">
|
|
||||||
<FormField v-slot="{ componentField }" type="checkbox" name="remember">
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<FaCheckbox :model-value="componentField.modelValue" @update:model-value="componentField['onUpdate:modelValue']?.($event)">
|
|
||||||
记住我
|
|
||||||
</FaCheckbox>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<FaButton variant="link" class="p-0 h-auto" type="button" @click="emits('onResetPassword', form.values.account)">
|
|
||||||
忘记密码了?
|
|
||||||
</FaButton>
|
|
||||||
</div>
|
|
||||||
<FaButton :loading="loading" size="lg" class="w-full" type="submit">
|
|
||||||
登录
|
|
||||||
</FaButton>
|
|
||||||
<div class="text-sm mt-4 flex-center gap-2">
|
|
||||||
<span class="text-secondary-foreground op-50">还没有帐号?</span>
|
|
||||||
<FaButton variant="link" class="p-0 h-auto" type="button" @click="emits('onRegister', form.values.account)">
|
|
||||||
注册新帐号
|
|
||||||
</FaButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div class="mt-4 text-center -mb-4">
|
|
||||||
<FaDivider>演示账号一键登录</FaDivider>
|
|
||||||
<div class="space-x-2">
|
|
||||||
<FaButton variant="default" size="sm" plain @click="testAccount('admin')">
|
|
||||||
admin
|
|
||||||
</FaButton>
|
|
||||||
<FaButton variant="outline" size="sm" plain @click="testAccount('test')">
|
|
||||||
test
|
|
||||||
</FaButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-show="type === 'qrcode'">
|
|
||||||
<div class="flex-col-center">
|
|
||||||
<img src="https://s2.loli.net/2024/04/26/GsahtuIZ9XOg5jr.png" class="h-[250px] w-[250px]">
|
|
||||||
<div class="text-sm text-secondary-foreground mt-2 op-50">
|
|
||||||
请使用微信扫码登录
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
|
||||||
import { useForm } from 'vee-validate'
|
|
||||||
import * as z from 'zod'
|
|
||||||
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'RegisterForm',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
account?: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emits = defineEmits<{
|
|
||||||
onLogin: [account?: string]
|
|
||||||
onRegister: [account?: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
validationSchema: toTypedSchema(
|
|
||||||
z.object({
|
|
||||||
account: z.string().min(1, '请输入用户名'),
|
|
||||||
password: z.string().min(1, '请输入密码').min(6, '密码长度为6到18位').max(18, '密码长度为6到18位'),
|
|
||||||
checkPassword: z.string().min(1, '请再次输入密码'),
|
|
||||||
}).refine(data => data.password === data.checkPassword, {
|
|
||||||
message: '两次输入的密码不一致',
|
|
||||||
path: ['checkPassword'],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
initialValues: {
|
|
||||||
account: props.account ?? '',
|
|
||||||
password: '',
|
|
||||||
checkPassword: '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const onSubmit = form.handleSubmit((values) => {
|
|
||||||
loading.value = true
|
|
||||||
emits('onRegister', values.account)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="p-12 flex-col-stretch-center min-h-500px w-full">
|
|
||||||
<form @submit="onSubmit">
|
|
||||||
<div class="mb-8 space-y-2">
|
|
||||||
<h3 class="text-4xl font-bold">
|
|
||||||
探索从这里开始 🚀
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-muted-foreground lg:text-base">
|
|
||||||
演示系统未提供该功能
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<FormField v-slot="{ componentField, errors }" name="account">
|
|
||||||
<FormItem class="pb-6 relative space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<FaInput type="text" placeholder="用户名" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
|
|
||||||
<template #start>
|
|
||||||
<FaIcon name="i-lucide:user" />
|
|
||||||
</template>
|
|
||||||
</FaInput>
|
|
||||||
</FormControl>
|
|
||||||
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
|
|
||||||
<FormMessage class="text-xs bottom-1 absolute" />
|
|
||||||
</Transition>
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
<FormField v-slot="{ componentField, value, errors }" name="password">
|
|
||||||
<FormItem class="pb-6 relative space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<FaInput type="password" placeholder="密码" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
|
|
||||||
<template #start>
|
|
||||||
<FaIcon name="i-lucide:lock" />
|
|
||||||
</template>
|
|
||||||
</FaInput>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
<FaPasswordStrength :password="value" />
|
|
||||||
</FormDescription>
|
|
||||||
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
|
|
||||||
<FormMessage class="text-xs bottom-1 absolute" />
|
|
||||||
</Transition>
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
<FormField v-slot="{ componentField, errors }" name="checkPassword">
|
|
||||||
<FormItem class="pb-6 relative space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<FaInput type="password" placeholder="确认密码" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
|
|
||||||
<template #start>
|
|
||||||
<FaIcon name="i-lucide:lock" />
|
|
||||||
</template>
|
|
||||||
</FaInput>
|
|
||||||
</FormControl>
|
|
||||||
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
|
|
||||||
<FormMessage class="text-xs bottom-1 absolute" />
|
|
||||||
</Transition>
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
<FaButton :loading="loading" size="lg" class="mt-4 w-full" type="submit">
|
|
||||||
注册
|
|
||||||
</FaButton>
|
|
||||||
<div class="text-sm mt-4 flex-center gap-2">
|
|
||||||
<span class="text-secondary-foreground op-50">已经有帐号?</span>
|
|
||||||
<FaButton variant="link" class="p-0 h-auto" @click="emits('onLogin', form.values.account)">
|
|
||||||
去登录
|
|
||||||
</FaButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
|
||||||
import { useForm } from 'vee-validate'
|
|
||||||
import * as z from 'zod'
|
|
||||||
import { FormControl, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'ResetPasswordForm',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
account?: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emits = defineEmits<{
|
|
||||||
onLogin: [account?: string]
|
|
||||||
onResetPassword: [account?: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
validationSchema: toTypedSchema(z.object({
|
|
||||||
account: z.string().min(1, '请输入用户名'),
|
|
||||||
captcha: z.string().min(6, '请输入验证码'),
|
|
||||||
newPassword: z.string().min(1, '请输入新密码').min(6, '密码长度为6到18位').max(18, '密码长度为6到18位'),
|
|
||||||
})),
|
|
||||||
initialValues: {
|
|
||||||
account: props.account ?? '',
|
|
||||||
captcha: '',
|
|
||||||
newPassword: '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const onSubmit = form.handleSubmit((values) => {
|
|
||||||
loading.value = true
|
|
||||||
emits('onResetPassword', values.account)
|
|
||||||
})
|
|
||||||
|
|
||||||
const countdown = ref(0)
|
|
||||||
const countdownInterval = ref(Number.NaN)
|
|
||||||
function handleSendCaptcha() {
|
|
||||||
countdown.value = 60
|
|
||||||
countdownInterval.value = window.setInterval(() => {
|
|
||||||
countdown.value--
|
|
||||||
if (countdown.value === 0) {
|
|
||||||
clearInterval(countdownInterval.value)
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="p-12 flex-col-stretch-center min-h-500px w-full">
|
|
||||||
<form @submit="onSubmit">
|
|
||||||
<div class="mb-8 space-y-2">
|
|
||||||
<h3 class="text-4xl font-bold">
|
|
||||||
忘记密码了? 🔒
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-muted-foreground lg:text-base">
|
|
||||||
演示系统未提供该功能
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<FormField v-slot="{ componentField, errors }" name="account">
|
|
||||||
<FormItem class="pb-6 relative space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<FaInput type="text" placeholder="用户名" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
|
|
||||||
<template #start>
|
|
||||||
<FaIcon name="i-lucide:user" />
|
|
||||||
</template>
|
|
||||||
</FaInput>
|
|
||||||
</FormControl>
|
|
||||||
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
|
|
||||||
<FormMessage class="text-xs bottom-1 absolute" />
|
|
||||||
</Transition>
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
<div class="flex-start-between gap-2">
|
|
||||||
<FormField v-slot="{ componentField, value, setValue }" name="captcha">
|
|
||||||
<FormItem class="pb-6 relative space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<FaInputOTP :model-value="value" :name="componentField.name" :length="6" class="border-destructive" @update:model-value="val => setValue(val)" />
|
|
||||||
</FormControl>
|
|
||||||
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
|
|
||||||
<FormMessage class="text-xs bottom-1 absolute" />
|
|
||||||
</Transition>
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
<FaButton variant="outline" :disabled="countdown > 0" class="px-4 flex-1" @click="handleSendCaptcha">
|
|
||||||
{{ countdown === 0 ? '发送验证码' : `${countdown} 秒后重发` }}
|
|
||||||
</FaButton>
|
|
||||||
</div>
|
|
||||||
<FormField v-slot="{ componentField, errors }" name="newPassword">
|
|
||||||
<FormItem class="pb-6 relative space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<FaInput type="password" placeholder="新密码" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
|
|
||||||
<template #start>
|
|
||||||
<FaIcon name="i-lucide:lock" />
|
|
||||||
</template>
|
|
||||||
</FaInput>
|
|
||||||
</FormControl>
|
|
||||||
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
|
|
||||||
<FormMessage class="text-xs bottom-1 absolute" />
|
|
||||||
</Transition>
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
<FaButton :loading="loading" size="lg" class="mt-4 w-full" type="submit">
|
|
||||||
确认
|
|
||||||
</FaButton>
|
|
||||||
<div class="text-sm mt-4 flex-center gap-2">
|
|
||||||
<FaButton variant="link" class="p-0 h-auto" @click="emits('onLogin', form.values.account)">
|
|
||||||
去登录
|
|
||||||
</FaButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineOptions({
|
|
||||||
name: 'AppAuth',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
value: string | string[]
|
|
||||||
all?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isCheck = computed(() => {
|
|
||||||
return props.all
|
|
||||||
? useAppAuth().authAll(typeof props.value === 'string' ? [props.value] : props.value)
|
|
||||||
: useAppAuth().auth(props.value)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<slot v-if="isCheck" />
|
|
||||||
<slot v-else name="no-auth" />
|
|
||||||
</template>
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useTimeoutFn } from '@vueuse/core'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'AppBackToTop',
|
|
||||||
})
|
|
||||||
|
|
||||||
const transitionClass = {
|
|
||||||
enterActiveClass: 'ease-out duration-300',
|
|
||||||
enterFromClass: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
|
|
||||||
enterToClass: 'opacity-100 translate-y-0 lg-scale-100',
|
|
||||||
leaveActiveClass: 'ease-in duration-200',
|
|
||||||
leaveFromClass: 'opacity-100 translate-y-0 lg-scale-100',
|
|
||||||
leaveToClass: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener('scroll', handleScroll)
|
|
||||||
handleScroll()
|
|
||||||
})
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener('scroll', handleScroll)
|
|
||||||
})
|
|
||||||
|
|
||||||
let timeout: (() => void) | undefined
|
|
||||||
|
|
||||||
const show = ref(false)
|
|
||||||
const scrollTop = ref(0)
|
|
||||||
function handleScroll() {
|
|
||||||
scrollTop.value = document.documentElement.scrollTop
|
|
||||||
}
|
|
||||||
watch(scrollTop, (val) => {
|
|
||||||
if (val >= 200) {
|
|
||||||
handleMouseenter()
|
|
||||||
handleMouseleave()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleMouseenter() {
|
|
||||||
timeout?.()
|
|
||||||
// 此处setTimeout是为了避免移动端下和click事件同时触发
|
|
||||||
setTimeout(() => {
|
|
||||||
show.value = true
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseleave() {
|
|
||||||
;({ stop: timeout } = useTimeoutFn(() => {
|
|
||||||
show.value = false
|
|
||||||
}, 2000))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBackToTop() {
|
|
||||||
show.value && document.documentElement.scrollTo({
|
|
||||||
top: 0,
|
|
||||||
behavior: 'smooth',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition v-bind="transitionClass">
|
|
||||||
<FaButton v-if="scrollTop >= 200" variant="outline" size="icon" class="rounded-full h-12 w-12 transition-all inset-b-4 fixed z-2000 dark:bg-background -inset-e-9 dark:hover:bg-background/50" :class="{ 'inset-e-3!': show }" @mouseenter="handleMouseenter" @mouseleave="handleMouseleave" @click="handleBackToTop">
|
|
||||||
<FaIcon name="i-icon-park-outline:to-top-one" class="size-6" />
|
|
||||||
</FaButton>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineOptions({
|
|
||||||
name: 'AppCopyright',
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<footer v-if="route.meta.copyright ?? appSettingsStore.settings.app.copyright.enable" class="text-sm text-secondary-foreground/50 my-4 px-4 flex flex-wrap items-center justify-center">
|
|
||||||
<span class="px-1">Copyright</span>
|
|
||||||
<FaIcon name="i-ri:copyright-line" class="size-5" />
|
|
||||||
<span v-if="appSettingsStore.settings.app.copyright.dates" class="px-1">{{ appSettingsStore.settings.app.copyright.dates }}</span>
|
|
||||||
<template v-if="appSettingsStore.settings.app.copyright.company">
|
|
||||||
<a v-if="appSettingsStore.settings.app.copyright.website" :href="appSettingsStore.settings.app.copyright.website" target="_blank" rel="noopener" class="px-1 text-center no-underline transition-colors hover-text-secondary-foreground">{{ appSettingsStore.settings.app.copyright.company }}</a>
|
|
||||||
<span v-else class="px-1">{{ appSettingsStore.settings.app.copyright.company }}</span>
|
|
||||||
</template>
|
|
||||||
</footer>
|
|
||||||
</template>
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineOptions({
|
|
||||||
name: 'AppNotAllowed',
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
const appTabbarStore = useAppTabbarStore()
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (appSettingsStore.settings.topbar.tabbar) {
|
|
||||||
appTabbarStore.remove(route.meta.activeMenu || route.fullPath)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function goBack() {
|
|
||||||
router.push(appSettingsStore.settings.app.home.fullPath)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col h-full">
|
|
||||||
<div class="flex h-[5vh] lg:h-[10vh]">
|
|
||||||
<div class="w-[5vh] lg:w-[10vh]" />
|
|
||||||
<div class="border-inline border-dashed flex-1" />
|
|
||||||
<div class="w-[5vh] lg:w-[10vh]" />
|
|
||||||
</div>
|
|
||||||
<div class="border-block border-dashed flex flex-1">
|
|
||||||
<div class="w-[5vh] lg:w-[10vh]" />
|
|
||||||
<div class="border-inline border-dashed flex-center flex-1 relative">
|
|
||||||
<div class="p-4 flex-col-center gap-4 lg:p-12">
|
|
||||||
<h1 class="text-6xl font-bold m-0 lg:text-9xl">
|
|
||||||
403
|
|
||||||
</h1>
|
|
||||||
<div class="text-xl text-secondary-foreground/50 mx-0 text-center">
|
|
||||||
抱歉,你无权访问该页面
|
|
||||||
</div>
|
|
||||||
<FaButton variant="link" class="text-unset" @click="goBack">
|
|
||||||
返回主页
|
|
||||||
</FaButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-[5vh] lg:w-[10vh]" />
|
|
||||||
</div>
|
|
||||||
<div class="flex h-[5vh] lg:h-[10vh]">
|
|
||||||
<div class="w-[5vh] lg:w-[10vh]" />
|
|
||||||
<div class="border-inline border-dashed flex-1" />
|
|
||||||
<div class="w-[5vh] lg:w-[10vh]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineOptions({
|
|
||||||
name: 'AppNotSupportedMobile',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col h-full">
|
|
||||||
<div class="flex h-[5vh] lg:h-[10vh]">
|
|
||||||
<div class="w-[5vh] lg:w-[10vh]" />
|
|
||||||
<div class="border-inline border-dashed flex-1" />
|
|
||||||
<div class="w-[5vh] lg:w-[10vh]" />
|
|
||||||
</div>
|
|
||||||
<div class="border-block border-dashed flex flex-1">
|
|
||||||
<div class="w-[5vh] lg:w-[10vh]" />
|
|
||||||
<div class="border-inline border-dashed flex-center flex-1 relative">
|
|
||||||
<div class="p-4 flex-col-center gap-4 lg:p-12">
|
|
||||||
<h1 class="text-6xl font-bold m-0 lg:text-9xl">
|
|
||||||
<FaIcon name="i-tdesign:mobile-blocked-filled" />
|
|
||||||
</h1>
|
|
||||||
<div class="text-xl text-secondary-foreground/50 mx-0 text-center">
|
|
||||||
抱歉,本网站不支持移动设备访问,请切换到桌面设备
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-[5vh] lg:w-[10vh]" />
|
|
||||||
</div>
|
|
||||||
<div class="flex h-[5vh] lg:h-[10vh]">
|
|
||||||
<div class="w-[5vh] lg:w-[10vh]" />
|
|
||||||
<div class="border-inline border-dashed flex-1" />
|
|
||||||
<div class="w-[5vh] lg:w-[10vh]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import eventBus from '@/utils/eventBus'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'AppSystemInfo',
|
|
||||||
})
|
|
||||||
|
|
||||||
const isShow = ref(false)
|
|
||||||
|
|
||||||
const { pkg, lastBuildTime } = __SYSTEM_INFO__
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
eventBus.on('global-system-info-open', () => {
|
|
||||||
isShow.value = true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
eventBus.off('global-system-info-open')
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<FaDrawer v-model="isShow" title="系统信息" :footer="false">
|
|
||||||
<div v-if="pkg.version">
|
|
||||||
<FaDivider>
|
|
||||||
版本号
|
|
||||||
</FaDivider>
|
|
||||||
<div class="text-lg font-bold font-sans text-center">
|
|
||||||
{{ pkg.version }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FaDivider>
|
|
||||||
最后编译时间
|
|
||||||
</FaDivider>
|
|
||||||
<div class="text-lg font-bold font-sans text-center">
|
|
||||||
{{ lastBuildTime }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FaDivider>
|
|
||||||
生产环境依赖
|
|
||||||
</FaDivider>
|
|
||||||
<ul class="text-sm list-none">
|
|
||||||
<li v-for="(val, key) in (pkg.dependencies as object)" :key="key" class="px-2 py-1.5 rounded-lg flex items-center justify-between hover-bg-secondary">
|
|
||||||
<div class="font-bold">
|
|
||||||
{{ key }}
|
|
||||||
</div>
|
|
||||||
<div class="font-sans">
|
|
||||||
{{ val }}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FaDivider>
|
|
||||||
开发环境依赖
|
|
||||||
</FaDivider>
|
|
||||||
<ul class="text-sm list-none">
|
|
||||||
<li v-for="(val, key) in (pkg.devDependencies as object)" :key="key" class="px-2 py-1.5 rounded-lg flex items-center justify-between hover-bg-secondary">
|
|
||||||
<div class="font-bold">
|
|
||||||
{{ key }}
|
|
||||||
</div>
|
|
||||||
<div class="font-sans">
|
|
||||||
{{ val }}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</FaDrawer>
|
|
||||||
</template>
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
export function useAppAuth() {
|
|
||||||
function hasPermission(permission: string) {
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
const appAccountStore = useAppAccountStore()
|
|
||||||
if (appSettingsStore.settings.app.account.auth) {
|
|
||||||
return appAccountStore.permissions.includes(permission)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function auth(value: string | string[]) {
|
|
||||||
let auth
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
auth = value !== '' ? hasPermission(value) : true
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
auth = value.length > 0 ? value.some(item => hasPermission(item)) : true
|
|
||||||
}
|
|
||||||
return auth
|
|
||||||
}
|
|
||||||
|
|
||||||
function authAll(value: string[]) {
|
|
||||||
return value.length > 0 ? value.every(item => hasPermission(item)) : true
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
auth,
|
|
||||||
authAll,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
export function useAppMenu() {
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
const appMenuStore = useAppMenuStore()
|
|
||||||
|
|
||||||
function generateTitle(title: string | (() => any) = '[ 无标题 ]') {
|
|
||||||
return typeof title === 'function'
|
|
||||||
? title()
|
|
||||||
: title
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchTo(index: number) {
|
|
||||||
appMenuStore.setActived(index)
|
|
||||||
if (
|
|
||||||
appSettingsStore.settings.menu.mainMenuClickMode === 'jump'
|
|
||||||
|| (appSettingsStore.settings.menu.mainMenuClickMode === 'smart' && appMenuStore.sidebarMenusHasOnlyMenu)
|
|
||||||
) {
|
|
||||||
router.push(appMenuStore.sidebarMenusFirstDeepestPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
generateTitle,
|
|
||||||
switchTo,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
export function useAppPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
|
|
||||||
function reload() {
|
|
||||||
appSettingsStore.setIsReloading(true)
|
|
||||||
router.push({
|
|
||||||
name: 'reload',
|
|
||||||
}).then(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
appSettingsStore.setIsReloading(false)
|
|
||||||
}, 100)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
reload,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
export function useAppTabbar() {
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const appTabbarStore = useAppTabbarStore()
|
|
||||||
|
|
||||||
function getId() {
|
|
||||||
return route.fullPath
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeById(tabId = getId()) {
|
|
||||||
if (checkClose(tabId, false)) {
|
|
||||||
const activedTabId = getId()
|
|
||||||
// 如果关闭的标签正好是当前路由
|
|
||||||
if (tabId === activedTabId) {
|
|
||||||
const index = appTabbarStore.list.findIndex(item => item.tabId === tabId)
|
|
||||||
if (index > 0) {
|
|
||||||
router.close(appTabbarStore.list[index - 1].fullPath)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
router.close(appTabbarStore.list[index + 1].fullPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
appTabbarStore.remove(tabId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关闭两侧标签页
|
|
||||||
*/
|
|
||||||
function closeOtherSide(tabId = getId()) {
|
|
||||||
const activedTabId = getId()
|
|
||||||
// 如果操作的是非当前路由标签页,则先跳转到指定路由标签页
|
|
||||||
if (tabId !== activedTabId) {
|
|
||||||
const index = appTabbarStore.list.findIndex(item => item.tabId === tabId)
|
|
||||||
router.push(appTabbarStore.list[index].fullPath)
|
|
||||||
}
|
|
||||||
appTabbarStore.removeOtherSide(tabId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关闭左侧标签页
|
|
||||||
*/
|
|
||||||
function closeLeftSide(tabId = getId()) {
|
|
||||||
const activedTabId = getId()
|
|
||||||
// 如果操作的是非当前路由标签页,需要判断当前标签页是否在指定标签页左侧,如果是则先跳转到指定路由标签页
|
|
||||||
if (tabId !== activedTabId) {
|
|
||||||
const index = appTabbarStore.list.findIndex(item => item.tabId === tabId)
|
|
||||||
const activedIndex = appTabbarStore.list.findIndex(item => item.tabId === activedTabId)
|
|
||||||
if (activedIndex < index) {
|
|
||||||
router.push(appTabbarStore.list[index].fullPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
appTabbarStore.removeLeftSide(tabId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关闭右侧标签页
|
|
||||||
*/
|
|
||||||
function closeRightSide(tabId = getId()) {
|
|
||||||
const activedTabId = getId()
|
|
||||||
// 如果操作的是非当前路由标签页,需要判断当前标签页是否在指定标签页右侧,如果是则先跳转到指定路由标签页
|
|
||||||
if (tabId !== activedTabId) {
|
|
||||||
const index = appTabbarStore.list.findIndex(item => item.tabId === tabId)
|
|
||||||
const activedIndex = appTabbarStore.list.findIndex(item => item.tabId === activedTabId)
|
|
||||||
if (activedIndex > index) {
|
|
||||||
router.push(appTabbarStore.list[index].fullPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
appTabbarStore.removeRightSide(tabId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验指定标签是否可关闭
|
|
||||||
*/
|
|
||||||
function checkClose(tabId = getId(), checkOnly = true) {
|
|
||||||
let flag = true
|
|
||||||
const index = appTabbarStore.list.findIndex(item => item.tabId === tabId)
|
|
||||||
if (index < 0) {
|
|
||||||
flag = false
|
|
||||||
!checkOnly && faToast.warning('关闭的标签页不存在', {
|
|
||||||
position: 'top-center',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
else if (appTabbarStore.list.length <= 1) {
|
|
||||||
flag = false
|
|
||||||
!checkOnly && faToast.warning('当前只有一个标签页,不可关闭', {
|
|
||||||
position: 'top-center',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return flag
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验指定标签两侧是否有可关闭的标签
|
|
||||||
*/
|
|
||||||
function checkCloseOtherSide(tabId = getId()) {
|
|
||||||
return appTabbarStore.list.some((item) => {
|
|
||||||
return item.tabId !== tabId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验指定标签左侧是否有可关闭的标签
|
|
||||||
*/
|
|
||||||
function checkCloseLeftSide(tabId = getId()) {
|
|
||||||
let flag = true
|
|
||||||
if (tabId === appTabbarStore.list[0]?.tabId) {
|
|
||||||
flag = false
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const index = appTabbarStore.list.findIndex(item => item.tabId === tabId)
|
|
||||||
flag = appTabbarStore.list.some((item, i) => {
|
|
||||||
return i < index && item.tabId !== tabId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return flag
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验指定标签右侧是否有可关闭的标签
|
|
||||||
*/
|
|
||||||
function checkCloseRightSide(tabId = getId()) {
|
|
||||||
let flag = true
|
|
||||||
if (tabId === appTabbarStore.list.at(-1)?.tabId) {
|
|
||||||
flag = false
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const index = appTabbarStore.list.findIndex(item => item.tabId === tabId)
|
|
||||||
flag = appTabbarStore.list.some((item, i) => {
|
|
||||||
return i >= index && item.tabId !== tabId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return flag
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
getId,
|
|
||||||
closeById,
|
|
||||||
closeOtherSide,
|
|
||||||
closeLeftSide,
|
|
||||||
closeRightSide,
|
|
||||||
checkClose,
|
|
||||||
checkCloseOtherSide,
|
|
||||||
checkCloseLeftSide,
|
|
||||||
checkCloseRightSide,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
# Hotkeys 设计说明
|
|
||||||
|
|
||||||
这个目录参考 `fantastic-admin/v6-dev/apps/example/src/hotkeys` 的设计,目标是把当前项目里分散在各组件中的快捷键逻辑收拢成一套轻量方案。
|
|
||||||
|
|
||||||
## 设计目标
|
|
||||||
|
|
||||||
当前方案只解决 4 件事:
|
|
||||||
|
|
||||||
1. 快捷键定义不要散落在多个组件里重复写
|
|
||||||
2. 快捷键帮助面板不要再手写一份展示文案
|
|
||||||
3. 业务组件不要反复手写 `hotkeys()` / `unbind()`
|
|
||||||
4. 不引入过重的 command system
|
|
||||||
|
|
||||||
所以采用:
|
|
||||||
|
|
||||||
- `registry.ts` 统一定义
|
|
||||||
- `useHotkeys.ts` 统一注册
|
|
||||||
- 业务 handler 仍留在业务组件
|
|
||||||
|
|
||||||
这是一种“集中定义 + 就地执行”的轻量折中方案。
|
|
||||||
|
|
||||||
## 目录职责
|
|
||||||
|
|
||||||
### `registry.ts`
|
|
||||||
|
|
||||||
统一维护当前项目的快捷键元数据:
|
|
||||||
|
|
||||||
- id
|
|
||||||
- keys
|
|
||||||
- enabled
|
|
||||||
- help
|
|
||||||
|
|
||||||
这里负责:
|
|
||||||
|
|
||||||
- 统一的 id 常量
|
|
||||||
- 真实键位
|
|
||||||
- 是否启用的条件
|
|
||||||
- 帮助面板展示文案
|
|
||||||
- 聚合业务扩展
|
|
||||||
|
|
||||||
> 注意:这里不放业务逻辑 handler。
|
|
||||||
|
|
||||||
### `registry.extend.ts`
|
|
||||||
|
|
||||||
用于放置项目自定义扩展快捷键,避免把业务项全部堆进内建定义中。
|
|
||||||
|
|
||||||
### `types.ts`
|
|
||||||
|
|
||||||
定义快捷键系统的基础类型,包括:
|
|
||||||
|
|
||||||
- `HotkeyBinding`
|
|
||||||
- `HotkeyContext`
|
|
||||||
- `HotkeyHelpMeta`
|
|
||||||
- `HotkeyHandler`
|
|
||||||
|
|
||||||
### `useHotkeys.ts`
|
|
||||||
|
|
||||||
对 `hotkeys-js` 的薄封装,提供:
|
|
||||||
|
|
||||||
- `useHotkey`:注册单个快捷键
|
|
||||||
- `useHotkeyBindings`:批量注册快捷键
|
|
||||||
|
|
||||||
并统一处理:
|
|
||||||
|
|
||||||
- 注册 / 卸载
|
|
||||||
- `active` 启停
|
|
||||||
- `binding.enabled` 可用性判断
|
|
||||||
- 默认 `preventDefault`
|
|
||||||
|
|
||||||
## 当前项目已落地的快捷键
|
|
||||||
|
|
||||||
### 全局
|
|
||||||
|
|
||||||
- `system.info.open`
|
|
||||||
- `menuSearch.open`
|
|
||||||
- `page.reload`
|
|
||||||
|
|
||||||
### 主导航
|
|
||||||
|
|
||||||
- `menu.next`
|
|
||||||
- `menu.prev`
|
|
||||||
|
|
||||||
### 标签栏
|
|
||||||
|
|
||||||
- `tabbar.prev`
|
|
||||||
- `tabbar.next`
|
|
||||||
- `tabbar.closeCurrent`
|
|
||||||
- `tabbar.gotoVisibleIndex`
|
|
||||||
- `tabbar.gotoLast`
|
|
||||||
|
|
||||||
### 菜单搜索弹窗
|
|
||||||
|
|
||||||
- `menuSearch.moveUp`
|
|
||||||
- `menuSearch.moveDown`
|
|
||||||
- `menuSearch.confirm`
|
|
||||||
- `menuSearch.close`
|
|
||||||
|
|
||||||
## 使用约定
|
|
||||||
|
|
||||||
1. 新增快捷键,优先在 `registry.ts` 或 `registry.extend.ts` 补定义
|
|
||||||
2. 展示在帮助面板中的快捷键,必须配置 `help`
|
|
||||||
3. handler 尽量保留在最了解业务的组件中
|
|
||||||
4. 不直接在业务组件里继续散写 `hotkeys('xxx')`
|
|
||||||
|
|
||||||
## 现阶段差异
|
|
||||||
|
|
||||||
参考项目里还有页面最大化相关快捷键;当前项目暂未引入对应页面最大化能力,所以本次迁移只保留当前项目已有功能,并把原有 `F5` 页面刷新也纳入统一 registry 管理。
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import type { HotkeyBinding } from './types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 业务扩展快捷键 id
|
|
||||||
*
|
|
||||||
* 使用方式:
|
|
||||||
*
|
|
||||||
* export const EXT_HOTKEY_ID = {
|
|
||||||
* demoOpen: 'demo.open',
|
|
||||||
* } as const
|
|
||||||
*/
|
|
||||||
export const EXT_HOTKEY_ID = {} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 业务扩展全局快捷键
|
|
||||||
*/
|
|
||||||
export const extendGlobalHotkeyBindings: HotkeyBinding[] = []
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 业务扩展局部快捷键
|
|
||||||
*/
|
|
||||||
export const extendScopedHotkeyBindings: HotkeyBinding[] = []
|
|
||||||
@ -1,210 +0,0 @@
|
|||||||
import type { HotkeyBinding } from './types'
|
|
||||||
import { EXT_HOTKEY_ID, extendGlobalHotkeyBindings, extendScopedHotkeyBindings } from './registry.extend'
|
|
||||||
|
|
||||||
export const HOTKEY_ID = {
|
|
||||||
systemInfoOpen: 'system.info.open',
|
|
||||||
menuSearchOpen: 'menuSearch.open',
|
|
||||||
menuNext: 'menu.next',
|
|
||||||
menuPrev: 'menu.prev',
|
|
||||||
tabbarPrev: 'tabbar.prev',
|
|
||||||
tabbarNext: 'tabbar.next',
|
|
||||||
tabbarCloseCurrent: 'tabbar.closeCurrent',
|
|
||||||
tabbarGotoVisibleIndex: 'tabbar.gotoVisibleIndex',
|
|
||||||
tabbarGotoLast: 'tabbar.gotoLast',
|
|
||||||
pageReload: 'page.reload',
|
|
||||||
menuSearchMoveUp: 'menuSearch.moveUp',
|
|
||||||
menuSearchMoveDown: 'menuSearch.moveDown',
|
|
||||||
menuSearchConfirm: 'menuSearch.confirm',
|
|
||||||
menuSearchClose: 'menuSearch.close',
|
|
||||||
...EXT_HOTKEY_ID,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const hotkeyIds = Object.values(HOTKEY_ID)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hotkeys registry 维护约定
|
|
||||||
*
|
|
||||||
* 1. 新增快捷键优先在这里补充定义,不要在业务组件里直接写死键位字符串
|
|
||||||
* 2. 这里仅维护“定义”和“展示元数据”,不要把业务 handler 塞进 registry
|
|
||||||
* 3. 配置类开关尽量写在 enabled / help.visible 中,避免帮助面板与真实行为不一致
|
|
||||||
* 4. 需要展示在快捷键帮助面板中的项,必须补充 help
|
|
||||||
* 5. 仅局部场景使用的快捷键,也建议在这里定义,再由对应组件按需注册
|
|
||||||
*/
|
|
||||||
export const globalHotkeyBindings: HotkeyBinding[] = [
|
|
||||||
{
|
|
||||||
id: HOTKEY_ID.systemInfoOpen,
|
|
||||||
keys: ['command+i', 'ctrl+i'],
|
|
||||||
help: {
|
|
||||||
group: 'global',
|
|
||||||
titleKey: 'global.system',
|
|
||||||
order: 10,
|
|
||||||
displayKeys: {
|
|
||||||
default: ['Ctrl', 'I'],
|
|
||||||
mac: ['⌘', 'I'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: HOTKEY_ID.menuSearchOpen,
|
|
||||||
keys: ['command+k', 'ctrl+k'],
|
|
||||||
enabled: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
|
|
||||||
help: {
|
|
||||||
group: 'global',
|
|
||||||
titleKey: 'global.search',
|
|
||||||
order: 20,
|
|
||||||
visible: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
|
|
||||||
displayKeys: {
|
|
||||||
default: ['Ctrl', 'K'],
|
|
||||||
mac: ['⌘', 'K'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: HOTKEY_ID.menuNext,
|
|
||||||
keys: ['alt+`'],
|
|
||||||
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
|
|
||||||
help: {
|
|
||||||
group: 'nav',
|
|
||||||
titleKey: 'nav.next',
|
|
||||||
order: 10,
|
|
||||||
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
|
|
||||||
displayKeys: {
|
|
||||||
default: ['Alt', '`'],
|
|
||||||
mac: ['⌥', '`'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: HOTKEY_ID.menuPrev,
|
|
||||||
keys: ['alt+shift+`'],
|
|
||||||
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
|
|
||||||
help: {
|
|
||||||
group: 'nav',
|
|
||||||
titleKey: 'nav.prev',
|
|
||||||
order: 20,
|
|
||||||
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
|
|
||||||
displayKeys: {
|
|
||||||
default: ['Alt', 'Shift', '`'],
|
|
||||||
mac: ['⌥', '⇧', '`'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: HOTKEY_ID.tabbarPrev,
|
|
||||||
keys: ['alt+left'],
|
|
||||||
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
|
|
||||||
help: {
|
|
||||||
group: 'tabbar',
|
|
||||||
titleKey: 'tabbar.prev',
|
|
||||||
order: 10,
|
|
||||||
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
|
|
||||||
displayKeys: {
|
|
||||||
default: ['Alt', '←'],
|
|
||||||
mac: ['⌥', '←'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: HOTKEY_ID.tabbarNext,
|
|
||||||
keys: ['alt+right'],
|
|
||||||
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
|
|
||||||
help: {
|
|
||||||
group: 'tabbar',
|
|
||||||
titleKey: 'tabbar.next',
|
|
||||||
order: 20,
|
|
||||||
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
|
|
||||||
displayKeys: {
|
|
||||||
default: ['Alt', '→'],
|
|
||||||
mac: ['⌥', '→'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: HOTKEY_ID.tabbarCloseCurrent,
|
|
||||||
keys: ['alt+w'],
|
|
||||||
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
|
|
||||||
help: {
|
|
||||||
group: 'tabbar',
|
|
||||||
titleKey: 'tabbar.close',
|
|
||||||
order: 30,
|
|
||||||
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
|
|
||||||
displayKeys: {
|
|
||||||
default: ['Alt', 'W'],
|
|
||||||
mac: ['⌥', 'W'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: HOTKEY_ID.tabbarGotoVisibleIndex,
|
|
||||||
keys: ['alt+1', 'alt+2', 'alt+3', 'alt+4', 'alt+5', 'alt+6', 'alt+7', 'alt+8', 'alt+9'],
|
|
||||||
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
|
|
||||||
help: {
|
|
||||||
group: 'tabbar',
|
|
||||||
titleKey: 'tabbar.n',
|
|
||||||
order: 40,
|
|
||||||
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
|
|
||||||
displayKeys: {
|
|
||||||
default: ['Alt', '1~9'],
|
|
||||||
mac: ['⌥', '1~9'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: HOTKEY_ID.tabbarGotoLast,
|
|
||||||
keys: ['alt+0'],
|
|
||||||
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
|
|
||||||
help: {
|
|
||||||
group: 'tabbar',
|
|
||||||
titleKey: 'tabbar.last',
|
|
||||||
order: 50,
|
|
||||||
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
|
|
||||||
displayKeys: {
|
|
||||||
default: ['Alt', '0'],
|
|
||||||
mac: ['⌥', '0'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: HOTKEY_ID.pageReload,
|
|
||||||
keys: ['f5'],
|
|
||||||
},
|
|
||||||
...extendGlobalHotkeyBindings,
|
|
||||||
]
|
|
||||||
|
|
||||||
export const menuSearchHotkeyBindings: HotkeyBinding[] = [
|
|
||||||
{
|
|
||||||
id: HOTKEY_ID.menuSearchMoveUp,
|
|
||||||
keys: ['up'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: HOTKEY_ID.menuSearchMoveDown,
|
|
||||||
keys: ['down'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: HOTKEY_ID.menuSearchConfirm,
|
|
||||||
keys: ['enter'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: HOTKEY_ID.menuSearchClose,
|
|
||||||
keys: ['esc'],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const hotkeyBindings: HotkeyBinding[] = [
|
|
||||||
...globalHotkeyBindings,
|
|
||||||
...menuSearchHotkeyBindings,
|
|
||||||
...extendScopedHotkeyBindings,
|
|
||||||
]
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
const registeredIds = hotkeyBindings.map(item => item.id)
|
|
||||||
const missingIds = hotkeyIds.filter(id => !registeredIds.includes(id))
|
|
||||||
const duplicateIds = registeredIds.filter((id, index) => registeredIds.indexOf(id) !== index)
|
|
||||||
|
|
||||||
if (missingIds.length || duplicateIds.length) {
|
|
||||||
console.warn('[hotkeys] registry consistency check failed', {
|
|
||||||
missingIds,
|
|
||||||
duplicateIds: [...new Set(duplicateIds)],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
import type { useAppSettingsStore } from '@/store/modules/app/settings'
|
|
||||||
|
|
||||||
export type HotkeyHelpGroup = 'global' | 'nav' | 'tabbar' | 'page'
|
|
||||||
export type HotkeyId = typeof import('./registry').HOTKEY_ID[keyof typeof import('./registry').HOTKEY_ID]
|
|
||||||
|
|
||||||
export interface HotkeyContext {
|
|
||||||
settings: ReturnType<typeof useAppSettingsStore>['settings']
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HotkeyDisplayKeys {
|
|
||||||
default: string[]
|
|
||||||
mac?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HotkeyHelpMeta {
|
|
||||||
group: HotkeyHelpGroup
|
|
||||||
titleKey: string
|
|
||||||
order?: number
|
|
||||||
visible?: (ctx: HotkeyContext) => boolean
|
|
||||||
displayKeys: HotkeyDisplayKeys
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HotkeyBinding {
|
|
||||||
id: HotkeyId
|
|
||||||
keys: string[]
|
|
||||||
preventDefault?: boolean
|
|
||||||
enabled?: (ctx: HotkeyContext) => boolean
|
|
||||||
help?: HotkeyHelpMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HotkeyHandlerArgs {
|
|
||||||
event: KeyboardEvent
|
|
||||||
hotkey: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HotkeyHandler = (args: HotkeyHandlerArgs) => void
|
|
||||||
|
|
||||||
export type HotkeyHandlersMap = Partial<Record<HotkeyId, HotkeyHandler>>
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
import type { HotkeyHandler, HotkeyHandlersMap, HotkeyId } from './types'
|
|
||||||
import hotkeys from 'hotkeys-js'
|
|
||||||
import { hotkeyBindings, hotkeyIds } from './registry'
|
|
||||||
|
|
||||||
export function useHotkey(id: HotkeyId, handler: HotkeyHandler, active: MaybeRefOrGetter<boolean> = true) {
|
|
||||||
const binding = hotkeyBindings.find(item => item.id === id)
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
const hotkeyContext = {
|
|
||||||
settings: appSettingsStore.settings,
|
|
||||||
}
|
|
||||||
let currentHandler: ((event: KeyboardEvent, hotkeyHandler: { key: string }) => void) | undefined
|
|
||||||
|
|
||||||
function bind() {
|
|
||||||
if (currentHandler || !binding) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const currentBinding = binding
|
|
||||||
|
|
||||||
currentHandler = (event, hotkeyHandler) => {
|
|
||||||
if (currentBinding.enabled && !currentBinding.enabled(hotkeyContext)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentBinding.preventDefault !== false) {
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
handler({
|
|
||||||
event,
|
|
||||||
hotkey: hotkeyHandler.key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
hotkeys(currentBinding.keys.join(','), currentHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
function unbind() {
|
|
||||||
if (!currentHandler || !binding) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hotkeys.unbind(binding.keys.join(','), currentHandler)
|
|
||||||
currentHandler = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => toValue(active), (val) => {
|
|
||||||
if (val) {
|
|
||||||
bind()
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
unbind()
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
immediate: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
unbind()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useHotkeyBindings(handlers: HotkeyHandlersMap, active: MaybeRefOrGetter<boolean> = true) {
|
|
||||||
hotkeyIds.forEach((id) => {
|
|
||||||
const handler = handlers[id]
|
|
||||||
if (handler) {
|
|
||||||
useHotkey(id, handler, active)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@ -1 +0,0 @@
|
|||||||
{ "collections": ["ant-design", "ep", "flagpack", "icon-park", "mdi", "ri", "logos", "twemoji", "vscode-icons"], "isOfflineUse": false }
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { addCollection } from '@iconify/vue'
|
|
||||||
import data from './data.json'
|
|
||||||
|
|
||||||
export async function downloadAndInstall(name: string) {
|
|
||||||
const data = Object.freeze(await fetch(`./icons/${name}-raw.json`).then(r => r.json()))
|
|
||||||
addCollection(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const icons = data.sort((a, b) => a.info.name.localeCompare(b.info.name))
|
|
||||||
@ -1,440 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { diffTwoObj, setSettings } from '@fantastic-admin/settings'
|
|
||||||
import { useClipboard } from '@vueuse/core'
|
|
||||||
import eventBus from '@/utils/eventBus'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'AppSetting',
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
const settingsDefault = setSettings({})
|
|
||||||
const appMenuStore = useAppMenuStore()
|
|
||||||
|
|
||||||
const isShow = ref(false)
|
|
||||||
|
|
||||||
const themeRadius = computed<number[]>({
|
|
||||||
get() {
|
|
||||||
return [appSettingsStore.settings.theme.radius]
|
|
||||||
},
|
|
||||||
set(value) {
|
|
||||||
appSettingsStore.settings.theme.radius = value[0]
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => appSettingsStore.settings.menu.mode, (value) => {
|
|
||||||
if (value === 'single') {
|
|
||||||
appMenuStore.setActived(0)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
appMenuStore.setActived(route.fullPath)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
onMounted(() => {
|
|
||||||
eventBus.on('global-app-setting-toggle', () => {
|
|
||||||
isShow.value = !isShow.value
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const { copy, copied, isSupported } = useClipboard()
|
|
||||||
|
|
||||||
function handleCopy() {
|
|
||||||
copy(JSON.stringify(diffTwoObj(settingsDefault, appSettingsStore.settings), null, 2))
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<FaModal v-model="isShow" title="应用配置" description="在生产环境中应关闭该模块" :footer="isSupported" :destroy-on-close="false" class="sm:max-w-4xl" content-class="bg-[var(--g-main-area-bg)] transition-background-color">
|
|
||||||
<div
|
|
||||||
:class="{
|
|
||||||
'columns-1': appSettingsStore.mode === 'mobile',
|
|
||||||
'columns-2': appSettingsStore.mode === 'pc',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<FaPageMain title="主题" class="m-0 mb-4 break-inside-avoid light:border-none" title-class="font-bold" main-class="space-y-4">
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
颜色方案
|
|
||||||
</div>
|
|
||||||
<FaButtonGroup>
|
|
||||||
<FaButton
|
|
||||||
v-for="(item, index) in [
|
|
||||||
{ icon: 'i-ri:sun-line', value: 'light' },
|
|
||||||
{ icon: 'i-ri:moon-line', value: 'dark' },
|
|
||||||
{ icon: 'i-codicon:color-mode', value: '' },
|
|
||||||
]" :key="index" :variant="appSettingsStore.settings.theme.colorScheme === item.value ? 'default' : 'outline'" size="sm" :class="{ 'z-1': appSettingsStore.settings.theme.colorScheme === item.value }" @click="appSettingsStore.settings.theme.colorScheme = (item.value as any)"
|
|
||||||
>
|
|
||||||
<FaIcon :name="item.icon" />
|
|
||||||
</FaButton>
|
|
||||||
</FaButtonGroup>
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
圆角
|
|
||||||
</div>
|
|
||||||
<FaSlider v-model="themeRadius" :min="0" :max="1" :step="0.25" class="w-1/2" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
色弱模式
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.theme.colorAmblyopia" />
|
|
||||||
</div>
|
|
||||||
</FaPageMain>
|
|
||||||
<FaPageMain v-if="appSettingsStore.mode === 'pc'" title="导航菜单" class="m-0 mb-4 break-inside-avoid light:border-none" title-class="font-bold" main-class="space-y-4">
|
|
||||||
<div class="menu-mode">
|
|
||||||
<FaTooltip text="侧边栏模式 (有主导航菜单)" :delay="500">
|
|
||||||
<FaButton variant="outline" class="mode mode-side" :class="{ active: appSettingsStore.settings.menu.mode === 'side' }" @click="appSettingsStore.settings.menu.mode = 'side'">
|
|
||||||
<div class="mode-container" />
|
|
||||||
</FaButton>
|
|
||||||
</FaTooltip>
|
|
||||||
<FaTooltip text="顶部模式" :delay="500">
|
|
||||||
<FaButton variant="outline" class="mode mode-head" :class="{ active: appSettingsStore.settings.menu.mode === 'head' }" @click="appSettingsStore.settings.menu.mode = 'head'">
|
|
||||||
<div class="mode-container" />
|
|
||||||
</FaButton>
|
|
||||||
</FaTooltip>
|
|
||||||
<FaTooltip text="侧边栏模式 (无主导航菜单)" :delay="500">
|
|
||||||
<FaButton variant="outline" class="mode mode-single" :class="{ active: appSettingsStore.settings.menu.mode === 'single' }" @click="appSettingsStore.settings.menu.mode = 'single'">
|
|
||||||
<div class="mode-container" />
|
|
||||||
</FaButton>
|
|
||||||
</FaTooltip>
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label" :class="{ 'op-50': !['single', 'side', 'head'].includes(appSettingsStore.settings.menu.mode) }">
|
|
||||||
点击主导航菜单
|
|
||||||
<FaTooltip text="智能模式下默认执行切换操作,当次导航菜单只有一个可访问的导航菜单时执行跳转操作">
|
|
||||||
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
|
|
||||||
</FaTooltip>
|
|
||||||
</div>
|
|
||||||
<FaButtonGroup>
|
|
||||||
<FaButton
|
|
||||||
v-for="(item, index) in [
|
|
||||||
{ label: '切换', value: 'switch' },
|
|
||||||
{ label: '跳转', value: 'jump' },
|
|
||||||
{ label: '智能', value: 'smart' },
|
|
||||||
]" :key="index" :variant="appSettingsStore.settings.menu.mainMenuClickMode === item.value ? 'default' : 'outline'" size="sm" :disabled="!['single', 'side', 'head'].includes(appSettingsStore.settings.menu.mode)" :class="{ 'z-1': appSettingsStore.settings.menu.mainMenuClickMode === item.value }" @click="appSettingsStore.settings.menu.mainMenuClickMode = (item.value as any)"
|
|
||||||
>
|
|
||||||
{{ item.label }}
|
|
||||||
</FaButton>
|
|
||||||
</FaButtonGroup>
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
次导航菜单唯一展开
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.menu.subMenuUniqueExpand" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
次导航菜单收起
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.menu.subMenuCollapse" />
|
|
||||||
</div>
|
|
||||||
<div v-if="appSettingsStore.mode === 'pc'" class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
次导航菜单展开/收起按钮
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.menu.subMenuCollapseButton" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label" :class="{ 'op-50': appSettingsStore.settings.menu.mode === 'single' }">
|
|
||||||
快捷键
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.menu.hotkeys" :disabled="appSettingsStore.settings.menu.mode === 'single'" />
|
|
||||||
</div>
|
|
||||||
</FaPageMain>
|
|
||||||
<FaPageMain title="顶栏" class="m-0 mb-4 break-inside-avoid light:border-none" title-class="font-bold" main-class="space-y-4">
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
标签栏
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.topbar.tabbar" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
工具栏
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.topbar.toolbar" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
模式
|
|
||||||
</div>
|
|
||||||
<FaButtonGroup>
|
|
||||||
<FaButton
|
|
||||||
v-for="(item, index) in [
|
|
||||||
{ label: '静态', value: 'static' },
|
|
||||||
{ label: '固定', value: 'fixed' },
|
|
||||||
{ label: '粘性', value: 'sticky' },
|
|
||||||
]" :key="index" :variant="appSettingsStore.settings.topbar.mode === item.value ? 'default' : 'outline'" size="sm" :class="{ 'z-1': appSettingsStore.settings.topbar.mode === item.value }" :disabled="!appSettingsStore.settings.topbar.tabbar && !appSettingsStore.settings.topbar.toolbar" @click="appSettingsStore.settings.topbar.mode = (item.value as any)"
|
|
||||||
>
|
|
||||||
{{ item.label }}
|
|
||||||
</FaButton>
|
|
||||||
</FaButtonGroup>
|
|
||||||
</div>
|
|
||||||
</FaPageMain>
|
|
||||||
<FaPageMain title="标签栏" class="m-0 mb-4 break-inside-avoid light:border-none" title-class="font-bold" main-class="space-y-4">
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
显示图标
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.tabbar.icon" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
快捷键
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.tabbar.hotkeys" />
|
|
||||||
</div>
|
|
||||||
</FaPageMain>
|
|
||||||
<FaPageMain title="工具栏" class="m-0 mb-4 break-inside-avoid light:border-none" title-class="font-bold" main-class="space-y-4">
|
|
||||||
<div v-if="appSettingsStore.mode === 'pc'" class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
<FaIcon name="i-ic:twotone-double-arrow" />
|
|
||||||
面包屑导航
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.toolbar.breadcrumb" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
<FaIcon name="i-ri:search-line" />
|
|
||||||
导航搜索
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.toolbar.menuSearch.enable" />
|
|
||||||
</div>
|
|
||||||
<div class="ps-8 space-y-4">
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label" :class="{ 'op-50': !appSettingsStore.settings.toolbar.menuSearch.enable }">
|
|
||||||
快捷键
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.toolbar.menuSearch.hotkeys" :disabled="!appSettingsStore.settings.toolbar.menuSearch.enable" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="appSettingsStore.mode === 'pc'" class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
<FaIcon name="i-ri:fullscreen-line" />
|
|
||||||
全屏
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.toolbar.fullscreen" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
<FaIcon name="i-iconoir:refresh-double" />
|
|
||||||
页面刷新
|
|
||||||
<FaTooltip text="重新载入当前页面,并且不刷新浏览器">
|
|
||||||
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
|
|
||||||
</FaTooltip>
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.toolbar.pageReload" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
<FaIcon name="i-ri:sun-line" />
|
|
||||||
颜色主题
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.toolbar.colorScheme" />
|
|
||||||
</div>
|
|
||||||
</FaPageMain>
|
|
||||||
<FaPageMain title="页面" class="m-0 mb-4 break-inside-avoid light:border-none" title-class="font-bold" main-class="space-y-4">
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
载入进度条
|
|
||||||
<FaTooltip text="路由跳转时会在页面顶部显示进度条,该进度仅为模拟效果,并非真实加载进度">
|
|
||||||
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
|
|
||||||
</FaTooltip>
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.page.progress" />
|
|
||||||
</div>
|
|
||||||
</FaPageMain>
|
|
||||||
<FaPageMain title="应用" class="m-0 mb-4 break-inside-avoid light:border-none" title-class="font-bold" main-class="space-y-4">
|
|
||||||
<div class="p-4 pb-4 pt-14 border rounded-lg relative space-y-4">
|
|
||||||
<div class="font-bold px-4 py-2 border-b border-e rounded-rb-lg inset-s-0 inset-t-0 absolute">
|
|
||||||
账号
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
权限验证
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.app.account.auth" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
动态标题
|
|
||||||
<FaTooltip text="开启时页面标题会显示当前路由标题,格式为“页面标题 - 网站名称”;关闭时则显示网站名称,网站名称在项目根目录下 .env.* 文件里配置">
|
|
||||||
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
|
|
||||||
</FaTooltip>
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.app.dynamicTitle" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
哀悼模式
|
|
||||||
<FaTooltip text="网站整体变灰色">
|
|
||||||
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
|
|
||||||
</FaTooltip>
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.app.rip" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
移动端访问
|
|
||||||
<FaTooltip text="关闭后,将禁用移动端访问">
|
|
||||||
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
|
|
||||||
</FaTooltip>
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.app.mobile" />
|
|
||||||
</div>
|
|
||||||
<div class="p-4 pb-4 pt-14 border rounded-lg relative space-y-4">
|
|
||||||
<div class="font-bold px-4 py-2 border-b border-e rounded-rb-lg inset-s-0 inset-t-0 absolute">
|
|
||||||
主页
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
启用
|
|
||||||
<FaTooltip text="登录后默认进入主页,否则默认进入第一个导航页面">
|
|
||||||
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
|
|
||||||
</FaTooltip>
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.app.home.enable" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
标题
|
|
||||||
<FaTooltip text="用于设置首页显示标题,支持直接输入简体中文内容">
|
|
||||||
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
|
|
||||||
</FaTooltip>
|
|
||||||
</div>
|
|
||||||
<FaInput v-model="appSettingsStore.settings.app.home.title" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 pb-4 pt-14 border rounded-lg relative space-y-4">
|
|
||||||
<div class="font-bold px-4 py-2 border-b border-e rounded-rb-lg inset-s-0 inset-t-0 absolute">
|
|
||||||
版权
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
启用
|
|
||||||
</div>
|
|
||||||
<FaSwitch v-model="appSettingsStore.settings.app.copyright.enable" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
日期
|
|
||||||
</div>
|
|
||||||
<FaInput v-model="appSettingsStore.settings.app.copyright.dates" :disabled="!appSettingsStore.settings.app.copyright.enable" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
公司
|
|
||||||
</div>
|
|
||||||
<FaInput v-model="appSettingsStore.settings.app.copyright.company" :disabled="!appSettingsStore.settings.app.copyright.enable" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
网站
|
|
||||||
</div>
|
|
||||||
<FaInput v-model="appSettingsStore.settings.app.copyright.website" :disabled="!appSettingsStore.settings.app.copyright.enable" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FaPageMain>
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<div class="w-full">
|
|
||||||
<div class="text-sm/6 c-rose mb-2 px-4 py-2 text-center rounded-lg bg-rose/20">
|
|
||||||
在此处调整配置只是临时生效,要想真正应用于项目,请点击「复制配置」按钮,并粘贴到
|
|
||||||
<code class="text-sm font-mono font-semibold px-[0.3rem] py-[0.2rem] rounded bg-muted relative">src/settings.ts</code>
|
|
||||||
文件中。
|
|
||||||
</div>
|
|
||||||
<FaButton class="w-full" @click="handleCopy">
|
|
||||||
<FaIcon :name="copied ? 'i-tabler:clipboard-check' : 'i-tabler:clipboard'" class="size-5" />
|
|
||||||
复制配置
|
|
||||||
</FaButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</FaModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.menu-mode {
|
|
||||||
--uno: flex items-center justify-center gap-4;
|
|
||||||
|
|
||||||
.mode {
|
|
||||||
--uno: relative w-16 h-12;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
--uno: ring-primary ring-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before,
|
|
||||||
&::after,
|
|
||||||
.mode-container {
|
|
||||||
--uno: absolute pointer-events-none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
--uno: content-empty bg-primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
--uno: content-empty bg-primary/60;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-container {
|
|
||||||
--uno: bg-primary/20 border-width-1.5 border-dashed border-primary;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
--uno: content-empty absolute w-full h-full;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-side {
|
|
||||||
&::before {
|
|
||||||
--uno: top-2 bottom-2 start-2 w-2 rounded-ss-1 rounded-es-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
--uno: top-2 bottom-2 start-4.5 w-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-container {
|
|
||||||
--uno: inset-t-2 inset-e-2 inset-b-2 inset-s-8 rounded-se-1 rounded-ee-1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-head {
|
|
||||||
&::before {
|
|
||||||
--uno: top-2 start-2 end-2 h-2 rounded-ss-1 rounded-se-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
--uno: top-4.5 start-2 bottom-2 w-3 rounded-es-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-container {
|
|
||||||
--uno: inset-t-4.5 inset-e-2 inset-b-2 inset-s-5.5 rounded-ee-1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-single {
|
|
||||||
&::after {
|
|
||||||
--uno: top-2 start-2 bottom-2 w-3 rounded-ss-1 rounded-es-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-container {
|
|
||||||
--uno: inset-t-2 inset-e-2 inset-b-2 inset-s-5.5 rounded-se-1 rounded-ee-1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-item {
|
|
||||||
--uno: flex items-center justify-between gap-4;
|
|
||||||
|
|
||||||
.label {
|
|
||||||
--uno: flex items-center flex-shrink-0 gap-2 text-sm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="text-sm flex items-center">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
:deep(.breadcrumb-item) {
|
|
||||||
&:first-child {
|
|
||||||
.separator {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
.text {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { RouteLocationRaw } from 'vue-router'
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
to?: RouteLocationRaw
|
|
||||||
replace?: boolean
|
|
||||||
separator?: string
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
separator: '/',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
function onClick() {
|
|
||||||
if (props.to) {
|
|
||||||
props.replace ? router.replace(props.to) : router.push(props.to)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="breadcrumb-item text-foreground flex items-center">
|
|
||||||
<span class="separator mx-2">
|
|
||||||
{{ separator }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="text opacity-60 flex items-center"
|
|
||||||
:class="{
|
|
||||||
'is-link cursor-pointer transition-opacity hover-opacity-100': !!props.to,
|
|
||||||
}" @click="onClick"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,253 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { CSSProperties } from 'vue'
|
|
||||||
|
|
||||||
type SnapSide = 'left' | 'right'
|
|
||||||
|
|
||||||
const EDGE_OFFSET = 4
|
|
||||||
const DRAG_THRESHOLD = 8
|
|
||||||
const DEFAULT_BUTTON_SIZE = 40
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
const buttonRef = useTemplateRef('buttonRef')
|
|
||||||
|
|
||||||
const buttonState = ref({
|
|
||||||
initialized: false,
|
|
||||||
width: DEFAULT_BUTTON_SIZE,
|
|
||||||
height: DEFAULT_BUTTON_SIZE,
|
|
||||||
left: EDGE_OFFSET,
|
|
||||||
top: EDGE_OFFSET,
|
|
||||||
snapSide: 'left' as SnapSide,
|
|
||||||
})
|
|
||||||
|
|
||||||
const dragState = ref({
|
|
||||||
pointerId: -1,
|
|
||||||
active: false,
|
|
||||||
moved: false,
|
|
||||||
suppressClick: false,
|
|
||||||
startX: 0,
|
|
||||||
startY: 0,
|
|
||||||
startLeft: 0,
|
|
||||||
startTop: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const buttonStyle = computed<CSSProperties>(() => ({
|
|
||||||
left: buttonState.value.initialized
|
|
||||||
? `${buttonState.value.left}px`
|
|
||||||
: `${EDGE_OFFSET}px`,
|
|
||||||
right: buttonState.value.initialized
|
|
||||||
? 'auto'
|
|
||||||
: 'auto',
|
|
||||||
top: buttonState.value.initialized
|
|
||||||
? `${buttonState.value.top}px`
|
|
||||||
: 'auto',
|
|
||||||
bottom: buttonState.value.initialized
|
|
||||||
? 'auto'
|
|
||||||
: `${EDGE_OFFSET}px`,
|
|
||||||
transition: dragState.value.active ? 'none' : 'left 200ms ease, top 200ms ease',
|
|
||||||
willChange: 'left, top',
|
|
||||||
}))
|
|
||||||
|
|
||||||
function getButtonElement() {
|
|
||||||
const el = buttonRef.value?.$el
|
|
||||||
return el instanceof HTMLElement ? el : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number) {
|
|
||||||
return Math.min(Math.max(value, min), max)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getViewportBounds() {
|
|
||||||
return {
|
|
||||||
minLeft: EDGE_OFFSET,
|
|
||||||
maxLeft: Math.max(EDGE_OFFSET, window.innerWidth - buttonState.value.width - EDGE_OFFSET),
|
|
||||||
minTop: EDGE_OFFSET,
|
|
||||||
maxTop: Math.max(EDGE_OFFSET, window.innerHeight - buttonState.value.height - EDGE_OFFSET),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncButtonMetrics() {
|
|
||||||
const rect = getButtonElement()?.getBoundingClientRect()
|
|
||||||
if (!rect) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
buttonState.value.width = rect.width || DEFAULT_BUTTON_SIZE
|
|
||||||
buttonState.value.height = rect.height || DEFAULT_BUTTON_SIZE
|
|
||||||
}
|
|
||||||
|
|
||||||
function setButtonPosition(left = buttonState.value.left, top = buttonState.value.top) {
|
|
||||||
const bounds = getViewportBounds()
|
|
||||||
buttonState.value.left = clamp(left, bounds.minLeft, bounds.maxLeft)
|
|
||||||
buttonState.value.top = clamp(top, bounds.minTop, bounds.maxTop)
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapToSide(side: SnapSide) {
|
|
||||||
const bounds = getViewportBounds()
|
|
||||||
buttonState.value.snapSide = side
|
|
||||||
buttonState.value.left = side === 'left' ? bounds.minLeft : bounds.maxLeft
|
|
||||||
buttonState.value.top = clamp(buttonState.value.top, bounds.minTop, bounds.maxTop)
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncPositionToViewport() {
|
|
||||||
syncButtonMetrics()
|
|
||||||
const bounds = getViewportBounds()
|
|
||||||
buttonState.value.top = clamp(buttonState.value.top, bounds.minTop, bounds.maxTop)
|
|
||||||
buttonState.value.left = buttonState.value.snapSide === 'right'
|
|
||||||
? bounds.maxLeft
|
|
||||||
: clamp(buttonState.value.left, bounds.minLeft, bounds.maxLeft)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializeButtonPosition() {
|
|
||||||
if (appSettingsStore.mode !== 'mobile') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
syncButtonMetrics()
|
|
||||||
if (!getButtonElement()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!buttonState.value.initialized) {
|
|
||||||
const bounds = getViewportBounds()
|
|
||||||
buttonState.value.snapSide = 'left'
|
|
||||||
buttonState.value.left = bounds.minLeft
|
|
||||||
buttonState.value.top = bounds.maxTop
|
|
||||||
buttonState.value.initialized = true
|
|
||||||
}
|
|
||||||
|
|
||||||
setButtonPosition()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerDown(event: PointerEvent) {
|
|
||||||
if (!event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = event.currentTarget
|
|
||||||
if (!(target instanceof HTMLElement)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
syncButtonMetrics()
|
|
||||||
dragState.value.pointerId = event.pointerId
|
|
||||||
dragState.value.active = true
|
|
||||||
dragState.value.moved = false
|
|
||||||
dragState.value.startX = event.clientX
|
|
||||||
dragState.value.startY = event.clientY
|
|
||||||
dragState.value.startLeft = buttonState.value.left
|
|
||||||
dragState.value.startTop = buttonState.value.top
|
|
||||||
target.setPointerCapture?.(event.pointerId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerMove(event: PointerEvent) {
|
|
||||||
if (!dragState.value.active || dragState.value.pointerId !== event.pointerId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaX = event.clientX - dragState.value.startX
|
|
||||||
const deltaY = event.clientY - dragState.value.startY
|
|
||||||
|
|
||||||
if (!dragState.value.moved && Math.hypot(deltaX, deltaY) < DRAG_THRESHOLD) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dragState.value.moved = true
|
|
||||||
setButtonPosition(dragState.value.startLeft + deltaX, dragState.value.startTop + deltaY)
|
|
||||||
}
|
|
||||||
|
|
||||||
function finishDrag(target?: EventTarget | null) {
|
|
||||||
const pointerId = dragState.value.pointerId
|
|
||||||
const shouldSuppressClick = dragState.value.moved
|
|
||||||
|
|
||||||
dragState.value.pointerId = -1
|
|
||||||
dragState.value.active = false
|
|
||||||
dragState.value.moved = false
|
|
||||||
|
|
||||||
if (target instanceof HTMLElement && pointerId >= 0 && target.hasPointerCapture?.(pointerId)) {
|
|
||||||
target.releasePointerCapture(pointerId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldSuppressClick) {
|
|
||||||
const nextSide: SnapSide = buttonState.value.left + buttonState.value.width / 2 <= window.innerWidth / 2 ? 'left' : 'right'
|
|
||||||
snapToSide(nextSide)
|
|
||||||
}
|
|
||||||
|
|
||||||
dragState.value.suppressClick = shouldSuppressClick
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerUp(event: PointerEvent) {
|
|
||||||
if (dragState.value.pointerId !== event.pointerId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
finishDrag(event.currentTarget)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerCancel(event: PointerEvent) {
|
|
||||||
if (dragState.value.pointerId !== event.pointerId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
finishDrag(event.currentTarget)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLostPointerCapture(event: PointerEvent) {
|
|
||||||
if (dragState.value.active && dragState.value.pointerId === event.pointerId) {
|
|
||||||
finishDrag(event.currentTarget)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
if (dragState.value.suppressClick) {
|
|
||||||
dragState.value.suppressClick = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
appSettingsStore.toggleSidebarCollapse()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleViewportResize() {
|
|
||||||
if (appSettingsStore.mode !== 'mobile' || !buttonState.value.initialized) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
syncPositionToViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => appSettingsStore.mode, async (mode) => {
|
|
||||||
if (mode === 'mobile') {
|
|
||||||
await initializeButtonPosition()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dragState.value.pointerId = -1
|
|
||||||
dragState.value.active = false
|
|
||||||
dragState.value.moved = false
|
|
||||||
}, {
|
|
||||||
immediate: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener('resize', handleViewportResize, { passive: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('resize', handleViewportResize)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<FaButton
|
|
||||||
v-if="appSettingsStore.mode === 'mobile' && !appSettingsStore.settings.topbar.toolbar"
|
|
||||||
ref="buttonRef"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
class="rounded-full size-10 select-none shadow-sm fixed z-1008 touch-none"
|
|
||||||
:class="{ 'cursor-grabbing': dragState.active, 'cursor-grab': !dragState.active }"
|
|
||||||
:style="buttonStyle"
|
|
||||||
@pointerdown="handlePointerDown"
|
|
||||||
@pointermove="handlePointerMove"
|
|
||||||
@pointerup="handlePointerUp"
|
|
||||||
@pointercancel="handlePointerCancel"
|
|
||||||
@lostpointercapture="handleLostPointerCapture"
|
|
||||||
@click="handleClick"
|
|
||||||
>
|
|
||||||
<FaIcon name="app-toolbar-collapse" class="size-4 rotate-180" />
|
|
||||||
</FaButton>
|
|
||||||
</template>
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useSlots } from '@/slots'
|
|
||||||
import Logo from '../Logo/index.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'LayoutHeader',
|
|
||||||
})
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
enable: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
const appMenuStore = useAppMenuStore()
|
|
||||||
|
|
||||||
const { generateTitle, switchTo } = useAppMenu()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Transition name="header">
|
|
||||||
<header v-if="enable">
|
|
||||||
<div class="header-container">
|
|
||||||
<Component :is="useSlots('header-start')" />
|
|
||||||
<Logo class="title" />
|
|
||||||
<Component :is="useSlots('header-after-logo')" />
|
|
||||||
<FaScrollArea :scrollbar="false" mask horizontal class="menu-container overscroll-contain flex-1 h-full">
|
|
||||||
<!-- 顶部模式 -->
|
|
||||||
<div
|
|
||||||
v-if="appSettingsStore.settings.menu.mode === 'head'" class="menu flex h-full transition-all of-hidden"
|
|
||||||
>
|
|
||||||
<template v-for="(item, index) in appMenuStore.allMenus" :key="index">
|
|
||||||
<div
|
|
||||||
class="menu-item mx-1 py-2 transition-all relative" :class="{
|
|
||||||
active: index === appMenuStore.actived,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<FaTooltip v-if="item.children && item.children.length !== 0" :disabled="!generateTitle(item.meta?.title)" :text="generateTitle(item.meta?.title)" side="bottom">
|
|
||||||
<div
|
|
||||||
class="group menu-item-container text-[var(--g-header-menu-color)] px-3 rounded-lg flex gap-1 h-full w-full cursor-pointer transition-colors items-center justify-between relative hover-(text-[var(--g-header-menu-hover-color)] bg-[var(--g-header-menu-hover-bg)])" :class="{
|
|
||||||
'text-[var(--g-header-menu-active-color)]! bg-[var(--g-header-menu-active-bg)]!': index === appMenuStore.actived,
|
|
||||||
}" @click="switchTo(index)"
|
|
||||||
>
|
|
||||||
<div class="inline-flex flex-1 gap-1 items-center justify-center">
|
|
||||||
<FaIcon v-if="item.meta?.icon" :name="item.meta.icon" class="menu-item-container-icon transition-transform group-hover-scale-120" />
|
|
||||||
<span class="text-sm flex-1 w-full truncate transition-height transition-opacity transition-width empty:hidden">
|
|
||||||
{{ generateTitle(item.meta?.title) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FaTooltip>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</FaScrollArea>
|
|
||||||
<Component :is="useSlots('header-after-menu')" />
|
|
||||||
<div class="flex-center">
|
|
||||||
<AppAccountButton only-avatar dropdown-side="bottom" class="p-2 size-12" />
|
|
||||||
</div>
|
|
||||||
<Component :is="useSlots('header-end')" />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
header {
|
|
||||||
position: fixed;
|
|
||||||
top: var(--g-slots-layout-top-height);
|
|
||||||
right: var(--scrollbar-width, 0);
|
|
||||||
left: 0;
|
|
||||||
z-index: 1020;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: calc(100% - var(--scrollbar-width, 0px));
|
|
||||||
height: var(--g-header-height);
|
|
||||||
margin: 0 auto;
|
|
||||||
color: var(--g-header-color);
|
|
||||||
background-color: var(--g-header-bg);
|
|
||||||
box-shadow: inset 0 -1px 0 0 oklch(var(--border));
|
|
||||||
transition: width 0.3s, background-color 0.15s, box-shadow 0.15s;
|
|
||||||
|
|
||||||
.header-container {
|
|
||||||
display: flex;
|
|
||||||
gap: 30px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
max-width: var(--g-header-width);
|
|
||||||
height: 100%;
|
|
||||||
padding: 0 12px;
|
|
||||||
margin: 0 auto;
|
|
||||||
|
|
||||||
:deep(a.title) {
|
|
||||||
position: relative;
|
|
||||||
flex: 0;
|
|
||||||
width: inherit;
|
|
||||||
height: inherit;
|
|
||||||
padding: 0;
|
|
||||||
background-color: inherit;
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
width: initial;
|
|
||||||
max-width: initial;
|
|
||||||
height: min(70%, 50px);
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 20px;
|
|
||||||
color: var(--g-header-color);
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-container {
|
|
||||||
.menu {
|
|
||||||
display: inline-flex;
|
|
||||||
|
|
||||||
:deep(.menu-item) {
|
|
||||||
.menu-item-container {
|
|
||||||
color: var(--g-header-menu-color);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--g-header-menu-hover-color);
|
|
||||||
background-color: var(--g-header-menu-hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item-container-icon {
|
|
||||||
font-size: 20px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active .menu-item-container {
|
|
||||||
color: var(--g-header-menu-active-color) !important;
|
|
||||||
background-color: var(--g-header-menu-active-bg) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 头部动画 */
|
|
||||||
.header-enter-active,
|
|
||||||
.header-leave-active {
|
|
||||||
transition: transform 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-enter-from,
|
|
||||||
.header-leave-to {
|
|
||||||
transform: translateY(calc(var(--g-header-height) * -1));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { globalHotkeyBindings } from '@/hotkeys/registry'
|
|
||||||
import eventBus from '@/utils/eventBus'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'HotkeysIntro',
|
|
||||||
})
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
const hotkeyContext = {
|
|
||||||
settings: appSettingsStore.settings,
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupTitleMap = {
|
|
||||||
global: '全局',
|
|
||||||
nav: '主导航',
|
|
||||||
tabbar: '标签栏',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const itemTitleMap: Record<string, string> = {
|
|
||||||
'global.system': '查看系统信息',
|
|
||||||
'global.search': '唤起导航搜索',
|
|
||||||
'nav.next': '激活下一个主导航',
|
|
||||||
'nav.prev': '激活上一个主导航',
|
|
||||||
'tabbar.prev': '切换到上一个标签页',
|
|
||||||
'tabbar.next': '切换到下一个标签页',
|
|
||||||
'tabbar.close': '关闭当前标签页',
|
|
||||||
'tabbar.n': '切换到第 n 个标签页',
|
|
||||||
'tabbar.last': '切换到最后一个标签页',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const isShow = ref(false)
|
|
||||||
|
|
||||||
const helpGroups = computed(() => {
|
|
||||||
const groups = [
|
|
||||||
{ id: 'global', title: groupTitleMap.global },
|
|
||||||
{ id: 'nav', title: groupTitleMap.nav },
|
|
||||||
{ id: 'tabbar', title: groupTitleMap.tabbar },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
return groups.map((group) => {
|
|
||||||
const items = globalHotkeyBindings
|
|
||||||
.filter(binding => binding.help?.group === group.id)
|
|
||||||
.filter((binding) => {
|
|
||||||
if (!binding.help) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (binding.help.visible) {
|
|
||||||
return binding.help.visible(hotkeyContext)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.sort((a, b) => (a.help?.order ?? 0) - (b.help?.order ?? 0))
|
|
||||||
.map((binding) => {
|
|
||||||
const displayKeys = appSettingsStore.os === 'mac' && binding.help?.displayKeys.mac
|
|
||||||
? binding.help.displayKeys.mac
|
|
||||||
: binding.help?.displayKeys.default ?? []
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: binding.id,
|
|
||||||
title: binding.help?.titleKey ? itemTitleMap[binding.help.titleKey] : '',
|
|
||||||
displayKeys,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
...group,
|
|
||||||
items,
|
|
||||||
}
|
|
||||||
}).filter(group => group.items.length > 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
eventBus.on('global-hotkeys-intro-toggle', () => {
|
|
||||||
isShow.value = !isShow.value
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
eventBus.off('global-hotkeys-intro-toggle')
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<FaModal v-model="isShow" title="快捷键" :footer="false">
|
|
||||||
<div class="px-4">
|
|
||||||
<div class="gap-4 grid sm-grid-cols-2">
|
|
||||||
<div v-for="group in helpGroups" :key="group.id">
|
|
||||||
<h2 class="text-lg font-bold m-0">
|
|
||||||
{{ group.title }}
|
|
||||||
</h2>
|
|
||||||
<ul class="text-sm ps-2 pt-2 list-none">
|
|
||||||
<li v-for="item in group.items" :key="item.id" class="py-1 flex-baseline gap-2">
|
|
||||||
<FaKbdGroup>
|
|
||||||
<FaKbd v-for="key in item.displayKeys" :key="key">
|
|
||||||
{{ key }}
|
|
||||||
</FaKbd>
|
|
||||||
</FaKbdGroup>
|
|
||||||
{{ item.title }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FaModal>
|
|
||||||
</template>
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import imgLogo from '@/assets/images/logo.svg'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'Logo',
|
|
||||||
})
|
|
||||||
|
|
||||||
withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
showLogo?: boolean
|
|
||||||
showTitle?: boolean
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
showLogo: true,
|
|
||||||
showTitle: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
|
|
||||||
const title = ref(import.meta.env.VITE_APP_TITLE)
|
|
||||||
const logo = ref(imgLogo)
|
|
||||||
|
|
||||||
const to = computed(() => appSettingsStore.settings.app.home.enable ? appSettingsStore.settings.app.home.fullPath : '')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<RouterLink :to class="text-primary px-3 no-underline flex-center gap-2 h-[var(--g-sidebar-logo-height)] w-inherit" :class="{ 'cursor-default': !appSettingsStore.settings.app.home.enable }" :title="title">
|
|
||||||
<img v-if="showLogo" :src="logo" class="logo h-[30px] w-[30px] object-contain">
|
|
||||||
<span v-if="showTitle" class="font-bold block truncate">{{ title }}</span>
|
|
||||||
</RouterLink>
|
|
||||||
</template>
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
|
|
||||||
import { useSlots } from '@/slots'
|
|
||||||
import Logo from '../Logo/index.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'MainSidebar',
|
|
||||||
})
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
enable: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
const appMenuStore = useAppMenuStore()
|
|
||||||
|
|
||||||
const { generateTitle, switchTo } = useAppMenu()
|
|
||||||
|
|
||||||
useHotkeyBindings({
|
|
||||||
'menu.next': () => {
|
|
||||||
switchTo(appMenuStore.actived + 1 < appMenuStore.allMenus.length ? appMenuStore.actived + 1 : 0)
|
|
||||||
},
|
|
||||||
'menu.prev': () => {
|
|
||||||
switchTo(appMenuStore.actived - 1 >= 0 ? appMenuStore.actived - 1 : appMenuStore.allMenus.length - 1)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Transition name="main-sidebar">
|
|
||||||
<div v-if="enable" class="main-sidebar-container">
|
|
||||||
<Component :is="useSlots('main-sidebar-top')" />
|
|
||||||
<Logo :show-title="false" class="sidebar-logo" />
|
|
||||||
<Component :is="useSlots('main-sidebar-after-logo')" />
|
|
||||||
<FaScrollArea :scrollbar="false" mask gradient-color="var(--g-main-sidebar-bg)" class="menu overscroll-contain flex-1">
|
|
||||||
<!-- 侧边栏模式(含主导航) -->
|
|
||||||
<div
|
|
||||||
v-if="appSettingsStore.settings.menu.mode === 'side' || (appSettingsStore.mode === 'mobile' && appSettingsStore.settings.menu.mode !== 'single')" class="py-1 flex flex-col w-full transition-all of-hidden -mt-2"
|
|
||||||
>
|
|
||||||
<template v-for="(item, index) in appMenuStore.allMenus" :key="index">
|
|
||||||
<div
|
|
||||||
class="menu-item px-2 py-1 transition-all relative" :class="{
|
|
||||||
active: index === appMenuStore.actived,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<FaTooltip v-if="item.children && item.children.length !== 0" :disabled="!generateTitle(item.meta?.title)" :text="generateTitle(item.meta?.title)" side="right">
|
|
||||||
<div
|
|
||||||
class="group menu-item-container text-[var(--g-main-sidebar-menu-color)] py-4 rounded-lg flex gap-1 h-full w-full cursor-pointer transition-colors items-center justify-between relative hover-(text-[var(--g-main-sidebar-menu-hover-color)] bg-[var(--g-main-sidebar-menu-hover-bg)]) px-2!" :class="{
|
|
||||||
'text-[var(--g-main-sidebar-menu-active-color)]! bg-[var(--g-main-sidebar-menu-active-bg)]!': index === appMenuStore.actived,
|
|
||||||
}" @click="switchTo(index)"
|
|
||||||
>
|
|
||||||
<div class="inline-flex flex-1 flex-col gap-1 w-full items-center justify-center">
|
|
||||||
<FaIcon v-if="item.meta?.icon" :name="item.meta.icon" class="menu-item-container-icon transition-transform group-hover-scale-120" />
|
|
||||||
<span class="text-sm text-center flex-1 w-full truncate transition-height transition-opacity transition-width empty:hidden">
|
|
||||||
{{ generateTitle(item.meta?.title) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FaTooltip>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</FaScrollArea>
|
|
||||||
<Component :is="useSlots('main-sidebar-after-menu')" />
|
|
||||||
<div class="px-4 py-3 flex-center">
|
|
||||||
<AppAccountButton only-avatar :button-variant="appSettingsStore.settings.menu.mode === 'side' ? 'secondary' : 'ghost'" class="p-2 size-12" />
|
|
||||||
</div>
|
|
||||||
<Component :is="useSlots('main-sidebar-bottom')" />
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.main-sidebar-container {
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: var(--g-main-sidebar-width);
|
|
||||||
color: var(--g-main-sidebar-menu-color);
|
|
||||||
background-color: var(--g-main-sidebar-bg);
|
|
||||||
box-shadow: 1px 0 0 0 oklch(var(--border));
|
|
||||||
transition: color 0.15s, background-color 0.15s, box-shadow 0.15s;
|
|
||||||
|
|
||||||
.sidebar-logo {
|
|
||||||
background-color: var(--g-main-sidebar-bg);
|
|
||||||
transition: background-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
:deep(.menu-item) {
|
|
||||||
.menu-item-container {
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
padding-block: 8px;
|
|
||||||
color: var(--g-main-sidebar-menu-color);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--g-main-sidebar-menu-hover-color);
|
|
||||||
background-color: var(--g-main-sidebar-menu-hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item-container-icon {
|
|
||||||
font-size: 20px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active .menu-item-container {
|
|
||||||
color: var(--g-main-sidebar-menu-active-color) !important;
|
|
||||||
background-color: var(--g-main-sidebar-menu-active-bg) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 主侧边栏动画 */
|
|
||||||
.main-sidebar-enter-active,
|
|
||||||
.main-sidebar-leave-active {
|
|
||||||
transition: 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-sidebar-enter-from,
|
|
||||||
.main-sidebar-leave-to {
|
|
||||||
transform: translateX(calc(var(--g-main-sidebar-width) * -1));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,188 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { MenuInjection, MenuProps } from './types'
|
|
||||||
import { cn } from '@/utils'
|
|
||||||
import Item from './item.vue'
|
|
||||||
import SubMenu from './sub.vue'
|
|
||||||
import { rootMenuInjectionKey } from './types'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'MainMenu',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<MenuProps>(),
|
|
||||||
{
|
|
||||||
accordion: true,
|
|
||||||
defaultExpandPaths: () => [],
|
|
||||||
mode: 'vertical',
|
|
||||||
collapse: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// 用于缓存对象到 ID 的映射
|
|
||||||
const idMap = new WeakMap<object, string>()
|
|
||||||
let idCounter = 0
|
|
||||||
// 获取对象的唯一 ID,如果已存在则返回缓存的 ID,否则生成新的
|
|
||||||
function getUseId(obj: object): string {
|
|
||||||
if (!idMap.has(obj)) {
|
|
||||||
idMap.set(obj, `menu-item-${++idCounter}`)
|
|
||||||
}
|
|
||||||
return idMap.get(obj)!
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeIndex = ref<MenuInjection['activeIndex']>(props.value)
|
|
||||||
const items = ref<MenuInjection['items']>({})
|
|
||||||
const subMenus = ref<MenuInjection['subMenus']>({})
|
|
||||||
const expandMenus = ref<MenuInjection['expandMenus']>(props.defaultExpandPaths.slice(0))
|
|
||||||
const mouseInMenu = ref<MenuInjection['mouseInMenu']>([])
|
|
||||||
const isMenuPopup = computed<MenuInjection['isMenuPopup']>(() => {
|
|
||||||
return props.mode === 'horizontal' || (props.mode === 'vertical' && props.collapse)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 解析传入的 menu 数据,并保存到 items 和 subMenus 对象中
|
|
||||||
function initItems(menu: MenuProps['menu'], parentPaths: string[] = []) {
|
|
||||||
menu.forEach((item) => {
|
|
||||||
const index = item.path ?? getUseId(item)
|
|
||||||
if (item.children?.some(item => item.meta?.menu !== false)) {
|
|
||||||
const indexPath = [...parentPaths, index]
|
|
||||||
subMenus.value[index] = {
|
|
||||||
index,
|
|
||||||
indexPath,
|
|
||||||
active: false,
|
|
||||||
}
|
|
||||||
initItems(item.children, indexPath)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
items.value[index] = {
|
|
||||||
index,
|
|
||||||
indexPath: parentPaths,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const openMenu: MenuInjection['openMenu'] = (index, indexPath) => {
|
|
||||||
if (expandMenus.value.includes(index)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (props.accordion) {
|
|
||||||
expandMenus.value = expandMenus.value.filter(key => indexPath.includes(key))
|
|
||||||
}
|
|
||||||
expandMenus.value.push(index)
|
|
||||||
}
|
|
||||||
const closeMenu: MenuInjection['closeMenu'] = (index) => {
|
|
||||||
if (Array.isArray(index)) {
|
|
||||||
nextTick(() => {
|
|
||||||
closeMenu(index.at(-1)!)
|
|
||||||
if (index.length > 1) {
|
|
||||||
closeMenu(index.slice(0, -1))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expandMenus.value = expandMenus.value.filter(item => item !== index)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSubMenusActive(index: string) {
|
|
||||||
for (const key in subMenus.value) {
|
|
||||||
subMenus.value[key].active = false
|
|
||||||
}
|
|
||||||
subMenus.value[index]?.indexPath.forEach((idx) => {
|
|
||||||
subMenus.value[idx].active = true
|
|
||||||
})
|
|
||||||
items.value[index]?.indexPath.forEach((idx) => {
|
|
||||||
subMenus.value[idx].active = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMenuItemClick: MenuInjection['handleMenuItemClick'] = (index) => {
|
|
||||||
if (props.mode === 'horizontal' || props.collapse) {
|
|
||||||
expandMenus.value = []
|
|
||||||
}
|
|
||||||
setSubMenusActive(index)
|
|
||||||
}
|
|
||||||
const handleSubMenuClick: MenuInjection['handleSubMenuClick'] = (index, indexPath) => {
|
|
||||||
if (expandMenus.value.includes(index)) {
|
|
||||||
closeMenu(index)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
openMenu(index, indexPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initMenu() {
|
|
||||||
const activeItem = activeIndex.value && items.value[activeIndex.value]
|
|
||||||
setSubMenusActive(activeIndex.value)
|
|
||||||
if (!activeItem || isMenuPopup.value || props.collapse) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 展开该菜单项的路径上所有子菜单
|
|
||||||
activeItem.indexPath.forEach((index) => {
|
|
||||||
const subMenu = subMenus.value[index]
|
|
||||||
subMenu && openMenu(index, subMenu.indexPath)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.menu, (val) => {
|
|
||||||
initItems(val)
|
|
||||||
initMenu()
|
|
||||||
}, {
|
|
||||||
deep: true,
|
|
||||||
immediate: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.value, (currentValue) => {
|
|
||||||
if (!items.value[currentValue]) {
|
|
||||||
activeIndex.value = ''
|
|
||||||
}
|
|
||||||
const item = items.value[currentValue] || (activeIndex.value && items.value[activeIndex.value]) || items.value[props.value]
|
|
||||||
if (item) {
|
|
||||||
activeIndex.value = item.index
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
activeIndex.value = currentValue
|
|
||||||
}
|
|
||||||
initMenu()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.collapse, (value) => {
|
|
||||||
if (value) {
|
|
||||||
expandMenus.value = []
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
expandMenus.value = props.defaultExpandPaths.slice(0)
|
|
||||||
}
|
|
||||||
initMenu()
|
|
||||||
})
|
|
||||||
|
|
||||||
provide(rootMenuInjectionKey, reactive({
|
|
||||||
props,
|
|
||||||
getUseId,
|
|
||||||
items,
|
|
||||||
subMenus,
|
|
||||||
activeIndex,
|
|
||||||
expandMenus,
|
|
||||||
mouseInMenu,
|
|
||||||
isMenuPopup,
|
|
||||||
openMenu,
|
|
||||||
closeMenu,
|
|
||||||
handleMenuItemClick,
|
|
||||||
handleSubMenuClick,
|
|
||||||
}))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
:class="cn('h-full w-full flex flex-col of-hidden', {
|
|
||||||
'flex-row w-auto': isMenuPopup && props.mode === 'horizontal',
|
|
||||||
'py-1': props.mode === 'vertical',
|
|
||||||
})"
|
|
||||||
>
|
|
||||||
<template v-for="item in menu" :key="item.path ?? getUseId(item)">
|
|
||||||
<template v-if="item.meta?.menu !== false">
|
|
||||||
<SubMenu v-if="item.children?.length" :menu="item" :unique-key="[item.path ?? (item.children.every(item => item.meta?.menu === false) ? item.children[0].path! : getUseId(item))]" />
|
|
||||||
<Item v-else :item="item" :unique-key="[item.path ?? (item.children?.every(item => item.meta?.menu === false) ? item.children[0].path! : getUseId(item))]" @click="handleMenuItemClick(item.path ?? (item.children?.every(item => item.meta?.menu === false) ? item.children[0].path! : getUseId(item)))" />
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { SubMenuItemProps } from './types'
|
|
||||||
import { cn } from '@/utils'
|
|
||||||
import { rootMenuInjectionKey } from './types'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'SubMenuItem',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<SubMenuItemProps>(),
|
|
||||||
{
|
|
||||||
level: 0,
|
|
||||||
subMenu: false,
|
|
||||||
expand: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const { generateTitle } = useAppMenu()
|
|
||||||
|
|
||||||
const rootMenu = inject(rootMenuInjectionKey)!
|
|
||||||
|
|
||||||
const itemRef = ref<HTMLElement>()
|
|
||||||
|
|
||||||
const isActived = computed(() => {
|
|
||||||
return props.subMenu
|
|
||||||
? rootMenu.subMenus[props.uniqueKey.at(-1)!].active
|
|
||||||
: rootMenu.activeIndex === props.uniqueKey.at(-1)!
|
|
||||||
})
|
|
||||||
|
|
||||||
const isItemActive = computed(() => {
|
|
||||||
return isActived.value && (!props.subMenu || rootMenu.isMenuPopup)
|
|
||||||
})
|
|
||||||
|
|
||||||
const icon = computed(() => {
|
|
||||||
return props.item.meta?.icon
|
|
||||||
})
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
ref: itemRef,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
ref="itemRef" :class="cn('menu-item relative', {
|
|
||||||
'active': isItemActive,
|
|
||||||
'py-1 px-2': (rootMenu.isMenuPopup && rootMenu.props.mode === 'vertical') || (rootMenu.isMenuPopup && level !== 0 && rootMenu.props.mode === 'horizontal') || !rootMenu.isMenuPopup,
|
|
||||||
'px-1 py-2': rootMenu.isMenuPopup && level === 0 && rootMenu.props.mode === 'horizontal',
|
|
||||||
})"
|
|
||||||
>
|
|
||||||
<router-link v-slot="{ href, navigate }" custom :to="uniqueKey.at(-1) ?? ''">
|
|
||||||
<FaTooltip :disabled="!generateTitle(item.meta?.title) || !rootMenu.isMenuPopup || level !== 0 || subMenu" :text="generateTitle(item.meta?.title)" :side="rootMenu.props.mode === 'vertical' ? 'right' : 'bottom'" class="h-full w-full">
|
|
||||||
<Component
|
|
||||||
:is="subMenu ? 'div' : 'a'" v-bind="{
|
|
||||||
...(!subMenu && {
|
|
||||||
href: item.meta?.link ? item.meta.link : href,
|
|
||||||
target: item.meta?.link ? '_blank' : '_self',
|
|
||||||
class: 'no-underline',
|
|
||||||
}),
|
|
||||||
}" :class="cn('group menu-item-container relative h-full w-full min-w-0 flex items-center justify-between gap-1 rounded-lg', {
|
|
||||||
'px-4 py-3': true,
|
|
||||||
'cursor-pointer text-[var(--g-sub-sidebar-menu-color)] transition-colors hover-(bg-[var(--g-sub-sidebar-menu-hover-bg)] text-[var(--g-sub-sidebar-menu-hover-color)])': true,
|
|
||||||
'text-[var(--g-sub-sidebar-menu-active-color)]! bg-[var(--g-sub-sidebar-menu-active-bg)]!': isItemActive,
|
|
||||||
'px-3': rootMenu.isMenuPopup && level === 0,
|
|
||||||
'py-3': rootMenu.isMenuPopup && level !== 0,
|
|
||||||
})" :title="generateTitle(item.meta?.title)" v-on="{
|
|
||||||
...(!subMenu && {
|
|
||||||
click: navigate,
|
|
||||||
}),
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
:class="cn('inline-flex min-w-0 flex-1 items-center justify-start', {
|
|
||||||
'gap-[12px]': true,
|
|
||||||
'ps-[calc(var(--indent-level)*20px)]': true,
|
|
||||||
'flex-col': rootMenu.isMenuPopup && level === 0 && rootMenu.props.mode === 'vertical',
|
|
||||||
})" :style="{
|
|
||||||
'--indent-level': !rootMenu.isMenuPopup ? props.level ?? 0 : 0,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<FaIcon
|
|
||||||
v-if="icon" :name="icon" :class="cn('menu-item-container-icon', {
|
|
||||||
'size-5': true,
|
|
||||||
'transition-transform group-hover-scale-120': true,
|
|
||||||
})"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
v-if="!(rootMenu.isMenuPopup && level === 0)" :class="cn('block min-w-0 flex-1 truncate text-sm transition-height transition-opacity transition-width', {
|
|
||||||
'empty:hidden': !generateTitle(item.meta?.title),
|
|
||||||
})"
|
|
||||||
>
|
|
||||||
{{ generateTitle(item.meta?.title) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<i
|
|
||||||
v-if="
|
|
||||||
subMenu
|
|
||||||
&& (!rootMenu.isMenuPopup || level !== 0) // 非收起模式,非一级的子菜单
|
|
||||||
" :class="cn('relative ms-1 w-[10px] after:absolute before:absolute after:h-[1.5px] after:w-[6px] before:h-[1.5px] before:w-[6px] after:bg-current before:bg-current after:transition-transform-200 before:transition-transform-200 after:content-empty before:content-empty after:-translate-y-[1px] before:-translate-y-[1px]', {
|
|
||||||
[expand ? 'before:-rotate-45 before:-translate-x-[2px] after:rotate-45 after:translate-x-[2px]' : 'before:rotate-45 before:-translate-x-[2px] after:-rotate-45 after:translate-x-[2px]']: true,
|
|
||||||
'opacity-0': rootMenu.isMenuPopup && level === 0,
|
|
||||||
'-rotate-90 -top-[1.5px]': rootMenu.isMenuPopup && level !== 0,
|
|
||||||
})"
|
|
||||||
/>
|
|
||||||
</component>
|
|
||||||
</FaTooltip>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,230 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { SubMenuProps } from './types'
|
|
||||||
import { useTimeoutFn } from '@vueuse/core'
|
|
||||||
import { cn } from '@/utils'
|
|
||||||
import Item from './item.vue'
|
|
||||||
import { rootMenuInjectionKey } from './types'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'SubMenu',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<SubMenuProps>(),
|
|
||||||
{
|
|
||||||
level: 0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const itemRef = useTemplateRef('itemRef')
|
|
||||||
const subMenuRef = useTemplateRef('subMenuRef')
|
|
||||||
const rootMenu = inject(rootMenuInjectionKey)!
|
|
||||||
|
|
||||||
const index = props.menu.path ?? rootMenu.getUseId(props.menu)
|
|
||||||
|
|
||||||
const expand = computed(() => rootMenu.expandMenus.includes(props.uniqueKey.at(-1)!))
|
|
||||||
|
|
||||||
const transitionEvent = computed(() => {
|
|
||||||
if (rootMenu.isMenuPopup) {
|
|
||||||
return {
|
|
||||||
enter(el: HTMLElement) {
|
|
||||||
if (el.offsetHeight > window.innerHeight) {
|
|
||||||
el.style.height = `${window.innerHeight}px`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
afterEnter: () => {},
|
|
||||||
beforeLeave: (el: HTMLElement) => {
|
|
||||||
el.style.maxHeight = `${el.offsetHeight}px`
|
|
||||||
el.style.overflow = 'hidden'
|
|
||||||
},
|
|
||||||
leave: (el: HTMLElement) => {
|
|
||||||
el.style.maxHeight = '0'
|
|
||||||
},
|
|
||||||
afterLeave(el: HTMLElement) {
|
|
||||||
el.style.maxHeight = ''
|
|
||||||
el.style.overflow = ''
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (CSS.supports('height', 'calc-size(auto, size)')) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
enter(el: HTMLElement) {
|
|
||||||
el.dataset.height = el.offsetHeight.toString()
|
|
||||||
el.style.maxHeight = '0'
|
|
||||||
el.style.overflow = 'hidden'
|
|
||||||
void el.offsetHeight // 强制重排,确保浏览器应用初始状态
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
el.style.maxHeight = `${el.dataset.height}px`
|
|
||||||
})
|
|
||||||
},
|
|
||||||
afterEnter(el: HTMLElement) {
|
|
||||||
el.style.maxHeight = ''
|
|
||||||
el.style.overflow = ''
|
|
||||||
},
|
|
||||||
enterCancelled(el: HTMLElement) {
|
|
||||||
el.style.maxHeight = ''
|
|
||||||
el.style.overflow = ''
|
|
||||||
},
|
|
||||||
beforeLeave(el: HTMLElement) {
|
|
||||||
el.style.maxHeight = `${el.offsetHeight}px`
|
|
||||||
el.style.overflow = 'hidden'
|
|
||||||
void el.offsetHeight // 强制重排,确保浏览器应用初始状态
|
|
||||||
},
|
|
||||||
leave(el: HTMLElement) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
el.style.maxHeight = '0'
|
|
||||||
})
|
|
||||||
},
|
|
||||||
afterLeave(el: HTMLElement) {
|
|
||||||
el.style.maxHeight = ''
|
|
||||||
el.style.overflow = ''
|
|
||||||
},
|
|
||||||
leaveCancelled(el: HTMLElement) {
|
|
||||||
el.style.maxHeight = ''
|
|
||||||
el.style.overflow = ''
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const transitionClass = computed(() => {
|
|
||||||
if (rootMenu.isMenuPopup) {
|
|
||||||
return {
|
|
||||||
enterActiveClass: 'ease-in-out duration-300',
|
|
||||||
enterFromClass: 'opacity-0 translate-x-4',
|
|
||||||
enterToClass: 'opacity-100',
|
|
||||||
leaveActiveClass: 'ease-in-out duration-300',
|
|
||||||
leaveFromClass: 'opacity-100',
|
|
||||||
leaveToClass: 'opacity-0',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
enterActiveClass: 'ease-in-out duration-200',
|
|
||||||
enterFromClass: cn('opacity-0 translate-y-2', CSS.supports('height', 'calc-size(auto, size)') && 'h-0'),
|
|
||||||
enterToClass: 'opacity-100 translate-y-0',
|
|
||||||
leaveActiveClass: 'ease-in-out duration-200',
|
|
||||||
leaveFromClass: 'opacity-100 translate-y-0',
|
|
||||||
leaveToClass: cn('opacity-0 translate-y-2', CSS.supports('height', 'calc-size(auto, size)') && 'h-0'),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasChildren = computed(() => props.menu.children?.some((item: any) => item.meta?.menu !== false) ?? false)
|
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
if (
|
|
||||||
(rootMenu.isMenuPopup && hasChildren.value)
|
|
||||||
|| props.menu.meta?.link
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (hasChildren.value) {
|
|
||||||
rootMenu.handleSubMenuClick(index, props.uniqueKey)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
rootMenu.handleMenuItemClick(index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeout: (() => void) | undefined
|
|
||||||
|
|
||||||
function handleMouseenter() {
|
|
||||||
if (!rootMenu.isMenuPopup) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rootMenu.mouseInMenu = props.uniqueKey
|
|
||||||
timeout?.()
|
|
||||||
;({ stop: timeout } = useTimeoutFn(() => {
|
|
||||||
if (hasChildren.value) {
|
|
||||||
rootMenu.openMenu(index, props.uniqueKey)
|
|
||||||
nextTick(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const el = itemRef.value?.ref
|
|
||||||
const subMenuEl = subMenuRef.value?.$el
|
|
||||||
if (!el || !subMenuEl) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const rect = el.getBoundingClientRect()
|
|
||||||
const { top, left, width, height } = rect
|
|
||||||
let menuTop = 0
|
|
||||||
let menuLeft = 0
|
|
||||||
if (rootMenu.props.mode === 'vertical' || props.level !== 0) {
|
|
||||||
menuTop = top + el.scrollTop - 5 // 4px padding + 1px border
|
|
||||||
menuLeft = left + width + 4
|
|
||||||
// 处理边界情况
|
|
||||||
if (menuTop + subMenuEl.offsetHeight > window.innerHeight) {
|
|
||||||
menuTop = Math.max(0, window.innerHeight - subMenuEl.offsetHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
menuTop = top + height + 3
|
|
||||||
menuLeft = left
|
|
||||||
menuLeft -= 5 // 4px padding + 1px border
|
|
||||||
// 处理边界情况
|
|
||||||
if (menuTop + subMenuEl.offsetHeight > window.innerHeight) {
|
|
||||||
subMenuEl.style.height = `${window.innerHeight - menuTop}px`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 处理边界情况
|
|
||||||
if (menuLeft + subMenuEl.offsetWidth > document.documentElement.clientWidth) {
|
|
||||||
menuLeft = left - width
|
|
||||||
}
|
|
||||||
// 设置样式
|
|
||||||
Object.assign(subMenuEl.style, {
|
|
||||||
top: `${menuTop}px`,
|
|
||||||
insetInlineStart: `${menuLeft}px`,
|
|
||||||
willChange: 'transform',
|
|
||||||
transform: 'translateZ(0)',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const path = props.menu.children ? rootMenu.subMenus[index].indexPath.at(-1)! : rootMenu.items[index].indexPath.at(-1)!
|
|
||||||
rootMenu.openMenu(path, rootMenu.subMenus[path].indexPath)
|
|
||||||
}
|
|
||||||
}, 300))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseleave() {
|
|
||||||
if (!rootMenu.isMenuPopup) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rootMenu.mouseInMenu = []
|
|
||||||
timeout?.()
|
|
||||||
;({ stop: timeout } = useTimeoutFn(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (rootMenu.mouseInMenu.length === 0) {
|
|
||||||
rootMenu.closeMenu(props.uniqueKey)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if (hasChildren.value) {
|
|
||||||
!rootMenu.mouseInMenu.includes(props.uniqueKey.at(-1)!) && rootMenu.closeMenu(props.uniqueKey.at(-1)!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, 300))
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Item ref="itemRef" :unique-key="uniqueKey" :item="menu" :level="level" :sub-menu="hasChildren" :expand @click="handleClick" @mouseenter="handleMouseenter" @mouseleave="handleMouseleave" />
|
|
||||||
<template v-if="hasChildren">
|
|
||||||
<Teleport to="body" :disabled="!rootMenu.isMenuPopup">
|
|
||||||
<Transition v-bind="transitionClass" v-on="transitionEvent">
|
|
||||||
<FaScrollArea
|
|
||||||
v-if="expand" ref="subMenuRef" :scrollbar="false" :mask="rootMenu.isMenuPopup"
|
|
||||||
:class="cn('sub-menu static h-[calc-size(auto,size)] rounded-lg', {
|
|
||||||
'bg-[var(--g-sub-sidebar-bg)]': rootMenu.isMenuPopup,
|
|
||||||
'border shadow-xl fixed z-3000 w-[200px]': rootMenu.isMenuPopup,
|
|
||||||
'py-1 overscroll-contain': rootMenu.isMenuPopup,
|
|
||||||
})"
|
|
||||||
>
|
|
||||||
<template v-for="item in menu.children" :key="item.path ?? rootMenu.getUseId(item)">
|
|
||||||
<SubMenu v-if="item.meta?.menu !== false" :unique-key="[...uniqueKey, item.path ?? rootMenu.getUseId(item)]" :menu="item" :level="level + 1" />
|
|
||||||
</template>
|
|
||||||
</FaScrollArea>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
import type { MenuRecordRaw } from '@fantastic-admin/types'
|
|
||||||
|
|
||||||
export interface MenuItem {
|
|
||||||
index: string
|
|
||||||
indexPath: string[]
|
|
||||||
active?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MenuProps {
|
|
||||||
menu: MenuRecordRaw[]
|
|
||||||
value: string
|
|
||||||
accordion?: boolean
|
|
||||||
defaultExpandPaths?: string[]
|
|
||||||
mode?: 'horizontal' | 'vertical'
|
|
||||||
collapse?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MenuInjection {
|
|
||||||
props: MenuProps
|
|
||||||
getUseId: (obj: object) => string
|
|
||||||
items: Record<string, MenuItem>
|
|
||||||
subMenus: Record<string, MenuItem>
|
|
||||||
activeIndex: MenuProps['value']
|
|
||||||
expandMenus: string[]
|
|
||||||
mouseInMenu: string[]
|
|
||||||
isMenuPopup: boolean
|
|
||||||
openMenu: (index: string, indexPath: string[]) => void
|
|
||||||
closeMenu: (index: string | string[]) => void
|
|
||||||
handleMenuItemClick: (index: string) => void
|
|
||||||
handleSubMenuClick: (index: string, indexPath: string[]) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const rootMenuInjectionKey = Symbol('rootMenu') as InjectionKey<MenuInjection>
|
|
||||||
|
|
||||||
export interface SubMenuProps {
|
|
||||||
uniqueKey: string[]
|
|
||||||
menu: MenuRecordRaw
|
|
||||||
level?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubMenuItemProps {
|
|
||||||
uniqueKey: string[]
|
|
||||||
item: MenuRecordRaw
|
|
||||||
level?: number
|
|
||||||
subMenu?: boolean
|
|
||||||
expand?: boolean
|
|
||||||
}
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useSlots } from '@/slots'
|
|
||||||
import Logo from '../Logo/index.vue'
|
|
||||||
import Menu from '../Menu/index.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'SubSidebar',
|
|
||||||
})
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
enable: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
const appMenuStore = useAppMenuStore()
|
|
||||||
|
|
||||||
const isCollapse = computed(() => appSettingsStore.settings.menu.subMenuCollapse)
|
|
||||||
|
|
||||||
const transitionName = ref('')
|
|
||||||
watch(() => appMenuStore.actived, (val, oldVal) => {
|
|
||||||
if (appSettingsStore.mode === 'mobile' || appSettingsStore.settings.menu.mode === 'side') {
|
|
||||||
if (val > oldVal) {
|
|
||||||
transitionName.value = 'sub-sidebar-y-start'
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
transitionName.value = 'sub-sidebar-y-end'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (appSettingsStore.settings.menu.mode === 'head') {
|
|
||||||
if (val > oldVal) {
|
|
||||||
transitionName.value = 'sub-sidebar-x-start'
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
transitionName.value = 'sub-sidebar-x-end'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Transition name="sub-sidebar">
|
|
||||||
<div
|
|
||||||
v-if="enable" class="sub-sidebar-container" :class="{
|
|
||||||
'is-collapse': isCollapse,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Component :is="useSlots('sub-sidebar-top')" />
|
|
||||||
<Logo
|
|
||||||
v-if="['side', 'single'].includes(appSettingsStore.settings.menu.mode) || appSettingsStore.mode === 'mobile'" :show-logo="appSettingsStore.settings.menu.mode === 'single'" class="sidebar-logo" :class="{
|
|
||||||
single: appSettingsStore.settings.menu.mode === 'single',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<Component :is="useSlots('sub-sidebar-after-logo')" />
|
|
||||||
<FaScrollArea :scrollbar="false" mask class="overscroll-contain flex-1">
|
|
||||||
<TransitionGroup :name="transitionName">
|
|
||||||
<template v-for="(mainItem, mainIndex) in appMenuStore.allMenus" :key="mainIndex">
|
|
||||||
<div v-show="mainIndex === appMenuStore.actived">
|
|
||||||
<Menu
|
|
||||||
:menu="mainItem.children" :value="route.meta.activeMenu || route.path" :default-expand-paths="appMenuStore.defaultExpandPaths" :accordion="appSettingsStore.settings.menu.subMenuUniqueExpand" :collapse="isCollapse" class="menu" :class="{
|
|
||||||
'-mt-2': !(isCollapse || ['head', 'single'].includes(appSettingsStore.settings.menu.mode)),
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</TransitionGroup>
|
|
||||||
</FaScrollArea>
|
|
||||||
<div
|
|
||||||
v-if="appSettingsStore.mode === 'pc' && appSettingsStore.settings.menu.subMenuCollapseButton" class="px-4 py-3 flex items-center relative" :class="{
|
|
||||||
'justify-center': isCollapse,
|
|
||||||
'justify-end': !isCollapse,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<FaButton variant="secondary" size="icon-sm" @click="appSettingsStore.toggleSidebarCollapse()">
|
|
||||||
<FaIcon name="app-toolbar-collapse" class="size-4 transition" :class="{ 'rotate-z--180': appSettingsStore.settings.menu.subMenuCollapse }" />
|
|
||||||
</FaButton>
|
|
||||||
</div>
|
|
||||||
<Component :is="useSlots('sub-sidebar-after-menu')" />
|
|
||||||
<div v-if="appSettingsStore.settings.menu.mode === 'single'" class="px-4 pb-3 flex-center">
|
|
||||||
<AppAccountButton :only-avatar="isCollapse" dropdown-align="center" :dropdown-side="isCollapse ? 'right' : 'top'" button-variant="secondary" :class="{ 'w-full': !isCollapse }" />
|
|
||||||
</div>
|
|
||||||
<Component :is="useSlots('sub-sidebar-bottom')" />
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.sub-sidebar-container {
|
|
||||||
position: absolute;
|
|
||||||
inset-inline-start: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: var(--g-sub-sidebar-width);
|
|
||||||
background-color: var(--g-sub-sidebar-bg);
|
|
||||||
box-shadow: 1px 0 0 0 oklch(var(--border));
|
|
||||||
transition: inset-inline-start 0.3s, width 0.3s, background-color 0.15s, box-shadow 0.15s;
|
|
||||||
|
|
||||||
&.is-collapse {
|
|
||||||
width: var(--g-sub-sidebar-collapse-width);
|
|
||||||
|
|
||||||
.sidebar-logo {
|
|
||||||
&:not(.single) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(span) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 次侧边栏动画 */
|
|
||||||
.sub-sidebar-x-start-enter-active,
|
|
||||||
.sub-sidebar-x-end-enter-active,
|
|
||||||
.sub-sidebar-y-start-enter-active,
|
|
||||||
.sub-sidebar-y-end-enter-active {
|
|
||||||
transition: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-sidebar-x-start-enter-from,
|
|
||||||
.sub-sidebar-x-start-leave-active {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(30px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-sidebar-x-end-enter-from,
|
|
||||||
.sub-sidebar-x-end-leave-active {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-30px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-sidebar-y-start-enter-from,
|
|
||||||
.sub-sidebar-y-start-leave-active {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(30px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-sidebar-y-end-enter-from,
|
|
||||||
.sub-sidebar-y-end-leave-active {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-30px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-sidebar-x-start-leave-active,
|
|
||||||
.sub-sidebar-x-end-leave-active,
|
|
||||||
.sub-sidebar-y-start-leave-active,
|
|
||||||
.sub-sidebar-y-end-leave-active {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 次侧边栏动画 */
|
|
||||||
.sub-sidebar-enter-active,
|
|
||||||
.sub-sidebar-leave-active {
|
|
||||||
transition: 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-sidebar-enter-from,
|
|
||||||
.sub-sidebar-leave-to {
|
|
||||||
transform: translateX(calc(var(--g-sub-sidebar-width) * -1));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,437 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { TabbarRecordRaw } from '@fantastic-admin/types'
|
|
||||||
import { useMagicKeys } from '@vueuse/core'
|
|
||||||
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
|
|
||||||
import { useSlots } from '@/slots'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'Tabbar',
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
const appTabbarStore = useAppTabbarStore()
|
|
||||||
|
|
||||||
const tabbar = useAppTabbar()
|
|
||||||
const mainPage = useAppPage()
|
|
||||||
|
|
||||||
const keys = useMagicKeys({ reactive: true })
|
|
||||||
|
|
||||||
const { generateTitle } = useAppMenu()
|
|
||||||
|
|
||||||
const activedTabId = computed(() => tabbar.getId())
|
|
||||||
|
|
||||||
const tabsRef = useTemplateRef('tabsRef')
|
|
||||||
const tabContainerRef = useTemplateRef('tabContainerRef')
|
|
||||||
const tabRef = useTemplateRef<HTMLElement[]>('tabRef')
|
|
||||||
|
|
||||||
const isAnimating = ref(false)
|
|
||||||
const isInit = ref(true)
|
|
||||||
|
|
||||||
function onBeforeLeave(el: Element) {
|
|
||||||
isAnimating.value = true
|
|
||||||
const htmlEl = el as HTMLElement
|
|
||||||
htmlEl.style.left = `${htmlEl.offsetLeft}px`
|
|
||||||
htmlEl.style.top = `${htmlEl.offsetTop}px`
|
|
||||||
htmlEl.style.width = `${htmlEl.offsetWidth}px`
|
|
||||||
}
|
|
||||||
onMounted(() => {
|
|
||||||
nextTick(() => {
|
|
||||||
isInit.value = false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => route, (val) => {
|
|
||||||
if (appSettingsStore.settings.topbar.tabbar) {
|
|
||||||
appTabbarStore.add(val)
|
|
||||||
nextTick(() => {
|
|
||||||
const index = appTabbarStore.list.findIndex(item => item.tabId === activedTabId.value)
|
|
||||||
if (index !== -1) {
|
|
||||||
const tabEl = tabRef.value?.find(item => Number.parseInt(item.dataset.index!) === index)
|
|
||||||
const containerEl = tabsRef.value?.ref?.$el
|
|
||||||
if (tabEl && containerEl) {
|
|
||||||
const tabLeft = tabEl.offsetLeft
|
|
||||||
const tabWidth = tabEl.offsetWidth
|
|
||||||
const containerWidth = containerEl.clientWidth
|
|
||||||
// 计算滚动位置,使标签页居中
|
|
||||||
const scrollLeft = tabLeft - (containerWidth - tabWidth) / 2
|
|
||||||
// 首次加载时直接定位到指定位置
|
|
||||||
tabsRef.value?.scrollTo(scrollLeft, !isInit.value ? 'smooth' : undefined)
|
|
||||||
}
|
|
||||||
tabbarScrollTip()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
immediate: true,
|
|
||||||
deep: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
function tabbarScrollTip() {
|
|
||||||
if (tabContainerRef.value?.$el.clientWidth > (tabsRef.value?.ref?.$el.clientWidth ?? 0) && !localStorage.has('tabbarScrollTip')) {
|
|
||||||
localStorage.setItem('tabbarScrollTip', '')
|
|
||||||
const tips = faToast.info('温馨提示', {
|
|
||||||
description: '标签栏数量超过展示区域范围,可以将鼠标移到标签栏上,通过鼠标滚轮滑动浏览',
|
|
||||||
position: 'top-center',
|
|
||||||
duration: Infinity,
|
|
||||||
action: {
|
|
||||||
label: '知道了',
|
|
||||||
onClick: () => faToast.dismiss(tips),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function contextMenuItems(routeItem: TabbarRecordRaw) {
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
label: '重新加载',
|
|
||||||
icon: 'i-ri:refresh-line',
|
|
||||||
disabled: routeItem.tabId !== activedTabId.value,
|
|
||||||
handle: () => mainPage.reload(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '关闭标签页',
|
|
||||||
icon: 'i-ri:close-line',
|
|
||||||
disabled: !tabbar.checkClose(routeItem.tabId),
|
|
||||||
handle: () => tabbar.closeById(routeItem.tabId),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
label: '关闭其它标签页',
|
|
||||||
icon: 'i-mdi:close',
|
|
||||||
disabled: !tabbar.checkCloseOtherSide(routeItem.tabId),
|
|
||||||
handle: () => {
|
|
||||||
tabbar.closeOtherSide(routeItem.tabId)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '关闭左侧标签页',
|
|
||||||
icon: 'i-mdi:arrow-expand-left',
|
|
||||||
disabled: !tabbar.checkCloseLeftSide(routeItem.tabId),
|
|
||||||
handle: () => {
|
|
||||||
tabbar.closeLeftSide(routeItem.tabId)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '关闭右侧标签页',
|
|
||||||
icon: 'i-mdi:arrow-expand-right',
|
|
||||||
disabled: !tabbar.checkCloseRightSide(routeItem.tabId),
|
|
||||||
handle: () => {
|
|
||||||
tabbar.closeRightSide(routeItem.tabId)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function iconName(icon: TabbarRecordRaw['icon']) {
|
|
||||||
return icon
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleTabIndex = ref<number[]>([])
|
|
||||||
function getVisibleTabs() {
|
|
||||||
const containerWidth = tabsRef.value?.ref?.$el.clientWidth ?? 0
|
|
||||||
const scrollLeft = tabsRef.value?.ref?.el?.viewportElement?.scrollLeft ?? 0
|
|
||||||
visibleTabIndex.value = []
|
|
||||||
if (tabRef.value) {
|
|
||||||
for (let i = 0; i < tabRef.value.length; i++) {
|
|
||||||
const tab = tabRef.value[i]
|
|
||||||
const tabLeft = tab.offsetLeft
|
|
||||||
const tabRight = tabLeft + tab.offsetWidth
|
|
||||||
// 检查标签页是否在可视区域内
|
|
||||||
if (tabLeft < scrollLeft + containerWidth && tabRight > scrollLeft) {
|
|
||||||
if (i >= 0 && i < appTabbarStore.list.length) {
|
|
||||||
visibleTabIndex.value.push(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function getVisibleTabIndex(arrayIndex: number) {
|
|
||||||
return visibleTabIndex.value.findIndex(visibleTab => visibleTab === arrayIndex) ?? -1
|
|
||||||
}
|
|
||||||
|
|
||||||
useHotkeyBindings({
|
|
||||||
'tabbar.prev': () => {
|
|
||||||
if (appTabbarStore.list[0]?.tabId !== activedTabId.value) {
|
|
||||||
const index = appTabbarStore.list.findIndex(item => item.tabId === activedTabId.value)
|
|
||||||
index > 0 && router.push(appTabbarStore.list[index - 1].fullPath)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'tabbar.next': () => {
|
|
||||||
if (appTabbarStore.list.at(-1)?.tabId !== activedTabId.value) {
|
|
||||||
const index = appTabbarStore.list.findIndex(item => item.tabId === activedTabId.value)
|
|
||||||
index >= 0 && router.push(appTabbarStore.list[index + 1].fullPath)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'tabbar.closeCurrent': () => {
|
|
||||||
tabbar.closeById(activedTabId.value)
|
|
||||||
},
|
|
||||||
'tabbar.gotoVisibleIndex': ({ hotkey }) => {
|
|
||||||
const number = Number(hotkey.split('+')[1])
|
|
||||||
if (visibleTabIndex.value[number - 1] !== undefined) {
|
|
||||||
router.push(appTabbarStore.list[visibleTabIndex.value[number - 1]].fullPath)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'tabbar.gotoLast': () => {
|
|
||||||
const last = appTabbarStore.list.at(-1)
|
|
||||||
last && router.push(last.fullPath)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => keys.alt, (val) => {
|
|
||||||
if (val) {
|
|
||||||
getVisibleTabs()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="tabbar">
|
|
||||||
<Component :is="useSlots('tabbar-start')" />
|
|
||||||
<div class="tabbar-container">
|
|
||||||
<FaScrollArea
|
|
||||||
ref="tabsRef" :scrollbar="false" mask horizontal class="tabs overscroll-contain"
|
|
||||||
>
|
|
||||||
<TransitionGroup ref="tabContainerRef" :name="!isInit ? 'tabbar' : undefined" tag="div" class="tab-container" @before-leave="onBeforeLeave" @after-leave="isAnimating = false">
|
|
||||||
<div
|
|
||||||
v-for="(element, index) in appTabbarStore.list" :key="element.tabId"
|
|
||||||
ref="tabRef" :data-index="index" class="tab" :class="{
|
|
||||||
actived: element.tabId === activedTabId,
|
|
||||||
}" @click="!isAnimating && element.tabId !== activedTabId && router.push(element.fullPath)"
|
|
||||||
>
|
|
||||||
<FaContextMenu :items="contextMenuItems(element)">
|
|
||||||
<div class="size-full">
|
|
||||||
<div class="tab-dividers" />
|
|
||||||
<div class="tab-background" />
|
|
||||||
<FaTooltip :delay="1000" side="bottom">
|
|
||||||
<div class="tab-content">
|
|
||||||
<div :key="element.tabId" class="title">
|
|
||||||
<FaIcon v-if="appSettingsStore.settings.tabbar.icon && iconName(element.icon)" :name="iconName(element.icon)!" class="icon" />
|
|
||||||
{{ generateTitle(element.title) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="appTabbarStore.list.length > 1" class="action-icon" @click.stop="tabbar.closeById(element.tabId)">
|
|
||||||
<FaIcon name="i-ri:close-fill" />
|
|
||||||
</div>
|
|
||||||
<div v-show="appSettingsStore.settings.tabbar.hotkeys && keys.alt && getVisibleTabIndex(index) >= 0 && getVisibleTabIndex(index) < 9" class="hotkey-number">
|
|
||||||
{{ getVisibleTabIndex(index) + 1 }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template #content>
|
|
||||||
<div class="text-sm">
|
|
||||||
{{ generateTitle(element.title) }}
|
|
||||||
</div>
|
|
||||||
<div class="text-accent/50">
|
|
||||||
{{ element.fullPath }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</FaTooltip>
|
|
||||||
</div>
|
|
||||||
</FaContextMenu>
|
|
||||||
</div>
|
|
||||||
</TransitionGroup>
|
|
||||||
</FaScrollArea>
|
|
||||||
</div>
|
|
||||||
<Component :is="useSlots('tabbar-end')" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tabbar {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: var(--g-tabbar-height);
|
|
||||||
background-color: var(--g-tabbar-bg);
|
|
||||||
box-shadow: 0 1px 0 0 oklch(var(--border)), 0 -1px 0 0 oklch(var(--border));
|
|
||||||
|
|
||||||
.tabbar-container {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
flex: 1;
|
|
||||||
align-items: end;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
.tab-container {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
&:not(.actived):hover {
|
|
||||||
z-index: 3;
|
|
||||||
|
|
||||||
&::before,
|
|
||||||
&::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
& + .tab .tab-dividers::before {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content {
|
|
||||||
.title,
|
|
||||||
.action-icon {
|
|
||||||
color: var(--g-tabbar-tab-hover-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-background {
|
|
||||||
background-color: var(--g-tabbar-tab-hover-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
min-width: 150px;
|
|
||||||
max-width: 150px;
|
|
||||||
height: var(--g-tabbar-height);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: calc(var(--g-tabbar-height) - 2px);
|
|
||||||
vertical-align: bottom;
|
|
||||||
pointer-events: none;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
* {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
& + .tab:hover,
|
|
||||||
& + .tab.actived {
|
|
||||||
.tab-dividers::before {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.actived {
|
|
||||||
z-index: 5;
|
|
||||||
|
|
||||||
&::before,
|
|
||||||
&::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
& + .tab .tab-dividers::before {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content {
|
|
||||||
.title,
|
|
||||||
.action-icon {
|
|
||||||
color: var(--g-tabbar-tab-active-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-background {
|
|
||||||
background-color: var(--g-tabbar-tab-active-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-dividers {
|
|
||||||
position: absolute;
|
|
||||||
inset-inline: -1px;
|
|
||||||
top: 50%;
|
|
||||||
z-index: 0;
|
|
||||||
height: 14px;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
position: absolute;
|
|
||||||
inset-inline-start: 1px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: block;
|
|
||||||
width: 1px;
|
|
||||||
content: "";
|
|
||||||
background-color: oklch(var(--border));
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.3s, background-color 0.15s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:first-child .tab-dividers::before {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-background {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: background-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: all;
|
|
||||||
|
|
||||||
.title {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
gap: 5px;
|
|
||||||
align-items: center;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0 10px;
|
|
||||||
margin-inline-end: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--g-tabbar-tab-color);
|
|
||||||
white-space: nowrap;
|
|
||||||
mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent);
|
|
||||||
transition: margin-inline-end 0.3s;
|
|
||||||
|
|
||||||
&:has(+ .action-icon) {
|
|
||||||
margin-inline-end: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-icon {
|
|
||||||
--uno: transition absolute inset-e-2 top-1/2 -translate-y-1/2 rounded-full z-10 w-5 h-5 flex-center text-xs "text-[var(--g-tabbar-tab-color)]" hover:(border bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hotkey-number {
|
|
||||||
--uno: border bg-secondary absolute inset-e-2 top-1/2 -translate-y-1/2 rounded-full z-10 w-5 h-5 flex-center text-xs "text-[var(--g-tabbar-tab-color)]";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 标签栏动画 */
|
|
||||||
.tabs {
|
|
||||||
.tabbar-move,
|
|
||||||
.tabbar-enter-active,
|
|
||||||
.tabbar-leave-active {
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbar-enter-from,
|
|
||||||
.tabbar-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(30px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbar-leave-active {
|
|
||||||
position: absolute !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { compile } from 'path-to-regexp'
|
|
||||||
import Breadcrumb from '../../../Breadcrumb/index.vue'
|
|
||||||
import BreadcrumbItem from '../../../Breadcrumb/item.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'ToolbarBreadcrumb',
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
|
|
||||||
const { generateTitle } = useAppMenu()
|
|
||||||
|
|
||||||
interface BreadcrumbRecord {
|
|
||||||
path: string
|
|
||||||
title?: string | (() => string)
|
|
||||||
}
|
|
||||||
|
|
||||||
let breadcrumbListBackup: BreadcrumbRecord[] = []
|
|
||||||
const breadcrumbList = computed<BreadcrumbRecord[]>(() => {
|
|
||||||
if (route.name === 'reload') {
|
|
||||||
return breadcrumbListBackup
|
|
||||||
}
|
|
||||||
const list: BreadcrumbRecord[] = []
|
|
||||||
if (appSettingsStore.settings.app.home.enable) {
|
|
||||||
list.push({
|
|
||||||
path: appSettingsStore.settings.app.home.fullPath,
|
|
||||||
title: appSettingsStore.settings.app.home.title,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
route.matched.forEach((item) => {
|
|
||||||
if (item.meta?.breadcrumb !== false) {
|
|
||||||
list.push({
|
|
||||||
path: item.path,
|
|
||||||
title: item.meta?.title,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
breadcrumbListBackup = list.filter(item => generateTitle(item.title))
|
|
||||||
return breadcrumbListBackup
|
|
||||||
})
|
|
||||||
|
|
||||||
function pathCompile(path: string) {
|
|
||||||
const toPath = compile(path)
|
|
||||||
return toPath(route.params)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Breadcrumb v-if="appSettingsStore.mode === 'pc'" class="breadcrumb px-2 whitespace-nowrap">
|
|
||||||
<TransitionGroup name="breadcrumb">
|
|
||||||
<BreadcrumbItem v-for="(item, index) in breadcrumbList" :key="`${index}_${item.path}_${item.title}`" :to="index < breadcrumbList.length - 1 && item.path !== '' ? pathCompile(item.path) : ''">
|
|
||||||
{{ generateTitle(item.title) }}
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</TransitionGroup>
|
|
||||||
</Breadcrumb>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 面包屑动画 */
|
|
||||||
.breadcrumb-enter-active {
|
|
||||||
transition: transform 0.3s, opacity 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-enter-from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(30px) skewX(-50deg);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineOptions({
|
|
||||||
name: 'ToolbarColorScheme',
|
|
||||||
})
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
|
|
||||||
function toggleColorScheme(event: MouseEvent) {
|
|
||||||
if (!document.startViewTransition || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
||||||
appSettingsStore.currentColorScheme && appSettingsStore.setColorScheme(appSettingsStore.currentColorScheme === 'dark' ? 'light' : 'dark')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const target = event.target as HTMLElement
|
|
||||||
const { left, top, width, height } = target.getBoundingClientRect()
|
|
||||||
const x = left + width / 2
|
|
||||||
const y = top + height / 2
|
|
||||||
const endRadius = Math.hypot(
|
|
||||||
Math.max(x, innerWidth - x),
|
|
||||||
Math.max(y, innerHeight - y),
|
|
||||||
)
|
|
||||||
const ratioX = (100 * x) / innerWidth
|
|
||||||
const ratioY = (100 * y) / innerHeight
|
|
||||||
const referR = Math.hypot(innerWidth, innerHeight) / Math.SQRT2
|
|
||||||
const ratioR = (100 * endRadius) / referR
|
|
||||||
const transition = document.startViewTransition(async () => {
|
|
||||||
appSettingsStore.currentColorScheme && appSettingsStore.setColorScheme(appSettingsStore.currentColorScheme === 'dark' ? 'light' : 'dark')
|
|
||||||
await nextTick()
|
|
||||||
})
|
|
||||||
transition.ready.then(() => {
|
|
||||||
const clipPath = [
|
|
||||||
`circle(0% at ${ratioX}% ${ratioY}%)`,
|
|
||||||
`circle(${ratioR}% at ${ratioX}% ${ratioY}%)`,
|
|
||||||
]
|
|
||||||
document.documentElement.animate(
|
|
||||||
{
|
|
||||||
clipPath: appSettingsStore.currentColorScheme === 'light' ? clipPath : clipPath.toReversed(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
duration: 500,
|
|
||||||
easing: 'ease-in-out',
|
|
||||||
fill: 'both',
|
|
||||||
pseudoElement: appSettingsStore.currentColorScheme === 'light' ? '::view-transition-new(root)' : '::view-transition-old(root)',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<FaButton variant="ghost" size="icon-sm" @click="toggleColorScheme">
|
|
||||||
<FaIcon
|
|
||||||
:name="{
|
|
||||||
light: 'i-ri:sun-line',
|
|
||||||
dark: 'i-ri:moon-line',
|
|
||||||
}[appSettingsStore.currentColorScheme!]" class="size-4"
|
|
||||||
/>
|
|
||||||
</FaButton>
|
|
||||||
</template>
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useFullscreen } from '@vueuse/core'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'ToolbarFullscreen',
|
|
||||||
})
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
|
|
||||||
const { isFullscreen, toggle } = useFullscreen()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<FaButton v-if="appSettingsStore.mode === 'pc'" variant="ghost" size="icon-sm" @click="toggle">
|
|
||||||
<FaIcon :name="isFullscreen ? 'i-ri:fullscreen-exit-line' : 'i-ri:fullscreen-line'" class="size-4" />
|
|
||||||
</FaButton>
|
|
||||||
</template>
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
|
|
||||||
import Search from './search.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'ToolbarMenuSearch',
|
|
||||||
})
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
|
|
||||||
const isShow = ref(false)
|
|
||||||
|
|
||||||
const useMobileStyle = computed(() => {
|
|
||||||
return appSettingsStore.mode !== 'pc' || !appSettingsStore.settings.toolbar.menuSearch.hotkeys
|
|
||||||
})
|
|
||||||
|
|
||||||
useHotkeyBindings({
|
|
||||||
'menuSearch.open': () => {
|
|
||||||
isShow.value = true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<FaButton :variant="useMobileStyle ? 'ghost' : 'outline'" :size="useMobileStyle ? 'icon-sm' : undefined" :class="{ 'mx-2 px-3 h-9': !useMobileStyle }" @click="isShow = true">
|
|
||||||
<FaIcon name="i-ri:search-line" class="size-4" />
|
|
||||||
<template v-if="!useMobileStyle">
|
|
||||||
<FaKbdGroup v-if="appSettingsStore.settings.toolbar.menuSearch.hotkeys" class="-me-1">
|
|
||||||
<FaKbd>{{ appSettingsStore.os === 'mac' ? '⌘' : 'Ctrl' }}</FaKbd>
|
|
||||||
<FaKbd>K</FaKbd>
|
|
||||||
</FaKbdGroup>
|
|
||||||
</template>
|
|
||||||
</FaButton>
|
|
||||||
<Search v-model="isShow" />
|
|
||||||
</template>
|
|
||||||
@ -1,256 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { MenuRecordRaw } from '@fantastic-admin/types'
|
|
||||||
import { match } from 'pinyin-pro'
|
|
||||||
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
|
|
||||||
import Breadcrumb from '@/layouts/components/Breadcrumb/index.vue'
|
|
||||||
import BreadcrumbItem from '@/layouts/components/Breadcrumb/item.vue'
|
|
||||||
import { resolveRoutePath } from '@/utils'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'ToolbarMenuSearchModal',
|
|
||||||
})
|
|
||||||
|
|
||||||
const isShow = defineModel<boolean>({
|
|
||||||
default: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
const appRouteStore = useAppRouteStore()
|
|
||||||
const appMenuStore = useAppMenuStore()
|
|
||||||
|
|
||||||
const { generateTitle } = useAppMenu()
|
|
||||||
|
|
||||||
interface listTypes {
|
|
||||||
path: string
|
|
||||||
icon?: string
|
|
||||||
title?: string | (() => string)
|
|
||||||
link?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchInput = ref('')
|
|
||||||
const sourceList = ref<listTypes[]>([])
|
|
||||||
const actived = ref(0)
|
|
||||||
|
|
||||||
const searchResultRef = useTemplateRef('searchResultRef')
|
|
||||||
const searchResultItemRef = useTemplateRef<HTMLElement[]>('searchResultItemRef')
|
|
||||||
|
|
||||||
const resultList = computed(() => {
|
|
||||||
let result = []
|
|
||||||
result = sourceList.value.filter((item) => {
|
|
||||||
let flag = false
|
|
||||||
if (searchInput.value !== '') {
|
|
||||||
if (item.path.includes(searchInput.value)) {
|
|
||||||
flag = true
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
generateTitle(item.title).toString().includes(searchInput.value)
|
|
||||||
|| (match(generateTitle(item.title).toString(), searchInput.value, { continuous: true })?.length ?? 0) > 0
|
|
||||||
) {
|
|
||||||
flag = true
|
|
||||||
}
|
|
||||||
if (appRouteStore.getRouteMatchedByPath(item.path).some((b) => {
|
|
||||||
return generateTitle(b.meta?.title).toString().includes(searchInput.value)
|
|
||||||
|| (match(generateTitle(b.meta?.title).toString(), searchInput.value, { continuous: true })?.length ?? 0) > 0
|
|
||||||
})) { flag = true }
|
|
||||||
}
|
|
||||||
return flag
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
useHotkeyBindings({
|
|
||||||
'menuSearch.moveUp': () => {
|
|
||||||
keyUp()
|
|
||||||
},
|
|
||||||
'menuSearch.moveDown': () => {
|
|
||||||
keyDown()
|
|
||||||
},
|
|
||||||
'menuSearch.confirm': () => {
|
|
||||||
keyEnter()
|
|
||||||
},
|
|
||||||
'menuSearch.close': () => {
|
|
||||||
isShow.value = false
|
|
||||||
},
|
|
||||||
}, () => isShow.value)
|
|
||||||
|
|
||||||
watch(() => isShow.value, (val) => {
|
|
||||||
if (val) {
|
|
||||||
searchInput.value = ''
|
|
||||||
actived.value = 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
watch(() => resultList.value, () => {
|
|
||||||
actived.value = 0
|
|
||||||
handleScroll()
|
|
||||||
})
|
|
||||||
watch(() => actived.value, () => {
|
|
||||||
handleScroll()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
initSourceList()
|
|
||||||
})
|
|
||||||
|
|
||||||
function initSourceList() {
|
|
||||||
sourceList.value = []
|
|
||||||
appMenuStore.allMenus.forEach((item) => {
|
|
||||||
getSourceList(item.children)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasChildren(item: MenuRecordRaw) {
|
|
||||||
let flag = true
|
|
||||||
if (item.children?.every(i => i.meta?.menu === false)) {
|
|
||||||
flag = false
|
|
||||||
}
|
|
||||||
return flag
|
|
||||||
}
|
|
||||||
function getSourceList(arr: MenuRecordRaw[], basePath?: string, icon?: string) {
|
|
||||||
arr.forEach((item) => {
|
|
||||||
if (item.meta?.menu !== false) {
|
|
||||||
if (item.children && hasChildren(item)) {
|
|
||||||
getSourceList(
|
|
||||||
item.children,
|
|
||||||
resolveRoutePath(basePath, item.path),
|
|
||||||
item.meta?.icon ?? icon,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
sourceList.value.push({
|
|
||||||
path: resolveRoutePath(basePath, item.path),
|
|
||||||
icon: item.meta?.icon ?? icon,
|
|
||||||
title: item.meta?.title,
|
|
||||||
link: item.meta?.link,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function keyUp() {
|
|
||||||
if (resultList.value.length) {
|
|
||||||
actived.value -= 1
|
|
||||||
if (actived.value < 0) {
|
|
||||||
actived.value = resultList.value.length - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function keyDown() {
|
|
||||||
if (resultList.value.length) {
|
|
||||||
actived.value += 1
|
|
||||||
if (actived.value > resultList.value.length - 1) {
|
|
||||||
actived.value = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function keyEnter() {
|
|
||||||
searchResultItemRef.value?.find(item => Number.parseInt(item.dataset.index!) === actived.value)?.click()
|
|
||||||
}
|
|
||||||
function handleScroll() {
|
|
||||||
if (searchResultRef.value?.ref?.el?.viewportElement) {
|
|
||||||
const contentDom = searchResultRef.value.ref.el.viewportElement
|
|
||||||
let scrollTo = 0
|
|
||||||
if (resultList.value.length > 0) {
|
|
||||||
scrollTo = contentDom.scrollTop
|
|
||||||
const activedOffsetTop = searchResultItemRef.value?.find(item => Number.parseInt(item.dataset.index!) === actived.value)?.offsetTop ?? 0
|
|
||||||
const activedClientHeight = searchResultItemRef.value?.find(item => Number.parseInt(item.dataset.index!) === actived.value)?.clientHeight ?? 0
|
|
||||||
const searchScrollTop = contentDom.scrollTop
|
|
||||||
const searchClientHeight = contentDom.clientHeight
|
|
||||||
if (activedOffsetTop + activedClientHeight > searchScrollTop + searchClientHeight) {
|
|
||||||
scrollTo = activedOffsetTop + activedClientHeight - searchClientHeight
|
|
||||||
}
|
|
||||||
else if (activedOffsetTop <= searchScrollTop) {
|
|
||||||
scrollTo = activedOffsetTop - 16
|
|
||||||
}
|
|
||||||
}
|
|
||||||
contentDom.scrollTo({
|
|
||||||
top: scrollTo,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pageJump(path: listTypes['path'], link: listTypes['link']) {
|
|
||||||
if (link) {
|
|
||||||
window.open(link, '_blank')
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
router.push(path)
|
|
||||||
}
|
|
||||||
isShow.value = false
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<FaModal v-model="isShow" :footer="appSettingsStore.mode === 'pc'" :closable="false" open-auto-focus border class="w-full lg-max-w-2xl" content-class="flex flex-col p-0 min-h-auto" header-class="p-0" footer-class="p-0">
|
|
||||||
<template #header>
|
|
||||||
<div class="flex flex-shrink-0 h-12 items-center">
|
|
||||||
<div class="flex-center h-full w-14">
|
|
||||||
<FaIcon name="i-ri:search-line" class="text-foreground/30 size-4" />
|
|
||||||
</div>
|
|
||||||
<input v-model="searchInput" placeholder="支持标题、拼音(首字母)、URL模糊查询" class="text-base text-foreground border-0 rounded-md bg-transparent h-full w-full focus-outline-none placeholder-foreground/30" @keydown.esc.prevent="isShow = false" @keydown.up.prevent="keyUp" @keydown.down.prevent="keyDown" @keydown.enter.prevent="keyEnter">
|
|
||||||
<div v-if="appSettingsStore.mode === 'mobile'" class="border-s flex-center h-full w-14">
|
|
||||||
<FaIcon name="i-carbon:close" class="size-4" @click="isShow = false" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<div class="px-4 py-3 flex w-full justify-between">
|
|
||||||
<div class="flex gap-8">
|
|
||||||
<div class="text-xs inline-flex gap-1 items-center">
|
|
||||||
<FaKbd>⏎</FaKbd>
|
|
||||||
<span>访问</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs inline-flex gap-1 items-center">
|
|
||||||
<FaKbd>
|
|
||||||
<FaIcon name="i-ant-design:caret-up-filled" />
|
|
||||||
</FaKbd>
|
|
||||||
<FaKbd>
|
|
||||||
<FaIcon name="i-ant-design:caret-down-filled" />
|
|
||||||
</FaKbd>
|
|
||||||
<span>切换</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="appSettingsStore.settings.toolbar.menuSearch.hotkeys" class="text-xs inline-flex gap-1 items-center">
|
|
||||||
<FaKbd>Esc</FaKbd>
|
|
||||||
<span>退出</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<FaScrollArea ref="searchResultRef">
|
|
||||||
<template v-if="resultList.length > 0">
|
|
||||||
<div v-for="(item, index) in resultList" ref="searchResultItemRef" :key="item.path" class="p-4" :data-index="index" @click="pageJump(item.path, item.link)" @mouseover="actived = index">
|
|
||||||
<a class="border rounded-lg flex cursor-pointer items-center" :class="{ '-mt-4': index !== 0, 'bg-accent border-primary shadow-[0_0_0_1px_oklch(var(--primary))]': index === actived, 'op-50': index !== actived }">
|
|
||||||
<div class="flex-center basis-16 -me-4">
|
|
||||||
<FaIcon v-if="item.icon" :name="item.icon" class="size-5 transition" :class="{ 'scale-120 text-primary': index === actived }" />
|
|
||||||
</div>
|
|
||||||
<div class="px-4 py-3 flex flex-1 flex-col gap-1 truncate">
|
|
||||||
<div class="text-base font-bold text-start truncate">{{ generateTitle(item.title) }}</div>
|
|
||||||
<Breadcrumb v-if="appRouteStore.getRouteMatchedByPath(item.path).length" class="truncate">
|
|
||||||
<BreadcrumbItem v-for="(bc, bcIndex) in appRouteStore.getRouteMatchedByPath(item.path)" :key="bcIndex" class="text-xs">
|
|
||||||
{{ generateTitle(bc.meta?.title) }}
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</Breadcrumb>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="searchInput === ''">
|
|
||||||
<div class="text-secondary-foreground/50 py-6 flex-col-center h-full">
|
|
||||||
<FaIcon name="i-tabler:mood-smile" class="size-10" />
|
|
||||||
<p class="text-base m-2">
|
|
||||||
输入你要搜索的导航
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="text-secondary-foreground/50 py-6 flex-col-center h-full">
|
|
||||||
<FaIcon name="i-tabler:mood-empty" class="size-10" />
|
|
||||||
<p class="text-base m-2">
|
|
||||||
没有找到你想要的
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</FaScrollArea>
|
|
||||||
</FaModal>
|
|
||||||
</template>
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineOptions({
|
|
||||||
name: 'ToolbarPageReload',
|
|
||||||
})
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
const mainPage = useAppPage()
|
|
||||||
|
|
||||||
const isAnimating = ref(false)
|
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
isAnimating.value = true
|
|
||||||
mainPage.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCtrlClick() {
|
|
||||||
location.reload()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<FaTooltip side="bottom" :disabled="appSettingsStore.os === 'mac'">
|
|
||||||
<template #content>
|
|
||||||
<div class="flex-col-center gap-2">
|
|
||||||
<p class="flex-center gap-1">
|
|
||||||
按住 <FaKbd>Ctrl</FaKbd> 键并点击
|
|
||||||
</p>
|
|
||||||
<p>可切换为浏览器原生刷新</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<FaButton variant="ghost" size="icon-sm" @click.exact="handleClick" @click.ctrl.exact="handleCtrlClick" @animationend="isAnimating = false">
|
|
||||||
<FaIcon name="i-iconoir:refresh-double" class="size-4" :class="{ animation: isAnimating }" />
|
|
||||||
</FaButton>
|
|
||||||
</FaTooltip>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.animation {
|
|
||||||
animation: animation 1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes animation {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useSlots } from '@/slots'
|
|
||||||
import Tools from './tools.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'ToolbarRightSide',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<Tools mode="right-side" />
|
|
||||||
<Component :is="useSlots('toolbar-end')" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import EndSide from './endSide.vue'
|
|
||||||
import StartSide from './startSide.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'Toolbar',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="bg-[var(--g-toolbar-bg)] flex h-[var(--g-toolbar-height)] items-center justify-between">
|
|
||||||
<div class="start-side pe-16 ps-2 flex h-full items-center of-hidden">
|
|
||||||
<StartSide />
|
|
||||||
</div>
|
|
||||||
<div class="px-2 flex flex-shrink-0 h-full items-center justify-end">
|
|
||||||
<EndSide />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.start-side {
|
|
||||||
mask-image: linear-gradient(to right, #000 0%, #000 calc(100% - 50px), transparent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useSlots } from '@/slots'
|
|
||||||
import Tools from './tools.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'ToolbarLeftSide',
|
|
||||||
})
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<FaButton v-if="appSettingsStore.mode === 'mobile'" variant="ghost" size="icon" class="size-9" @click="appSettingsStore.toggleSidebarCollapse()">
|
|
||||||
<FaIcon name="app-toolbar-collapse" class="size-4" />
|
|
||||||
</FaButton>
|
|
||||||
<Component :is="useSlots('toolbar-start')" />
|
|
||||||
<Tools mode="left-side" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ToolbarSettings } from '@fantastic-admin/settings'
|
|
||||||
import { pascalCase } from 'scule'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'ToolbarTools',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
mode: 'left-side' | 'right-side'
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const modules = import.meta.glob('./*/index.vue', { import: 'default', eager: true })
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
|
|
||||||
const toolbarTools = {
|
|
||||||
'left-side': ['breadcrumb'],
|
|
||||||
'right-side': ['menuSearch', 'fullscreen', 'pageReload', 'colorScheme'],
|
|
||||||
} satisfies Record<typeof props.mode, (keyof ToolbarSettings)[]>
|
|
||||||
|
|
||||||
const tools = computed(() => toolbarTools[props.mode])
|
|
||||||
|
|
||||||
function checkVisible(item: boolean | { enable: boolean }) {
|
|
||||||
if (typeof item === 'boolean') {
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
else if (typeof item === 'object' && item !== null) {
|
|
||||||
return item.enable ?? false
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<template v-for="item in tools" :key="item">
|
|
||||||
<Component :is="modules[`./${pascalCase(item)}/index.vue`]" v-if="checkVisible(appSettingsStore.settings.toolbar[item])" />
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useElementSize, useScroll } from '@vueuse/core'
|
|
||||||
import eventBus from '@/utils/eventBus'
|
|
||||||
import Tabbar from './Tabbar/index.vue'
|
|
||||||
import Toolbar from './Toolbar/index.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'Topbar',
|
|
||||||
})
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
enable: boolean
|
|
||||||
enableTabbar: boolean
|
|
||||||
enableToolbar: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const appSettingsStore = useAppSettingsStore()
|
|
||||||
|
|
||||||
const { height: topbarHeight } = useElementSize(useTemplateRef('topbarRef'))
|
|
||||||
const { y } = useScroll(window)
|
|
||||||
const scrollOnHide = ref(false)
|
|
||||||
|
|
||||||
watch(y, (val, oldVal) => {
|
|
||||||
scrollOnHide.value = appSettingsStore.settings.topbar.mode === 'sticky' && val > (oldVal ?? 0) && val > topbarHeight.value
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(scrollOnHide, (val) => {
|
|
||||||
eventBus.emit('topbar-scroll-visible-or-hidden', val)
|
|
||||||
}, {
|
|
||||||
immediate: true,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Transition name="topbar">
|
|
||||||
<div
|
|
||||||
v-if="enable" ref="topbarRef" class="flex flex-col w-full shadow-[0_1px_0_0_oklch(var(--border))] transition-transform-300"
|
|
||||||
>
|
|
||||||
<Tabbar v-if="enableTabbar" class="z-2" />
|
|
||||||
<Toolbar v-if="enableToolbar" class="z-1" />
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 顶栏动画 */
|
|
||||||
.topbar-enter-active,
|
|
||||||
.topbar-leave-active {
|
|
||||||
transition: transform 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-enter-from,
|
|
||||||
.topbar-leave-to {
|
|
||||||
transform: translateY(calc(var(--g-topbar-height) * -1));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useClipboard } from '@vueuse/core'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'LinkView',
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const { copy, copied } = useClipboard()
|
|
||||||
watch(copied, (val) => {
|
|
||||||
val && faToast.success('复制成功')
|
|
||||||
})
|
|
||||||
|
|
||||||
function open() {
|
|
||||||
window.open(route.meta.link, '_blank')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col h-full w-full absolute">
|
|
||||||
<Transition name="fade" mode="out-in" appear>
|
|
||||||
<FaPageMain :key="route.meta.link" class="flex flex-1 flex-col justify-center">
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<FaIcon name="i-icon-park-twotone:planet" class="text-primary/80 size-30" />
|
|
||||||
<div class="text-xl text-dark my-2 dark-text-white">
|
|
||||||
是否访问此链接
|
|
||||||
</div>
|
|
||||||
<div class="text-[14px] text-secondary-foreground/50 my-2 text-center max-w-[300px] cursor-pointer" @click="route.meta.link && copy(route.meta.link)">
|
|
||||||
<FaTooltip text="复制链接">
|
|
||||||
<div class="line-clamp-3">
|
|
||||||
{{ route.meta.link }}
|
|
||||||
</div>
|
|
||||||
</FaTooltip>
|
|
||||||
</div>
|
|
||||||
<FaButton class="my-4" @click="open">
|
|
||||||
<FaIcon name="i-ri:external-link-fill" />
|
|
||||||
立即访问
|
|
||||||
</FaButton>
|
|
||||||
</div>
|
|
||||||
</FaPageMain>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.fade-enter-active {
|
|
||||||
transition: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-leave-active {
|
|
||||||
transition: 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user