feat: 优化字典管理页面布局,修复部门人员操作列,完善LDAP集成

- 字典管理页面改为左右分栏 a-menu 布局,对齐系统配置页面风格
- 左侧菜单项显示字典名称、编码(hover展开)及数据数量徽标
- 修复部门人员操作列因 ProTable dataIndex='action' 拦截导致按钮不显示
- 字典类型编辑/删除移至右侧工具栏,操作列增加序号列
- 新增 LDAP 配置管理与同步功能(UnboundID LDAP SDK)
- 清理废弃的 fantastic-admin 目录
This commit is contained in:
李龙龙 2026-05-18 18:00:41 +08:00
parent 567aa74833
commit a5176016b3
2978 changed files with 123738 additions and 156134 deletions

View File

@ -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. 测试账号

View File

@ -0,0 +1,2 @@
allowBuilds:
'@parcel/watcher': set this to true or false

View 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 };
}

View File

@ -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,
}; };

View File

@ -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;
} }

View File

@ -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",
},
}; };

View File

@ -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}",
},
}; };

View File

@ -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 {

View File

@ -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,
},
},
], ],
}, },
{ {

View File

@ -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"));
} }

View File

@ -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
v-model:value="searchKeyword"
placeholder="搜索分类名称或编码"
style="width: 280px"
@search="handleSearch"
/>
<a-button type="primary" @click="handleAdd(null)">
<PlusOutlined /> 新增顶级分类
</a-button>
</div>
<!-- 树形表格 -->
<a-table
:columns="columns"
:data-source="treeData"
:pagination="false"
:default-expand-all-rows="true"
row-key="id"
size="middle"
class="category-table"
>
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'category_type'"> <template v-if="column.key === 'name'">
<a-tag>{{ record.category_type }}</a-tag> <span :class="{ 'is-leaf': !record.children || !record.children.length }">
{{ record.name }}
</span>
</template> </template>
<template v-if="column.key === 'operations'"> <template v-if="column.key === 'operations'">
<a-space :size="4" @click.stop> <a-space :size="4">
<a-button v-permission="'base:category:edit'" type="link" size="small" @click.stop="handleEdit(record)"><EditOutlined /> 编辑</a-button> <a-button type="link" size="small" @click="handleAdd(record)">
<a-popconfirm v-permission="'base:category:delete'" title="确认删除?" @confirm="handleDelete(record.id)"> <PlusOutlined /> 子分类
<a-button type="link" size="small" danger @click.stop><DeleteOutlined /> 删除</a-button> </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-popconfirm>
</a-space> </a-space>
</template> </template>
</template> </template>
</ProTable> </a-table>
<a-modal v-model:open="visible" :title="isEdit ? '编辑分类' : '新增分类'" @ok="handleSubmit" width="600px"> <!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="visible"
:title="isEdit ? '编辑分类' : '新增分类'"
@ok="handleSubmit"
width="500px"
>
<a-form :model="form" :label-col="{ span: 6 }"> <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-form-item label="分类编码" required><a-input v-model:value="form.code" /></a-form-item> <a-input v-model:value="form.name" />
<a-form-item label="分类大类" required> </a-form-item>
<a-select v-model:value="form.category_type" :options="categoryTypeOptions" /> <a-form-item label="分类编码" required>
<a-input v-model:value="form.code" />
</a-form-item> </a-form-item>
<a-form-item label="父级分类"> <a-form-item label="父级分类">
<a-select v-model:value="form.parent_id" show-search placeholder="留空为顶级分类" option-filter-prop="label" :options="parentOptions" allow-clear /> <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-item>
</a-form> </a-form>
</a-modal> </a-modal>
</a-card>
<a-modal v-model:open="logVisible" title="变更记录" width="800px" :footer="null">
<a-table :columns="logColumns" :data-source="logData" :pagination="false" size="small" row-key="id">
<template #bodyCell="{ column, record: row }">
<template v-if="column.key === 'change_type'">
<a-tag :color="row.change_type === 'create' ? 'green' : row.change_type === 'delete' ? 'red' : 'blue'">{{ row.change_type === 'create' ? '新增' : row.change_type === 'delete' ? '删除' : '修改' }}</a-tag>
</template>
</template>
</a-table>
</a-modal>
</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) => {
try {
await categoryApi.del(id); await categoryApi.del(id);
message.success("删除成功"); message.success("删除成功");
tableRef.value?.reload(); 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.warning("名称和编码不能为空");
return;
}
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 ? "更新成功" : "创建成功"); message.success(isEdit.value ? "更新成功" : "创建成功");
visible.value = false; visible.value = false;
tableRef.value?.reload(); 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>

View File

@ -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) {

View File

@ -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[]>(() => [

View File

@ -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"));
loadDictTypes();
if (selectedTypeCode.value === type.code) {
selectedTypeCode.value = ""; selectedTypeCode.value = "";
} loadDictTypes();
loadAllDictData();
} }
} 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;
padding: 0 8px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
&::-webkit-scrollbar-track {
background: transparent; background: transparent;
}
.dict-type-item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 12px 12px 16px;
border-radius: 8px;
cursor: pointer;
margin-bottom: 4px;
border: none; border: none;
transition: all 0.2s ease;
overflow: hidden; :deep(.ant-menu-item) {
margin: 4px 8px;
padding: 10px 12px !important;
border-radius: 8px;
height: auto;
transition: all 0.2s;
&::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;

View 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>

View File

@ -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>

View File

@ -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 {

View File

@ -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 {

View File

@ -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: '',
},
}

View File

@ -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

View File

@ -1,7 +0,0 @@
node_modules
.DS_Store
dist*
dist-ssr
*.local
.eslintcache
.stylelintcache

View File

@ -1,4 +0,0 @@
{
"*.{ts,tsx,vue}": "eslint --cache --fix",
"*.{css,scss,vue}": "stylelint --cache --fix"
}

View File

@ -1 +0,0 @@
24

View File

@ -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 # 运行全量 linttsc + 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 技能,询问用户是否将问题反馈给框架作者。

View File

@ -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.

View File

@ -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>&nbsp;|&nbsp;</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>
![hero](https://fantastic-admin.hurui.me/hero_preview.png)
## 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.
[![star](https://img.shields.io/github/stars/fantastic-admin/basic?style=social)](https://github.com/fantastic-admin/basic)
[![star](https://gitee.com/fantastic-admin/basic/badge/star.svg?theme=dark)](https://gitee.com/fantastic-admin/basic)
[![star](https://atomgit.com/fantastic-admin/basic/star/badge.svg)](https://atomgit.com/fantastic-admin/basic)
<details>
<summary>Github Stars Chart</summary>
[![Stargazers over time](https://starchart.cc/fantastic-admin/basic.svg)](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

View File

@ -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>&nbsp;|&nbsp;</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>
![hero](https://fantastic-admin.hurui.me/hero_preview.png)
## 特点
> 部分为专业版能力
- 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** 帮我点个 ⭐ ,这将对本产品的推广有极大帮助。
[![star](https://img.shields.io/github/stars/fantastic-admin/basic?style=social)](https://github.com/fantastic-admin/basic)
[![star](https://gitee.com/fantastic-admin/basic/badge/star.svg?theme=dark)](https://gitee.com/fantastic-admin/basic)
[![star](https://atomgit.com/fantastic-admin/basic/star/badge.svg)](https://atomgit.com/fantastic-admin/basic)
<details>
<summary>Github Stars 曲线</summary>
[![Stargazers over time](https://starchart.cc/fantastic-admin/basic.svg)](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 中后台管理系统框架

View File

@ -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

View File

@ -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 =

View File

@ -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 =

View File

@ -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"
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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:"
}
}

View File

@ -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

View File

@ -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;
}

View File

@ -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

View File

@ -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>

View File

@ -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,
},
}
},
},
])

View File

@ -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

View File

@ -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,
}),
}

View File

@ -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

View File

@ -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

View File

@ -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;
}

View File

@ -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); }
}

View File

@ -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: "";
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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?.()
// setTimeoutclick
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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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 管理。

View File

@ -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[] = []

View File

@ -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)],
})
}
}

View File

@ -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>>

View File

@ -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

View File

@ -1 +0,0 @@
{ "collections": ["ant-design", "ep", "flagpack", "icon-park", "mdi", "ri", "logos", "twemoji", "vscode-icons"], "isOfflineUse": false }

View File

@ -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))

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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