This commit is contained in:
vastxie
2025-05-31 02:28:46 +08:00
parent 0f7adc5c65
commit 86e2eecc1f
1808 changed files with 183083 additions and 86701 deletions

95
admin/src/App.vue Executable file
View 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
View 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;

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

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

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

View File

@@ -0,0 +1,5 @@
import api from '../index';
export default {
queryChatAll: (params: any) => api.get('chatLog/chatAll', { params }),
};

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

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

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

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

View 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'),
};

View 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'),
};

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

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

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

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

View 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

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

View 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

View 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

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

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

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

View File

@@ -0,0 +1 @@
// 全局变量

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

View 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

File diff suppressed because it is too large Load Diff

14
admin/src/iconify/index.json Executable file
View 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
View 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));

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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