mirror of
https://github.com/vastxie/99AI.git
synced 2026-04-05 18:04:25 +08:00
v4.3.0
This commit is contained in:
95
admin/src/App.vue
Executable file
95
admin/src/App.vue
Executable file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
import eruda from 'eruda';
|
||||
import hotkeys from 'hotkeys-js';
|
||||
import VConsole from 'vconsole';
|
||||
import Provider from './ui-provider/index.vue';
|
||||
import eventBus from './utils/eventBus';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const { auth } = useAuth();
|
||||
|
||||
const isAuth = computed(() => {
|
||||
return route.matched.every((item) => {
|
||||
return auth(item.meta.auth ?? '');
|
||||
});
|
||||
});
|
||||
|
||||
// 侧边栏主导航当前实际宽度
|
||||
const mainSidebarActualWidth = computed(() => {
|
||||
let actualWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--g-main-sidebar-width'),
|
||||
);
|
||||
if (
|
||||
settingsStore.settings.menu.menuMode === 'single' ||
|
||||
(settingsStore.settings.menu.menuMode === 'head' && settingsStore.mode !== 'mobile')
|
||||
) {
|
||||
actualWidth = 0;
|
||||
}
|
||||
return `${actualWidth}px`;
|
||||
});
|
||||
|
||||
// 侧边栏次导航当前实际宽度
|
||||
const subSidebarActualWidth = computed(() => {
|
||||
let actualWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--g-sub-sidebar-width'),
|
||||
);
|
||||
if (settingsStore.settings.menu.subMenuCollapse && settingsStore.mode !== 'mobile') {
|
||||
actualWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue(
|
||||
'--g-sub-sidebar-collapse-width',
|
||||
),
|
||||
);
|
||||
}
|
||||
return `${actualWidth}px`;
|
||||
});
|
||||
|
||||
// 设置网页 title
|
||||
watch(
|
||||
[() => settingsStore.settings.app.enableDynamicTitle, () => settingsStore.title],
|
||||
() => {
|
||||
if (settingsStore.settings.app.enableDynamicTitle && settingsStore.title) {
|
||||
const title =
|
||||
typeof settingsStore.title === 'function' ? settingsStore.title() : settingsStore.title;
|
||||
document.title = `${title} - ${import.meta.env.VITE_APP_TITLE}`;
|
||||
} else {
|
||||
document.title = import.meta.env.VITE_APP_TITLE;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
settingsStore.setMode(document.documentElement.clientWidth);
|
||||
window.addEventListener('resize', () => {
|
||||
settingsStore.setMode(document.documentElement.clientWidth);
|
||||
});
|
||||
hotkeys('alt+i', () => {
|
||||
eventBus.emit('global-system-info-toggle');
|
||||
});
|
||||
});
|
||||
|
||||
import.meta.env.VITE_APP_DEBUG_TOOL === 'eruda' && eruda.init();
|
||||
import.meta.env.VITE_APP_DEBUG_TOOL === 'vconsole' && new VConsole();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Provider>
|
||||
<RouterView
|
||||
v-slot="{ Component }"
|
||||
:style="{
|
||||
'--g-main-sidebar-actual-width': mainSidebarActualWidth,
|
||||
'--g-sub-sidebar-actual-width': subSidebarActualWidth,
|
||||
}"
|
||||
>
|
||||
<component :is="Component" v-if="isAuth" />
|
||||
<NotAllowed v-else />
|
||||
</RouterView>
|
||||
<SystemInfo />
|
||||
</Provider>
|
||||
</template>
|
||||
71
admin/src/api/index.ts
Executable file
71
admin/src/api/index.ts
Executable file
@@ -0,0 +1,71 @@
|
||||
import router from '@/router/index';
|
||||
import useUserStore from '@/store/modules/user';
|
||||
import axios from 'axios';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL:
|
||||
import.meta.env.DEV && import.meta.env.VITE_OPEN_PROXY === 'true'
|
||||
? '/proxy/'
|
||||
: import.meta.env.VITE_APP_API_BASEURL,
|
||||
timeout: 1000 * 60,
|
||||
responseType: 'json',
|
||||
});
|
||||
|
||||
api.interceptors.request.use((request) => {
|
||||
const userStore = useUserStore();
|
||||
/**
|
||||
* 全局拦截请求发送前提交的参数
|
||||
* 以下代码为示例,在请求头里带上 token 信息
|
||||
*/
|
||||
if (userStore.isLogin && request.headers) {
|
||||
request.headers.Authorization = userStore.token ? `Bearer ${userStore.token}` : '';
|
||||
}
|
||||
// 是否将 POST 请求参数进行字符串化处理
|
||||
if (request.method === 'post') {
|
||||
// request.data = qs.stringify(request.data, {
|
||||
// arrayFormat: 'brackets',
|
||||
// })
|
||||
}
|
||||
return request;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
/**
|
||||
* 全局拦截请求发送后返回的数据,如果数据有报错则在这做全局的错误提示
|
||||
* 假设返回数据格式为:{ status: 1, error: '', data: '' }
|
||||
* 规则是当 status 为 1 时表示请求成功,为 0 时表示接口需要登录或者登录状态失效,需要重新登录
|
||||
* 请求出错时 error 会返回错误信息
|
||||
*/
|
||||
return Promise.resolve(response.data);
|
||||
},
|
||||
(error) => {
|
||||
let msg = '';
|
||||
if (error?.response) {
|
||||
const { data, status } = error.response;
|
||||
if (status === 401) {
|
||||
msg = '权限验证失败,请重新登录';
|
||||
// loginout
|
||||
if (data.code === 401 && data.message.includes('请登录后继续操作')) {
|
||||
const userStore = useUserStore();
|
||||
userStore.logout().then(() => {
|
||||
router.push({ name: 'login' });
|
||||
});
|
||||
}
|
||||
}
|
||||
const { message, code } = data;
|
||||
message && (msg = message);
|
||||
} else {
|
||||
msg = '接口请求异常,请稍后再试';
|
||||
}
|
||||
|
||||
ElMessage({
|
||||
message: msg,
|
||||
type: 'error',
|
||||
});
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
12
admin/src/api/modules/app.ts
Normal file
12
admin/src/api/modules/app.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
queryCats: (params: any) => api.get('app/queryAppCats', { params }),
|
||||
deleteCats: (data: { id: number }) => api.post('app/delAppCats', data),
|
||||
createCats: (data: any) => api.post('app/createAppCats', data),
|
||||
updateCats: (data: any) => api.post('app/updateAppCats', data),
|
||||
queryApp: (params: any) => api.get('app/queryApp', { params }),
|
||||
deleteApp: (data: { id: number }) => api.post('app/delApp', data),
|
||||
createApp: (data: any) => api.post('app/createApp', data),
|
||||
updateApp: (data: any) => api.post('app/updateApp', data),
|
||||
};
|
||||
10
admin/src/api/modules/autoReply.ts
Normal file
10
admin/src/api/modules/autoReply.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
queryAutoReply: (params: { page?: number; size?: number; prompt?: string; status?: number }) =>
|
||||
api.get('autoReply/query', { params }),
|
||||
delAutoReply: (data: { id: number }) => api.post('autoReply/del', data),
|
||||
addAutoReply: (data: { prompt: string; answer: string }) => api.post('autoReply/add', data),
|
||||
updateAutoReply: (data: { id: number; prompt: string; answer: string; status: number }) =>
|
||||
api.post('autoReply/update', data),
|
||||
};
|
||||
10
admin/src/api/modules/badWords.ts
Normal file
10
admin/src/api/modules/badWords.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
queryBadWords: (params = {}) => api.get('badwords/query', { params }),
|
||||
queryViolation: (params = {}) => api.get('badwords/violation', { params }),
|
||||
delBadWords: (data: { id: number }) => api.post('badwords/del', data),
|
||||
addBadWords: (data: { word: string }) => api.post('badwords/add', data),
|
||||
updateBadWords: (data: { id: number; word: string; status: number }) =>
|
||||
api.post('badwords/update', data),
|
||||
};
|
||||
5
admin/src/api/modules/chat.ts
Normal file
5
admin/src/api/modules/chat.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
queryChatAll: (params: any) => api.get('chatLog/chatAll', { params }),
|
||||
};
|
||||
12
admin/src/api/modules/config.ts
Normal file
12
admin/src/api/modules/config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import api from '../index';
|
||||
|
||||
interface KeyValue {
|
||||
configKey: string;
|
||||
configVal: any;
|
||||
}
|
||||
|
||||
export default {
|
||||
queryAllConfig: () => api.get('config/queryAll'),
|
||||
queryConfig: (data: any) => api.post('config/query', data),
|
||||
setConfig: (data: { settings: KeyValue[] }) => api.post('config/set', data),
|
||||
};
|
||||
19
admin/src/api/modules/dashboard.ts
Normal file
19
admin/src/api/modules/dashboard.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { AxiosResponse } from 'axios';
|
||||
import api from '../index';
|
||||
|
||||
const apiDashboard = {
|
||||
getBaseInfo: () => api.get('/statistic/base'),
|
||||
getChatStatistic: (params: { days: number }): Promise<AxiosResponse<any>> => {
|
||||
return api.get('/statistic/chatStatistic', {
|
||||
params: { days: params.days },
|
||||
});
|
||||
},
|
||||
getBaiduVisit: (params: any): Promise<AxiosResponse<any>> => {
|
||||
return api.get('/statistic/baiduVisit', {
|
||||
params: { days: params.days },
|
||||
});
|
||||
},
|
||||
getObserverCharts: (params: any) => api.get('/statistic/observerCharts', { params }),
|
||||
};
|
||||
|
||||
export default apiDashboard;
|
||||
8
admin/src/api/modules/mcp.ts
Normal file
8
admin/src/api/modules/mcp.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
queryMcpConfig: (params: any) => api.get('model-context-protocol', { params }),
|
||||
deleteMcpConfig: (id: number) => api.delete(`model-context-protocol/${id}`),
|
||||
setMcpConfig: (data: any) => api.post('model-context-protocol', data),
|
||||
updateMcpConfig: (id: number, data: any) => api.put(`model-context-protocol/${id}`, data),
|
||||
};
|
||||
7
admin/src/api/modules/models.ts
Normal file
7
admin/src/api/modules/models.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
queryModels: (params: any) => api.get('models/query', { params }),
|
||||
setModels: (data: any) => api.post('models/setModel', data),
|
||||
delModels: (data: any) => api.post('models/delModel', data),
|
||||
};
|
||||
10
admin/src/api/modules/official.ts
Normal file
10
admin/src/api/modules/official.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
// 创建自定义菜单
|
||||
createOfficialMenu: (data: any) => api.post('official/menu', data),
|
||||
// 查询自定义菜单
|
||||
queryOfficialMenu: () => api.get('official/menu'),
|
||||
// 删除自定义菜单
|
||||
deleteOfficialMenu: () => api.delete('official/menu'),
|
||||
};
|
||||
7
admin/src/api/modules/order.ts
Normal file
7
admin/src/api/modules/order.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
queryAllOrder: (params: any) => api.get('order/queryAll', { params }),
|
||||
deleteOrder: (data: any) => api.post('order/delete', data),
|
||||
deleteNotPay: () => api.post('order/deleteNotPay'),
|
||||
};
|
||||
12
admin/src/api/modules/package.ts
Normal file
12
admin/src/api/modules/package.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
queryAllPackage: (params: any) => api.get('crami/queryAllPackage', { params }),
|
||||
updatePackage: (data: any) => api.post('crami/updatePackage', data),
|
||||
createPackage: (data: any) => api.post('crami/createPackage', data),
|
||||
delPackage: (data: any) => api.post('crami/delPackage', data),
|
||||
queryAllCrami: (params: any) => api.get('crami/queryAllCrami', { params }),
|
||||
delCrami: (data: any) => api.post('crami/delCrami', data),
|
||||
createCrami: (data: any) => api.post('crami/createCrami', data),
|
||||
batchDelCrami: (data: any) => api.post('crami/batchDelCrami', data),
|
||||
};
|
||||
8
admin/src/api/modules/plugin.ts
Normal file
8
admin/src/api/modules/plugin.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
pluginList: (params: any) => api.get('plugin/pluginList', { params }),
|
||||
delPlugin: (data: { id: number }) => api.post('plugin/delPlugin', data),
|
||||
createPlugin: (data: any) => api.post('plugin/createPlugin', data),
|
||||
updatePlugin: (data: any) => api.post('plugin/updatePlugin', data),
|
||||
};
|
||||
14
admin/src/api/modules/upload.ts
Normal file
14
admin/src/api/modules/upload.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
/**
|
||||
* 上传文件
|
||||
* @param data FormData对象,必须包含file字段
|
||||
* @param dir 可选的目录参数
|
||||
* @returns
|
||||
*/
|
||||
uploadFile: (data: FormData, dir?: string) => {
|
||||
const url = dir ? `upload/file?dir=${encodeURIComponent(dir)}` : 'upload/file';
|
||||
return api.post(url, data);
|
||||
},
|
||||
};
|
||||
27
admin/src/api/modules/user.ts
Normal file
27
admin/src/api/modules/user.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
login: (data: { username: string; password: string }) => api.post('auth/login', data),
|
||||
|
||||
permission: () => api.get('auth/getInfo'),
|
||||
|
||||
getInfo: () => api.get('auth/getInfo'),
|
||||
|
||||
queryAllUser: (params: any) => api.get('user/queryAll', { params }),
|
||||
|
||||
updateUserStatus: (data: { status: string }) => api.post('user/updateStatus', data),
|
||||
|
||||
resetUserPassword: (data: { id: number }) => api.post('user/resetUserPass', data),
|
||||
|
||||
sendUserCrami: (data: {
|
||||
userId: number;
|
||||
model3Count: number;
|
||||
model4Count: number;
|
||||
drawMjCount: number;
|
||||
}) => api.post('user/recharge', data),
|
||||
|
||||
passwordEdit: (data: { oldPassword: string; password: string }) =>
|
||||
api.post('auth/updatePassword', data),
|
||||
|
||||
queryUserAccountLog: (params: any) => api.get('balance/accountLog', { params }),
|
||||
};
|
||||
31
admin/src/assets/CHANGELOG.md
Normal file
31
admin/src/assets/CHANGELOG.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 项目说明
|
||||
|
||||
99AI 是一个**可商业化的 AI Web 平台**,提供一站式的人工智能服务解决方案。支持私有化部署,内置多用户管理,适合企业、团队或个人快速构建 AI 服务。
|
||||
|
||||
## 开源协议与使用条款
|
||||
|
||||
### 开源协议
|
||||
|
||||
本项目采用 **Apache 2.0 开源协议**,在遵循协议条款的前提下,您可以自由使用、修改和分发本项目。
|
||||
|
||||
### 使用要求
|
||||
|
||||
- **保留署名**:使用本项目时请保留项目署名和链接
|
||||
- **项目地址**:https://github.com/vastxie/99AI
|
||||
- **项目文档**:https://docs.lightai.cloud/
|
||||
|
||||
### 商业化支持
|
||||
|
||||
- **直接商用**:支持直接用于商业用途,需保留本页面信息
|
||||
- **闭源分发**:如需闭源分发,请联系作者获得授权
|
||||
|
||||
## 社区交流
|
||||
|
||||
### 微信交流群
|
||||
|
||||
<img src="https://asst.lightai.cloud/file/system/others/1748593304611_ucu1.png" alt="微信群二维码" width="200" />
|
||||
|
||||
**入群说明:**
|
||||
|
||||
- 添加微信备注「**99**」进群交流
|
||||
- 作者不提供私聊技术咨询,请优先阅读群公告
|
||||
1
admin/src/assets/icons/403.svg
Executable file
1
admin/src/assets/icons/403.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.2 KiB |
1
admin/src/assets/icons/404.svg
Executable file
1
admin/src/assets/icons/404.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.5 KiB |
1
admin/src/assets/icons/image-load-fail.svg
Executable file
1
admin/src/assets/icons/image-load-fail.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path d="M704 328a72 72 0 1 0 144 0 72 72 0 1 0-144 0z"/><path d="M999.904 116.608a32 32 0 0 0-21.952-10.912L521.76 73.792a31.552 31.552 0 0 0-27.2 11.904l-92.192 114.848a32 32 0 0 0 .672 40.896l146.144 169.952-147.456 194.656 36.48-173.376a32 32 0 0 0-11.136-31.424L235.616 245.504l79.616-125.696a32 32 0 0 0-29.28-49.024L45.76 87.552a32 32 0 0 0-29.696 34.176l55.808 798.016a32.064 32.064 0 0 0 34.304 29.696l176.512-13.184c17.632-1.312 30.848-16.672 29.504-34.272s-16.576-31.04-34.304-29.536L133.44 883.232l-6.432-92.512 125.312-12.576a32 32 0 0 0 28.672-35.04 32.16 32.16 0 0 0-35.04-28.672L122.56 726.848 82.144 149.184l145.152-10.144-60.96 96.224a32 32 0 0 0 6.848 41.952l198.4 161.344-58.752 279.296a30.912 30.912 0 0 0 .736 14.752 31.68 31.68 0 0 0 1.408 11.04l51.52 154.56a31.968 31.968 0 0 0 27.456 21.76l523.104 47.552a32.064 32.064 0 0 0 34.848-29.632l55.776-798.048a32.064 32.064 0 0 0-7.776-23.232zm-98.912 630.848-412.576-39.648a31.52 31.52 0 0 0-34.912 28.768 32 32 0 0 0 28.8 34.912l414.24 39.808-6.272 89.536-469.728-42.72-39.584-118.72 234.816-310.016a31.936 31.936 0 0 0-1.248-40.192L468.896 219.84l65.088-81.056 407.584 28.48-40.576 580.192z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
admin/src/assets/icons/toolbar-collapse.svg
Executable file
1
admin/src/assets/icons/toolbar-collapse.svg
Executable file
@@ -0,0 +1 @@
|
||||
<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.667zm-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.666zm0 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.666H516.267zm4.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.667zm-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.266z"/></svg>
|
||||
|
After Width: | Height: | Size: 998 B |
144
admin/src/assets/styles/globals.scss
Executable file
144
admin/src/assets/styles/globals.scss
Executable file
@@ -0,0 +1,144 @@
|
||||
// 页面布局 CSS 变量
|
||||
:root {
|
||||
// 头部高度
|
||||
--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;
|
||||
}
|
||||
|
||||
// 明暗模式 CSS 变量
|
||||
/* stylelint-disable-next-line no-duplicate-selectors */
|
||||
:root {
|
||||
&::view-transition-old(root),
|
||||
&::view-transition-new(root) {
|
||||
mix-blend-mode: normal;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&::view-transition-new(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
--g-box-shadow-color: rgb(0 0 0 / 12%);
|
||||
|
||||
&.dark {
|
||||
&::view-transition-old(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
&::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
--g-box-shadow-color: rgb(0 0 0 / 72%);
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(0 0 0 / 40%);
|
||||
background-clip: padding-box;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-family: Lato, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background-color: var(--g-container-bg);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
// 右侧内容区针对fixed元素,有横向铺满的需求,可在fixed元素上设置 [data-fixed-calc-width]
|
||||
[data-fixed-calc-width] {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
left: 50%;
|
||||
width: calc(100% - var(--g-main-sidebar-actual-width) - var(--g-sub-sidebar-actual-width));
|
||||
transform: translateX(-50%) translateX(calc(var(--g-main-sidebar-actual-width) / 2))
|
||||
translateX(calc(var(--g-sub-sidebar-actual-width) / 2));
|
||||
}
|
||||
|
||||
[data-mode='mobile'] {
|
||||
[data-fixed-calc-width] {
|
||||
width: 100% !important;
|
||||
transform: translateX(-50%) !important;
|
||||
}
|
||||
}
|
||||
// textarea 字体跟随系统
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Overrides Floating Vue */
|
||||
.v-popper--theme-dropdown,
|
||||
.v-popper--theme-tooltip {
|
||||
--at-apply: inline-flex;
|
||||
}
|
||||
|
||||
.v-popper--theme-dropdown .v-popper__inner,
|
||||
.v-popper--theme-tooltip .v-popper__inner {
|
||||
--at-apply: bg-white dark-bg-stone-8 text-dark dark-text-white rounded shadow ring-1 ring-gray-200
|
||||
dark-ring-gray-800 border border-solid border-stone/20 text-xs font-normal;
|
||||
|
||||
box-shadow: 0 6px 30px rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
.v-popper--theme-tooltip .v-popper__arrow-inner,
|
||||
.v-popper--theme-dropdown .v-popper__arrow-inner {
|
||||
visibility: visible;
|
||||
|
||||
--at-apply: border-white dark-border-stone-8;
|
||||
}
|
||||
|
||||
.v-popper--theme-tooltip .v-popper__arrow-outer,
|
||||
.v-popper--theme-dropdown .v-popper__arrow-outer {
|
||||
--at-apply: border-stone/20;
|
||||
}
|
||||
|
||||
.v-popper--theme-tooltip.v-popper--shown,
|
||||
.v-popper--theme-tooltip.v-popper--shown * {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
[data-overlayscrollbars-contents] {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
73
admin/src/assets/styles/nprogress.scss
Executable file
73
admin/src/assets/styles/nprogress.scss
Executable file
@@ -0,0 +1,73 @@
|
||||
#nprogress {
|
||||
pointer-events: none;
|
||||
|
||||
.bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2000;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: rgb(var(--ui-primary));
|
||||
}
|
||||
|
||||
.peg {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
display: block;
|
||||
width: 100px;
|
||||
height: 100%;
|
||||
box-shadow:
|
||||
0 0 10px rgb(var(--ui-primary)),
|
||||
0 0 5px rgb(var(--ui-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: rgb(var(--ui-primary));
|
||||
border-left-color: rgb(var(--ui-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);
|
||||
}
|
||||
}
|
||||
53
admin/src/assets/styles/resources/utils.scss
Executable file
53
admin/src/assets/styles/resources/utils.scss
Executable file
@@ -0,0 +1,53 @@
|
||||
// 文字超出隐藏,默认为单行超出隐藏,可设置多行
|
||||
@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;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: $line;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
// 定位居中,默认水平居中,可选择垂直居中,或者水平垂直都居中
|
||||
@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 {
|
||||
display: block;
|
||||
clear: both;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
1
admin/src/assets/styles/resources/variables.scss
Executable file
1
admin/src/assets/styles/resources/variables.scss
Executable file
@@ -0,0 +1 @@
|
||||
// 全局变量
|
||||
20
admin/src/components/Auth/index.vue
Executable file
20
admin/src/components/Auth/index.vue
Executable file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'Auth',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
value: string | string[];
|
||||
}>();
|
||||
|
||||
function check() {
|
||||
return useAuth().auth(props.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<slot v-if="check()" />
|
||||
<slot v-else name="no-auth" />
|
||||
</div>
|
||||
</template>
|
||||
20
admin/src/components/AuthAll/index.vue
Executable file
20
admin/src/components/AuthAll/index.vue
Executable file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'AuthAll',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
value: string[];
|
||||
}>();
|
||||
|
||||
function check() {
|
||||
return useAuth().authAll(props.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<slot v-if="check()" />
|
||||
<slot v-else name="no-auth" />
|
||||
</div>
|
||||
</template>
|
||||
107
admin/src/components/FileUpload/index.vue
Executable file
107
admin/src/components/FileUpload/index.vue
Executable file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadProps, UploadUserFile } from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
defineOptions({
|
||||
name: 'FileUpload',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action: UploadProps['action'];
|
||||
headers?: UploadProps['headers'];
|
||||
data?: UploadProps['data'];
|
||||
name?: UploadProps['name'];
|
||||
size?: number;
|
||||
max?: number;
|
||||
files?: UploadUserFile[];
|
||||
notip?: boolean;
|
||||
ext?: string[];
|
||||
}>(),
|
||||
{
|
||||
name: 'file',
|
||||
size: 2,
|
||||
max: 3,
|
||||
files: () => [],
|
||||
notip: false,
|
||||
ext: () => ['zip', 'rar'],
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
onSuccess: [res: any, file: UploadUserFile, fileList: UploadUserFile[]];
|
||||
}>();
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
const fileName = file.name.split('.');
|
||||
const fileExt = fileName.at(-1) ?? '';
|
||||
const isTypeOk = props.ext.includes(fileExt);
|
||||
const isSizeOk = file.size / 1024 / 1024 < props.size;
|
||||
if (!isTypeOk) {
|
||||
ElMessage.error(`上传文件只支持 ${props.ext.join(' / ')} 格式!`);
|
||||
}
|
||||
if (!isSizeOk) {
|
||||
ElMessage.error(`上传文件大小不能超过 ${props.size}MB!`);
|
||||
}
|
||||
return isTypeOk && isSizeOk;
|
||||
};
|
||||
|
||||
const onExceed: UploadProps['onExceed'] = () => {
|
||||
ElMessage.warning('文件上传超过限制');
|
||||
};
|
||||
|
||||
const onSuccess: UploadProps['onSuccess'] = (res, file, fileList) => {
|
||||
emits('onSuccess', res, file, fileList);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElUpload
|
||||
:headers="headers"
|
||||
:action="action"
|
||||
:data="data"
|
||||
:name="name"
|
||||
:before-upload="beforeUpload"
|
||||
:on-exceed="onExceed"
|
||||
:on-success="onSuccess"
|
||||
:file-list="files"
|
||||
:limit="max"
|
||||
drag
|
||||
>
|
||||
<div class="slot">
|
||||
<SvgIcon name="i-ep:upload-filled" class="el-icon--upload" />
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div v-if="!notip" class="el-upload__tip">
|
||||
<div style="display: inline-block">
|
||||
<ElAlert
|
||||
:title="`上传文件支持 ${ext.join(' / ')} 格式,单个文件大小不超过 ${size}MB,且文件数量不超过 ${max} 个`"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElUpload>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-upload.is-drag) {
|
||||
display: inline-block;
|
||||
|
||||
.el-upload-dragger {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&.is-dragover {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.slot {
|
||||
width: 300px;
|
||||
padding: 40px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
47
admin/src/components/FixedActionBar/index.vue
Executable file
47
admin/src/components/FixedActionBar/index.vue
Executable file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'FixedActionBar',
|
||||
});
|
||||
|
||||
const isBottom = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
});
|
||||
|
||||
function onScroll() {
|
||||
// 变量scrollTop是滚动条滚动时,滚动条上端距离顶部的距离
|
||||
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
|
||||
// 变量windowHeight是可视区的高度
|
||||
const windowHeight = document.documentElement.clientHeight || document.body.clientHeight;
|
||||
// 变量scrollHeight是滚动条的总高度(当前可滚动的页面的总高度)
|
||||
const scrollHeight = document.documentElement.scrollHeight || document.body.scrollHeight;
|
||||
// 滚动条到底部
|
||||
isBottom.value = Math.ceil(scrollTop + windowHeight) >= scrollHeight;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fixed-action-bar bottom-0 z-4 bg-[var(--g-container-bg)] p-5 text-center transition"
|
||||
:class="{ shadow: !isBottom }"
|
||||
data-fixed-calc-width
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fixed-action-bar {
|
||||
box-shadow: 0 0 1px 0 var(--g-box-shadow-color);
|
||||
|
||||
&.shadow {
|
||||
box-shadow: 0 -10px 10px -10px var(--g-box-shadow-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
admin/src/components/IconifyIcon/index.vue
Normal file
21
admin/src/components/IconifyIcon/index.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, useAttrs } from 'vue';
|
||||
|
||||
interface Props {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const bindAttrs = computed<{ class: string; style: string }>(() => ({
|
||||
class: (attrs.class as string) || '',
|
||||
style: (attrs.style as string) || 'width: 2em, height: 2em',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon icon="icon" v-bind="bindAttrs" />
|
||||
</template>
|
||||
71
admin/src/components/ImagePreview/index.vue
Executable file
71
admin/src/components/ImagePreview/index.vue
Executable file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'ImagePreview',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
src: string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
}>(),
|
||||
{
|
||||
width: 200,
|
||||
height: 200,
|
||||
},
|
||||
);
|
||||
|
||||
const realWidth = computed(() => {
|
||||
return typeof props.width === 'string' ? props.width : `${props.width}px`;
|
||||
});
|
||||
|
||||
const realHeight = computed(() => {
|
||||
return typeof props.height === 'string' ? props.height : `${props.height}px`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElImage
|
||||
:src="src"
|
||||
fit="cover"
|
||||
:style="`width:${realWidth};height:${realHeight};`"
|
||||
:preview-src-list="[src]"
|
||||
preview-teleported
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-slot">
|
||||
<SvgIcon name="image-load-fail" />
|
||||
</div>
|
||||
</template>
|
||||
</ElImage>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-image {
|
||||
background-color: var(--el-fill-color);
|
||||
border-radius: 5px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
transition:
|
||||
background-color 0.3s,
|
||||
var(--el-transition-box-shadow);
|
||||
|
||||
:deep(.el-image__inner) {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.image-slot) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 30px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
274
admin/src/components/ImageUpload/index.vue
Executable file
274
admin/src/components/ImageUpload/index.vue
Executable file
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadProps } from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
defineOptions({
|
||||
name: 'ImageUpload',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action: UploadProps['action'];
|
||||
headers?: UploadProps['headers'];
|
||||
data?: UploadProps['data'];
|
||||
name?: UploadProps['name'];
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
placeholder?: string;
|
||||
notip?: boolean;
|
||||
ext?: string[];
|
||||
}>(),
|
||||
{
|
||||
name: 'file',
|
||||
size: 2,
|
||||
width: 150,
|
||||
height: 150,
|
||||
placeholder: '',
|
||||
notip: false,
|
||||
ext: () => ['jpg', 'png', 'gif', 'bmp'],
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
onSuccess: [res: any];
|
||||
}>();
|
||||
|
||||
const url = defineModel<string>({
|
||||
default: '',
|
||||
});
|
||||
|
||||
const uploadData = ref({
|
||||
imageViewerVisible: false,
|
||||
progress: {
|
||||
preview: '',
|
||||
percent: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// 预览
|
||||
function preview() {
|
||||
uploadData.value.imageViewerVisible = true;
|
||||
}
|
||||
// 关闭预览
|
||||
function previewClose() {
|
||||
uploadData.value.imageViewerVisible = false;
|
||||
}
|
||||
// 移除
|
||||
function remove() {
|
||||
url.value = '';
|
||||
}
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
const fileName = file.name.split('.');
|
||||
const fileExt = fileName.at(-1) ?? '';
|
||||
const isTypeOk = props.ext.includes(fileExt);
|
||||
const isSizeOk = file.size / 1024 / 1024 < props.size;
|
||||
if (!isTypeOk) {
|
||||
ElMessage.error(`上传图片只支持 ${props.ext.join(' / ')} 格式!`);
|
||||
}
|
||||
if (!isSizeOk) {
|
||||
ElMessage.error(`上传图片大小不能超过 ${props.size}MB!`);
|
||||
}
|
||||
if (isTypeOk && isSizeOk) {
|
||||
uploadData.value.progress.preview = URL.createObjectURL(file);
|
||||
}
|
||||
return isTypeOk && isSizeOk;
|
||||
};
|
||||
const onProgress: UploadProps['onProgress'] = (file) => {
|
||||
uploadData.value.progress.percent = ~~file.percent;
|
||||
};
|
||||
const onSuccess: UploadProps['onSuccess'] = (res) => {
|
||||
uploadData.value.progress.preview = '';
|
||||
uploadData.value.progress.percent = 0;
|
||||
emits('onSuccess', res);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upload-container">
|
||||
<ElUpload
|
||||
:show-file-list="false"
|
||||
:headers="headers"
|
||||
:action="action"
|
||||
:data="data"
|
||||
:name="name"
|
||||
:before-upload="beforeUpload"
|
||||
:on-progress="onProgress"
|
||||
:on-success="onSuccess"
|
||||
drag
|
||||
class="image-upload"
|
||||
>
|
||||
<ElImage
|
||||
v-if="url === ''"
|
||||
:src="url === '' ? placeholder : url"
|
||||
:style="`width:${width}px;height:${height}px;`"
|
||||
fit="fill"
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
|
||||
<SvgIcon name="i-ep:plus" class="icon" />
|
||||
</div>
|
||||
</template>
|
||||
</ElImage>
|
||||
<div v-else class="image">
|
||||
<ElImage :src="url" :style="`width:${width}px;height:${height}px;`" fit="fill" />
|
||||
<div class="mask">
|
||||
<div class="actions">
|
||||
<span title="预览" @click.stop="preview">
|
||||
<SvgIcon name="i-ep:zoom-in" class="icon" />
|
||||
</span>
|
||||
<span title="移除" @click.stop="remove">
|
||||
<SvgIcon name="i-ep:delete" class="icon" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-show="url === '' && uploadData.progress.percent"
|
||||
class="progress"
|
||||
:style="`width:${width}px;height:${height}px;`"
|
||||
>
|
||||
<ElImage
|
||||
:src="uploadData.progress.preview"
|
||||
:style="`width:${width}px;height:${height}px;`"
|
||||
fit="fill"
|
||||
/>
|
||||
<ElProgress
|
||||
type="circle"
|
||||
:width="Math.min(width, height) * 0.8"
|
||||
:percentage="uploadData.progress.percent"
|
||||
/>
|
||||
</div>
|
||||
</ElUpload>
|
||||
<div v-if="!notip" class="el-upload__tip">
|
||||
<div style="display: inline-block">
|
||||
<ElAlert
|
||||
:title="`上传图片支持 ${ext.join(' / ')} 格式,且图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height}`"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ElImageViewer
|
||||
v-if="uploadData.imageViewerVisible"
|
||||
:url-list="[url]"
|
||||
teleported
|
||||
@close="previewClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-container {
|
||||
line-height: initial;
|
||||
}
|
||||
|
||||
.el-image {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--el-overlay-color-lighter);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
|
||||
@include position-center(xy);
|
||||
|
||||
span {
|
||||
width: 50%;
|
||||
color: var(--el-color-white);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.1s,
|
||||
transform 0.1s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .mask {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image-upload {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
:deep(.el-upload) {
|
||||
.el-upload-dragger {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
|
||||
&.is-dragover {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.image-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--el-text-color-placeholder);
|
||||
background-color: transparent;
|
||||
|
||||
.icon {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: '';
|
||||
background-color: var(--el-overlay-color-lighter);
|
||||
}
|
||||
|
||||
.el-progress {
|
||||
z-index: 1;
|
||||
|
||||
@include position-center(xy);
|
||||
|
||||
.el-progress__text {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
310
admin/src/components/ImagesUpload/index.vue
Executable file
310
admin/src/components/ImagesUpload/index.vue
Executable file
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadProps } from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
defineOptions({
|
||||
name: 'ImagesUpload',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action: UploadProps['action'];
|
||||
headers?: UploadProps['headers'];
|
||||
data?: UploadProps['data'];
|
||||
name?: UploadProps['name'];
|
||||
size?: number;
|
||||
max?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
placeholder?: string;
|
||||
notip?: boolean;
|
||||
ext?: string[];
|
||||
}>(),
|
||||
{
|
||||
name: 'file',
|
||||
size: 2,
|
||||
max: 3,
|
||||
width: 150,
|
||||
height: 150,
|
||||
placeholder: '',
|
||||
notip: false,
|
||||
ext: () => ['jpg', 'png', 'gif', 'bmp'],
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
onSuccess: [res: any];
|
||||
}>();
|
||||
|
||||
const url = defineModel<string[]>({
|
||||
default: [],
|
||||
});
|
||||
|
||||
const uploadData = ref({
|
||||
dialogImageIndex: 0,
|
||||
imageViewerVisible: false,
|
||||
progress: {
|
||||
preview: '',
|
||||
percent: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// 预览
|
||||
function preview(index: number) {
|
||||
uploadData.value.dialogImageIndex = index;
|
||||
uploadData.value.imageViewerVisible = true;
|
||||
}
|
||||
// 关闭预览
|
||||
function previewClose() {
|
||||
uploadData.value.imageViewerVisible = false;
|
||||
}
|
||||
// 移除
|
||||
function remove(index: number) {
|
||||
url.value.splice(index, 1);
|
||||
}
|
||||
// 移动
|
||||
function move(index: number, type: 'left' | 'right') {
|
||||
if (type === 'left' && index !== 0) {
|
||||
url.value[index] = url.value.splice(index - 1, 1, url.value[index])[0];
|
||||
}
|
||||
if (type === 'right' && index !== url.value.length - 1) {
|
||||
url.value[index] = url.value.splice(index + 1, 1, url.value[index])[0];
|
||||
}
|
||||
}
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
const fileName = file.name.split('.');
|
||||
const fileExt = fileName.at(-1) ?? '';
|
||||
const isTypeOk = props.ext.includes(fileExt);
|
||||
const isSizeOk = file.size / 1024 / 1024 < props.size;
|
||||
if (!isTypeOk) {
|
||||
ElMessage.error(`上传图片只支持 ${props.ext.join(' / ')} 格式!`);
|
||||
}
|
||||
if (!isSizeOk) {
|
||||
ElMessage.error(`上传图片大小不能超过 ${props.size}MB!`);
|
||||
}
|
||||
if (isTypeOk && isSizeOk) {
|
||||
uploadData.value.progress.preview = URL.createObjectURL(file);
|
||||
}
|
||||
return isTypeOk && isSizeOk;
|
||||
};
|
||||
const onProgress: UploadProps['onProgress'] = (file) => {
|
||||
uploadData.value.progress.percent = ~~file.percent;
|
||||
};
|
||||
const onSuccess: UploadProps['onSuccess'] = (res) => {
|
||||
uploadData.value.progress.preview = '';
|
||||
uploadData.value.progress.percent = 0;
|
||||
emits('onSuccess', res);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upload-container">
|
||||
<div v-for="(item, index) in url as string[]" :key="index" class="images">
|
||||
<ElImage
|
||||
v-if="index < max"
|
||||
:src="item"
|
||||
:style="`width:${width}px;height:${height}px;`"
|
||||
fit="cover"
|
||||
/>
|
||||
<div class="mask">
|
||||
<div class="actions">
|
||||
<span title="预览" @click="preview(index)">
|
||||
<SvgIcon name="i-ep:zoom-in" class="icon" />
|
||||
</span>
|
||||
<span title="移除" @click="remove(index)">
|
||||
<SvgIcon name="i-ep:delete" class="icon" />
|
||||
</span>
|
||||
<span
|
||||
v-show="url.length > 1"
|
||||
title="左移"
|
||||
:class="{ disabled: index === 0 }"
|
||||
@click="move(index, 'left')"
|
||||
>
|
||||
<SvgIcon name="i-ep:back" class="icon" />
|
||||
</span>
|
||||
<span
|
||||
v-show="url.length > 1"
|
||||
title="右移"
|
||||
:class="{ disabled: index === url.length - 1 }"
|
||||
@click="move(index, 'right')"
|
||||
>
|
||||
<SvgIcon name="i-ep:right" class="icon" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ElUpload
|
||||
v-show="url.length < max"
|
||||
:show-file-list="false"
|
||||
:headers="headers"
|
||||
:action="action"
|
||||
:data="data"
|
||||
:name="name"
|
||||
:before-upload="beforeUpload"
|
||||
:on-progress="onProgress"
|
||||
:on-success="onSuccess"
|
||||
drag
|
||||
class="images-upload"
|
||||
>
|
||||
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
|
||||
<SvgIcon name="i-ep:plus" class="icon" />
|
||||
</div>
|
||||
<div
|
||||
v-show="uploadData.progress.percent"
|
||||
class="progress"
|
||||
:style="`width:${width}px;height:${height}px;`"
|
||||
>
|
||||
<ElImage
|
||||
:src="uploadData.progress.preview"
|
||||
:style="`width:${width}px;height:${height}px;`"
|
||||
fit="fill"
|
||||
/>
|
||||
<ElProgress
|
||||
type="circle"
|
||||
:width="Math.min(width, height) * 0.8"
|
||||
:percentage="uploadData.progress.percent"
|
||||
/>
|
||||
</div>
|
||||
</ElUpload>
|
||||
<div v-if="!notip" class="el-upload__tip">
|
||||
<div style="display: inline-block">
|
||||
<ElAlert
|
||||
:title="`上传图片支持 ${ext.join(' / ')} 格式,单张图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height},且图片数量不超过 ${max} 张`"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ElImageViewer
|
||||
v-if="uploadData.imageViewerVisible"
|
||||
:url-list="url as string[]"
|
||||
:initial-index="uploadData.dialogImageIndex"
|
||||
teleported
|
||||
@close="previewClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-container {
|
||||
line-height: initial;
|
||||
}
|
||||
|
||||
.el-image {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.images {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--el-overlay-color-lighter);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
|
||||
@include position-center(xy);
|
||||
|
||||
span {
|
||||
width: 50%;
|
||||
color: var(--el-color-white);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.1s,
|
||||
transform 0.1s;
|
||||
|
||||
&.disabled {
|
||||
color: var(--el-text-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .mask {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.images-upload {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
:deep(.el-upload) {
|
||||
.el-upload-dragger {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
|
||||
&.is-dragover {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.image-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--el-text-color-placeholder);
|
||||
background-color: transparent;
|
||||
|
||||
.icon {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: '';
|
||||
background-color: var(--el-overlay-color-lighter);
|
||||
}
|
||||
|
||||
.el-progress {
|
||||
z-index: 1;
|
||||
|
||||
@include position-center(xy);
|
||||
|
||||
.el-progress__text {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
49
admin/src/components/NotAllowed/index.vue
Executable file
49
admin/src/components/NotAllowed/index.vue
Executable file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
|
||||
defineOptions({
|
||||
name: 'NotAllowed',
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const data = ref({
|
||||
inter: Number.NaN,
|
||||
countdown: 5,
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
data.value.inter && window.clearInterval(data.value.inter);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
data.value.inter = window.setInterval(() => {
|
||||
data.value.countdown--;
|
||||
if (data.value.countdown === 0) {
|
||||
data.value.inter && window.clearInterval(data.value.inter);
|
||||
goBack();
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
router.push(settingsStore.settings.home.fullPath);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute left-[50%] top-[50%] flex flex-col items-center justify-between lg-flex-row -translate-x-50% -translate-y-50% lg-gap-12"
|
||||
>
|
||||
<SvgIcon name="403" class="text-[300px] lg-text-[400px]" />
|
||||
<div class="flex flex-col gap-4">
|
||||
<h1 class="m-0 text-6xl font-sans">403</h1>
|
||||
<div class="desc mx-0 text-xl text-stone-5">抱歉,你无权访问该页面</div>
|
||||
<div>
|
||||
<HButton @click="goBack"> {{ data.countdown }} 秒后,返回首页 </HButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
34
admin/src/components/PageHeader/index.vue
Executable file
34
admin/src/components/PageHeader/index.vue
Executable file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'PageHeader',
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
title?: string;
|
||||
content?: string;
|
||||
}>();
|
||||
|
||||
const slots = useSlots();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="page-header mb-5 flex flex-wrap items-center justify-between gap-5 bg-[var(--g-container-bg)] px-5 py-4 transition-background-color-300"
|
||||
>
|
||||
<div class="main flex-[1_1_70%]">
|
||||
<div class="text-2xl">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-stone-5 empty-hidden">
|
||||
<slot name="content">
|
||||
{{ content }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="slots.default" class="ml-a flex-none">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
56
admin/src/components/PageMain/index.vue
Executable file
56
admin/src/components/PageMain/index.vue
Executable file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'PageMain',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
collaspe?: boolean;
|
||||
height?: string;
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
collaspe: false,
|
||||
height: '',
|
||||
},
|
||||
);
|
||||
|
||||
const titleSlot = !!useSlots().title;
|
||||
|
||||
const isCollaspe = ref(props.collaspe);
|
||||
function unCollaspe() {
|
||||
isCollaspe.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="page-main relative m-4 flex flex-col bg-[var(--g-container-bg)] transition-background-color-300"
|
||||
:class="{
|
||||
'of-hidden': isCollaspe,
|
||||
}"
|
||||
:style="{
|
||||
height: isCollaspe ? height : '',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="titleSlot || title"
|
||||
class="title-container border-b-1 border-b-[var(--g-bg)] border-b-solid px-5 py-4 transition-border-color-300"
|
||||
>
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</div>
|
||||
<div class="main-container p-5">
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
v-if="isCollaspe"
|
||||
class="collaspe absolute bottom-0 w-full cursor-pointer from-transparent to-[var(--g-container-bg)] bg-gradient-to-b pb-2 pt-10 text-center"
|
||||
@click="unCollaspe"
|
||||
>
|
||||
<SvgIcon name="i-ep:arrow-down" class="text-xl op-30 transition-opacity hover-op-100" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
151
admin/src/components/PcasCascader/index.vue
Executable file
151
admin/src/components/PcasCascader/index.vue
Executable file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
// 行政区划数据来源于 https://github.com/modood/Administrative-divisions-of-China
|
||||
import pcasRaw from './pcas-code.json';
|
||||
|
||||
defineOptions({
|
||||
name: 'PcasCascader',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
type?: 'pc' | 'pca' | 'pcas';
|
||||
format?: 'code' | 'name' | 'both';
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
type: 'pca',
|
||||
format: 'code',
|
||||
},
|
||||
);
|
||||
|
||||
const value = defineModel<
|
||||
| string[]
|
||||
| {
|
||||
code: string;
|
||||
name: string;
|
||||
}[]
|
||||
>({
|
||||
default: [],
|
||||
});
|
||||
|
||||
interface pcasItem {
|
||||
code: string;
|
||||
name: string;
|
||||
children?: pcasItem[];
|
||||
}
|
||||
|
||||
const pcasData = computed(() => {
|
||||
const data: pcasItem[] = [];
|
||||
// 省份
|
||||
pcasRaw.forEach((p) => {
|
||||
const tempP: pcasItem = {
|
||||
code: p.code,
|
||||
name: p.name,
|
||||
};
|
||||
const tempChildrenC: pcasItem[] = [];
|
||||
// 城市
|
||||
p.children.forEach((c) => {
|
||||
const tempC: pcasItem = {
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
};
|
||||
if (['pca', 'pcas'].includes(props.type)) {
|
||||
const tempChildrenA: pcasItem[] = [];
|
||||
// 区县
|
||||
c.children.forEach((a) => {
|
||||
const tempA: pcasItem = {
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
};
|
||||
if (props.type === 'pcas') {
|
||||
const tempChildrenS: pcasItem[] = [];
|
||||
// 街道
|
||||
a.children.forEach((s) => {
|
||||
const tempS: pcasItem = {
|
||||
code: s.code,
|
||||
name: s.name,
|
||||
};
|
||||
tempChildrenS.push(tempS);
|
||||
});
|
||||
tempA.children = tempChildrenS;
|
||||
}
|
||||
tempChildrenA.push(tempA);
|
||||
});
|
||||
tempC.children = tempChildrenA;
|
||||
}
|
||||
tempChildrenC.push(tempC);
|
||||
});
|
||||
tempP.children = tempChildrenC;
|
||||
data.push(tempP);
|
||||
});
|
||||
return data;
|
||||
});
|
||||
|
||||
const myValue = computed({
|
||||
// 将入参数据转成 code 码
|
||||
get: () => {
|
||||
return anyToCode(value.value);
|
||||
},
|
||||
// 将 code 码转成出参数据
|
||||
set: (val) => {
|
||||
value.value = val ? codeToAny(val) : [];
|
||||
},
|
||||
});
|
||||
|
||||
function anyToCode(value: any[], dictionarie: any[] = pcasData.value) {
|
||||
const input: string[] = [];
|
||||
if (value.length > 0) {
|
||||
const findItem = dictionarie.find((item) => {
|
||||
if (props.format === 'code') {
|
||||
return item.code === value[0];
|
||||
} else if (props.format === 'name') {
|
||||
return item.name === value[0];
|
||||
} else {
|
||||
return item.name === value[0].name && item.code === value[0].code;
|
||||
}
|
||||
});
|
||||
input.push(findItem.code);
|
||||
if (findItem.children) {
|
||||
input.push(...anyToCode(value.slice(1 - value.length), findItem.children));
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function codeToAny(codes: string[], dictionarie: any[] = pcasData.value): any {
|
||||
const output = [];
|
||||
const findItem = dictionarie.find((item) => item.code === codes[0]);
|
||||
if (findItem) {
|
||||
switch (props.format) {
|
||||
case 'code':
|
||||
output.push(findItem.code);
|
||||
break;
|
||||
case 'name':
|
||||
output.push(findItem.name);
|
||||
break;
|
||||
case 'both':
|
||||
output.push({
|
||||
code: findItem.code,
|
||||
name: findItem.name,
|
||||
});
|
||||
}
|
||||
const newCodes = codes.slice(1 - codes.length);
|
||||
if (newCodes.length > 0 && findItem.children) {
|
||||
output.push(...codeToAny(newCodes, findItem.children));
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCascader
|
||||
v-model="myValue"
|
||||
:options="pcasData as any[]"
|
||||
:props="{ value: 'code', label: 'name' }"
|
||||
:disabled="disabled"
|
||||
clearable
|
||||
filterable
|
||||
/>
|
||||
</template>
|
||||
61401
admin/src/components/PcasCascader/pcas-code.json
Executable file
61401
admin/src/components/PcasCascader/pcas-code.json
Executable file
File diff suppressed because it is too large
Load Diff
299
admin/src/components/PromptTemplateEditor/index.vue
Normal file
299
admin/src/components/PromptTemplateEditor/index.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
import { Delete, Plus, Rank } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { computed, ref } from 'vue';
|
||||
import draggable from 'vuedraggable';
|
||||
|
||||
// 定义字段类型接口
|
||||
interface TemplateField {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'input' | 'select';
|
||||
placeholder: string;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
// 定义 Props 和 Emits
|
||||
const props = defineProps<{
|
||||
modelValue: TemplateField[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: TemplateField[]): void;
|
||||
}>();
|
||||
|
||||
// 本地状态,避免直接修改 prop
|
||||
const localFields = ref<TemplateField[]>([]);
|
||||
|
||||
// 使用计算属性同步 prop 和本地状态
|
||||
const fields = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
// 监听外部 modelValue 变化,同步到本地(如果需要深度监听或特殊处理)
|
||||
// watch(() => props.modelValue, (newValue) => {
|
||||
// // 可以添加深拷贝或其他逻辑
|
||||
// localFields.value = JSON.parse(JSON.stringify(newValue));
|
||||
// }, { deep: true, immediate: true });
|
||||
|
||||
// 添加新字段
|
||||
const addField = (type: 'input' | 'select' = 'input') => {
|
||||
fields.value = [
|
||||
...fields.value,
|
||||
{
|
||||
id: uuidv4(),
|
||||
type,
|
||||
title: '',
|
||||
placeholder: '',
|
||||
options: type === 'select' ? [''] : undefined, // Select 默认带一个空选项
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// 删除字段
|
||||
const removeField = (id: string) => {
|
||||
fields.value = fields.value.filter((field) => field.id !== id);
|
||||
};
|
||||
|
||||
// 添加选项(仅用于 select)
|
||||
const addOption = (fieldId: string) => {
|
||||
fields.value = fields.value.map((field) => {
|
||||
if (field.id === fieldId && field.type === 'select') {
|
||||
// 确保 options 数组存在
|
||||
const options = field.options ? [...field.options] : [];
|
||||
options.push(''); // 添加一个空选项
|
||||
return { ...field, options };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
};
|
||||
|
||||
// 删除选项(仅用于 select)
|
||||
const removeOption = (fieldId: string, optionIndex: number) => {
|
||||
fields.value = fields.value.map((field) => {
|
||||
if (field.id === fieldId && field.type === 'select' && field.options) {
|
||||
const options = [...field.options];
|
||||
if (options.length > 1) {
|
||||
// 至少保留一个选项输入框
|
||||
options.splice(optionIndex, 1);
|
||||
return { ...field, options };
|
||||
} else {
|
||||
ElMessage.warning('下拉框至少需要一个选项');
|
||||
}
|
||||
}
|
||||
return field;
|
||||
});
|
||||
};
|
||||
|
||||
// 更新字段类型
|
||||
const updateFieldType = (id: string, newType: 'input' | 'select') => {
|
||||
fields.value = fields.value.map((field) => {
|
||||
if (field.id === id) {
|
||||
return {
|
||||
...field,
|
||||
type: newType,
|
||||
// 从 input 转 select 时,添加默认 options
|
||||
options: newType === 'select' && !field.options ? [''] : field.options,
|
||||
// 从 select 转 input 时,移除 options (可选,也可保留)
|
||||
// options: newType === 'input' ? undefined : field.options,
|
||||
};
|
||||
}
|
||||
return field;
|
||||
});
|
||||
};
|
||||
|
||||
// 更新选项值
|
||||
const updateOptionValue = (fieldId: string, optionIndex: number, value: string) => {
|
||||
fields.value = fields.value.map((field) => {
|
||||
if (field.id === fieldId && field.type === 'select' && field.options) {
|
||||
const options = [...field.options];
|
||||
options[optionIndex] = value;
|
||||
return { ...field, options };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
};
|
||||
|
||||
// 更新 Placeholder 值
|
||||
const updatePlaceholderValue = (fieldId: string, value: string) => {
|
||||
fields.value = fields.value.map((field) => {
|
||||
if (field.id === fieldId) {
|
||||
return { ...field, placeholder: value };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
};
|
||||
|
||||
// 更新 Title 值
|
||||
const updateTitleValue = (fieldId: string, value: string) => {
|
||||
fields.value = fields.value.map((field) => {
|
||||
if (field.id === fieldId) {
|
||||
return { ...field, title: value };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
};
|
||||
|
||||
// Draggable 配置
|
||||
const dragOptions = {
|
||||
animation: 200,
|
||||
ghostClass: 'ghost', // Class name for the drop placeholder
|
||||
handle: '.drag-handle', // Specify the handle element
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prompt-template-editor">
|
||||
<!-- Use CSS Grid for layout -->
|
||||
<draggable v-model="fields" item-key="id" v-bind="dragOptions" tag="div" class="field-grid">
|
||||
<template #item="{ element: field, index }">
|
||||
<el-card shadow="never" class="field-item border border-gray-200 relative group">
|
||||
<div class="flex items-start space-x-3">
|
||||
<!-- Add Number Prefix -->
|
||||
<div class="field-number font-semibold text-gray-400 pt-2 mr-1">{{ index + 1 }}.</div>
|
||||
<!-- Drag Handle -->
|
||||
<div class="drag-handle cursor-move text-gray-400 hover:text-gray-600 pt-2">
|
||||
<el-icon :size="20"><Rank /></el-icon>
|
||||
</div>
|
||||
<!-- Field Content -->
|
||||
<div class="flex-grow">
|
||||
<el-form label-position="top" size="small">
|
||||
<div class="flex items-center mb-2 space-x-4">
|
||||
<el-radio-group
|
||||
:model-value="field.type"
|
||||
@update:modelValue="
|
||||
(newType) => updateFieldType(field.id, newType as 'input' | 'select')
|
||||
"
|
||||
size="small"
|
||||
>
|
||||
<el-radio-button label="input">输入框</el-radio-button>
|
||||
<el-radio-button label="select">下拉框</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
link
|
||||
class="ml-auto !p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click="removeField(field.id)"
|
||||
/>
|
||||
</div>
|
||||
<el-form-item label="字段名称 (Title / Label)">
|
||||
<el-input
|
||||
:model-value="field.title"
|
||||
@update:modelValue="(val) => updateTitleValue(field.id, val)"
|
||||
placeholder="例如:您的姓名"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="提示文字 (Placeholder)">
|
||||
<el-input
|
||||
:model-value="field.placeholder"
|
||||
@update:modelValue="(val) => updatePlaceholderValue(field.id, val)"
|
||||
placeholder="例如:请输入您的姓名"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<div v-if="field.type === 'select'">
|
||||
<el-form-item label="下拉选项">
|
||||
<div class="space-y-2 w-full">
|
||||
<div
|
||||
v-for="(option, index) in field.options"
|
||||
:key="index"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<el-input
|
||||
:model-value="option"
|
||||
@update:modelValue="(val) => updateOptionValue(field.id, index, val)"
|
||||
placeholder="选项内容"
|
||||
size="small"
|
||||
clearable
|
||||
class="flex-grow"
|
||||
/>
|
||||
<el-button
|
||||
:icon="Delete"
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
class="!p-1"
|
||||
:disabled="field.options && field.options.length <= 1"
|
||||
@click="removeOption(field.id, index)"
|
||||
/>
|
||||
</div>
|
||||
<el-button
|
||||
:icon="Plus"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="addOption(field.id)"
|
||||
>
|
||||
添加选项
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<div class="mt-4 flex justify-center space-x-2">
|
||||
<el-button :icon="Plus" type="primary" plain @click="addField('input')">添加输入框</el-button>
|
||||
<el-button :icon="Plus" type="success" plain @click="addField('select')"
|
||||
>添加下拉框</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prompt-template-editor {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
/* Use Grid for layout */
|
||||
display: grid;
|
||||
/* grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); */ /* Try forcing 3 columns */
|
||||
grid-template-columns: repeat(3, 1fr); /* Force 3 columns */
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-item {
|
||||
background-color: #fdfdfd;
|
||||
transition: box-shadow 0.2s ease-in-out;
|
||||
width: 100%; /* Ensure card takes full width of cell */
|
||||
}
|
||||
.field-item:hover {
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.field-number {
|
||||
/* Style for number prefix */
|
||||
min-width: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
background: #c8ebfb;
|
||||
border: 1px dashed #409eff;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
line-height: normal;
|
||||
margin-bottom: 4px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
49
admin/src/components/SearchBar/index.vue
Executable file
49
admin/src/components/SearchBar/index.vue
Executable file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'SearchBar',
|
||||
});
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
showToggle?: boolean;
|
||||
background?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showToggle: true,
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
toggle: [value: boolean];
|
||||
}>();
|
||||
|
||||
const fold = defineModel<boolean>('fold', {
|
||||
default: true,
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
fold.value = !fold.value;
|
||||
emits('toggle', fold.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative"
|
||||
:class="{
|
||||
'py-4': showToggle,
|
||||
'px-4 bg-[var(--g-bg)] transition': background,
|
||||
}"
|
||||
>
|
||||
<slot :fold="fold" :toggle="toggle" />
|
||||
<div v-if="showToggle" class="absolute bottom-0 left-0 w-full translate-y-1/2 text-center">
|
||||
<button
|
||||
class="h-5 inline-flex cursor-pointer select-none items-center border-size-0 rounded bg-[var(--g-bg)] px-2 text-xs font-medium outline-none"
|
||||
@click="toggle"
|
||||
>
|
||||
<SvgIcon :name="fold ? 'i-ep:caret-bottom' : 'i-ep:caret-top'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
69
admin/src/components/SvgIcon/index.vue
Executable file
69
admin/src/components/SvgIcon/index.vue
Executable file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'SvgIcon',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
flip?: 'horizontal' | 'vertical' | 'both';
|
||||
rotate?: number;
|
||||
color?: string;
|
||||
size?: string | number;
|
||||
}>();
|
||||
|
||||
const outputType = computed(() => {
|
||||
if (/^https?:\/\//.test(props.name)) {
|
||||
return 'img';
|
||||
} else if (/i-[^:]+:[^:]+/.test(props.name)) {
|
||||
return 'unocss';
|
||||
} else if (props.name.includes(':')) {
|
||||
return 'iconify';
|
||||
} else {
|
||||
return 'svg';
|
||||
}
|
||||
});
|
||||
|
||||
const style = computed(() => {
|
||||
const transform = [];
|
||||
if (props.flip) {
|
||||
switch (props.flip) {
|
||||
case 'horizontal':
|
||||
transform.push('rotateY(180deg)');
|
||||
break;
|
||||
case 'vertical':
|
||||
transform.push('rotateX(180deg)');
|
||||
break;
|
||||
case 'both':
|
||||
transform.push('rotateX(180deg)');
|
||||
transform.push('rotateY(180deg)');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (props.rotate) {
|
||||
transform.push(`rotate(${props.rotate % 360}deg)`);
|
||||
}
|
||||
return {
|
||||
...(props.color && { color: props.color }),
|
||||
...(props.size && {
|
||||
fontSize: typeof props.size === 'number' ? `${props.size}px` : props.size,
|
||||
}),
|
||||
...(transform.length && { transform: transform.join(' ') }),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<i
|
||||
class="relative h-[1em] w-[1em] flex-inline items-center justify-center fill-current leading-[1em]"
|
||||
:class="{ [name]: outputType === 'unocss' }"
|
||||
:style="style"
|
||||
>
|
||||
<Icon v-if="outputType === 'iconify'" :icon="name" />
|
||||
<svg v-else-if="outputType === 'svg'" class="h-[1em] w-[1em]" aria-hidden="true">
|
||||
<use :xlink:href="`#icon-${name}`" />
|
||||
</svg>
|
||||
<img v-else-if="outputType === 'img'" :src="name" class="h-[1em] w-[1em]" />
|
||||
</i>
|
||||
</template>
|
||||
58
admin/src/components/SystemInfo/index.vue
Executable file
58
admin/src/components/SystemInfo/index.vue
Executable file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import eventBus from '@/utils/eventBus';
|
||||
|
||||
const isShow = ref(false);
|
||||
|
||||
const { pkg, lastBuildTime } = __SYSTEM_INFO__;
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('global-system-info-toggle', () => {
|
||||
isShow.value = !isShow.value;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HSlideover v-model="isShow" title="系统信息">
|
||||
<div class="px-4">
|
||||
<h2 class="m-0 text-lg font-bold">最后编译时间</h2>
|
||||
<div class="my-4 text-center text-lg font-sans">
|
||||
{{ lastBuildTime }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4">
|
||||
<h2 class="m-0 text-lg font-bold">生产环境依赖</h2>
|
||||
<ul class="list-none pl-0 text-sm">
|
||||
<li
|
||||
v-for="(val, key) in pkg.dependencies as object"
|
||||
:key="key"
|
||||
class="flex items-center justify-between rounded px-2 py-1.5 hover-bg-stone-1 dark-hover-bg-stone-9"
|
||||
>
|
||||
<div class="font-bold">
|
||||
{{ key }}
|
||||
</div>
|
||||
<div class="font-sans">
|
||||
{{ val }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="px-4">
|
||||
<h2 class="m-0 text-lg font-bold">开发环境依赖</h2>
|
||||
<ul class="list-none pl-0 text-sm">
|
||||
<li
|
||||
v-for="(val, key) in pkg.devDependencies as object"
|
||||
:key="key"
|
||||
class="flex items-center justify-between rounded px-2 py-1.5 hover-bg-stone-1 dark-hover-bg-stone-9"
|
||||
>
|
||||
<div class="font-bold">
|
||||
{{ key }}
|
||||
</div>
|
||||
<div class="font-sans">
|
||||
{{ val }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</HSlideover>
|
||||
</template>
|
||||
38
admin/src/components/Trend/index.vue
Executable file
38
admin/src/components/Trend/index.vue
Executable file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'Trend',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: string;
|
||||
type?: 'up' | 'down';
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
reverse?: boolean;
|
||||
}>(),
|
||||
{
|
||||
type: 'up',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
reverse: false,
|
||||
},
|
||||
);
|
||||
|
||||
const isUp = computed(() => {
|
||||
let isUp = props.type === 'up';
|
||||
if (props.reverse) {
|
||||
isUp = !isUp;
|
||||
}
|
||||
return isUp;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center transition" :class="`${isUp ? 'c-green' : 'c-red'}`">
|
||||
<span v-if="prefix" class="prefix">{{ prefix }}</span>
|
||||
<span class="text">{{ value }}</span>
|
||||
<span v-if="suffix" class="suffix">{{ suffix }}</span>
|
||||
<SvgIcon name="i-ep:caret-top" :rotate="isUp ? 0 : 180" class="ml-1 transition" />
|
||||
</div>
|
||||
</template>
|
||||
17
admin/src/constants/copyright.ts
Normal file
17
admin/src/constants/copyright.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const copyRight = {
|
||||
wex: '5qyi6L+O5L2T6aqMTmluZUFJ',
|
||||
qnum: 'MjAyMyAtIDIwMjQ=',
|
||||
website: '',
|
||||
/* 下三个是个人的 上面是公开的 */
|
||||
// wex: 'Vng6IEpfbG9uZ3lhbg==',
|
||||
// qnum: 'UVE6IDkyNzg5ODYzOQ==',
|
||||
// website: 'aHR0cHM6Ly9haS5qaWFuZ2x5LmNvbQ==',
|
||||
name: 'TmluZSBBaQ==',
|
||||
};
|
||||
|
||||
export function atob(str: string) {
|
||||
if (!str) {
|
||||
return '';
|
||||
}
|
||||
return decodeURIComponent(escape(window.atob(str)));
|
||||
}
|
||||
484
admin/src/constants/index.ts
Normal file
484
admin/src/constants/index.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
export const USER_STATUS_OPTIONS = [
|
||||
{ value: 0, label: '待激活' },
|
||||
{ value: 1, label: '正常' },
|
||||
{ value: 2, label: '已封禁' },
|
||||
{ value: 3, label: '黑名单' },
|
||||
];
|
||||
|
||||
export const USER_STATUS_MAP = {
|
||||
0: '待激活',
|
||||
1: '正常',
|
||||
2: '已封禁',
|
||||
3: '黑名单',
|
||||
};
|
||||
|
||||
export const USER_STATUS_TYPE_MAP = {
|
||||
0: 'info',
|
||||
1: 'success',
|
||||
2: 'danger',
|
||||
3: 'danger',
|
||||
} as const;
|
||||
|
||||
export type UserStatus = keyof typeof USER_STATUS_TYPE_MAP;
|
||||
|
||||
// 充值类型map 1: 注册赠送 2: 受邀请赠送 3: 邀请人赠送 4: 购买套餐赠送 5: 管理员赠送 6:扫码支付 7: 绘画失败退款 8: 签到奖励
|
||||
export const RECHARGE_TYPE_MAP = {
|
||||
1: '注册赠送',
|
||||
2: '受邀请赠送',
|
||||
3: '邀请人赠送',
|
||||
4: '购买套餐赠送',
|
||||
5: '管理员赠送',
|
||||
6: '扫码支付',
|
||||
7: '绘画失败退款',
|
||||
8: '签到奖励',
|
||||
};
|
||||
|
||||
// 充值数组
|
||||
export const RECHARGE_TYPE_OPTIONS = [
|
||||
{ value: 1, label: '注册赠送' },
|
||||
{ value: 2, label: '受邀请赠送' },
|
||||
{ value: 3, label: '邀请人赠送' },
|
||||
{ value: 4, label: '购买套餐赠送' },
|
||||
{ value: 5, label: '管理员赠送' },
|
||||
{ value: 6, label: '扫码支付' },
|
||||
{ value: 7, label: '绘画失败退款' },
|
||||
{ value: 8, label: '签到奖励' },
|
||||
];
|
||||
|
||||
// 是否开启额外赠送
|
||||
export const IS_OPTIONS = {
|
||||
0: '关闭',
|
||||
1: '开启',
|
||||
};
|
||||
|
||||
// 是否开启额外赠送类型
|
||||
export const IS_TYPE_MAP = {
|
||||
0: 'danger',
|
||||
1: 'success',
|
||||
};
|
||||
|
||||
export const PACKAGE_TYPE_OPTIONS = [
|
||||
{ value: 0, label: '禁用' },
|
||||
{ value: 1, label: '启动' },
|
||||
];
|
||||
|
||||
// 扣费形式 1: 按次数扣费 2:按Token扣费
|
||||
export const DEDUCTION_TYPE_OPTIONS = [
|
||||
{ value: 1, label: '按次数扣费' },
|
||||
{ value: 2, label: '按Token扣费' },
|
||||
];
|
||||
|
||||
// 扣费形式 map
|
||||
export const DEDUCTION_TYPE_MAP = {
|
||||
1: '按次数扣费',
|
||||
2: '按Token扣费',
|
||||
};
|
||||
|
||||
export const CRAMI_STATUS_OPTIONS = [
|
||||
{ value: 0, label: '未使用' },
|
||||
{ value: 1, label: '已使用' },
|
||||
];
|
||||
|
||||
// 图片推荐状态0未推荐1已推荐
|
||||
export const RECOMMEND_STATUS_OPTIONS = [
|
||||
{ value: 0, label: '未推荐' },
|
||||
{ value: 1, label: '已推荐' },
|
||||
];
|
||||
|
||||
// 0 禁用 1 启用
|
||||
export const ENABLE_STATUS_OPTIONS = [
|
||||
{ value: 0, label: '禁用' },
|
||||
{ value: 1, label: '启用' },
|
||||
];
|
||||
|
||||
// 问题状态 0 未解决 1 已解决
|
||||
export const QUESTION_STATUS_OPTIONS = [
|
||||
{ value: '0', label: '未启用' },
|
||||
{ value: '1', label: '已启用' },
|
||||
];
|
||||
|
||||
// 问题状态 0 未解决 1 已解决
|
||||
export const ORDER_STATUS_OPTIONS = [
|
||||
{ value: 0, label: '待审核' },
|
||||
{ value: 1, label: '已通过' },
|
||||
{ value: -1, label: '已拒绝' },
|
||||
];
|
||||
|
||||
// 0:未推荐 1:已推荐 数组
|
||||
export const RECOMMEND_STATUS = [
|
||||
{ value: 0, label: '未推荐' },
|
||||
{ value: 1, label: '已推荐' },
|
||||
];
|
||||
|
||||
// 提现渠道 支付宝 微信
|
||||
export const WITHDRAW_CHANNEL_OPTIONS = [
|
||||
{ value: 1, label: '支付宝' },
|
||||
{ value: 2, label: '微信' },
|
||||
];
|
||||
|
||||
// 1 排队中 2 处理中 3 已完成 4 失败 5 超时
|
||||
export const WITHDRAW_STATUS_OPTIONS = [
|
||||
{ value: 1, label: '正在排队' },
|
||||
{ value: 2, label: '正在绘制' },
|
||||
{ value: 3, label: '绘制完成' },
|
||||
{ value: 4, label: '绘制失败' },
|
||||
{ value: 5, label: '绘制超时' },
|
||||
];
|
||||
|
||||
// 0 禁用 warning 1启用 状态 success
|
||||
export const ENABLE_STATUS_TYPE_MAP: QuestionStatusMap = {
|
||||
0: 'danger',
|
||||
1: 'success',
|
||||
};
|
||||
|
||||
interface QuestionStatusMap {
|
||||
[key: number]: string;
|
||||
}
|
||||
|
||||
// 问题状态 0 未解决 1 已解决 映射
|
||||
export const QUESTION_STATUS_MAP: QuestionStatusMap = {
|
||||
'-1': '欠费锁定',
|
||||
'0': '未启用',
|
||||
'1': '已启用',
|
||||
'3': '待审核',
|
||||
'4': '拒绝共享',
|
||||
'5': '通过共享',
|
||||
};
|
||||
|
||||
// 问题状态 0 被封号 1 正常 映射
|
||||
export const KEY_STATUS_MAP: QuestionStatusMap = {
|
||||
0: '被封禁',
|
||||
1: '工作中',
|
||||
};
|
||||
|
||||
// 模型列表
|
||||
export const MODEL_LIST = [
|
||||
// OpenAI
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'chatgpt-4o-latest',
|
||||
'gpt-4.5-preview',
|
||||
'o1',
|
||||
'o1-mini',
|
||||
'o3-mini',
|
||||
'o3',
|
||||
'gpt-4-all',
|
||||
'gpt-4o-all',
|
||||
'gpt-4o-image',
|
||||
'gpt-4o-image-vip',
|
||||
'sora_image',
|
||||
'gpt-4.1',
|
||||
'gpt-4.1-nano',
|
||||
'gpt-4.1-mini',
|
||||
'o1-mini-all',
|
||||
'o1-all',
|
||||
'o1-pro-all',
|
||||
'o3-mini-all',
|
||||
'o3-mini-high-all',
|
||||
'o4-mini',
|
||||
// Claude
|
||||
'claude-3-sonnet-20240229',
|
||||
'claude-3-opus-20240229',
|
||||
'claude-3-haiku-20240307',
|
||||
'claude-3-5-sonnet-latest',
|
||||
'claude-3-7-sonnet-20250219',
|
||||
'claude-3-7-sonnet-thinking',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-sonnet-4-20250514-thinking',
|
||||
'claude-opus-4-20250514-thinking',
|
||||
// X AI
|
||||
'grok-2-1212',
|
||||
'grok-2-vision-1212',
|
||||
'grok-2-latest',
|
||||
'grok-2-vision-latest',
|
||||
'grok-3',
|
||||
'grok-3-reasoner',
|
||||
'grok-3-deepsearch',
|
||||
// Gemini
|
||||
'gemini-pro',
|
||||
'gemini-pro-vision',
|
||||
'gemini-pro-1.5',
|
||||
'gemini-1.5-flash',
|
||||
'gemini-1.5-pro-latest',
|
||||
'gemini-1.5-flash-002',
|
||||
'gemini-2.0-flash-exp',
|
||||
'gemini-2.0-flash-lite',
|
||||
'gemini-2.0-flash-thinking-exp',
|
||||
'gemini-2.5-pro-exp-03-25',
|
||||
// DeepSeek
|
||||
'deepseek-r1',
|
||||
'deepseek-reasoner',
|
||||
'deepseek-v3',
|
||||
'deepseek-chat',
|
||||
'deepseek-reasoner-all',
|
||||
// 百度文心
|
||||
'ERNIE-Bot',
|
||||
'ERNIE-Bot-4',
|
||||
'ERNIE-3.5-8K',
|
||||
'ERNIE-Bot-turbo',
|
||||
// 阿里通义
|
||||
'qwen-turbo',
|
||||
'qwen-plus',
|
||||
'qwen-max',
|
||||
'qwen-max-longcontext',
|
||||
// 腾讯混元
|
||||
'hunyuan',
|
||||
// 清华智谱
|
||||
'chatglm_turbo',
|
||||
'chatglm_pro',
|
||||
'chatglm_std',
|
||||
'chatglm_lite',
|
||||
'glm-3-turbo',
|
||||
'glm-4',
|
||||
'glm-4v',
|
||||
// 百川智能
|
||||
'Baichuan2-53B',
|
||||
'Baichuan2-Turbo',
|
||||
'Baichuan2-Turbo-192k',
|
||||
// 零一万物
|
||||
'yi-34b-chat-0205',
|
||||
'yi-34b-chat-200k',
|
||||
'yi-vl-plus',
|
||||
// 360 智脑
|
||||
'360GPT_S2_V9',
|
||||
// 讯飞星火
|
||||
'SparkDesk',
|
||||
'SparkDesk-v1.1',
|
||||
'SparkDesk-v2.1',
|
||||
'SparkDesk-v3.1',
|
||||
'SparkDesk-v3.5',
|
||||
// moonshot
|
||||
'moonshot-v1-8k',
|
||||
'moonshot-v1-32k',
|
||||
'moonshot-v1-128k',
|
||||
// 创意模型
|
||||
'dall-e-3',
|
||||
'gpt-image-1',
|
||||
'midjourney',
|
||||
'stable-diffusion',
|
||||
'suno-music',
|
||||
'luma-video',
|
||||
'flux-draw',
|
||||
'cog-video',
|
||||
'gpt-4o-image',
|
||||
'gpt-4o-image-vip',
|
||||
'grok-2-image-latest',
|
||||
'seededit',
|
||||
'seedream-3.0',
|
||||
// 特殊模型
|
||||
'tts-1',
|
||||
'gpts',
|
||||
];
|
||||
|
||||
// 支付状态列表 status 0:未支付、1:已支付、2、支付失败、3:支付超时
|
||||
export const PAY_STATUS_OPTIONS = [
|
||||
{ value: 0, label: '未支付' },
|
||||
{ value: 1, label: '已支付' },
|
||||
{ value: 2, label: '支付失败' },
|
||||
{ value: 3, label: '支付超时' },
|
||||
];
|
||||
|
||||
// 支付状态 status 0:未支付、1:已支付、2、支付失败、3:支付超时
|
||||
export const PAY_STATUS_MAP: QuestionStatusMap = {
|
||||
0: '未支付',
|
||||
1: '已支付',
|
||||
2: '支付失败',
|
||||
3: '支付超时',
|
||||
};
|
||||
|
||||
// 平台列表 epay: 易支付 hupi:虎皮椒 ltzf:蓝兔支付
|
||||
export const PAY_PLATFORM_LIST = [
|
||||
{ value: 'epay', label: '易支付' },
|
||||
{ value: 'hupi', label: '虎皮椒' },
|
||||
{ value: 'wechat', label: '微信支付' },
|
||||
{ value: 'mpay', label: '码支付' },
|
||||
{ value: 'ltzf', label: '蓝兔支付' },
|
||||
];
|
||||
|
||||
// 支付对应
|
||||
export const PAY_PLATFORM_MAP = {
|
||||
epay: '易支付',
|
||||
hupi: '虎皮椒',
|
||||
wechat: '微信支付',
|
||||
mpay: '码支付',
|
||||
ltzf: '蓝兔支付',
|
||||
};
|
||||
|
||||
// 绘画状态 1: 等待中 2: 绘制中 3: 绘制完成 4: 绘制失败 5: 绘制超时
|
||||
export const DRAW_MJ_STATUS_LIST = [
|
||||
{ value: 1, label: '等待中' },
|
||||
{ value: 2, label: '绘制中' },
|
||||
{ value: 3, label: '绘制完成' },
|
||||
{ value: 4, label: '绘制失败' },
|
||||
{ value: 5, label: '绘制超时' },
|
||||
];
|
||||
|
||||
// App角色 系统 system 用户 user
|
||||
export const APP_ROLE_LIST = [
|
||||
{ value: 'system', label: '系统' },
|
||||
{ value: 'user', label: '用户' },
|
||||
];
|
||||
|
||||
// 绘画状态 1:排队中 2:绘制中 3:绘制完成 4:绘制失败 5:绘制超时
|
||||
export const DRAW_STATUS_MAP = {
|
||||
1: '排队中',
|
||||
2: '绘制中',
|
||||
3: '绘制完成',
|
||||
4: '绘制失败',
|
||||
5: '绘制超时',
|
||||
};
|
||||
|
||||
export const TYPEORIGINLIST = [
|
||||
{ value: '百度云检测', label: '百度云检测' },
|
||||
{ value: '自定义检测', label: '自定义检测' },
|
||||
];
|
||||
|
||||
export const MODELTYPELIST = [
|
||||
{ value: 1, label: '基础对话' },
|
||||
// { value: 2, label: '创意模型' },
|
||||
{ value: 3, label: '特殊模型' },
|
||||
];
|
||||
|
||||
export const MODELTYPEMAP = {
|
||||
1: '基础对话',
|
||||
2: '创意模型',
|
||||
3: '特殊模型',
|
||||
};
|
||||
|
||||
export const MODELSMAPLIST = {
|
||||
1: [
|
||||
// OpenAI
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'chatgpt-4o-latest',
|
||||
'gpt-4.5-preview',
|
||||
'o1',
|
||||
'o3',
|
||||
'o1-mini',
|
||||
'o3-mini',
|
||||
'o4-mini',
|
||||
'gpt-4-all',
|
||||
'gpt-4o-all',
|
||||
'gpt-4o-image',
|
||||
'gpt-4o-image-vip',
|
||||
'gpt-4.1',
|
||||
'gpt-4.1-nano',
|
||||
'gpt-4.1-mini',
|
||||
'o1-mini-all',
|
||||
'o1-all',
|
||||
'o1-pro-all',
|
||||
'o3-mini-all',
|
||||
'o3-mini-high-all',
|
||||
// Claude
|
||||
'claude-3-sonnet-20240229',
|
||||
'claude-3-opus-20240229',
|
||||
'claude-3-haiku-20240307',
|
||||
'claude-3-5-sonnet-latest',
|
||||
'claude-3-7-sonnet-20250219',
|
||||
'claude-3-7-sonnet-thinking',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-sonnet-4-20250514-thinking',
|
||||
'claude-opus-4-20250514-thinking',
|
||||
// X AI
|
||||
'grok-2-1212',
|
||||
'grok-2-vision-1212',
|
||||
'grok-2-latest',
|
||||
'grok-2-vision-latest',
|
||||
'grok-3',
|
||||
'grok-3-reasoner',
|
||||
'grok-3-deepsearch',
|
||||
// Gemini
|
||||
'gemini-pro',
|
||||
'gemini-pro-vision',
|
||||
'gemini-pro-1.5',
|
||||
'gemini-1.5-flash',
|
||||
'gemini-1.5-pro-latest',
|
||||
'gemini-1.5-flash-002',
|
||||
'gemini-2.0-flash-exp',
|
||||
'gemini-2.0-flash-lite',
|
||||
'gemini-2.0-flash-thinking-exp',
|
||||
'gemini-2.5-pro-exp-03-25',
|
||||
// DeepSeek
|
||||
'deepseek-r1',
|
||||
'deepseek-reasoner',
|
||||
'deepseek-v3',
|
||||
'deepseek-chat',
|
||||
'deepseek-reasoner-all',
|
||||
// 百度文心
|
||||
'ERNIE-Bot',
|
||||
'ERNIE-Bot-4',
|
||||
'ERNIE-3.5-8K',
|
||||
'ERNIE-Bot-turbo',
|
||||
// 阿里通义
|
||||
'qwen-turbo',
|
||||
'qwen-plus',
|
||||
'qwen-max',
|
||||
'qwen-max-longcontext',
|
||||
// 腾讯混元
|
||||
'hunyuan',
|
||||
// 清华智谱
|
||||
'chatglm_turbo',
|
||||
'chatglm_pro',
|
||||
'chatglm_std',
|
||||
'chatglm_lite',
|
||||
'glm-3-turbo',
|
||||
'glm-4',
|
||||
'glm-4v',
|
||||
// 百川智能
|
||||
'Baichuan2-53B',
|
||||
'Baichuan2-Turbo',
|
||||
'Baichuan2-Turbo-192k',
|
||||
// 零一万物
|
||||
'yi-34b-chat-0205',
|
||||
'yi-34b-chat-200k',
|
||||
'yi-vl-plus',
|
||||
// 360 智脑
|
||||
'360GPT_S2_V9',
|
||||
// 讯飞星火
|
||||
'SparkDesk',
|
||||
'SparkDesk-v1.1',
|
||||
'SparkDesk-v2.1',
|
||||
'SparkDesk-v3.1',
|
||||
'SparkDesk-v3.5',
|
||||
// moonshot
|
||||
'moonshot-v1-8k',
|
||||
'moonshot-v1-32k',
|
||||
'moonshot-v1-128k',
|
||||
],
|
||||
2: [
|
||||
'dall-e-3',
|
||||
'gpt-image-1',
|
||||
'midjourney',
|
||||
'stable-diffusion',
|
||||
'suno-music',
|
||||
'luma-video',
|
||||
'flux-draw',
|
||||
'cog-video',
|
||||
'gpt-4o-image',
|
||||
'gpt-4o-image-vip',
|
||||
'sora_image',
|
||||
'grok-2-image-latest',
|
||||
'seededit',
|
||||
'seedream-3.0',
|
||||
],
|
||||
3: ['tts-1', 'gpts', 'deepseek-r1', 'deepseek-reasoner', 'flowith'],
|
||||
};
|
||||
|
||||
/* 扣费类型 普通余额还是高级余额 */
|
||||
export const DEDUCTTYPELIST = [
|
||||
{ value: 1, label: '普通积分' },
|
||||
{ value: 2, label: '高级积分' },
|
||||
{ value: 3, label: '绘画积分' },
|
||||
];
|
||||
|
||||
/* 绘画类型选项列表 */
|
||||
export const DRAWING_TYPE_LIST = [
|
||||
{ value: 0, label: '不是绘画' },
|
||||
{ value: 1, label: 'dalle兼容' },
|
||||
{ value: 2, label: 'gpt-image-1兼容' },
|
||||
{ value: 3, label: 'midjourney' },
|
||||
{ value: 4, label: 'chat正则提取' },
|
||||
{ value: 5, label: '豆包' },
|
||||
];
|
||||
21650
admin/src/iconify/data.json
Normal file
21650
admin/src/iconify/data.json
Normal file
File diff suppressed because it is too large
Load Diff
14
admin/src/iconify/index.json
Executable file
14
admin/src/iconify/index.json
Executable file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"collections": [
|
||||
"ant-design",
|
||||
"ep",
|
||||
"flagpack",
|
||||
"icon-park",
|
||||
"mdi",
|
||||
"ri",
|
||||
"logos",
|
||||
"twemoji",
|
||||
"vscode-icons"
|
||||
],
|
||||
"isOfflineUse": false
|
||||
}
|
||||
9
admin/src/iconify/index.ts
Executable file
9
admin/src/iconify/index.ts
Executable file
@@ -0,0 +1,9 @@
|
||||
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));
|
||||
428
admin/src/layouts/components/AppSetting/index.vue
Executable file
428
admin/src/layouts/components/AppSetting/index.vue
Executable file
@@ -0,0 +1,428 @@
|
||||
<script setup lang="ts">
|
||||
import settingsDefault from '@/settings.default';
|
||||
import useMenuStore from '@/store/modules/menu';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
import eventBus from '@/utils/eventBus';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
|
||||
defineOptions({
|
||||
name: 'AppSetting',
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const menuStore = useMenuStore();
|
||||
|
||||
const isShow = ref(false);
|
||||
|
||||
watch(
|
||||
() => settingsStore.settings.menu.menuMode,
|
||||
(value) => {
|
||||
if (value === 'single') {
|
||||
menuStore.setActived(0);
|
||||
} else {
|
||||
menuStore.setActived(route.fullPath);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('global-app-setting-toggle', () => {
|
||||
isShow.value = !isShow.value;
|
||||
});
|
||||
});
|
||||
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
|
||||
// watch(copied, (val) => {
|
||||
// if (val) {
|
||||
// Message.success('复制成功,请粘贴到 src/settings.ts 文件中!', {
|
||||
// zIndex: 2000,
|
||||
// })
|
||||
// }
|
||||
// })
|
||||
|
||||
function isObject(value: any) {
|
||||
return typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
// 比较两个对象,并提取出不同的部分
|
||||
function getObjectDiff(originalObj: Record<string, any>, diffObj: Record<string, any>) {
|
||||
if (!isObject(originalObj) || !isObject(diffObj)) {
|
||||
return diffObj;
|
||||
}
|
||||
const diff: Record<string, any> = {};
|
||||
for (const key in diffObj) {
|
||||
const originalValue = originalObj[key];
|
||||
const diffValue = diffObj[key];
|
||||
if (JSON.stringify(originalValue) !== JSON.stringify(diffValue)) {
|
||||
if (isObject(originalValue) && isObject(diffValue)) {
|
||||
const nestedDiff = getObjectDiff(originalValue, diffValue);
|
||||
if (Object.keys(nestedDiff).length > 0) {
|
||||
diff[key] = nestedDiff;
|
||||
}
|
||||
} else {
|
||||
diff[key] = diffValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
copy(JSON.stringify(getObjectDiff(settingsDefault, settingsStore.settings), null, 2));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HSlideover v-model="isShow" title="应用配置">
|
||||
<div class="rounded-2 bg-rose/20 px-4 py-2 text-sm/6 c-rose">
|
||||
<p class="my-1">
|
||||
应用配置可实时预览效果,但只是临时生效,要想真正应用于项目,可以点击下方的「复制配置」按钮,并将配置粘贴到
|
||||
src/settings.ts 文件中。
|
||||
</p>
|
||||
<p class="my-1">注意:在生产环境中应关闭该模块。</p>
|
||||
</div>
|
||||
<div class="divider">颜色主题风格</div>
|
||||
<div class="flex items-center justify-center pb-4">
|
||||
<HTabList
|
||||
v-model="settingsStore.settings.app.colorScheme"
|
||||
:options="[
|
||||
{ icon: 'i-ri:sun-line', label: '明亮', value: 'light' },
|
||||
{ icon: 'i-ri:moon-line', label: '暗黑', value: 'dark' },
|
||||
{ icon: 'i-codicon:color-mode', label: '系统', value: '' },
|
||||
]"
|
||||
class="w-60"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="settingsStore.mode === 'pc'" class="divider">导航栏模式</div>
|
||||
<div v-if="settingsStore.mode === 'pc'" class="menu-mode">
|
||||
<HTooltip text="侧边栏模式 (含主导航)" placement="bottom" :delay="500">
|
||||
<div
|
||||
class="mode mode-side"
|
||||
:class="{ active: settingsStore.settings.menu.menuMode === 'side' }"
|
||||
@click="settingsStore.settings.menu.menuMode = 'side'"
|
||||
>
|
||||
<div class="mode-container" />
|
||||
</div>
|
||||
</HTooltip>
|
||||
<HTooltip text="顶部模式" placement="bottom" :delay="500">
|
||||
<div
|
||||
class="mode mode-head"
|
||||
:class="{ active: settingsStore.settings.menu.menuMode === 'head' }"
|
||||
@click="settingsStore.settings.menu.menuMode = 'head'"
|
||||
>
|
||||
<div class="mode-container" />
|
||||
</div>
|
||||
</HTooltip>
|
||||
<HTooltip text="侧边栏模式 (不含主导航)" placement="bottom" :delay="500">
|
||||
<div
|
||||
class="mode mode-single"
|
||||
:class="{ active: settingsStore.settings.menu.menuMode === 'single' }"
|
||||
@click="settingsStore.settings.menu.menuMode = 'single'"
|
||||
>
|
||||
<div class="mode-container" />
|
||||
</div>
|
||||
</HTooltip>
|
||||
</div>
|
||||
<div class="divider">导航栏</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
主导航切换跳转
|
||||
<HTooltip text="开启该功能后,切换主导航时,页面自动跳转至该主导航下,次导航里第一个导航">
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle
|
||||
v-model="settingsStore.settings.menu.switchMainMenuAndPageJump"
|
||||
:disabled="['single'].includes(settingsStore.settings.menu.menuMode)"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
次导航保持展开一个
|
||||
<HTooltip text="开启该功能后,次导航只保持单个菜单的展开">
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle v-model="settingsStore.settings.menu.subMenuUniqueOpened" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">次导航是否折叠</div>
|
||||
<HToggle v-model="settingsStore.settings.menu.subMenuCollapse" />
|
||||
</div>
|
||||
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
|
||||
<div class="label">显示次导航折叠按钮</div>
|
||||
<HToggle v-model="settingsStore.settings.menu.enableSubMenuCollapseButton" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否启用快捷键</div>
|
||||
<HToggle
|
||||
v-model="settingsStore.settings.menu.enableHotkeys"
|
||||
:disabled="['single'].includes(settingsStore.settings.menu.menuMode)"
|
||||
/>
|
||||
</div>
|
||||
<div class="divider">顶栏</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">模式</div>
|
||||
<HCheckList
|
||||
v-model="settingsStore.settings.topbar.mode"
|
||||
:options="[
|
||||
{ label: '静止', value: 'static' },
|
||||
{ label: '固定', value: 'fixed' },
|
||||
{ label: '粘性', value: 'sticky' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="divider">标签栏</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否启用</div>
|
||||
<HToggle v-model="settingsStore.settings.tabbar.enable" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否显示图标</div>
|
||||
<HToggle
|
||||
v-model="settingsStore.settings.tabbar.enableIcon"
|
||||
:disabled="!settingsStore.settings.tabbar.enable"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否启用快捷键</div>
|
||||
<HToggle
|
||||
v-model="settingsStore.settings.tabbar.enableHotkeys"
|
||||
:disabled="!settingsStore.settings.tabbar.enable"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider">工具栏</div>
|
||||
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
|
||||
<div class="label">面包屑导航</div>
|
||||
<HToggle v-model="settingsStore.settings.toolbar.breadcrumb" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
导航搜索
|
||||
<HTooltip text="对导航进行快捷搜索">
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle v-model="settingsStore.settings.toolbar.navSearch" />
|
||||
</div>
|
||||
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
|
||||
<div class="label">全屏</div>
|
||||
<HToggle v-model="settingsStore.settings.toolbar.fullscreen" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
页面刷新
|
||||
<HTooltip text="使用框架内提供的刷新功能进行页面刷新">
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle v-model="settingsStore.settings.toolbar.pageReload" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
颜色主题
|
||||
<HTooltip text="开启后可在明亮/暗黑模式中切换">
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle v-model="settingsStore.settings.toolbar.colorScheme" />
|
||||
</div>
|
||||
<div class="divider">页面</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否启用快捷键</div>
|
||||
<HToggle v-model="settingsStore.settings.mainPage.enableHotkeys" />
|
||||
</div>
|
||||
<div class="divider">导航搜索</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否启用快捷键</div>
|
||||
<HToggle
|
||||
v-model="settingsStore.settings.navSearch.enableHotkeys"
|
||||
:disabled="!settingsStore.settings.toolbar.navSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="divider">底部版权</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否启用</div>
|
||||
<HToggle v-model="settingsStore.settings.copyright.enable" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">日期</div>
|
||||
<HInput
|
||||
v-model="settingsStore.settings.copyright.dates"
|
||||
:disabled="!settingsStore.settings.copyright.enable"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">公司</div>
|
||||
<HInput
|
||||
v-model="settingsStore.settings.copyright.company"
|
||||
:disabled="!settingsStore.settings.copyright.enable"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">网址</div>
|
||||
<HInput
|
||||
v-model="settingsStore.settings.copyright.website"
|
||||
:disabled="!settingsStore.settings.copyright.enable"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">备案</div>
|
||||
<HInput
|
||||
v-model="settingsStore.settings.copyright.beian"
|
||||
:disabled="!settingsStore.settings.copyright.enable"
|
||||
/>
|
||||
</div>
|
||||
<div class="divider">主页</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
是否启用
|
||||
<HTooltip text="该功能开启时,登录成功默认进入主页,反之则默认进入导航栏里第一个导航页面">
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle v-model="settingsStore.settings.home.enable" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
主页名称
|
||||
<HTooltip text="开启国际化时,该设置无效">
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HInput v-model="settingsStore.settings.home.title" />
|
||||
</div>
|
||||
<div class="divider">其它</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否启用权限</div>
|
||||
<HToggle v-model="settingsStore.settings.app.enablePermission" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
载入进度条
|
||||
<HTooltip text="该功能开启时,跳转路由会看到页面顶部有进度条">
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle v-model="settingsStore.settings.app.enableProgress" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
动态标题
|
||||
<HTooltip
|
||||
text="该功能开启时,页面标题会显示当前路由标题,格式为“页面标题 - 网站名称”;关闭时则显示网站名称,网站名称在项目根目录下 .env.* 文件里配置"
|
||||
>
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle v-model="settingsStore.settings.app.enableDynamicTitle" />
|
||||
</div>
|
||||
<template v-if="isSupported" #footer>
|
||||
<HButton block @click="handleCopy">
|
||||
<SvgIcon name="i-ep:document-copy" />
|
||||
复制配置
|
||||
</HButton>
|
||||
</template>
|
||||
</HSlideover>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.divider {
|
||||
--at-apply: flex items-center justify-between gap-4 my-4 whitespace-nowrap text-sm font-500;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
--at-apply: content-empty w-full h-1px bg-stone-2 dark-bg-stone-6;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-mode {
|
||||
--at-apply: flex items-center justify-center gap-4 pb-4;
|
||||
|
||||
.mode {
|
||||
--at-apply: relative w-16 h-12 rounded-2 ring-1 ring-stone-2 dark-ring-stone-7 cursor-pointer
|
||||
transition;
|
||||
|
||||
&.active {
|
||||
--at-apply: ring-ui-primary ring-2;
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after,
|
||||
.mode-container {
|
||||
--at-apply: absolute pointer-events-none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
--at-apply: content-empty bg-ui-primary;
|
||||
}
|
||||
|
||||
&::after {
|
||||
--at-apply: content-empty bg-ui-primary/60;
|
||||
}
|
||||
|
||||
.mode-container {
|
||||
--at-apply: bg-ui-primary/20 border-dashed border-ui-primary;
|
||||
|
||||
&::before {
|
||||
--at-apply: content-empty absolute w-full h-full;
|
||||
}
|
||||
}
|
||||
|
||||
&-side {
|
||||
&::before {
|
||||
--at-apply: top-2 bottom-2 left-2 w-2 rounded-tl-1 rounded-bl-1;
|
||||
}
|
||||
|
||||
&::after {
|
||||
--at-apply: top-2 bottom-2 left-4.5 w-3;
|
||||
}
|
||||
|
||||
.mode-container {
|
||||
--at-apply: inset-t-2 inset-r-2 inset-b-2 inset-l-8 rounded-tr-1 rounded-br-1;
|
||||
}
|
||||
}
|
||||
|
||||
&-head {
|
||||
&::before {
|
||||
--at-apply: top-2 left-2 right-2 h-2 rounded-tl-1 rounded-tr-1;
|
||||
}
|
||||
|
||||
&::after {
|
||||
--at-apply: top-4.5 left-2 bottom-2 w-3 rounded-bl-1;
|
||||
}
|
||||
|
||||
.mode-container {
|
||||
--at-apply: inset-t-4.5 inset-r-2 inset-b-2 inset-l-5.5 rounded-br-1;
|
||||
}
|
||||
}
|
||||
|
||||
&-single {
|
||||
&::after {
|
||||
--at-apply: top-2 left-2 bottom-2 w-3 rounded-tl-1 rounded-bl-1;
|
||||
}
|
||||
|
||||
.mode-container {
|
||||
--at-apply: inset-t-2 inset-r-2 inset-b-2 inset-l-5.5 rounded-tr-1 rounded-br-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
--at-apply: flex items-center justify-between gap-4 px-4 py-2 rounded-2 transition
|
||||
hover-bg-stone-1 dark-hover-bg-stone-9;
|
||||
|
||||
.label {
|
||||
--at-apply: flex items-center flex-shrink-0 gap-2 text-sm;
|
||||
|
||||
i {
|
||||
--at-apply: text-xl text-orange cursor-help;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
49
admin/src/layouts/components/BackTop/index.vue
Normal file
49
admin/src/layouts/components/BackTop/index.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'BackTop',
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
const scrollTop = ref<number | null>(null);
|
||||
function handleScroll() {
|
||||
scrollTop.value = document.documentElement.scrollTop;
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
document.documentElement.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition v-bind="transitionClass">
|
||||
<div
|
||||
v-if="scrollTop && scrollTop >= 200"
|
||||
class="fixed bottom-4 right-4 z-1000 h-12 w-12 flex cursor-pointer items-center justify-center rounded-full bg-white shadow-lg ring-1 ring-stone-3 ring-inset dark-bg-dark hover-bg-stone-1 dark-ring-stone-7 dark-hover-bg-dark/50"
|
||||
@click="handleClick"
|
||||
>
|
||||
<SvgIcon name="i-icon-park-outline:to-top-one" :size="24" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
21
admin/src/layouts/components/Breadcrumb/index.vue
Normal file
21
admin/src/layouts/components/Breadcrumb/index.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="flex items-center text-sm">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.breadcrumb-item) {
|
||||
&:first-child {
|
||||
.separator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
.text {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
39
admin/src/layouts/components/Breadcrumb/item.vue
Normal file
39
admin/src/layouts/components/Breadcrumb/item.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<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 flex items-center text-dark dark-text-white">
|
||||
<span class="separator mx-2">
|
||||
{{ separator }}
|
||||
</span>
|
||||
<span
|
||||
class="text flex items-center opacity-60"
|
||||
:class="{
|
||||
'is-link cursor-pointer transition-opacity hover-opacity-100': !!props.to,
|
||||
}"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
52
admin/src/layouts/components/Copyright/index.vue
Executable file
52
admin/src/layouts/components/Copyright/index.vue
Executable file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
|
||||
defineOptions({
|
||||
name: 'Copyright',
|
||||
});
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer v-if="settingsStore.settings.copyright.enable" class="copyright">
|
||||
<span>Copyright</span>
|
||||
<SvgIcon name="i-ri:copyright-line" :size="18" />
|
||||
<span v-if="settingsStore.settings.copyright.dates">{{
|
||||
settingsStore.settings.copyright.dates
|
||||
}}</span>
|
||||
<template v-if="settingsStore.settings.copyright.company">
|
||||
<a
|
||||
v-if="settingsStore.settings.copyright.website"
|
||||
:href="settingsStore.settings.copyright.website"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ settingsStore.settings.copyright.company }}</a
|
||||
>
|
||||
<span v-else>{{ settingsStore.settings.copyright.company }}</span>
|
||||
</template>
|
||||
<a
|
||||
v-if="settingsStore.settings.copyright.beian"
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ settingsStore.settings.copyright.beian }}</a
|
||||
>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.copyright {
|
||||
--at-apply: flex items-center justify-center flex-wrap my-4 px-4 text-sm text-stone-5;
|
||||
|
||||
span,
|
||||
a {
|
||||
--at-apply: px-1;
|
||||
}
|
||||
|
||||
a {
|
||||
--at-apply: text-center no-underline text-stone-5 hover-text-dark dark-hover-text-light
|
||||
transition;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
189
admin/src/layouts/components/Header/index.vue
Executable file
189
admin/src/layouts/components/Header/index.vue
Executable file
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import Logo from '../Logo/index.vue';
|
||||
import ToolbarRightSide from '../Topbar/Toolbar/rightSide.vue';
|
||||
import useMenuStore from '@/store/modules/menu';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
|
||||
defineOptions({
|
||||
name: 'LayoutHeader',
|
||||
});
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const menuStore = useMenuStore();
|
||||
|
||||
const { switchTo } = useMenu();
|
||||
|
||||
const menuRef = ref();
|
||||
|
||||
// 顶部模式鼠标滚动
|
||||
function handlerMouserScroll(event: WheelEvent) {
|
||||
if (event.deltaY || event.detail !== 0) {
|
||||
menuRef.value.scrollBy({
|
||||
left: (event.deltaY || event.detail) > 0 ? 50 : -50,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="header">
|
||||
<header v-if="settingsStore.mode === 'pc' && settingsStore.settings.menu.menuMode === 'head'">
|
||||
<div class="header-container">
|
||||
<Logo class="title" />
|
||||
<div ref="menuRef" class="menu-container" @wheel.prevent="handlerMouserScroll">
|
||||
<!-- 顶部模式 -->
|
||||
<div class="menu flex of-hidden transition-all">
|
||||
<template v-for="(item, index) in menuStore.allMenus" :key="index">
|
||||
<div
|
||||
class="menu-item relative transition-all"
|
||||
:class="{
|
||||
active: index === menuStore.actived,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="item.children && item.children.length !== 0"
|
||||
class="group menu-item-container h-full w-full flex cursor-pointer items-center justify-between gap-1 px-3 text-[var(--g-header-menu-color)] transition-all hover-(bg-[var(--g-header-menu-hover-bg)] text-[var(--g-header-menu-hover-color)])"
|
||||
:class="{
|
||||
'text-[var(--g-header-menu-active-color)]! bg-[var(--g-header-menu-active-bg)]!':
|
||||
index === menuStore.actived,
|
||||
}"
|
||||
:title="
|
||||
typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title
|
||||
"
|
||||
@click="switchTo(index)"
|
||||
>
|
||||
<div class="inline-flex flex-1 items-center justify-center gap-1">
|
||||
<SvgIcon
|
||||
v-if="item.meta?.icon"
|
||||
:name="item.meta?.icon"
|
||||
:size="20"
|
||||
class="menu-item-container-icon transition-transform group-hover-scale-120"
|
||||
async
|
||||
/>
|
||||
<span
|
||||
class="w-full flex-1 truncate text-sm transition-height transition-opacity transition-width"
|
||||
>
|
||||
{{
|
||||
typeof item.meta?.title === 'function'
|
||||
? item.meta?.title()
|
||||
: item.meta?.title
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<ToolbarRightSide />
|
||||
</div>
|
||||
</header>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: var(--g-header-height);
|
||||
padding: 0 20px;
|
||||
margin: 0 auto;
|
||||
color: var(--g-header-color);
|
||||
background-color: var(--g-header-bg);
|
||||
box-shadow:
|
||||
-1px 0 0 0 var(--g-border-color),
|
||||
1px 0 0 0 var(--g-border-color),
|
||||
0 1px 0 0 var(--g-border-color);
|
||||
transition: background-color 0.3s;
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
|
||||
:deep(a.title) {
|
||||
position: relative;
|
||||
flex: 0;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
padding: inherit;
|
||||
background-color: inherit;
|
||||
|
||||
.logo {
|
||||
width: initial;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 20px;
|
||||
color: var(--g-header-color);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
padding: 0 20px;
|
||||
overflow-x: auto;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
#000 20px,
|
||||
#000 calc(100% - 20px),
|
||||
transparent
|
||||
);
|
||||
|
||||
// firefox隐藏滚动条
|
||||
scrollbar-width: none;
|
||||
|
||||
// chrome隐藏滚动条
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: inline-flex;
|
||||
height: 100%;
|
||||
|
||||
: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);
|
||||
}
|
||||
}
|
||||
|
||||
&.active .menu-item-container {
|
||||
color: var(--g-header-menu-active-color);
|
||||
background-color: var(--g-header-menu-active-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 头部动画
|
||||
.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>
|
||||
95
admin/src/layouts/components/HotkeysIntro/index.vue
Executable file
95
admin/src/layouts/components/HotkeysIntro/index.vue
Executable file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import eventBus from '@/utils/eventBus';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
|
||||
defineOptions({
|
||||
name: 'HotkeysIntro',
|
||||
});
|
||||
|
||||
const isShow = ref(false);
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('global-hotkeys-intro-toggle', () => {
|
||||
isShow.value = !isShow.value;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HDialog v-model="isShow" title="快捷键介绍">
|
||||
<div class="px-4">
|
||||
<div class="grid gap-2 sm-grid-cols-2">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-bold">全局</h2>
|
||||
<ul class="list-none pl-4 text-sm">
|
||||
<li class="py-1">
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>I</HKbd>
|
||||
查看系统信息
|
||||
</li>
|
||||
<li
|
||||
v-if="
|
||||
settingsStore.settings.toolbar.navSearch &&
|
||||
settingsStore.settings.navSearch.enableHotkeys
|
||||
"
|
||||
class="py-1"
|
||||
>
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>S</HKbd>
|
||||
唤起导航搜索
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
settingsStore.settings.menu.enableHotkeys &&
|
||||
['side', 'head'].includes(settingsStore.settings.menu.menuMode)
|
||||
"
|
||||
>
|
||||
<h2 class="m-0 text-lg font-bold">主导航</h2>
|
||||
<ul class="list-none pl-4 text-sm">
|
||||
<li class="py-1">
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>`</HKbd>
|
||||
激活下一个主导航
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="settingsStore.settings.tabbar.enable && settingsStore.settings.tabbar.enableHotkeys"
|
||||
>
|
||||
<h2 class="m-0 text-lg font-bold">标签栏</h2>
|
||||
<ul class="list-none pl-4 text-sm">
|
||||
<li class="py-1">
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>←</HKbd>
|
||||
切换到上一个标签页
|
||||
</li>
|
||||
<li class="py-1">
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>→</HKbd>
|
||||
切换到下一个标签页
|
||||
</li>
|
||||
<li class="py-1">
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>W</HKbd>
|
||||
关闭当前标签页
|
||||
</li>
|
||||
<li class="py-1">
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>1~9</HKbd>
|
||||
切换到第 n 个标签页
|
||||
</li>
|
||||
<li class="py-1">
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>0</HKbd>
|
||||
切换到最后一个标签页
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HDialog>
|
||||
</template>
|
||||
49
admin/src/layouts/components/Logo/index.vue
Executable file
49
admin/src/layouts/components/Logo/index.vue
Executable file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
|
||||
defineOptions({
|
||||
name: 'Logo',
|
||||
});
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
showLogo?: boolean;
|
||||
showTitle?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showLogo: true,
|
||||
showTitle: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { pkg } = __SYSTEM_INFO__;
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const title = ref(import.meta.env.VITE_APP_TITLE);
|
||||
|
||||
// 校验 title 是否包含 "AIWeb"
|
||||
const encodedKeyword = 'QUlXZWI='; // "AIWeb" 的 Base64 编码
|
||||
const decodedKeyword = atob(encodedKeyword);
|
||||
|
||||
if (!title.value.includes(decodedKeyword)) {
|
||||
document.body.innerHTML = '<h1></h1>';
|
||||
throw new Error('');
|
||||
}
|
||||
|
||||
const to = computed(() =>
|
||||
settingsStore.settings.home.enable ? settingsStore.settings.home.fullPath : '',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="to"
|
||||
class="h-[var(--g-sidebar-logo-height)] w-inherit flex-center gap-2 px-3 text-inherit no-underline"
|
||||
:class="{ 'cursor-pointer': settingsStore.settings.home.enable }"
|
||||
:title="title"
|
||||
>
|
||||
<!-- <img v-if="showLogo" :src="logo" class="logo h-[30px] w-[30px] object-contain"> -->
|
||||
<span v-if="showTitle" class="block truncate font-bold">{{ title }}-{{ pkg.version }}</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
138
admin/src/layouts/components/MainSidebar/index.vue
Executable file
138
admin/src/layouts/components/MainSidebar/index.vue
Executable file
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import Logo from '../Logo/index.vue';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
import useMenuStore from '@/store/modules/menu';
|
||||
|
||||
defineOptions({
|
||||
name: 'MainSidebar',
|
||||
});
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const menuStore = useMenuStore();
|
||||
|
||||
const { switchTo } = useMenu();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="main-sidebar">
|
||||
<div
|
||||
v-if="
|
||||
settingsStore.settings.menu.menuMode === 'side' ||
|
||||
(settingsStore.mode === 'mobile' && settingsStore.settings.menu.menuMode !== 'single')
|
||||
"
|
||||
class="main-sidebar-container"
|
||||
>
|
||||
<Logo :show-title="false" class="sidebar-logo" />
|
||||
<!-- 侧边栏模式(含主导航) -->
|
||||
<div class="menu flex flex-col of-hidden transition-all">
|
||||
<template v-for="(item, index) in menuStore.allMenus" :key="index">
|
||||
<div
|
||||
class="menu-item relative transition-all"
|
||||
:class="{
|
||||
active: index === menuStore.actived,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="item.children && item.children.length !== 0"
|
||||
class="group menu-item-container h-full w-full flex cursor-pointer items-center justify-between gap-1 py-4 text-[var(--g-main-sidebar-menu-color)] transition-all hover-(bg-[var(--g-main-sidebar-menu-hover-bg)] text-[var(--g-main-sidebar-menu-hover-color)]) px-2!"
|
||||
:class="{
|
||||
'text-[var(--g-main-sidebar-menu-active-color)]! bg-[var(--g-main-sidebar-menu-active-bg)]!':
|
||||
index === menuStore.actived,
|
||||
}"
|
||||
:title="
|
||||
typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title
|
||||
"
|
||||
@click="switchTo(index)"
|
||||
>
|
||||
<div class="w-full inline-flex flex-1 flex-col items-center justify-center gap-[2px]">
|
||||
<SvgIcon
|
||||
v-if="item.meta?.icon"
|
||||
:name="item.meta?.icon"
|
||||
:size="20"
|
||||
class="menu-item-container-icon transition-transform group-hover-scale-120"
|
||||
async
|
||||
/>
|
||||
<span
|
||||
class="w-full flex-1 truncate text-center text-sm transition-height transition-opacity transition-width"
|
||||
>
|
||||
{{
|
||||
typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.main-sidebar-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
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 var(--g-border-color);
|
||||
transition:
|
||||
background-color 0.3s,
|
||||
color 0.3s,
|
||||
box-shadow 0.3s;
|
||||
|
||||
.sidebar-logo {
|
||||
background-color: var(--g-main-sidebar-bg);
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.menu {
|
||||
flex: 1;
|
||||
width: initial;
|
||||
overflow: hidden auto;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
// firefox隐藏滚动条
|
||||
scrollbar-width: none;
|
||||
|
||||
// chrome隐藏滚动条
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.menu-item) {
|
||||
.menu-item-container {
|
||||
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: 24px !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>
|
||||
199
admin/src/layouts/components/Menu/index.vue
Normal file
199
admin/src/layouts/components/Menu/index.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
import SubMenu from './sub.vue';
|
||||
import Item from './item.vue';
|
||||
import type { MenuInjection, MenuProps } from './types';
|
||||
import { rootMenuInjectionKey } from './types';
|
||||
|
||||
defineOptions({
|
||||
name: 'MainMenu',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<MenuProps>(), {
|
||||
accordion: true,
|
||||
defaultOpeneds: () => [],
|
||||
mode: 'vertical',
|
||||
collapse: false,
|
||||
showCollapseName: false,
|
||||
});
|
||||
|
||||
const activeIndex = ref<MenuInjection['activeIndex']>(props.value);
|
||||
const items = ref<MenuInjection['items']>({});
|
||||
const subMenus = ref<MenuInjection['subMenus']>({});
|
||||
const openedMenus = ref<MenuInjection['openedMenus']>(props.defaultOpeneds.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 ?? JSON.stringify(item);
|
||||
if (item.children) {
|
||||
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 (openedMenus.value.includes(index)) {
|
||||
return;
|
||||
}
|
||||
if (props.accordion) {
|
||||
openedMenus.value = openedMenus.value.filter((key) => indexPath.includes(key));
|
||||
}
|
||||
openedMenus.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;
|
||||
}
|
||||
Object.keys(subMenus.value).forEach((item) => {
|
||||
if (subMenus.value[item].indexPath.includes(index)) {
|
||||
openedMenus.value = openedMenus.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) {
|
||||
openedMenus.value = [];
|
||||
}
|
||||
setSubMenusActive(index);
|
||||
};
|
||||
const handleSubMenuClick: MenuInjection['handleSubMenuClick'] = (index, indexPath) => {
|
||||
if (openedMenus.value.includes(index)) {
|
||||
closeMenu(index);
|
||||
} else {
|
||||
openMenu(index, indexPath);
|
||||
}
|
||||
};
|
||||
|
||||
function initMenu() {
|
||||
const activeItem = activeIndex.value && items.value[activeIndex.value];
|
||||
setSubMenusActive(activeIndex.value);
|
||||
if (!activeItem || 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) {
|
||||
openedMenus.value = [];
|
||||
}
|
||||
initMenu();
|
||||
},
|
||||
);
|
||||
|
||||
provide(
|
||||
rootMenuInjectionKey,
|
||||
reactive({
|
||||
props,
|
||||
items,
|
||||
subMenus,
|
||||
activeIndex,
|
||||
openedMenus,
|
||||
mouseInMenu,
|
||||
isMenuPopup,
|
||||
openMenu,
|
||||
closeMenu,
|
||||
handleMenuItemClick,
|
||||
handleSubMenuClick,
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col of-hidden transition-all"
|
||||
:class="{
|
||||
'w-[200px]': !isMenuPopup && props.mode === 'vertical',
|
||||
'w-[64px]': isMenuPopup && props.mode === 'vertical',
|
||||
'h-[80px]': props.mode === 'horizontal',
|
||||
'flex-row! w-auto': isMenuPopup && props.mode === 'horizontal',
|
||||
}"
|
||||
>
|
||||
<template v-for="item in menu" :key="item.path ?? JSON.stringify(item)">
|
||||
<template v-if="item.meta?.menu !== false">
|
||||
<SubMenu
|
||||
v-if="item.children?.length"
|
||||
:menu="item"
|
||||
:unique-key="[item.path ?? JSON.stringify(item)]"
|
||||
/>
|
||||
<Item
|
||||
v-else
|
||||
:item="item"
|
||||
:unique-key="[item.path ?? JSON.stringify(item)]"
|
||||
@click="handleMenuItemClick(item.path ?? JSON.stringify(item))"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
122
admin/src/layouts/components/Menu/item.vue
Normal file
122
admin/src/layouts/components/Menu/item.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import type { SubMenuItemProps } from './types';
|
||||
import { rootMenuInjectionKey } from './types';
|
||||
|
||||
const props = withDefaults(defineProps<SubMenuItemProps>(), {
|
||||
level: 0,
|
||||
subMenu: false,
|
||||
expand: false,
|
||||
});
|
||||
|
||||
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 indentStyle = computed(() => {
|
||||
return !rootMenu.isMenuPopup ? `padding-left: ${20 * (props.level ?? 0)}px` : '';
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
ref: itemRef,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="itemRef"
|
||||
class="menu-item relative transition-all"
|
||||
:class="{
|
||||
active: isItemActive,
|
||||
}"
|
||||
>
|
||||
<router-link v-slot="{ href, navigate }" custom :to="uniqueKey.at(-1) ?? ''">
|
||||
<HTooltip
|
||||
:enable="rootMenu.isMenuPopup && level === 0 && !subMenu"
|
||||
:text="
|
||||
(typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title) ?? ''
|
||||
"
|
||||
placement="right"
|
||||
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="group menu-item-container h-full w-full flex cursor-pointer items-center justify-between gap-1 px-5 py-4 text-[var(--g-sub-sidebar-menu-color)] transition-all hover-(bg-[var(--g-sub-sidebar-menu-hover-bg)] text-[var(--g-sub-sidebar-menu-hover-color)])"
|
||||
:class="{
|
||||
'text-[var(--g-sub-sidebar-menu-active-color)]! bg-[var(--g-sub-sidebar-menu-active-bg)]!':
|
||||
isItemActive,
|
||||
'px-3!': rootMenu.isMenuPopup && level === 0,
|
||||
}"
|
||||
:title="typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title"
|
||||
v-on="{
|
||||
...(!subMenu && {
|
||||
click: navigate,
|
||||
}),
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="inline-flex flex-1 items-center justify-center gap-[12px]"
|
||||
:class="{
|
||||
'flex-col': rootMenu.isMenuPopup && level === 0 && rootMenu.props.mode === 'vertical',
|
||||
'gap-1!': rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName,
|
||||
'w-full':
|
||||
rootMenu.isMenuPopup &&
|
||||
level === 0 &&
|
||||
rootMenu.props.showCollapseName &&
|
||||
rootMenu.props.mode === 'vertical',
|
||||
}"
|
||||
:style="indentStyle"
|
||||
>
|
||||
<SvgIcon
|
||||
v-if="props.item.meta?.icon"
|
||||
:name="props.item.meta.icon"
|
||||
:size="20"
|
||||
class="menu-item-container-icon transition-transform group-hover-scale-120"
|
||||
async
|
||||
/>
|
||||
<span
|
||||
v-if="!(rootMenu.isMenuPopup && level === 0 && !rootMenu.props.showCollapseName)"
|
||||
class="w-0 flex-1 truncate text-sm transition-height transition-opacity transition-width"
|
||||
:class="{
|
||||
'opacity-0 w-0 h-0':
|
||||
rootMenu.isMenuPopup && level === 0 && !rootMenu.props.showCollapseName,
|
||||
'w-full text-center':
|
||||
rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName,
|
||||
}"
|
||||
>
|
||||
{{ typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="subMenu && !(rootMenu.isMenuPopup && level === 0)"
|
||||
class="relative ml-1 w-[10px] after:(absolute h-[1.5px] w-[6px] bg-current transition-transform-200 content-empty -translate-y-[1px]) before:(absolute h-[1.5px] w-[6px] bg-current transition-transform-200 content-empty -translate-y-[1px])"
|
||||
:class="[
|
||||
expand
|
||||
? 'before:(-rotate-45 -translate-x-[2px]) after:(rotate-45 translate-x-[2px])'
|
||||
: 'before:(rotate-45 -translate-x-[2px]) after:(-rotate-45 translate-x-[2px])',
|
||||
rootMenu.isMenuPopup && level === 0 && 'opacity-0',
|
||||
rootMenu.isMenuPopup && level !== 0 && '-rotate-90 -top-[1.5px]',
|
||||
]"
|
||||
/>
|
||||
</component>
|
||||
</HTooltip>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
213
admin/src/layouts/components/Menu/sub.vue
Normal file
213
admin/src/layouts/components/Menu/sub.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core';
|
||||
import type { OverlayScrollbarsComponentRef } from 'overlayscrollbars-vue';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue';
|
||||
import Item from './item.vue';
|
||||
import type { SubMenuProps } from './types';
|
||||
import { rootMenuInjectionKey } from './types';
|
||||
|
||||
defineOptions({
|
||||
name: 'SubMenu',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<SubMenuProps>(), {
|
||||
level: 0,
|
||||
});
|
||||
|
||||
const index = props.menu.path ?? JSON.stringify(props.menu);
|
||||
const itemRef = shallowRef();
|
||||
const subMenuRef = shallowRef<OverlayScrollbarsComponentRef>();
|
||||
const rootMenu = inject(rootMenuInjectionKey)!;
|
||||
|
||||
const opened = computed(() => {
|
||||
return rootMenu.openedMenus.includes(props.uniqueKey.at(-1)!);
|
||||
});
|
||||
|
||||
const transitionEvent = computed(() => {
|
||||
return rootMenu.isMenuPopup
|
||||
? {
|
||||
enter(el: HTMLElement) {
|
||||
if (el.offsetHeight > window.innerHeight) {
|
||||
el.style.height = `${window.innerHeight}px`;
|
||||
}
|
||||
},
|
||||
afterEnter: () => {},
|
||||
beforeLeave: (el: HTMLElement) => {
|
||||
el.style.overflow = 'hidden';
|
||||
el.style.maxHeight = `${el.offsetHeight}px`;
|
||||
},
|
||||
leave: (el: HTMLElement) => {
|
||||
el.style.maxHeight = '0';
|
||||
},
|
||||
afterLeave(el: HTMLElement) {
|
||||
el.style.overflow = '';
|
||||
el.style.maxHeight = '';
|
||||
},
|
||||
}
|
||||
: {
|
||||
enter(el: HTMLElement) {
|
||||
const memorizedHeight = el.offsetHeight;
|
||||
el.style.maxHeight = '0';
|
||||
el.style.overflow = 'hidden';
|
||||
void el.offsetHeight;
|
||||
el.style.maxHeight = `${memorizedHeight}px`;
|
||||
},
|
||||
afterEnter(el: HTMLElement) {
|
||||
el.style.overflow = '';
|
||||
el.style.maxHeight = '';
|
||||
},
|
||||
beforeLeave(el: HTMLElement) {
|
||||
el.style.overflow = 'hidden';
|
||||
el.style.maxHeight = `${el.offsetHeight}px`;
|
||||
},
|
||||
leave(el: HTMLElement) {
|
||||
el.style.maxHeight = '0';
|
||||
},
|
||||
afterLeave(el: HTMLElement) {
|
||||
el.style.overflow = '';
|
||||
el.style.maxHeight = '';
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const transitionClass = computed(() => {
|
||||
return rootMenu.isMenuPopup
|
||||
? {
|
||||
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',
|
||||
}
|
||||
: {
|
||||
enterActiveClass: 'ease-in-out duration-300',
|
||||
enterFromClass: 'opacity-0',
|
||||
enterToClass: 'opacity-100',
|
||||
leaveActiveClass: 'ease-in-out duration-300',
|
||||
leaveFromClass: 'opacity-100',
|
||||
leaveToClass: 'opacity-0',
|
||||
};
|
||||
});
|
||||
|
||||
const hasChildren = computed(() => {
|
||||
let flag = true;
|
||||
if (props.menu.children) {
|
||||
if (props.menu.children.every((item: any) => item.meta?.menu === false)) {
|
||||
flag = false;
|
||||
}
|
||||
} else {
|
||||
flag = false;
|
||||
}
|
||||
return flag;
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
if (rootMenu.isMenuPopup && hasChildren.value) {
|
||||
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(() => {
|
||||
const el = itemRef.value.ref;
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
if (rootMenu.props.mode === 'vertical' || props.level !== 0) {
|
||||
top = el.getBoundingClientRect().top + el.scrollTop;
|
||||
left = el.getBoundingClientRect().left + el.getBoundingClientRect().width;
|
||||
if (top + subMenuRef.value!.getElement()!.offsetHeight > window.innerHeight) {
|
||||
top = window.innerHeight - subMenuRef.value!.getElement()!.offsetHeight;
|
||||
}
|
||||
} else {
|
||||
top = el.getBoundingClientRect().top + el.getBoundingClientRect().height;
|
||||
left = el.getBoundingClientRect().left;
|
||||
if (top + subMenuRef.value!.getElement()!.offsetHeight > window.innerHeight) {
|
||||
subMenuRef.value!.getElement()!.style.height = `${window.innerHeight - top}px`;
|
||||
}
|
||||
}
|
||||
subMenuRef.value!.getElement()!.style.top = `${top}px`;
|
||||
subMenuRef.value!.getElement()!.style.left = `${left}px`;
|
||||
});
|
||||
} 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(() => {
|
||||
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="opened"
|
||||
@click="handleClick"
|
||||
@mouseenter="handleMouseenter"
|
||||
@mouseleave="handleMouseleave"
|
||||
/>
|
||||
<Teleport v-if="hasChildren" to="body" :disabled="!rootMenu.isMenuPopup">
|
||||
<Transition v-bind="transitionClass" v-on="transitionEvent">
|
||||
<OverlayScrollbarsComponent
|
||||
v-if="opened"
|
||||
ref="subMenuRef"
|
||||
:options="{ scrollbars: { visibility: 'hidden' } }"
|
||||
defer
|
||||
class="sub-menu"
|
||||
:class="{
|
||||
'bg-[var(--g-sub-sidebar-bg)]': rootMenu.isMenuPopup,
|
||||
'ring-1 ring-stone-2 dark-ring-stone-8 shadow-xl fixed z-3000 w-[200px]':
|
||||
rootMenu.isMenuPopup,
|
||||
'mx-2': rootMenu.isMenuPopup && (rootMenu.props.mode === 'vertical' || level !== 0),
|
||||
}"
|
||||
>
|
||||
<template v-for="item in menu.children" :key="item.path ?? JSON.stringify(item)">
|
||||
<SubMenu
|
||||
v-if="item.meta?.menu !== false"
|
||||
:unique-key="[...uniqueKey, item.path ?? JSON.stringify(item)]"
|
||||
:menu="item"
|
||||
:level="level + 1"
|
||||
/>
|
||||
</template>
|
||||
</OverlayScrollbarsComponent>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
48
admin/src/layouts/components/Menu/types.ts
Normal file
48
admin/src/layouts/components/Menu/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createInjectionKey } from '@/utils/injectionKeys';
|
||||
import type { Menu } from '#/global';
|
||||
|
||||
export interface MenuItem {
|
||||
index: string;
|
||||
indexPath: string[];
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface MenuProps {
|
||||
menu: Menu.recordRaw[];
|
||||
value: string;
|
||||
accordion?: boolean;
|
||||
defaultOpeneds?: string[];
|
||||
mode?: 'horizontal' | 'vertical';
|
||||
collapse?: boolean;
|
||||
showCollapseName?: boolean;
|
||||
}
|
||||
|
||||
export interface MenuInjection {
|
||||
props: MenuProps;
|
||||
items: Record<string, MenuItem>;
|
||||
subMenus: Record<string, MenuItem>;
|
||||
activeIndex: MenuProps['value'];
|
||||
openedMenus: 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 = createInjectionKey<MenuInjection>('rootMenu');
|
||||
|
||||
export interface SubMenuProps {
|
||||
uniqueKey: string[];
|
||||
menu: Menu.recordRaw;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
export interface SubMenuItemProps {
|
||||
uniqueKey: string[];
|
||||
item: Menu.recordRaw;
|
||||
level?: number;
|
||||
subMenu?: boolean;
|
||||
expand?: boolean;
|
||||
}
|
||||
405
admin/src/layouts/components/Search/index.vue
Executable file
405
admin/src/layouts/components/Search/index.vue
Executable file
@@ -0,0 +1,405 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogPanel,
|
||||
TransitionChild,
|
||||
TransitionRoot,
|
||||
} from '@headlessui/vue';
|
||||
import type { OverlayScrollbarsComponentRef } from 'overlayscrollbars-vue';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import hotkeys from 'hotkeys-js';
|
||||
import Breadcrumb from '../Breadcrumb/index.vue';
|
||||
import BreadcrumbItem from '../Breadcrumb/item.vue';
|
||||
import { resolveRoutePath } from '@/utils';
|
||||
import eventBus from '@/utils/eventBus';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
import useMenuStore from '@/store/modules/menu';
|
||||
import type { Menu } from '@/types/global';
|
||||
|
||||
defineOptions({
|
||||
name: 'Search',
|
||||
});
|
||||
|
||||
const overlayTransitionClass = ref({
|
||||
enter: 'ease-in-out duration-500',
|
||||
enterFrom: 'opacity-0',
|
||||
enterTo: 'opacity-100',
|
||||
leave: 'ease-in-out duration-500',
|
||||
leaveFrom: 'opacity-100',
|
||||
leaveTo: 'opacity-0',
|
||||
});
|
||||
|
||||
const transitionClass = computed(() => {
|
||||
return {
|
||||
enter: 'ease-out duration-300',
|
||||
enterFrom: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
|
||||
enterTo: 'opacity-100 translate-y-0 lg-scale-100',
|
||||
leave: 'ease-in duration-200',
|
||||
leaveFrom: 'opacity-100 translate-y-0 lg-scale-100',
|
||||
leaveTo: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
|
||||
};
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const settingsStore = useSettingsStore();
|
||||
const menuStore = useMenuStore();
|
||||
|
||||
interface listTypes {
|
||||
path: string;
|
||||
icon?: string;
|
||||
title?: string | (() => string);
|
||||
link?: string;
|
||||
breadcrumb: {
|
||||
title?: string | (() => string);
|
||||
}[];
|
||||
}
|
||||
|
||||
const isShow = ref(false);
|
||||
const searchInput = ref('');
|
||||
const sourceList = ref<listTypes[]>([]);
|
||||
const actived = ref(-1);
|
||||
|
||||
const searchInputRef = ref();
|
||||
const searchResultRef = ref<OverlayScrollbarsComponentRef>();
|
||||
const searchResultItemRef = ref<HTMLElement[]>([]);
|
||||
onBeforeUpdate(() => {
|
||||
searchResultItemRef.value = [];
|
||||
});
|
||||
|
||||
const resultList = computed(() => {
|
||||
let result = [];
|
||||
result = sourceList.value.filter((item) => {
|
||||
let flag = false;
|
||||
if (item.title) {
|
||||
if (typeof item.title === 'function') {
|
||||
if (item.title().includes(searchInput.value)) {
|
||||
flag = true;
|
||||
}
|
||||
} else {
|
||||
if (item.title.includes(searchInput.value)) {
|
||||
flag = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.path.includes(searchInput.value)) {
|
||||
flag = true;
|
||||
}
|
||||
if (
|
||||
item.breadcrumb.some((b) => {
|
||||
if (typeof b.title === 'function') {
|
||||
if (b.title().includes(searchInput.value)) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
if (b.title?.includes(searchInput.value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
) {
|
||||
flag = true;
|
||||
}
|
||||
return flag;
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => isShow.value,
|
||||
(val) => {
|
||||
if (val) {
|
||||
searchInput.value = '';
|
||||
actived.value = -1;
|
||||
// 当搜索显示的时候绑定上、下、回车快捷键,隐藏的时候再解绑。另外当 input 处于 focus 状态时,采用 vue 来绑定键盘事件
|
||||
hotkeys('up', keyUp);
|
||||
hotkeys('down', keyDown);
|
||||
hotkeys('enter', keyEnter);
|
||||
} else {
|
||||
hotkeys.unbind('up', keyUp);
|
||||
hotkeys.unbind('down', keyDown);
|
||||
hotkeys.unbind('enter', keyEnter);
|
||||
}
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => resultList.value,
|
||||
() => {
|
||||
actived.value = -1;
|
||||
handleScroll();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('global-search-toggle', () => {
|
||||
if (!isShow.value) {
|
||||
initSourceList();
|
||||
}
|
||||
isShow.value = !isShow.value;
|
||||
});
|
||||
hotkeys('alt+s', (e) => {
|
||||
if (
|
||||
settingsStore.settings.toolbar.navSearch &&
|
||||
settingsStore.settings.navSearch.enableHotkeys
|
||||
) {
|
||||
e.preventDefault();
|
||||
initSourceList();
|
||||
isShow.value = true;
|
||||
}
|
||||
});
|
||||
hotkeys('esc', (e) => {
|
||||
if (
|
||||
settingsStore.settings.toolbar.navSearch &&
|
||||
settingsStore.settings.navSearch.enableHotkeys
|
||||
) {
|
||||
e.preventDefault();
|
||||
isShow.value = false;
|
||||
}
|
||||
});
|
||||
initSourceList();
|
||||
});
|
||||
|
||||
function initSourceList() {
|
||||
sourceList.value = [];
|
||||
menuStore.allMenus.forEach((item) => {
|
||||
getSourceListByMenus(item.children);
|
||||
});
|
||||
}
|
||||
|
||||
function hasChildren(item: Menu.recordRaw) {
|
||||
let flag = true;
|
||||
if (item.children?.every((i) => i.meta?.menu === false)) {
|
||||
flag = false;
|
||||
}
|
||||
return flag;
|
||||
}
|
||||
function getSourceListByMenus(
|
||||
arr: Menu.recordRaw[],
|
||||
basePath?: string,
|
||||
icon?: string,
|
||||
breadcrumb?: { title?: string | (() => string) }[],
|
||||
) {
|
||||
arr.forEach((item) => {
|
||||
if (item.meta?.menu !== false) {
|
||||
const breadcrumbTemp = cloneDeep(breadcrumb) || [];
|
||||
if (item.children && hasChildren(item)) {
|
||||
breadcrumbTemp.push({
|
||||
title: item.meta?.title,
|
||||
});
|
||||
getSourceListByMenus(
|
||||
item.children,
|
||||
resolveRoutePath(basePath, item.path),
|
||||
item.meta?.icon ?? icon,
|
||||
breadcrumbTemp,
|
||||
);
|
||||
} else {
|
||||
breadcrumbTemp.push({
|
||||
title: item.meta?.title,
|
||||
});
|
||||
sourceList.value.push({
|
||||
path: resolveRoutePath(basePath, item.path),
|
||||
icon: item.meta?.icon ?? icon,
|
||||
title: item.meta?.title,
|
||||
link: item.meta?.link,
|
||||
breadcrumb: breadcrumbTemp,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function keyUp() {
|
||||
if (resultList.value.length) {
|
||||
actived.value -= 1;
|
||||
if (actived.value < 0) {
|
||||
actived.value = resultList.value.length - 1;
|
||||
}
|
||||
handleScroll();
|
||||
}
|
||||
}
|
||||
function keyDown() {
|
||||
if (resultList.value.length) {
|
||||
actived.value += 1;
|
||||
if (actived.value > resultList.value.length - 1) {
|
||||
actived.value = 0;
|
||||
}
|
||||
handleScroll();
|
||||
}
|
||||
}
|
||||
function keyEnter() {
|
||||
if (actived.value !== -1) {
|
||||
searchResultItemRef.value
|
||||
.find((item) => Number.parseInt(item.dataset.index!) === actived.value)
|
||||
?.click();
|
||||
}
|
||||
}
|
||||
function handleScroll() {
|
||||
if (searchResultRef.value) {
|
||||
const contentDom = searchResultRef.value.osInstance()!.elements().content;
|
||||
let scrollTo = 0;
|
||||
if (actived.value !== -1) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
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>
|
||||
<TransitionRoot as="template" :show="isShow">
|
||||
<Dialog
|
||||
:initial-focus="searchInputRef"
|
||||
class="fixed inset-0 z-2000 flex"
|
||||
@close="isShow && eventBus.emit('global-search-toggle')"
|
||||
>
|
||||
<TransitionChild as="template" v-bind="overlayTransitionClass">
|
||||
<div
|
||||
class="fixed inset-0 bg-stone-200/75 backdrop-blur-sm transition-opacity dark-bg-stone-8/75"
|
||||
/>
|
||||
</TransitionChild>
|
||||
<div class="fixed inset-0">
|
||||
<div class="h-full flex items-end justify-center p-4 text-center lg-items-center">
|
||||
<TransitionChild as="template" v-bind="transitionClass">
|
||||
<DialogPanel
|
||||
class="relative h-full max-h-4/5 w-full flex flex-col text-left lg-max-w-2xl"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col overflow-y-auto rounded-xl bg-white shadow-xl dark-bg-stone-8"
|
||||
>
|
||||
<div class="flex items-center px-4 py-3" border-b="~ solid stone-2 dark-stone-7">
|
||||
<SvgIcon name="i-ep:search" :size="18" class="text-stone-5" />
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchInput"
|
||||
placeholder="搜索页面,支持标题、URL模糊查询"
|
||||
class="w-full border-0 rounded-md bg-transparent px-3 text-base text-dark dark-text-white focus-outline-none placeholder-stone-4 dark-placeholder-stone-5"
|
||||
@keydown.esc="eventBus.emit('global-search-toggle')"
|
||||
@keydown.up.prevent="keyUp"
|
||||
@keydown.down.prevent="keyDown"
|
||||
@keydown.enter.prevent="keyEnter"
|
||||
/>
|
||||
</div>
|
||||
<DialogDescription class="relative m-0 of-y-hidden">
|
||||
<OverlayScrollbarsComponent
|
||||
ref="searchResultRef"
|
||||
:options="{ scrollbars: { autoHide: 'leave', autoHideDelay: 300 } }"
|
||||
defer
|
||||
class="h-full"
|
||||
>
|
||||
<template v-if="resultList.length > 0">
|
||||
<a
|
||||
v-for="(item, index) in resultList"
|
||||
ref="searchResultItemRef"
|
||||
:key="item.path"
|
||||
class="flex cursor-pointer items-center"
|
||||
:class="{ 'bg-stone-2/40 dark-bg-stone-7/40': index === actived }"
|
||||
:data-index="index"
|
||||
@click="pageJump(item.path, item.link)"
|
||||
@mouseover="actived = index"
|
||||
>
|
||||
<SvgIcon
|
||||
v-if="item.icon"
|
||||
:name="item.icon"
|
||||
:size="20"
|
||||
class="basis-16 transition"
|
||||
:class="{ 'scale-120 text-ui-primary': index === actived }"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-1 flex-col gap-1 truncate px-4 py-3"
|
||||
border-l="~ solid stone-2 dark-stone-7"
|
||||
>
|
||||
<div class="truncate text-base font-bold">
|
||||
{{
|
||||
(typeof item.title === 'function' ? item.title() : item.title) ??
|
||||
'[ 无标题 ]'
|
||||
}}
|
||||
</div>
|
||||
<Breadcrumb v-if="item.breadcrumb.length" class="truncate">
|
||||
<BreadcrumbItem
|
||||
v-for="(bc, bcIndex) in item.breadcrumb"
|
||||
:key="bcIndex"
|
||||
class="text-xs"
|
||||
>
|
||||
{{
|
||||
(typeof bc.title === 'function' ? bc.title() : bc.title) ??
|
||||
'[ 无标题 ]'
|
||||
}}
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div flex="center col" py-6 text-stone-5>
|
||||
<SvgIcon name="i-tabler:mood-empty" :size="40" />
|
||||
<p m-2 text-base>没有找到你想要的</p>
|
||||
</div>
|
||||
</template>
|
||||
</OverlayScrollbarsComponent>
|
||||
</DialogDescription>
|
||||
<div
|
||||
v-if="settingsStore.mode === 'pc'"
|
||||
class="flex justify-between px-4 py-3"
|
||||
border-t="~ solid stone-2 dark-stone-7"
|
||||
>
|
||||
<div class="flex gap-8">
|
||||
<div class="inline-flex items-center gap-1 text-xs">
|
||||
<HKbd>
|
||||
<SvgIcon name="i-ion:md-return-left" :size="14" />
|
||||
</HKbd>
|
||||
<span>访问</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 text-xs">
|
||||
<HKbd>
|
||||
<SvgIcon name="i-ant-design:caret-up-filled" :size="14" />
|
||||
</HKbd>
|
||||
<HKbd>
|
||||
<SvgIcon name="i-ant-design:caret-down-filled" :size="14" />
|
||||
</HKbd>
|
||||
<span>切换</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="settingsStore.settings.navSearch.enableHotkeys"
|
||||
class="inline-flex items-center gap-1 text-xs"
|
||||
>
|
||||
<HKbd> ESC </HKbd>
|
||||
<span>退出</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
198
admin/src/layouts/components/SubSidebar/index.vue
Executable file
198
admin/src/layouts/components/SubSidebar/index.vue
Executable file
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import Logo from '../Logo/index.vue';
|
||||
import Menu from '../Menu/index.vue';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
import useMenuStore from '@/store/modules/menu';
|
||||
|
||||
defineOptions({
|
||||
name: 'SubSidebar',
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const menuStore = useMenuStore();
|
||||
|
||||
const subSidebarRef = ref();
|
||||
const showShadowTop = ref(false);
|
||||
const showShadowBottom = ref(false);
|
||||
function onSidebarScroll() {
|
||||
const scrollTop = subSidebarRef.value.scrollTop;
|
||||
showShadowTop.value = scrollTop > 0;
|
||||
const clientHeight = subSidebarRef.value.clientHeight;
|
||||
const scrollHeight = subSidebarRef.value.scrollHeight;
|
||||
showShadowBottom.value = Math.ceil(scrollTop + clientHeight) < scrollHeight;
|
||||
}
|
||||
|
||||
const menuRef = ref();
|
||||
|
||||
onMounted(() => {
|
||||
onSidebarScroll();
|
||||
const { height } = useElementSize(menuRef);
|
||||
watch(
|
||||
() => height.value,
|
||||
() => {
|
||||
if (height.value > 0) {
|
||||
onSidebarScroll();
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="sub-sidebar-container"
|
||||
:class="{
|
||||
'is-collapse': settingsStore.mode === 'pc' && settingsStore.settings.menu.subMenuCollapse,
|
||||
}"
|
||||
>
|
||||
<Logo
|
||||
:show-logo="settingsStore.settings.menu.menuMode === 'single'"
|
||||
class="sidebar-logo"
|
||||
:class="{
|
||||
'sidebar-logo-bg': settingsStore.settings.menu.menuMode === 'single',
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
ref="subSidebarRef"
|
||||
class="sub-sidebar flex-1 transition-shadow-300"
|
||||
:class="{
|
||||
'shadow-top': showShadowTop,
|
||||
'shadow-bottom': showShadowBottom,
|
||||
}"
|
||||
@scroll="onSidebarScroll"
|
||||
>
|
||||
<div ref="menuRef">
|
||||
<TransitionGroup name="sub-sidebar">
|
||||
<template v-for="(mainItem, mainIndex) in menuStore.allMenus" :key="mainIndex">
|
||||
<div v-show="mainIndex === menuStore.actived">
|
||||
<Menu
|
||||
:menu="mainItem.children"
|
||||
:value="route.meta.activeMenu || route.path"
|
||||
:default-openeds="menuStore.defaultOpenedPaths"
|
||||
:accordion="settingsStore.settings.menu.subMenuUniqueOpened"
|
||||
:collapse="
|
||||
settingsStore.mode === 'pc' && settingsStore.settings.menu.subMenuCollapse
|
||||
"
|
||||
class="menu"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="settingsStore.mode === 'pc'"
|
||||
class="relative flex items-center px-4 py-3"
|
||||
:class="[settingsStore.settings.menu.subMenuCollapse ? 'justify-center' : 'justify-end']"
|
||||
>
|
||||
<span
|
||||
v-show="settingsStore.settings.menu.enableSubMenuCollapseButton"
|
||||
class="flex-center cursor-pointer rounded bg-stone-1 p-2 transition dark-bg-stone-9 hover-bg-stone-2 dark-hover-bg-stone-8"
|
||||
:class="{ '-rotate-z-180': settingsStore.settings.menu.subMenuCollapse }"
|
||||
@click="settingsStore.toggleSidebarCollapse()"
|
||||
>
|
||||
<SvgIcon name="toolbar-collapse" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-sidebar-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--g-sub-sidebar-width);
|
||||
background-color: var(--g-sub-sidebar-bg);
|
||||
transition:
|
||||
background-color 0.3s,
|
||||
left 0.3s,
|
||||
width 0.3s;
|
||||
|
||||
&.is-collapse {
|
||||
width: var(--g-sub-sidebar-collapse-width);
|
||||
|
||||
.sidebar-logo {
|
||||
&:not(.sidebar-logo-bg) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(span) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
background-color: var(--g-sub-sidebar-bg);
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&.sidebar-logo-bg {
|
||||
background-color: var(--g-sub-sidebar-logo-bg);
|
||||
|
||||
:deep(span) {
|
||||
color: var(--g-sub-sidebar-logo-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sub-sidebar {
|
||||
overflow: hidden auto;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
// firefox隐藏滚动条
|
||||
scrollbar-width: none;
|
||||
|
||||
// chrome隐藏滚动条
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.shadow-top {
|
||||
box-shadow:
|
||||
inset 0 10px 10px -10px var(--g-box-shadow-color),
|
||||
inset 0 0 0 transparent;
|
||||
}
|
||||
|
||||
&.shadow-bottom {
|
||||
box-shadow:
|
||||
inset 0 0 0 transparent,
|
||||
inset 0 -10px 10px -10px var(--g-box-shadow-color);
|
||||
}
|
||||
|
||||
&.shadow-top.shadow-bottom {
|
||||
box-shadow:
|
||||
inset 0 10px 10px -10px var(--g-box-shadow-color),
|
||||
inset 0 -10px 10px -10px var(--g-box-shadow-color);
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// 次侧边栏动画
|
||||
.sub-sidebar-enter-active {
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.sub-sidebar-enter-from,
|
||||
.sub-sidebar-leave-active {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) skewY(10deg);
|
||||
}
|
||||
|
||||
.sub-sidebar-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
496
admin/src/layouts/components/Topbar/Tabbar/index.vue
Normal file
496
admin/src/layouts/components/Topbar/Tabbar/index.vue
Normal file
@@ -0,0 +1,496 @@
|
||||
<script setup lang="ts">
|
||||
import ContextMenu from '@imengyu/vue3-context-menu';
|
||||
import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css';
|
||||
import hotkeys from 'hotkeys-js';
|
||||
import Message from 'vue-m-message';
|
||||
import { useMagicKeys } from '@vueuse/core';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
import useTabbarStore from '@/store/modules/tabbar';
|
||||
import type { Tabbar } from '#/global';
|
||||
|
||||
defineOptions({
|
||||
name: 'Tabbar',
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const tabbarStore = useTabbarStore();
|
||||
|
||||
const tabbar = useTabbar();
|
||||
const mainPage = useMainPage();
|
||||
|
||||
const keys = useMagicKeys({ reactive: true });
|
||||
|
||||
const activedTabId = computed(() => tabbar.getId());
|
||||
|
||||
const tabsRef = ref();
|
||||
const tabContainerRef = ref();
|
||||
const tabRef = shallowRef<HTMLElement[]>([]);
|
||||
onBeforeUpdate(() => {
|
||||
tabRef.value = [];
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route,
|
||||
(val) => {
|
||||
if (settingsStore.settings.tabbar.enable) {
|
||||
tabbarStore.add(val).then(() => {
|
||||
const index = tabbarStore.list.findIndex((item) => item.tabId === activedTabId.value);
|
||||
if (index !== -1) {
|
||||
scrollTo(tabRef.value[index].offsetLeft);
|
||||
tabbarScrollTip();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
function tabbarScrollTip() {
|
||||
if (
|
||||
tabContainerRef.value.$el.clientWidth > tabsRef.value.clientWidth &&
|
||||
localStorage.getItem('tabbarScrollTip') === undefined
|
||||
) {
|
||||
localStorage.setItem('tabbarScrollTip', '');
|
||||
Message.info('标签栏数量超过展示区域范围,可以将鼠标移到标签栏上,通过鼠标滚轮滑动浏览', {
|
||||
title: '温馨提示',
|
||||
duration: 5000,
|
||||
closable: true,
|
||||
zIndex: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
function handlerMouserScroll(event: WheelEvent) {
|
||||
tabsRef.value.scrollBy({
|
||||
left: event.deltaY || event.detail,
|
||||
});
|
||||
}
|
||||
function scrollTo(offsetLeft: number) {
|
||||
tabsRef.value.scrollTo({
|
||||
left: offsetLeft - 50,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
function onTabbarContextmenu(event: MouseEvent, routeItem: Tabbar.recordRaw) {
|
||||
event.preventDefault();
|
||||
ContextMenu.showContextMenu({
|
||||
x: event.x,
|
||||
y: event.y,
|
||||
zIndex: 1050,
|
||||
iconFontClass: '',
|
||||
customClass: 'tabbar-contextmenu',
|
||||
items: [
|
||||
{
|
||||
label: '重新加载',
|
||||
icon: 'i-ri:refresh-line',
|
||||
disabled: routeItem.tabId !== activedTabId.value,
|
||||
onClick: () => mainPage.reload(),
|
||||
},
|
||||
{
|
||||
label: '关闭标签页',
|
||||
icon: 'i-ri:close-line',
|
||||
disabled: tabbarStore.list.length <= 1,
|
||||
divided: true,
|
||||
onClick: () => {
|
||||
tabbar.closeById(routeItem.tabId);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '关闭其他标签页',
|
||||
disabled: !tabbar.checkCloseOtherSide(routeItem.tabId),
|
||||
onClick: () => {
|
||||
tabbar.closeOtherSide(routeItem.tabId);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '关闭左侧标签页',
|
||||
disabled: !tabbar.checkCloseLeftSide(routeItem.tabId),
|
||||
onClick: () => {
|
||||
tabbar.closeLeftSide(routeItem.tabId);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '关闭右侧标签页',
|
||||
disabled: !tabbar.checkCloseRightSide(routeItem.tabId),
|
||||
onClick: () => {
|
||||
tabbar.closeRightSide(routeItem.tabId);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
hotkeys(
|
||||
'alt+left,alt+right,alt+w,alt+1,alt+2,alt+3,alt+4,alt+5,alt+6,alt+7,alt+8,alt+9,alt+0',
|
||||
(e, handle) => {
|
||||
if (settingsStore.settings.tabbar.enable && settingsStore.settings.tabbar.enableHotkeys) {
|
||||
e.preventDefault();
|
||||
switch (handle.key) {
|
||||
// 切换到当前标签页紧邻的上一个标签页
|
||||
case 'alt+left':
|
||||
if (tabbarStore.list[0].tabId !== activedTabId.value) {
|
||||
const index = tabbarStore.list.findIndex(
|
||||
(item) => item.tabId === activedTabId.value,
|
||||
);
|
||||
router.push(tabbarStore.list[index - 1].fullPath);
|
||||
}
|
||||
break;
|
||||
// 切换到当前标签页紧邻的下一个标签页
|
||||
case 'alt+right':
|
||||
if (tabbarStore.list.at(-1)?.tabId !== activedTabId.value) {
|
||||
const index = tabbarStore.list.findIndex(
|
||||
(item) => item.tabId === activedTabId.value,
|
||||
);
|
||||
router.push(tabbarStore.list[index + 1].fullPath);
|
||||
}
|
||||
break;
|
||||
// 关闭当前标签页
|
||||
case 'alt+w':
|
||||
tabbar.closeById(activedTabId.value);
|
||||
break;
|
||||
// 切换到第 n 个标签页
|
||||
case 'alt+1':
|
||||
case 'alt+2':
|
||||
case 'alt+3':
|
||||
case 'alt+4':
|
||||
case 'alt+5':
|
||||
case 'alt+6':
|
||||
case 'alt+7':
|
||||
case 'alt+8':
|
||||
case 'alt+9': {
|
||||
const number = Number(handle.key.split('+')[1]);
|
||||
tabbarStore.list[number - 1]?.fullPath &&
|
||||
router.push(tabbarStore.list[number - 1].fullPath);
|
||||
break;
|
||||
}
|
||||
// 切换到最后一个标签页
|
||||
case 'alt+0':
|
||||
router.push(tabbarStore.list[tabbarStore.list.length - 1].fullPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
hotkeys.unbind(
|
||||
'alt+left,alt+right,alt+w,alt+1,alt+2,alt+3,alt+4,alt+5,alt+6,alt+7,alt+8,alt+9,alt+0',
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tabbar-container">
|
||||
<div ref="tabsRef" class="tabs" @wheel.prevent="handlerMouserScroll">
|
||||
<TransitionGroup ref="tabContainerRef" name="tabbar" tag="div" class="tab-container">
|
||||
<div
|
||||
v-for="(element, index) in tabbarStore.list"
|
||||
:key="element.tabId"
|
||||
ref="tabRef"
|
||||
:data-index="index"
|
||||
class="tab"
|
||||
:class="{
|
||||
actived: element.tabId === activedTabId,
|
||||
}"
|
||||
:title="typeof element?.title === 'function' ? element.title() : element.title"
|
||||
@click="router.push(element.fullPath)"
|
||||
@contextmenu="onTabbarContextmenu($event, element)"
|
||||
>
|
||||
<div class="tab-dividers" />
|
||||
<div class="tab-background" />
|
||||
<div class="tab-content">
|
||||
<div :key="element.tabId" class="title">
|
||||
<SvgIcon
|
||||
v-if="settingsStore.settings.tabbar.enableIcon && element.icon"
|
||||
:name="element.icon"
|
||||
class="icon"
|
||||
/>
|
||||
{{ typeof element?.title === 'function' ? element.title() : element.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="tabbarStore.list.length > 1"
|
||||
class="action-icon"
|
||||
@click.stop="tabbar.closeById(element.tabId)"
|
||||
>
|
||||
<SvgIcon name="i-ri:close-fill" />
|
||||
</div>
|
||||
<div v-show="keys.alt && index < 9" class="hotkey-number">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.tabbar-contextmenu {
|
||||
z-index: 1000;
|
||||
|
||||
.mx-context-menu {
|
||||
--at-apply: fixed ring-1 ring-stone-2 dark-ring-stone-7 shadow-2xl;
|
||||
|
||||
background-color: var(--g-container-bg);
|
||||
|
||||
.mx-context-menu-items .mx-context-menu-item {
|
||||
--at-apply: transition-background-color;
|
||||
|
||||
&:not(.disabled):hover {
|
||||
--at-apply: cursor-pointer bg-stone-1 dark-bg-stone-9;
|
||||
}
|
||||
|
||||
span {
|
||||
color: initial;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: initial;
|
||||
}
|
||||
|
||||
&.disabled span,
|
||||
&.disabled .icon {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
.mx-context-menu-item-sperator {
|
||||
background-color: var(--g-container-bg);
|
||||
|
||||
&::after {
|
||||
--at-apply: bg-stone-2 dark-bg-stone-7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tabbar-container {
|
||||
position: relative;
|
||||
height: var(--g-tabbar-height);
|
||||
background-color: var(--g-bg);
|
||||
transition: background-color 0.3s;
|
||||
|
||||
.tabs {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
// firefox隐藏滚动条
|
||||
scrollbar-width: none;
|
||||
|
||||
// chrome隐藏滚动条
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
display: inline-block;
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
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;
|
||||
|
||||
&: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);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&.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-container-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-dividers {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -1px;
|
||||
left: -1px;
|
||||
z-index: 0;
|
||||
height: 14px;
|
||||
transform: translateY(-50%);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 1px;
|
||||
display: block;
|
||||
width: 1px;
|
||||
content: '';
|
||||
background-color: var(--g-tabbar-dividers-bg);
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
background-color 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
&: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:
|
||||
opacity 0.3s,
|
||||
background-color 0.3s;
|
||||
}
|
||||
|
||||
.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-right: 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-right 0.3s;
|
||||
|
||||
&:has(+ .action-icon) {
|
||||
margin-right: 28px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.5em;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
font-size: 12px;
|
||||
color: var(--g-tabbar-tab-color);
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
&:hover {
|
||||
--at-apply: ring-1 ring-stone-3 dark-ring-stone-7;
|
||||
|
||||
background-color: var(--g-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.hotkey-number {
|
||||
--at-apply: ring-1 ring-stone-3 dark-ring-stone-7;
|
||||
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.5em;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
font-size: 12px;
|
||||
color: var(--g-tabbar-tab-color);
|
||||
background-color: var(--g-bg);
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标签栏动画
|
||||
.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>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { compile } from 'path-to-regexp';
|
||||
import Breadcrumb from '../../../Breadcrumb/index.vue';
|
||||
import BreadcrumbItem from '../../../Breadcrumb/item.vue';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const breadcrumbList = computed(() => {
|
||||
const breadcrumbList = [];
|
||||
if (settingsStore.settings.home.enable) {
|
||||
breadcrumbList.push({
|
||||
path: settingsStore.settings.home.fullPath,
|
||||
title: settingsStore.settings.home.title,
|
||||
});
|
||||
}
|
||||
if (route.meta.breadcrumbNeste) {
|
||||
route.meta.breadcrumbNeste.forEach((item) => {
|
||||
if (item.hide === false) {
|
||||
breadcrumbList.push({
|
||||
path: item.path,
|
||||
title: item.title,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return breadcrumbList;
|
||||
});
|
||||
|
||||
function pathCompile(path: string) {
|
||||
const toPath = compile(path);
|
||||
return toPath(route.params);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Breadcrumb
|
||||
v-if="settingsStore.mode === 'pc' && settingsStore.settings.app.routeBaseOn !== 'filesystem'"
|
||||
class="breadcrumb whitespace-nowrap px-2"
|
||||
>
|
||||
<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) : ''"
|
||||
>
|
||||
{{ item.title }}
|
||||
</BreadcrumbItem>
|
||||
</TransitionGroup>
|
||||
</Breadcrumb>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 面包屑动画
|
||||
.breadcrumb-enter-active {
|
||||
transition:
|
||||
transform 0.3s,
|
||||
opacity 0.3s;
|
||||
}
|
||||
|
||||
.breadcrumb-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px) skewX(-50deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
|
||||
defineOptions({
|
||||
name: 'ColorScheme',
|
||||
});
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
function toggleColorScheme(event: MouseEvent) {
|
||||
const { startViewTransition } = useViewTransition(() => {
|
||||
settingsStore.currentColorScheme &&
|
||||
settingsStore.setColorScheme(
|
||||
settingsStore.currentColorScheme === 'dark' ? 'light' : 'dark',
|
||||
);
|
||||
});
|
||||
startViewTransition()?.ready.then(() => {
|
||||
const x = event.clientX;
|
||||
const y = event.clientY;
|
||||
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
|
||||
const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`];
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath:
|
||||
settingsStore.settings.app.colorScheme !== 'dark' ? clipPath : clipPath.reverse(),
|
||||
},
|
||||
{
|
||||
duration: 300,
|
||||
easing: 'ease-out',
|
||||
pseudoElement:
|
||||
settingsStore.settings.app.colorScheme !== 'dark'
|
||||
? '::view-transition-new(root)'
|
||||
: '::view-transition-old(root)',
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HDropdown class="flex-center cursor-pointer px-2 py-1">
|
||||
<SvgIcon
|
||||
:name="
|
||||
{
|
||||
'': 'i-codicon:color-mode',
|
||||
light: 'i-ri:sun-line',
|
||||
dark: 'i-ri:moon-line',
|
||||
}[settingsStore.settings.app.colorScheme]
|
||||
"
|
||||
@click="toggleColorScheme"
|
||||
/>
|
||||
<template #dropdown>
|
||||
<HTabList
|
||||
v-model="settingsStore.settings.app.colorScheme"
|
||||
:options="[
|
||||
{ icon: 'i-ri:sun-line', label: '', value: 'light' },
|
||||
{ icon: 'i-ri:moon-line', label: '', value: 'dark' },
|
||||
{ icon: 'i-codicon:color-mode', label: '', value: '' },
|
||||
]"
|
||||
class="m-3"
|
||||
/>
|
||||
</template>
|
||||
</HDropdown>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { useFullscreen } from '@vueuse/core';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
|
||||
defineOptions({
|
||||
name: 'Fullscreen',
|
||||
});
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const { isFullscreen, toggle } = useFullscreen();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="settingsStore.mode === 'pc'"
|
||||
class="flex-center cursor-pointer px-2 py-1"
|
||||
@click="toggle"
|
||||
>
|
||||
<SvgIcon :name="isFullscreen ? 'i-ri:fullscreen-exit-line' : 'i-ri:fullscreen-line'" />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import eventBus from '@/utils/eventBus';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
|
||||
defineOptions({
|
||||
name: 'ToolbarRightSide',
|
||||
});
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex-center cursor-pointer px-2 py-1" @click="eventBus.emit('global-search-toggle')">
|
||||
<SvgIcon v-if="settingsStore.mode === 'mobile'" name="i-ri:search-line" />
|
||||
<span
|
||||
v-else
|
||||
class="group inline-flex cursor-pointer items-center gap-1 whitespace-nowrap rounded-2 bg-stone-1 px-2 py-1.5 text-dark ring-stone-3 ring-inset transition dark-bg-stone-9 dark-text-white hover-ring-1 dark-ring-stone-7"
|
||||
>
|
||||
<SvgIcon name="i-ri:search-line" />
|
||||
<span
|
||||
class="text-sm text-stone-5 transition group-hover-text-dark dark-group-hover-text-white"
|
||||
>搜索</span
|
||||
>
|
||||
<HKbd v-if="settingsStore.settings.navSearch.enableHotkeys" class="ml-2"
|
||||
>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }} S</HKbd
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'PageReload',
|
||||
});
|
||||
|
||||
const mainPage = useMainPage();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex-center cursor-pointer px-2 py-1" @click="mainPage.reload()">
|
||||
<SvgIcon name="i-iconoir:refresh-double" />
|
||||
</span>
|
||||
</template>
|
||||
36
admin/src/layouts/components/Topbar/Toolbar/index.vue
Executable file
36
admin/src/layouts/components/Topbar/Toolbar/index.vue
Executable file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import LeftSide from './leftSide.vue';
|
||||
import RightSide from './rightSide.vue';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
|
||||
defineOptions({
|
||||
name: 'Toolbar',
|
||||
});
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbar-container flex items-center justify-between">
|
||||
<div
|
||||
class="h-full flex items-center of-hidden pl-2 pr-16"
|
||||
style="mask-image: linear-gradient(90deg, #000 0%, #000 calc(100% - 50px), transparent)"
|
||||
>
|
||||
<LeftSide />
|
||||
</div>
|
||||
<div
|
||||
v-show="['side', 'single'].includes(settingsStore.settings.menu.menuMode)"
|
||||
class="h-full flex items-center px-2"
|
||||
>
|
||||
<RightSide />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.toolbar-container {
|
||||
height: var(--g-toolbar-height);
|
||||
background-color: var(--g-container-bg);
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
</style>
|
||||
23
admin/src/layouts/components/Topbar/Toolbar/leftSide.vue
Normal file
23
admin/src/layouts/components/Topbar/Toolbar/leftSide.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import Breadcrumb from './Breadcrumb/index.vue';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
|
||||
defineOptions({
|
||||
name: 'ToolbarLeftSide',
|
||||
});
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
v-if="settingsStore.mode === 'mobile'"
|
||||
class="flex-center cursor-pointer px-2 py-1 -rotate-z-180"
|
||||
@click="settingsStore.toggleSidebarCollapse()"
|
||||
>
|
||||
<SvgIcon name="toolbar-collapse" />
|
||||
</div>
|
||||
<Breadcrumb v-if="settingsStore.settings.toolbar.breadcrumb" />
|
||||
</div>
|
||||
</template>
|
||||
73
admin/src/layouts/components/Topbar/Toolbar/rightSide.vue
Normal file
73
admin/src/layouts/components/Topbar/Toolbar/rightSide.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
import useUserStore from '@/store/modules/user';
|
||||
import ColorScheme from './ColorScheme/index.vue';
|
||||
import Fullscreen from './Fullscreen/index.vue';
|
||||
import NavSearch from './NavSearch/index.vue';
|
||||
import PageReload from './PageReload/index.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'Tools',
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const avatarError = ref(false);
|
||||
watch(
|
||||
() => userStore.avatar,
|
||||
() => {
|
||||
if (avatarError.value) {
|
||||
avatarError.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<NavSearch v-if="settingsStore.settings.toolbar.navSearch" />
|
||||
<Fullscreen v-if="settingsStore.settings.toolbar.fullscreen" />
|
||||
<PageReload v-if="settingsStore.settings.toolbar.pageReload" />
|
||||
<ColorScheme v-if="settingsStore.settings.toolbar.colorScheme" />
|
||||
<HDropdownMenu
|
||||
:items="[
|
||||
[
|
||||
{
|
||||
label: settingsStore.settings.home.title,
|
||||
handle: () => router.push({ path: settingsStore.settings.home.fullPath }),
|
||||
hide: !settingsStore.settings.home.enable,
|
||||
},
|
||||
{
|
||||
label: '个人设置',
|
||||
handle: () => router.push({ name: 'personalSetting' }),
|
||||
},
|
||||
],
|
||||
// [
|
||||
// { label: '快捷键介绍', handle: () => eventBus.emit('global-hotkeys-intro-toggle'), hide: settingsStore.mode !== 'pc' },
|
||||
// ],
|
||||
[{ label: '退出登录', handle: () => userStore.logout() }],
|
||||
]"
|
||||
class="flex-center cursor-pointer px-2"
|
||||
>
|
||||
<div class="flex-center gap-1">
|
||||
<!-- <img
|
||||
v-if="userStore.avatar && !avatarError"
|
||||
:src="userStore.avatar"
|
||||
:onerror="() => (avatarError = true)"
|
||||
class="h-[24px] w-[24px] rounded-full"
|
||||
/>
|
||||
<SvgIcon
|
||||
v-else
|
||||
name="i-carbon:user-avatar-filled-alt"
|
||||
:size="24"
|
||||
class="text-gray-400"
|
||||
/> -->
|
||||
{{ userStore.username }}
|
||||
<SvgIcon name="i-ep:caret-bottom" />
|
||||
</div>
|
||||
</HDropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
98
admin/src/layouts/components/Topbar/index.vue
Executable file
98
admin/src/layouts/components/Topbar/index.vue
Executable file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import Tabbar from './Tabbar/index.vue';
|
||||
import Toolbar from './Toolbar/index.vue';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
|
||||
defineOptions({
|
||||
name: 'Topbar',
|
||||
});
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const enableToolbar = computed(() => {
|
||||
return !(
|
||||
settingsStore.settings.menu.menuMode === 'head' &&
|
||||
(!settingsStore.settings.toolbar.breadcrumb ||
|
||||
settingsStore.settings.app.routeBaseOn === 'filesystem')
|
||||
);
|
||||
});
|
||||
|
||||
const scrollTop = ref(0);
|
||||
const scrollOnHide = ref(false);
|
||||
const topbarHeight = computed(() => {
|
||||
const tabbarHeight = settingsStore.settings.tabbar.enable
|
||||
? Number.parseInt(
|
||||
getComputedStyle(document.documentElement || document.body).getPropertyValue(
|
||||
'--g-tabbar-height',
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
const toolbarHeight = enableToolbar.value
|
||||
? Number.parseInt(
|
||||
getComputedStyle(document.documentElement || document.body).getPropertyValue(
|
||||
'--g-toolbar-height',
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
return tabbarHeight + toolbarHeight;
|
||||
});
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', onScroll);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
});
|
||||
function onScroll() {
|
||||
scrollTop.value = (document.documentElement || document.body).scrollTop;
|
||||
}
|
||||
watch(scrollTop, (val, oldVal) => {
|
||||
scrollOnHide.value =
|
||||
settingsStore.settings.topbar.mode === 'sticky' && val > oldVal && val > topbarHeight.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="topbar-container"
|
||||
:class="{
|
||||
'has-tabbar': settingsStore.settings.tabbar.enable,
|
||||
'has-toolbar': enableToolbar,
|
||||
[`topbar-${settingsStore.settings.topbar.mode}`]: true,
|
||||
shadow: scrollTop,
|
||||
hide: scrollOnHide,
|
||||
}"
|
||||
data-fixed-calc-width
|
||||
>
|
||||
<Tabbar v-if="settingsStore.settings.tabbar.enable" />
|
||||
<Toolbar v-if="enableToolbar" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.topbar-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 1px 0 0 var(--g-border-color);
|
||||
transition:
|
||||
width 0.3s,
|
||||
top 0.3s,
|
||||
transform 0.3s,
|
||||
box-shadow 0.3s;
|
||||
|
||||
&.topbar-fixed,
|
||||
&.topbar-sticky {
|
||||
position: fixed;
|
||||
|
||||
&.shadow {
|
||||
box-shadow: 0 10px 10px -10px var(--g-box-shadow-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.topbar-sticky.hide {
|
||||
top: calc((var(--g-tabbar-height) + var(--g-toolbar-height)) * -1) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
67
admin/src/layouts/components/views/link.vue
Executable file
67
admin/src/layouts/components/views/link.vue
Executable file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
|
||||
defineOptions({
|
||||
name: 'LinkView',
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const { copy, copied } = useClipboard();
|
||||
// watch(copied, (val) => {
|
||||
// val && Message.success('复制成功', {
|
||||
// zIndex: 2000,
|
||||
// })
|
||||
// })
|
||||
|
||||
function open() {
|
||||
window.open(route.meta.link, '_blank');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute h-full w-full flex flex-col">
|
||||
<Transition name="slide-right" mode="out-in" appear>
|
||||
<PageMain :key="route.meta.link" class="flex flex-1 flex-col justify-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<SvgIcon name="i-icon-park-twotone:planet" :size="120" class="text-ui-primary/80" />
|
||||
<div class="my-2 text-xl text-dark dark-text-white">是否访问此链接</div>
|
||||
<div
|
||||
class="my-2 max-w-[300px] cursor-pointer text-center text-[14px] text-stone-5"
|
||||
@click="route.meta.link && copy(route.meta.link)"
|
||||
>
|
||||
<HTooltip text="复制链接">
|
||||
<div class="line-clamp-3">
|
||||
{{ route.meta.link }}
|
||||
</div>
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HButton class="my-4" @click="open">
|
||||
<SvgIcon name="i-ri:external-link-fill" />
|
||||
立即访问
|
||||
</HButton>
|
||||
</div>
|
||||
</PageMain>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slide-right-enter-active {
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.slide-right-leave-active {
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.slide-right-enter-from {
|
||||
margin-left: -20px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-right-leave-to {
|
||||
margin-left: 20px;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
321
admin/src/layouts/index.vue
Executable file
321
admin/src/layouts/index.vue
Executable file
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
import hotkeys from 'hotkeys-js';
|
||||
import Header from './components/Header/index.vue';
|
||||
import MainSidebar from './components/MainSidebar/index.vue';
|
||||
import SubSidebar from './components/SubSidebar/index.vue';
|
||||
import Topbar from './components/Topbar/index.vue';
|
||||
import Search from './components/Search/index.vue';
|
||||
import HotkeysIntro from './components/HotkeysIntro/index.vue';
|
||||
import AppSetting from './components/AppSetting/index.vue';
|
||||
import LinkView from './components/views/link.vue';
|
||||
import Copyright from './components/Copyright/index.vue';
|
||||
import BackTop from './components/BackTop/index.vue';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
import useKeepAliveStore from '@/store/modules/keepAlive';
|
||||
import useMenuStore from '@/store/modules/menu';
|
||||
import eventBus from '@/utils/eventBus';
|
||||
|
||||
defineOptions({
|
||||
name: 'Layout',
|
||||
});
|
||||
|
||||
const routeInfo = useRoute();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const keepAliveStore = useKeepAliveStore();
|
||||
const menuStore = useMenuStore();
|
||||
|
||||
const mainPage = useMainPage();
|
||||
const menu = useMenu();
|
||||
|
||||
const isLink = computed(() => !!routeInfo.meta.link);
|
||||
|
||||
watch(
|
||||
() => settingsStore.settings.menu.subMenuCollapse,
|
||||
(val) => {
|
||||
if (settingsStore.mode === 'mobile') {
|
||||
if (!val) {
|
||||
document.body.classList.add('overflow-hidden');
|
||||
} else {
|
||||
document.body.classList.remove('overflow-hidden');
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => routeInfo.path,
|
||||
() => {
|
||||
if (settingsStore.mode === 'mobile') {
|
||||
settingsStore.$patch((state) => {
|
||||
state.settings.menu.subMenuCollapse = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
hotkeys('f5', (e) => {
|
||||
if (settingsStore.settings.toolbar.pageReload) {
|
||||
e.preventDefault();
|
||||
mainPage.reload();
|
||||
}
|
||||
});
|
||||
hotkeys('alt+`', (e) => {
|
||||
if (settingsStore.settings.menu.enableHotkeys) {
|
||||
e.preventDefault();
|
||||
menu.switchTo(
|
||||
menuStore.actived + 1 < menuStore.allMenus.length ? menuStore.actived + 1 : 0,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
onUnmounted(() => {
|
||||
hotkeys.unbind('f5');
|
||||
hotkeys.unbind('alt+`');
|
||||
});
|
||||
|
||||
const enableAppSetting = import.meta.env.VITE_APP_SETTING === 'true';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<div id="app-main">
|
||||
<Header />
|
||||
<div class="wrapper">
|
||||
<div
|
||||
class="sidebar-container"
|
||||
:class="{
|
||||
show: settingsStore.mode === 'mobile' && !settingsStore.settings.menu.subMenuCollapse,
|
||||
}"
|
||||
>
|
||||
<MainSidebar />
|
||||
<SubSidebar />
|
||||
</div>
|
||||
<div
|
||||
class="sidebar-mask"
|
||||
:class="{
|
||||
show: settingsStore.mode === 'mobile' && !settingsStore.settings.menu.subMenuCollapse,
|
||||
}"
|
||||
@click="settingsStore.toggleSidebarCollapse()"
|
||||
/>
|
||||
<div class="main-container">
|
||||
<Topbar />
|
||||
<div class="main">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition name="slide-right" mode="out-in" appear>
|
||||
<KeepAlive :include="keepAliveStore.list">
|
||||
<component :is="Component" v-show="!isLink" :key="route.fullPath" />
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
<LinkView v-if="isLink" />
|
||||
</div>
|
||||
<Copyright />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Search />
|
||||
<HotkeysIntro />
|
||||
<template v-if="enableAppSetting">
|
||||
<div class="app-setting" @click="eventBus.emit('global-app-setting-toggle')">
|
||||
<SvgIcon name="i-uiw:setting-o" class="icon" />
|
||||
</div>
|
||||
<AppSetting />
|
||||
</template>
|
||||
<BackTop />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
[data-mode='mobile'] {
|
||||
.sidebar-container {
|
||||
transform: translateX(calc((var(--g-main-sidebar-width) + var(--g-sub-sidebar-width)) * -1));
|
||||
|
||||
&.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.main-container {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
&[data-menu-mode='single'] {
|
||||
.sidebar-container {
|
||||
transform: translateX(calc(var(--g-sub-sidebar-width) * -1));
|
||||
|
||||
&.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#app-main {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: padding-top 0.3s;
|
||||
|
||||
.sidebar-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 1010;
|
||||
display: flex;
|
||||
width: calc(var(--g-main-sidebar-actual-width) + var(--g-sub-sidebar-actual-width));
|
||||
box-shadow:
|
||||
-1px 0 0 0 var(--g-border-color),
|
||||
1px 0 0 0 var(--g-border-color);
|
||||
transition:
|
||||
width 0.3s,
|
||||
transform 0.3s,
|
||||
box-shadow 0.3s,
|
||||
top 0.3s;
|
||||
|
||||
&:has(> .main-sidebar-container.main-sidebar-enter-active),
|
||||
&:has(> .main-sidebar-container.main-sidebar-leave-active) {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
visibility: hidden;
|
||||
background-image: radial-gradient(transparent 1px, rgb(0 0 0 / 30%) 1px);
|
||||
background-size: 4px 4px;
|
||||
backdrop-filter: saturate(50%) blur(4px);
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.show {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.main-sidebar-container:not(.main-sidebar-leave-active) + .sub-sidebar-container {
|
||||
left: var(--g-main-sidebar-width);
|
||||
}
|
||||
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
margin-left: calc(var(--g-main-sidebar-actual-width) + var(--g-sub-sidebar-actual-width));
|
||||
background-color: var(--g-bg);
|
||||
box-shadow:
|
||||
-1px 0 0 0 var(--g-border-color),
|
||||
1px 0 0 0 var(--g-border-color);
|
||||
transition:
|
||||
margin-left 0.3s,
|
||||
background-color 0.3s,
|
||||
box-shadow 0.3s;
|
||||
|
||||
.main {
|
||||
position: relative;
|
||||
flex: auto;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.topbar-container.has-tabbar + .main {
|
||||
margin: var(--g-tabbar-height) 0 0;
|
||||
}
|
||||
|
||||
.topbar-container.has-toolbar + .main {
|
||||
margin: var(--g-toolbar-height) 0 0;
|
||||
}
|
||||
|
||||
.topbar-container.has-tabbar.has-toolbar + .main {
|
||||
margin: calc(var(--g-tabbar-height) + var(--g-toolbar-height)) 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header:not(.header-leave-active) + .wrapper {
|
||||
padding-top: var(--g-header-height);
|
||||
|
||||
.sidebar-container {
|
||||
top: var(--g-header-height);
|
||||
|
||||
:deep(.sidebar-logo) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.main-container {
|
||||
.topbar-container {
|
||||
top: var(--g-header-height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-setting {
|
||||
--at-apply: text-white dark-text-dark bg-ui-primary;
|
||||
|
||||
position: fixed;
|
||||
top: calc(50% + 250px);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
border-top-left-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
|
||||
.icon {
|
||||
animation: rotate 5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主内容区动画
|
||||
.slide-right-enter-active {
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.slide-right-leave-active {
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.slide-right-enter-from {
|
||||
margin-left: -20px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-right-leave-to {
|
||||
margin-left: 20px;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
28
admin/src/layouts/ui-kit/HButton.vue
Normal file
28
admin/src/layouts/ui-kit/HButton.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
block?: boolean;
|
||||
outline?: boolean;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
block: false,
|
||||
outline: false,
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const buttonClass = computed(() => [
|
||||
'focus-outline-none focus-visible-outline-0 cursor-pointer disabled-cursor-not-allowed disabled-opacity-75 flex-shrink-0 gap-x-1.5 px-2.5 py-1.5 border-size-0 font-medium text-sm rounded-md select-none',
|
||||
props.block ? 'w-full flex justify-center items-center' : 'inline-flex items-center',
|
||||
props.outline
|
||||
? 'shadow-sm ring-1 ring-inset ring-ui-primary text-ui-primary bg-white dark-bg-dark hover-not-disabled-bg-ui-primary/10 dark-hover-not-disabled-bg-ui-primary/10 focus-visible-ring-2'
|
||||
: 'shadow-sm text-ui-text bg-ui-primary hover-bg-ui-primary/75 disabled-bg-ui-primary/90 focus-visible-ring-inset focus-visible-ring-2',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :disabled="disabled" :class="buttonClass">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
46
admin/src/layouts/ui-kit/HCheckList.vue
Normal file
46
admin/src/layouts/ui-kit/HCheckList.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
options: {
|
||||
label?: string | number;
|
||||
icon?: string;
|
||||
value: string | number;
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
change: [string | number | undefined];
|
||||
}>();
|
||||
|
||||
const value = defineModel<string | number>();
|
||||
|
||||
watch(value, (val) => {
|
||||
emits('change', val);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex select-none items-center justify-center of-hidden rounded-md bg-stone-3 dark-bg-stone-7"
|
||||
>
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:disabled="disabled || option.disabled"
|
||||
class="flex cursor-pointer items-center truncate border-size-0 bg-inherit px-2 py-1.5 text-sm disabled-cursor-not-allowed disabled-opacity-50 hover-not-disabled-(bg-ui-primary text-ui-text)"
|
||||
:class="{ 'text-ui-text bg-ui-primary': value === option.value }"
|
||||
@click="value = option.value"
|
||||
>
|
||||
<SvgIcon v-if="option.icon" :name="option.icon" />
|
||||
<template v-else>
|
||||
{{ option.label }}
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
106
admin/src/layouts/ui-kit/HDialog.vue
Normal file
106
admin/src/layouts/ui-kit/HDialog.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
TransitionChild,
|
||||
TransitionRoot,
|
||||
} from '@headlessui/vue';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
appear?: boolean;
|
||||
title?: string;
|
||||
preventClose?: boolean;
|
||||
overlay?: boolean;
|
||||
}>(),
|
||||
{
|
||||
appear: false,
|
||||
preventClose: false,
|
||||
overlay: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const isOpen = defineModel<boolean>({
|
||||
default: false,
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const overlayTransitionClass = ref({
|
||||
enter: 'ease-in-out duration-500',
|
||||
enterFrom: 'opacity-0',
|
||||
enterTo: 'opacity-100',
|
||||
leave: 'ease-in-out duration-500',
|
||||
leaveFrom: 'opacity-100',
|
||||
leaveTo: 'opacity-0',
|
||||
});
|
||||
|
||||
const transitionClass = computed(() => {
|
||||
return {
|
||||
enter: 'ease-out duration-300',
|
||||
enterFrom: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
|
||||
enterTo: 'opacity-100 translate-y-0 lg-scale-100',
|
||||
leave: 'ease-in duration-200',
|
||||
leaveFrom: 'opacity-100 translate-y-0 lg-scale-100',
|
||||
leaveTo: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
|
||||
};
|
||||
});
|
||||
|
||||
function close() {
|
||||
isOpen.value = false;
|
||||
emits('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TransitionRoot as="template" :appear="appear" :show="isOpen">
|
||||
<Dialog class="fixed inset-0 z-2000 flex" @close="!preventClose && close()">
|
||||
<TransitionChild as="template" :appear="appear" v-bind="overlayTransitionClass">
|
||||
<div
|
||||
class="fixed inset-0 bg-stone-2/75 transition-opacity dark-bg-stone-8/75"
|
||||
:class="{ 'backdrop-blur-sm': overlay }"
|
||||
/>
|
||||
</TransitionChild>
|
||||
<div class="fixed inset-0 overflow-y-auto">
|
||||
<div class="min-h-full flex items-end justify-center p-4 text-center lg-items-center">
|
||||
<TransitionChild as="template" :appear="appear" v-bind="transitionClass">
|
||||
<DialogPanel
|
||||
class="relative w-full flex flex-col overflow-hidden rounded-xl bg-white text-left shadow-xl lg-my-8 lg-max-w-lg dark-bg-stone-8"
|
||||
>
|
||||
<div
|
||||
flex="~ items-center justify-between"
|
||||
px-4
|
||||
py-3
|
||||
border-b="~ solid stone/15"
|
||||
text-6
|
||||
>
|
||||
<DialogTitle m-0 text-lg text-dark dark-text-white>
|
||||
{{ title }}
|
||||
</DialogTitle>
|
||||
<SvgIcon name="i-carbon:close" cursor-pointer @click="close" />
|
||||
</div>
|
||||
<DialogDescription m-0 overflow-y-auto p-4>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
<div
|
||||
v-if="!!slots.footer"
|
||||
flex="~ items-center justify-end"
|
||||
px-4
|
||||
py-3
|
||||
border-t="~ solid stone/15"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
15
admin/src/layouts/ui-kit/HDropdown.vue
Normal file
15
admin/src/layouts/ui-kit/HDropdown.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<VDropdown
|
||||
:show-triggers="['hover']"
|
||||
:hide-triggers="['hover']"
|
||||
:auto-hide="false"
|
||||
:popper-triggers="['hover']"
|
||||
:delay="200"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
<template #popper>
|
||||
<slot name="dropdown" />
|
||||
</template>
|
||||
</VDropdown>
|
||||
</template>
|
||||
47
admin/src/layouts/ui-kit/HDropdownMenu.vue
Normal file
47
admin/src/layouts/ui-kit/HDropdownMenu.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
items: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
hide?: boolean;
|
||||
handle?: () => void;
|
||||
}[][];
|
||||
}>();
|
||||
|
||||
const myItems = computed(() => {
|
||||
return props.items
|
||||
.map((item) => {
|
||||
return item.filter((v) => !v.hide);
|
||||
})
|
||||
.filter((v) => v.length);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VMenu
|
||||
:show-triggers="['hover']"
|
||||
:auto-hide="false"
|
||||
:popper-triggers="['hover', 'click']"
|
||||
:delay="200"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
<template #popper>
|
||||
<div
|
||||
v-for="(item, index) in myItems"
|
||||
:key="index"
|
||||
class="b-b-(stone-2 solid) p-1 last-b-b-size-0 dark-b-b-(stone-7)"
|
||||
>
|
||||
<button
|
||||
v-for="(v, i) in item"
|
||||
:key="i"
|
||||
:disabled="v.disabled"
|
||||
class="w-full flex cursor-pointer items-center gap-2 border-size-0 rounded-md bg-inherit px-2 py-1.5 text-sm text-dark disabled-cursor-not-allowed dark-text-white disabled-opacity-50 hover-not-disabled-bg-stone-1 dark-hover-not-disabled-bg-stone-9"
|
||||
@click="v.handle"
|
||||
>
|
||||
{{ v.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</VMenu>
|
||||
</template>
|
||||
31
admin/src/layouts/ui-kit/HInput.vue
Normal file
31
admin/src/layouts/ui-kit/HInput.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts" generic="T extends string | number">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const value = defineModel<T>();
|
||||
|
||||
const inputRef = ref();
|
||||
|
||||
defineExpose({
|
||||
ref: inputRef,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full lg-w-48">
|
||||
<input
|
||||
v-model="value"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
class="relative block w-full border-0 rounded-md bg-white px-2.5 py-1.5 text-sm shadow-sm ring-1 ring-stone-2 ring-inset disabled-cursor-not-allowed dark-bg-dark disabled-opacity-50 focus-outline-none focus-ring-2 dark-ring-stone-8 focus-ring-ui-primary placeholder-stone-4 dark-placeholder-stone-5"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
7
admin/src/layouts/ui-kit/HKbd.vue
Normal file
7
admin/src/layouts/ui-kit/HKbd.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<kbd
|
||||
class="mr-[4px] h-6 min-w-[24px] inline-flex items-center justify-center rounded bg-stone-1 px-1 text-[12px] text-dark font-medium font-sans ring-1 ring-stone-3 ring-inset last:mr-0 dark-bg-dark-9 dark-text-white dark-ring-stone-7"
|
||||
>
|
||||
<slot />
|
||||
</kbd>
|
||||
</template>
|
||||
64
admin/src/layouts/ui-kit/HSelect.vue
Normal file
64
admin/src/layouts/ui-kit/HSelect.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
options: {
|
||||
label: string | number;
|
||||
value: string | number;
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const value = defineModel<string | number>();
|
||||
|
||||
const selected = computed({
|
||||
get() {
|
||||
return props.options.find((option) => option.value === value.value) ?? props.options[0];
|
||||
},
|
||||
set(val) {
|
||||
value.value = val.value;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VMenu
|
||||
:triggers="['click']"
|
||||
:popper-triggers="['click']"
|
||||
:delay="0"
|
||||
:disabled="disabled"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div class="w-full inline-flex">
|
||||
<button
|
||||
class="relative block w-full flex cursor-default items-center gap-x-2 border-0 rounded-md bg-white px-2.5 py-1.5 pe-9 text-left text-sm shadow-sm ring-1 ring-stone-2 ring-inset lg-w-48 disabled-cursor-not-allowed dark-bg-dark focus-outline-none focus-ring-2 dark-ring-stone-8 focus-ring-ui-primary"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span class="block truncate">
|
||||
{{ selected.label }}
|
||||
</span>
|
||||
<span class="pointer-events-none absolute end-0 inset-y-0 flex items-center pe-2.5">
|
||||
<SvgIcon name="i-carbon:chevron-down" class="h-5 w-5 flex-shrink-0 text-stone-5" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<template #popper>
|
||||
<div class="max-h-60 w-full scroll-py-1 overflow-y-auto p-1 lg-w-48 focus-outline-none">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:disabled="option.disabled"
|
||||
class="w-full cursor-pointer truncate border-size-0 rounded-md bg-inherit px-2 py-1.5 text-left text-sm disabled-cursor-not-allowed hover-not-disabled-bg-stone-1 dark-hover-not-disabled-bg-stone-9"
|
||||
:class="{ 'font-bold': modelValue === option.value }"
|
||||
@click="selected = option"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</VMenu>
|
||||
</template>
|
||||
118
admin/src/layouts/ui-kit/HSlideover.vue
Normal file
118
admin/src/layouts/ui-kit/HSlideover.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
TransitionChild,
|
||||
TransitionRoot,
|
||||
} from '@headlessui/vue';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
appear?: boolean;
|
||||
side?: 'left' | 'right';
|
||||
title?: string;
|
||||
preventClose?: boolean;
|
||||
overlay?: boolean;
|
||||
}>(),
|
||||
{
|
||||
appear: false,
|
||||
side: 'right',
|
||||
preventClose: false,
|
||||
overlay: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const isOpen = defineModel<boolean>({
|
||||
default: false,
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const overlayTransitionClass = ref({
|
||||
enter: 'ease-in-out duration-500',
|
||||
enterFrom: 'opacity-0',
|
||||
enterTo: 'opacity-100',
|
||||
leave: 'ease-in-out duration-500',
|
||||
leaveFrom: 'opacity-100',
|
||||
leaveTo: 'opacity-0',
|
||||
});
|
||||
|
||||
const transitionClass = computed(() => {
|
||||
return {
|
||||
enter: 'transform transition ease-in-out duration-300',
|
||||
leave: 'transform transition ease-in-out duration-200',
|
||||
enterFrom: props.side === 'left' ? '-translate-x-full' : 'translate-x-full',
|
||||
enterTo: 'translate-x-0',
|
||||
leaveFrom: 'translate-x-0',
|
||||
leaveTo: props.side === 'left' ? '-translate-x-full' : 'translate-x-full',
|
||||
};
|
||||
});
|
||||
|
||||
function close() {
|
||||
isOpen.value = false;
|
||||
emits('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TransitionRoot as="template" :appear="appear" :show="isOpen">
|
||||
<Dialog
|
||||
class="fixed inset-0 z-2000 flex"
|
||||
:class="{ 'justify-end': side === 'right' }"
|
||||
@close="!preventClose && close()"
|
||||
>
|
||||
<TransitionChild as="template" :appear="appear" v-bind="overlayTransitionClass">
|
||||
<div
|
||||
class="fixed inset-0 bg-stone-2/75 transition-opacity dark-bg-stone-8/75"
|
||||
:class="{ 'backdrop-blur-sm': overlay }"
|
||||
/>
|
||||
</TransitionChild>
|
||||
<TransitionChild as="template" :appear="appear" v-bind="transitionClass">
|
||||
<DialogPanel
|
||||
relative
|
||||
max-w-md
|
||||
w-full
|
||||
w-screen
|
||||
flex
|
||||
flex-1
|
||||
flex-col
|
||||
bg-white
|
||||
dark-bg-stone-8
|
||||
focus-outline-none
|
||||
>
|
||||
<div flex="~ items-center justify-between" p-4 border-b="~ solid stone/15" text-6>
|
||||
<DialogTitle m-0 text-lg text-dark dark-text-white>
|
||||
{{ title }}
|
||||
</DialogTitle>
|
||||
<SvgIcon name="i-carbon:close" cursor-pointer @click="close" />
|
||||
</div>
|
||||
<DialogDescription m-0 flex-1 of-y-hidden>
|
||||
<OverlayScrollbarsComponent
|
||||
:options="{ scrollbars: { autoHide: 'leave', autoHideDelay: 300 } }"
|
||||
defer
|
||||
class="h-full p-4"
|
||||
>
|
||||
<slot />
|
||||
</OverlayScrollbarsComponent>
|
||||
</DialogDescription>
|
||||
<div
|
||||
v-if="!!slots.footer"
|
||||
flex="~ items-center justify-end"
|
||||
px-3
|
||||
py-2
|
||||
border-t="~ solid stone/15"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
55
admin/src/layouts/ui-kit/HTabList.vue
Normal file
55
admin/src/layouts/ui-kit/HTabList.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import { Tab, TabGroup, TabList } from '@headlessui/vue';
|
||||
|
||||
const props = defineProps<{
|
||||
options: {
|
||||
icon?: string;
|
||||
label: any;
|
||||
value: T;
|
||||
}[];
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
change: [T];
|
||||
}>();
|
||||
|
||||
const value = defineModel<T>();
|
||||
|
||||
const selectedIndex = computed({
|
||||
get() {
|
||||
return props.options.findIndex((option) => option.value === value.value);
|
||||
},
|
||||
set(val) {
|
||||
value.value = props.options[val].value;
|
||||
},
|
||||
});
|
||||
|
||||
watch(value, (val) => {
|
||||
val && emits('change', val);
|
||||
});
|
||||
|
||||
function handleChange(index: number) {
|
||||
value.value = props.options[index].value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabGroup :selected-index="selectedIndex" @change="handleChange">
|
||||
<TabList
|
||||
class="inline-flex select-none items-center justify-center rounded-md bg-stone-1 p-1 ring-1 ring-stone-2 dark-bg-stone-9 dark-ring-stone-8"
|
||||
>
|
||||
<Tab v-for="(option, index) in options" :key="index" v-slot="{ selected }" as="template">
|
||||
<button
|
||||
class="w-full inline-flex items-center justify-center gap-1 break-keep border-size-0 rounded-md bg-inherit px-2 py-1.5 text-sm text-dark ring-stone-2 ring-inset dark-text-white focus-outline-none focus-ring-2 dark-ring-stone-8"
|
||||
:class="{
|
||||
'cursor-default bg-white dark-bg-dark-9': selected,
|
||||
'cursor-pointer opacity-50 hover-(opacity-100)': !selected,
|
||||
}"
|
||||
>
|
||||
<SvgIcon v-if="option.icon" :name="option.icon" class="flex-shrink-0" />
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</Tab>
|
||||
</TabList>
|
||||
</TabGroup>
|
||||
</template>
|
||||
38
admin/src/layouts/ui-kit/HToggle.vue
Normal file
38
admin/src/layouts/ui-kit/HToggle.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { Switch } from '@headlessui/vue';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
onIcon?: string;
|
||||
offIcon?: string;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const enabled = defineModel<boolean>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Switch
|
||||
v-model="enabled"
|
||||
:disabled="disabled"
|
||||
class="relative h-5 w-10 inline-flex flex-shrink-0 cursor-pointer border-2 border-transparent rounded-full p-0 vertical-middle disabled-cursor-not-allowed disabled-opacity-50 focus-outline-none focus-visible-ring-2 focus-visible-ring-offset-2 focus-visible-ring-offset-white dark-focus-visible-ring-offset-gray-900"
|
||||
:class="[enabled ? 'bg-ui-primary' : 'bg-stone-3 dark-bg-stone-7']"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none relative inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out dark-bg-dark"
|
||||
:class="[enabled ? 'translate-x-5' : 'translate-x-0']"
|
||||
>
|
||||
<span class="absolute inset-0 h-full w-full flex items-center justify-center">
|
||||
<SvgIcon
|
||||
v-if="(enabled && onIcon) || (!enabled && offIcon)"
|
||||
:name="(enabled ? onIcon : offIcon) as string"
|
||||
class="h-3 w-3 text-stone-7 dark-text-stone-3"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</Switch>
|
||||
</template>
|
||||
26
admin/src/layouts/ui-kit/HTooltip.vue
Normal file
26
admin/src/layouts/ui-kit/HTooltip.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
text: string;
|
||||
enable?: boolean;
|
||||
}>(),
|
||||
{
|
||||
text: '',
|
||||
enable: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VTooltip v-if="enable" :popper-triggers="['hover']" v-bind="$attrs">
|
||||
<slot />
|
||||
<template #popper>
|
||||
<slot name="text">
|
||||
{{ text }}
|
||||
</slot>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<div v-else>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
45
admin/src/main.ts
Executable file
45
admin/src/main.ts
Executable file
@@ -0,0 +1,45 @@
|
||||
import '@/utils/system.copyright';
|
||||
|
||||
import FloatingVue from 'floating-vue';
|
||||
import 'floating-vue/dist/style.css';
|
||||
|
||||
import 'vue-m-message/dist/style.css';
|
||||
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import ui from './ui-provider';
|
||||
|
||||
// 自定义指令
|
||||
import directive from '@/utils/directive';
|
||||
|
||||
// 加载 svg 图标
|
||||
import 'virtual:svg-icons-register';
|
||||
|
||||
// 加载 iconify 图标
|
||||
import { downloadAndInstall } from '@/iconify';
|
||||
import icons from '@/iconify/index.json';
|
||||
|
||||
import 'virtual:uno.css';
|
||||
|
||||
// 全局样式
|
||||
import '@/assets/styles/globals.scss';
|
||||
import pinia from './store';
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(FloatingVue, {
|
||||
distance: 12,
|
||||
});
|
||||
// app.use(Message);
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(ui);
|
||||
directive(app);
|
||||
if (icons.isOfflineUse) {
|
||||
for (const info of icons.collections) {
|
||||
downloadAndInstall(info);
|
||||
}
|
||||
}
|
||||
|
||||
app.mount('#app');
|
||||
15
admin/src/menu/index.ts
Executable file
15
admin/src/menu/index.ts
Executable file
@@ -0,0 +1,15 @@
|
||||
import MultilevelMenuExample from './modules/multilevel.menu.example';
|
||||
|
||||
import type { Menu } from '#/global';
|
||||
|
||||
const menu: Menu.recordMainRaw[] = [
|
||||
{
|
||||
meta: {
|
||||
title: '演示',
|
||||
icon: 'uim:box',
|
||||
},
|
||||
children: [MultilevelMenuExample],
|
||||
},
|
||||
];
|
||||
|
||||
export default menu;
|
||||
50
admin/src/menu/modules/multilevel.menu.example.ts
Executable file
50
admin/src/menu/modules/multilevel.menu.example.ts
Executable file
@@ -0,0 +1,50 @@
|
||||
import type { Menu } from '#/global';
|
||||
|
||||
const menus: Menu.recordRaw = {
|
||||
meta: {
|
||||
title: '多级导航',
|
||||
icon: 'heroicons-solid:menu-alt-3',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/multilevel_menu_example/page',
|
||||
meta: {
|
||||
title: '导航1',
|
||||
},
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: '导航2',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/multilevel_menu_example/level2/page',
|
||||
meta: {
|
||||
title: '导航2-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: '导航2-2',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/multilevel_menu_example/level2/level3/page1',
|
||||
meta: {
|
||||
title: '导航2-2-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/multilevel_menu_example/level2/level3/page2',
|
||||
meta: {
|
||||
title: '导航2-2-2',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default menus;
|
||||
154
admin/src/mock/app.ts
Executable file
154
admin/src/mock/app.ts
Executable file
@@ -0,0 +1,154 @@
|
||||
import { defineFakeRoute } from 'vite-plugin-fake-server/client';
|
||||
|
||||
export default defineFakeRoute([
|
||||
{
|
||||
url: '/mock/app/route/list',
|
||||
method: 'get',
|
||||
response: () => {
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: [
|
||||
{
|
||||
meta: {
|
||||
title: '演示',
|
||||
icon: 'uim:box',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/multilevel_menu_example',
|
||||
component: 'Layout',
|
||||
redirect: '/multilevel_menu_example/page',
|
||||
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',
|
||||
redirect: '/multilevel_menu_example/level2/page',
|
||||
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',
|
||||
redirect: '/multilevel_menu_example/level2/level3/page1',
|
||||
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: '/mock/app/menu/list',
|
||||
method: 'get',
|
||||
response: () => {
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: [
|
||||
{
|
||||
meta: {
|
||||
title: '演示',
|
||||
icon: 'uim:box',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
meta: {
|
||||
title: '多级导航',
|
||||
icon: 'heroicons-solid:menu-alt-3',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/multilevel_menu_example/page',
|
||||
meta: {
|
||||
title: '导航1',
|
||||
},
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: '导航2',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/multilevel_menu_example/level2/page',
|
||||
meta: {
|
||||
title: '导航2-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: '导航2-2',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/multilevel_menu_example/level2/level3/page1',
|
||||
meta: {
|
||||
title: '导航2-2-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/multilevel_menu_example/level2/level3/page2',
|
||||
meta: {
|
||||
title: '导航2-2-2',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
]);
|
||||
57
admin/src/mock/user.ts
Executable file
57
admin/src/mock/user.ts
Executable file
@@ -0,0 +1,57 @@
|
||||
import { defineFakeRoute } from 'vite-plugin-fake-server/client';
|
||||
import Mock from 'mockjs';
|
||||
|
||||
export default defineFakeRoute([
|
||||
{
|
||||
url: '/mock/user/login',
|
||||
method: 'post',
|
||||
response: ({ body }) => {
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: Mock.mock({
|
||||
account: body.account,
|
||||
token: `${body.account}_@string`,
|
||||
avatar: 'https://fantastic-admin.github.io/logo.png',
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/mock/user/permission',
|
||||
method: 'get',
|
||||
response: ({ headers }) => {
|
||||
let permissions: string[] = [];
|
||||
if (headers.token?.indexOf('admin') === 0) {
|
||||
permissions = [
|
||||
'permission.browse',
|
||||
'permission.create',
|
||||
'permission.edit',
|
||||
'permission.remove',
|
||||
];
|
||||
} else if (headers.token?.indexOf('test') === 0) {
|
||||
permissions = ['permission.browse'];
|
||||
}
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: {
|
||||
permissions,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/mock/user/password/edit',
|
||||
method: 'post',
|
||||
response: () => {
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: {
|
||||
isSuccess: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
]);
|
||||
192
admin/src/router/index.ts
Executable file
192
admin/src/router/index.ts
Executable file
@@ -0,0 +1,192 @@
|
||||
import '@/assets/styles/nprogress.scss';
|
||||
import { useNProgress } from '@vueuse/integrations/useNProgress';
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
|
||||
// 路由相关数据
|
||||
import pinia from '@/store';
|
||||
import useKeepAliveStore from '@/store/modules/keepAlive';
|
||||
import useMenuStore from '@/store/modules/menu';
|
||||
import useRouteStore from '@/store/modules/route';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
import useUserStore from '@/store/modules/user';
|
||||
import {
|
||||
asyncRoutes,
|
||||
asyncRoutesByFilesystem,
|
||||
constantRoutes,
|
||||
constantRoutesByFilesystem,
|
||||
} from './routes';
|
||||
|
||||
const { isLoading } = useNProgress();
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(import.meta.env.VITE_BASE_PATH),
|
||||
routes:
|
||||
useSettingsStore(pinia).settings.app.routeBaseOn === 'filesystem'
|
||||
? constantRoutesByFilesystem
|
||||
: (constantRoutes as RouteRecordRaw[]),
|
||||
});
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const settingsStore = useSettingsStore();
|
||||
const userStore = useUserStore();
|
||||
const routeStore = useRouteStore();
|
||||
const menuStore = useMenuStore();
|
||||
settingsStore.settings.app.enableProgress && (isLoading.value = true);
|
||||
// 是否已登录
|
||||
if (userStore.isLogin) {
|
||||
// 是否已根据权限动态生成并注册路由
|
||||
if (routeStore.isGenerate) {
|
||||
// 导航栏如果不是 single 模式,则需要根据 path 定位主导航的选中状态
|
||||
settingsStore.settings.menu.menuMode !== 'single' && menuStore.setActived(to.path);
|
||||
// 如果已登录状态下,进入登录页会强制跳转到主页
|
||||
if (to.name === 'login') {
|
||||
next({
|
||||
path: settingsStore.settings.home.fullPath,
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
// 如果未开启主页,但进入的是主页,则会进入侧边栏导航第一个模块
|
||||
else if (
|
||||
!settingsStore.settings.home.enable &&
|
||||
to.fullPath === settingsStore.settings.home.fullPath
|
||||
) {
|
||||
if (menuStore.sidebarMenus.length > 0) {
|
||||
next({
|
||||
path: menuStore.sidebarMenusFirstDeepestPath,
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
// 如果侧边栏导航第一个模块均无法命中,则还是进入主页
|
||||
else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
// 正常访问页面
|
||||
else {
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
// 获取用户权限
|
||||
settingsStore.settings.app.enablePermission && (await userStore.getPermissions());
|
||||
// 生成动态路由
|
||||
switch (settingsStore.settings.app.routeBaseOn) {
|
||||
case 'frontend':
|
||||
routeStore.generateRoutesAtFront(asyncRoutes);
|
||||
break;
|
||||
// case 'backend':
|
||||
// await routeStore.generateRoutesAtBack()
|
||||
// break
|
||||
case 'filesystem':
|
||||
routeStore.generateRoutesAtFilesystem(asyncRoutesByFilesystem);
|
||||
// 文件系统生成的路由,需要手动生成导航数据
|
||||
switch (settingsStore.settings.menu.baseOn) {
|
||||
case 'frontend':
|
||||
menuStore.generateMenusAtFront();
|
||||
break;
|
||||
// case 'backend':
|
||||
// await menuStore.generateMenusAtBack()
|
||||
// break
|
||||
}
|
||||
break;
|
||||
}
|
||||
// 注册并记录路由数据
|
||||
// 记录的数据会在登出时会使用到,不使用 router.removeRoute 是考虑配置的路由可能不一定有设置 name ,则通过调用 router.addRoute() 返回的回调进行删除
|
||||
const removeRoutes: (() => void)[] = [];
|
||||
routeStore.flatRoutes.forEach((route) => {
|
||||
if (!/^(?:https?:|mailto:|tel:)/.test(route.path)) {
|
||||
removeRoutes.push(router.addRoute(route as RouteRecordRaw));
|
||||
}
|
||||
});
|
||||
if (settingsStore.settings.app.routeBaseOn !== 'filesystem') {
|
||||
routeStore.flatSystemRoutes.forEach((route) => {
|
||||
removeRoutes.push(router.addRoute(route as RouteRecordRaw));
|
||||
});
|
||||
}
|
||||
routeStore.setCurrentRemoveRoutes(removeRoutes);
|
||||
// 动态路由生成并注册后,重新进入当前路由
|
||||
next({
|
||||
path: to.path,
|
||||
query: to.query,
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (to.name !== 'login') {
|
||||
next({
|
||||
name: 'login',
|
||||
query: {
|
||||
redirect: to.fullPath !== settingsStore.settings.home.fullPath ? to.fullPath : undefined,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.afterEach((to, from) => {
|
||||
const settingsStore = useSettingsStore();
|
||||
const keepAliveStore = useKeepAliveStore();
|
||||
settingsStore.settings.app.enableProgress && (isLoading.value = false);
|
||||
// 设置页面 title
|
||||
if (settingsStore.settings.app.routeBaseOn !== 'filesystem') {
|
||||
settingsStore.setTitle(to.meta.breadcrumbNeste?.at(-1)?.title ?? to.meta.title);
|
||||
} else {
|
||||
settingsStore.setTitle(to.meta.title);
|
||||
}
|
||||
/**
|
||||
* 处理普通页面的缓存
|
||||
*/
|
||||
// 判断当前页面是否开启缓存,如果开启,则将当前页面的 name 信息存入 keep-alive 全局状态
|
||||
if (to.meta.cache) {
|
||||
const componentName = to.matched.at(-1)?.components?.default.name;
|
||||
if (componentName) {
|
||||
keepAliveStore.add(componentName);
|
||||
} else {
|
||||
// turbo-console-disable-next-line
|
||||
console.warn('[Fantastic-admin] 该页面组件未设置组件名,会导致缓存失效,请检查');
|
||||
}
|
||||
}
|
||||
// 判断离开页面是否开启缓存,如果开启,则根据缓存规则判断是否需要清空 keep-alive 全局状态里离开页面的 name 信息
|
||||
if (from.meta.cache) {
|
||||
const componentName = from.matched.at(-1)?.components?.default.name;
|
||||
if (componentName) {
|
||||
// 通过 meta.cache 判断针对哪些页面进行缓存
|
||||
switch (typeof from.meta.cache) {
|
||||
case 'string':
|
||||
if (from.meta.cache !== to.name) {
|
||||
keepAliveStore.remove(componentName);
|
||||
}
|
||||
break;
|
||||
case 'object':
|
||||
if (!from.meta.cache.includes(to.name as string)) {
|
||||
keepAliveStore.remove(componentName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// 通过 meta.noCache 判断针对哪些页面不需要进行缓存
|
||||
if (from.meta.noCache) {
|
||||
switch (typeof from.meta.noCache) {
|
||||
case 'string':
|
||||
if (from.meta.noCache === to.name) {
|
||||
keepAliveStore.remove(componentName);
|
||||
}
|
||||
break;
|
||||
case 'object':
|
||||
if (from.meta.noCache.includes(to.name as string)) {
|
||||
keepAliveStore.remove(componentName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 如果进入的是 reload 页面,则也将离开页面的缓存清空
|
||||
if (to.name === 'reload') {
|
||||
keepAliveStore.remove(componentName);
|
||||
}
|
||||
}
|
||||
}
|
||||
document.documentElement.scrollTop = 0;
|
||||
});
|
||||
|
||||
export default router;
|
||||
38
admin/src/router/modules/app.menu.ts
Normal file
38
admin/src/router/modules/app.menu.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
function Layout() {
|
||||
return import('@/layouts/index.vue');
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw = {
|
||||
path: '/app',
|
||||
component: Layout,
|
||||
redirect: '/app/classify',
|
||||
name: 'AppMenu',
|
||||
meta: {
|
||||
title: '插件应用',
|
||||
icon: 'tdesign:app',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'classify',
|
||||
name: 'AppMenuClassify',
|
||||
component: () => import('@/views/app/classify.vue'),
|
||||
meta: {
|
||||
title: '分类列表',
|
||||
icon: 'ph:list-fill',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'application',
|
||||
name: 'Application',
|
||||
component: () => import('@/views/app/application.vue'),
|
||||
meta: {
|
||||
title: '应用列表',
|
||||
icon: 'clarity:vmw-app-line',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default routes;
|
||||
47
admin/src/router/modules/chat.menu.ts
Normal file
47
admin/src/router/modules/chat.menu.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
function Layout() {
|
||||
return import('@/layouts/index.vue');
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw = {
|
||||
path: '/chat',
|
||||
component: Layout,
|
||||
redirect: '/chat/chat',
|
||||
name: 'chatMenu',
|
||||
meta: {
|
||||
title: '数据管理',
|
||||
icon: 'majesticons:data-line',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'dashboardMenu',
|
||||
component: () => import('@/views/users/index.vue'),
|
||||
meta: {
|
||||
title: '用户信息',
|
||||
icon: 'fa6-solid:list-ul',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'list',
|
||||
name: 'chatMenuList',
|
||||
component: () => import('@/views/chat/chat.vue'),
|
||||
meta: {
|
||||
title: '对话记录',
|
||||
icon: 'material-symbols-light:chat-outline',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'auto-reply',
|
||||
name: 'ReplyMenuList',
|
||||
component: () => import('@/views/sensitive/autpReply.vue'),
|
||||
meta: {
|
||||
title: '内容预设',
|
||||
icon: 'ic:outline-question-answer',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default routes;
|
||||
35
admin/src/router/modules/model.menu.ts
Normal file
35
admin/src/router/modules/model.menu.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
function Layout() {
|
||||
return import('@/layouts/index.vue');
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw = {
|
||||
path: '/ai',
|
||||
component: Layout,
|
||||
redirect: '/ai/chat-key-list',
|
||||
name: 'AiMenu',
|
||||
meta: {
|
||||
title: '模型管理',
|
||||
icon: 'hugeicons:ai-book',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'keys',
|
||||
name: 'AiMenuKeys',
|
||||
component: () => import('@/views/models/key.vue'),
|
||||
meta: { title: '模型设置', icon: 'ph:open-ai-logo-light' },
|
||||
},
|
||||
{
|
||||
path: 'baseSetting',
|
||||
name: 'baseSetting',
|
||||
component: () => import('@/views/models/baseSetting.vue'),
|
||||
meta: {
|
||||
title: '基础配置',
|
||||
icon: 'lets-icons:setting-line',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default routes;
|
||||
56
admin/src/router/modules/package.menu.ts
Normal file
56
admin/src/router/modules/package.menu.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
function Layout() {
|
||||
return import('@/layouts/index.vue');
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw = {
|
||||
path: '/package',
|
||||
component: Layout,
|
||||
redirect: '/package/list',
|
||||
name: 'packageMenu',
|
||||
meta: {
|
||||
title: '套餐管理',
|
||||
icon: 'icon-park-outline:buy',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'order-list',
|
||||
name: 'OrderMenuList',
|
||||
component: () => import('@/views/order/index.vue'),
|
||||
meta: {
|
||||
title: '订单列表',
|
||||
icon: 'lets-icons:order',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'account-log',
|
||||
name: 'AccountLogMenu',
|
||||
component: () => import('@/views/users/accountLog.vue'),
|
||||
meta: {
|
||||
title: '账户明细',
|
||||
icon: 'carbon:account',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'list',
|
||||
name: 'packageMenuList',
|
||||
component: () => import('@/views/package/package.vue'),
|
||||
meta: {
|
||||
title: '套餐设置',
|
||||
icon: 'icon-park-outline:commodity',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'crami',
|
||||
name: 'cramiMenuList',
|
||||
component: () => import('@/views/package/crami.vue'),
|
||||
meta: {
|
||||
title: '卡密管理',
|
||||
icon: 'solar:passport-broken',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default routes;
|
||||
74
admin/src/router/modules/pay.menu.ts
Normal file
74
admin/src/router/modules/pay.menu.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
function Layout() {
|
||||
return import('@/layouts/index.vue');
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw = {
|
||||
path: '/pay',
|
||||
component: Layout,
|
||||
redirect: '/pay/hupijiao',
|
||||
name: 'PayMenu',
|
||||
meta: {
|
||||
title: '支付管理',
|
||||
icon: 'mingcute:card-pay-line',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'wechat',
|
||||
name: 'WechatConfig',
|
||||
component: () => import('@/views/pay/wechat.vue'),
|
||||
meta: {
|
||||
title: '微信支付',
|
||||
icon: 'ic:baseline-wechat',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'duluPay',
|
||||
name: 'DuluPayConfig',
|
||||
component: () => import('@/views/pay/duluPay.vue'),
|
||||
meta: {
|
||||
title: '嘟噜支付',
|
||||
icon: 'ic:outline-payment',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'epay',
|
||||
name: 'EpayConfig',
|
||||
component: () => import('@/views/pay/epay.vue'),
|
||||
meta: {
|
||||
title: '易支付',
|
||||
icon: 'uiw:pay',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'mpay',
|
||||
name: 'MpayConfig',
|
||||
component: () => import('@/views/pay/mpay.vue'),
|
||||
meta: {
|
||||
title: '码支付',
|
||||
icon: 'ant-design:pay-circle-outlined',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'hupi',
|
||||
name: 'HupioConfig',
|
||||
component: () => import('@/views/pay/hupijiao.vue'),
|
||||
meta: {
|
||||
title: '虎皮椒支付',
|
||||
icon: 'token:pay',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'ltzf',
|
||||
name: 'LtzfConfig',
|
||||
component: () => import('@/views/pay/ltzf.vue'),
|
||||
meta: {
|
||||
title: '蓝兔支付',
|
||||
icon: 'ph:rabbit',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default routes;
|
||||
56
admin/src/router/modules/secure.menu.ts
Normal file
56
admin/src/router/modules/secure.menu.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
function Layout() {
|
||||
return import('@/layouts/index.vue');
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw = {
|
||||
path: '/secure',
|
||||
component: Layout,
|
||||
redirect: '/secure/sensitive-baidu',
|
||||
name: 'SecureMenu',
|
||||
meta: {
|
||||
title: '风控管理',
|
||||
icon: 'ri:secure-payment-line',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'identity-verification',
|
||||
name: 'IdentityVerification',
|
||||
component: () => import('@/views/sensitive/identityVerification.vue'),
|
||||
meta: {
|
||||
title: '风控安全配置',
|
||||
icon: 'hugeicons:identification',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'sensitive-violation',
|
||||
name: 'SensitiveViolationLog',
|
||||
component: () => import('@/views/sensitive/violation.vue'),
|
||||
meta: {
|
||||
title: '违规检测记录',
|
||||
icon: 'tabler:ban',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'sensitive-baidu',
|
||||
name: 'SensitiveBaiduyun',
|
||||
component: () => import('@/views/sensitive/baiduSensitive.vue'),
|
||||
meta: {
|
||||
title: '百度云敏感词',
|
||||
icon: 'ri:baidu-line',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'sensitive-custom',
|
||||
name: 'SensitiveCuston',
|
||||
component: () => import('@/views/sensitive/custom.vue'),
|
||||
meta: {
|
||||
title: '自定义敏感词',
|
||||
icon: 'carbon:word-cloud',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default routes;
|
||||
65
admin/src/router/modules/storage.menu.ts
Normal file
65
admin/src/router/modules/storage.menu.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
function Layout() {
|
||||
return import('@/layouts/index.vue');
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw = {
|
||||
path: '/storage',
|
||||
component: Layout,
|
||||
redirect: '/storage/config',
|
||||
name: 'StorageMenu',
|
||||
meta: {
|
||||
title: '存储配置',
|
||||
icon: 'mingcute:storage-line',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'localStorage',
|
||||
name: 'LocalStorage',
|
||||
component: () => import('@/views/storage/localStorage.vue'),
|
||||
meta: {
|
||||
title: '本地存储',
|
||||
icon: 'icon-park-outline:cloud-storage',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 's3',
|
||||
name: 'StorageS3',
|
||||
component: () => import('@/views/storage/s3.vue'),
|
||||
meta: {
|
||||
title: 'S3存储',
|
||||
icon: 'mdi:aws',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'tencent',
|
||||
name: 'StorageTencent',
|
||||
component: () => import('@/views/storage/tencent.vue'),
|
||||
meta: {
|
||||
title: '腾讯云COS',
|
||||
icon: 'teenyicons:cost-estimate-outline',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'ali',
|
||||
name: 'StorageAli',
|
||||
component: () => import('@/views/storage/ali.vue'),
|
||||
meta: {
|
||||
title: '阿里云OSS',
|
||||
icon: 'material-symbols:home-storage-outline',
|
||||
},
|
||||
},
|
||||
// {
|
||||
// path: 'chevereto',
|
||||
// name: 'StorageChevereto',
|
||||
// component: () => import('@/views/storage/chevereto.vue'),
|
||||
// meta: {
|
||||
// title: 'chevereto图床',
|
||||
// icon: 'material-symbols:image-outline',
|
||||
// },
|
||||
// },
|
||||
],
|
||||
};
|
||||
|
||||
export default routes;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user