🎨更换web页面框架

This commit is contained in:
coward
2024-07-15 16:44:52 +08:00
parent 3e1d3d63d0
commit 1f49b798eb
205 changed files with 2 additions and 23053 deletions

View File

@@ -1,26 +0,0 @@
<template>
<el-config-provider :locale="currentLocale">
<router-view />
<ReDialog />
</el-config-provider>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { ElConfigProvider } from "element-plus";
import { ReDialog } from "@/components/ReDialog";
import zhCn from "element-plus/es/locale/lang/zh-cn";
export default defineComponent({
name: "app",
components: {
[ElConfigProvider.name]: ElConfigProvider,
ReDialog
},
computed: {
currentLocale() {
return zhCn;
}
}
});
</script>

View File

@@ -1,10 +0,0 @@
import { http } from "@/utils/http";
type Result = {
success: boolean;
data: Array<any>;
};
export const getAsyncRoutes = () => {
return http.request<Result>("get", "/get-async-routes");
};

View File

@@ -1,43 +0,0 @@
import { http } from "@/utils/http";
export type UserResult = {
success: boolean;
data: {
/** 头像 */
avatar: string;
/** 用户名 */
username: string;
/** 昵称 */
nickname: string;
/** 当前登录用户的角色 */
roles: Array<string>;
/** `token` */
accessToken: string;
/** 用于调用刷新`accessToken`的接口时所需的`token` */
refreshToken: string;
/** `accessToken`的过期时间(格式'xxxx/xx/xx xx:xx:xx' */
expires: Date;
};
};
export type RefreshTokenResult = {
success: boolean;
data: {
/** `token` */
accessToken: string;
/** 用于调用刷新`accessToken`的接口时所需的`token` */
refreshToken: string;
/** `accessToken`的过期时间(格式'xxxx/xx/xx xx:xx:xx' */
expires: Date;
};
};
/** 登录 */
export const getLogin = (data?: object) => {
return http.request<UserResult>("post", "/login", { data });
};
/** 刷新`token` */
export const refreshTokenApi = (data?: object) => {
return http.request<RefreshTokenResult>("post", "/refresh-token", { data });
};

View File

@@ -1,27 +0,0 @@
@font-face {
font-family: "iconfont"; /* Project id 2208059 */
src:
url("iconfont.woff2?t=1671895108120") format("woff2"),
url("iconfont.woff?t=1671895108120") format("woff"),
url("iconfont.ttf?t=1671895108120") format("truetype");
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.pure-iconfont-tabs:before {
content: "\e63e";
}
.pure-iconfont-logo:before {
content: "\e620";
}
.pure-iconfont-new:before {
content: "\e615";
}

File diff suppressed because one or more lines are too long

View File

@@ -1,30 +0,0 @@
{
"id": "2208059",
"name": "pure-admin",
"font_family": "iconfont",
"css_prefix_text": "pure-iconfont-",
"description": "pure-admin-iconfont",
"glyphs": [
{
"icon_id": "20594647",
"name": "Tabs",
"font_class": "tabs",
"unicode": "e63e",
"unicode_decimal": 58942
},
{
"icon_id": "22129506",
"name": "PureLogo",
"font_class": "logo",
"unicode": "e620",
"unicode_decimal": 58912
},
{
"icon_id": "7795615",
"name": "New",
"font_class": "new",
"unicode": "e615",
"unicode_decimal": 58901
}
]
}

Binary file not shown.

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="icon" viewBox="0 0 1024 1024"><path fill="#386BF3" d="M410.558.109c0 210.974-300.876 361.752-300.876 633.548 0 174.943 134.704 316.787 300.876 316.787s300.877-141.817 300.877-316.787C711.408 361.752 410.558 210.974 410.558.109"/><path fill="#C3D2FB" d="M613.469 73.665c0 211.055-300.877 361.914-300.877 633.547C312.592 882.156 447.296 1024 613.47 1024s300.876-141.817 300.876-316.788C914.29 435.58 613.469 284.72 613.469 73.665"/><path fill="#303F5B" d="M312.592 707.212c0-183.713 137.636-312.171 226.723-441.39 81.702 106.112 172.12 218.74 172.12 367.726A309.755 309.755 0 0 1 420.36 950.064a323.1 323.1 0 0 1-107.769-242.852z"/></svg>

Before

Width:  |  Height:  |  Size: 706 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M2.88 18.054a35.9 35.9 0 0 1 8.531-16.32.8.8 0 0 1 1.178 0q.25.27.413.455a35.9 35.9 0 0 1 8.118 15.865c-2.141.451-4.34.747-6.584.874l-2.089 4.178a.5.5 0 0 1-.894 0l-2.089-4.178a44 44 0 0 1-6.584-.874m6.698-1.123 1.157.066L12 19.527l1.265-2.53 1.157-.066a42 42 0 0 0 4.227-.454A33.9 33.9 0 0 0 12 4.09a33.9 33.9 0 0 0-6.649 12.387q2.093.334 4.227.454M12 15a3 3 0 1 1 0-6 3 3 0 0 1 0 6m0-2a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/></svg>

Before

Width:  |  Height:  |  Size: 533 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path d="M11.38 2.019a7.5 7.5 0 1 0 10.6 10.6C21.662 17.854 17.316 22 12.001 22 6.477 22 2 17.523 2 12c0-5.315 4.146-9.661 9.38-9.981"/></svg>

Before

Width:  |  Height:  |  Size: 262 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 18a6 6 0 1 1 0-12 6 6 0 0 1 0 12M11 1h2v3h-2zm0 19h2v3h-2zM3.515 4.929l1.414-1.414L7.05 5.636 5.636 7.05zM16.95 18.364l1.414-1.414 2.121 2.121-1.414 1.414zm2.121-14.85 1.414 1.415-2.121 2.121-1.414-1.414 2.121-2.121zM5.636 16.95l1.414 1.414-2.121 2.121-1.414-1.414zM23 11v2h-3v-2zM4 11v2H1v-2z"/></svg>

Before

Width:  |  Height:  |  Size: 435 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" aria-hidden="true" class="iconify iconify--ant-design" viewBox="0 0 1024 1024"><path fill="currentColor" d="M864 170h-60c-4.4 0-8 3.6-8 8v518H310v-73c0-6.7-7.8-10.5-13-6.3l-141.9 112a8 8 0 0 0 0 12.6l141.9 112c5.3 4.2 13 .4 13-6.3v-75h498c35.3 0 64-28.7 64-64V178c0-4.4-3.6-8-8-8"/></svg>

Before

Width:  |  Height:  |  Size: 351 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" aria-hidden="true" class="re-screen" color="#00000073" viewBox="0 0 16 16"><path fill="currentColor" d="M3.5 4H1V3h2V1h1v2.5zM13 3V1h-1v2.5l.5.5H15V3zm-1 9.5V15h1v-2h2v-1h-2.5zM1 12v1h2v2h1v-2.5l-.5-.5zm11-1.5-.5.5h-7l-.5-.5v-5l.5-.5h7l.5.5zM10 7H6v2h4z"/></svg>

Before

Width:  |  Height:  |  Size: 327 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" aria-hidden="true" class="re-screen" color="#00000073" viewBox="0 0 16 16"><path fill="currentColor" d="M3 12h10V4H3zm2-6h6v4H5zM2 6H1V2.5l.5-.5H5v1H2zm13-3.5V6h-1V3h-3V2h3.5zM14 10h1v3.5l-.5.5H11v-1h3zM2 13h3v1H1.5l-.5-.5V10h1z"/></svg>

Before

Width:  |  Height:  |  Size: 302 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" aria-hidden="true" class="iconify iconify--mdi" viewBox="0 0 24 24"><path fill="currentColor" d="M1 7h6v2H3v2h4v2H3v2h4v2H1zm10 0h4v2h-4v2h2a2 2 0 0 1 2 2v2c0 1.11-.89 2-2 2H9v-2h4v-2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2m8 0h2a2 2 0 0 1 2 2v1h-2V9h-2v6h2v-1h2v1c0 1.11-.89 2-2 2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2"/></svg>

Before

Width:  |  Height:  |  Size: 379 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="icon" viewBox="0 0 1024 1024"><path d="M554 849.574c0 23.365-18.635 42.307-42 42.307s-42-18.941-42-42.307V662.719c0-23.365 18.635-42.307 42-42.307v-7.051c23.365 0 42 25.993 42 49.358z"/><path d="M893 888.5c0 17.397-14.103 31.5-31.5 31.5h-700c-17.397 0-31.5-14.103-31.5-31.5s14.103-31.5 31.5-31.5h700c17.397 0 31.5 14.103 31.5 31.5m33-714.074C926 135.484 894.686 105 855.744 105H168.256C129.314 105 98 135.484 98 174.426V533h828zM98 630.988C98 669.931 129.314 702 168.256 702h687.488C894.686 702 926 669.931 926 630.988V596H98z"/></svg>

Before

Width:  |  Height:  |  Size: 605 B

View File

@@ -1 +0,0 @@
<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M13.79 10.21a1 1 0 0 0 1.42 0 1 1 0 0 0 0-1.42l-2.5-2.5a1 1 0 0 0-.33-.21 1 1 0 0 0-.76 0 1 1 0 0 0-.33.21l-2.5 2.5a1 1 0 0 0 1.42 1.42l.79-.8v5.18l-.79-.8a1 1 0 0 0-1.42 1.42l2.5 2.5a1 1 0 0 0 .33.21.94.94 0 0 0 .76 0 1 1 0 0 0 .33-.21l2.5-2.5a1 1 0 0 0-1.42-1.42l-.79.8V9.41ZM7 4h10a1 1 0 0 0 0-2H7a1 1 0 0 0 0 2m10 16H7a1 1 0 0 0 0 2h10a1 1 0 0 0 0-2"/></svg>

Before

Width:  |  Height:  |  Size: 439 B

View File

@@ -1 +0,0 @@
<svg width="32" height="32" fill="currentColor" aria-hidden="true" data-icon="holder" viewBox="64 64 896 896"><path d="M300 276.5a56 56 0 1 0 56-97 56 56 0 0 0-56 97m0 284a56 56 0 1 0 56-97 56 56 0 0 0-56 97M640 228a56 56 0 1 0 112 0 56 56 0 0 0-112 0m0 284a56 56 0 1 0 112 0 56 56 0 0 0-112 0M300 844.5a56 56 0 1 0 56-97 56 56 0 0 0-56 97M640 796a56 56 0 1 0 112 0 56 56 0 0 0-112 0"/></svg>

Before

Width:  |  Height:  |  Size: 392 B

View File

@@ -1 +0,0 @@
<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M22 4V2H2v2h9v14.17l-5.5-5.5-1.42 1.41L12 22l7.92-7.92-1.42-1.41-5.5 5.5V4z"/></svg>

Before

Width:  |  Height:  |  Size: 161 B

View File

@@ -1 +0,0 @@
<svg width="32" height="32" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"/></svg>

Before

Width:  |  Height:  |  Size: 235 B

View File

@@ -1 +0,0 @@
<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M3.34 17a10 10 0 0 1-.978-2.326 3 3 0 0 0 .002-5.347A10 10 0 0 1 4.865 4.99a3 3 0 0 0 4.631-2.674 10 10 0 0 1 5.007.002 3 3 0 0 0 4.632 2.672A10 10 0 0 1 20.66 7c.433.749.757 1.53.978 2.326a3 3 0 0 0-.002 5.347 10 10 0 0 1-2.501 4.337 3 3 0 0 0-4.631 2.674 10 10 0 0 1-5.007-.002 3 3 0 0 0-4.632-2.672A10 10 0 0 1 3.34 17m5.66.196a5 5 0 0 1 2.25 2.77q.75.071 1.499.001A5 5 0 0 1 15 17.197a5 5 0 0 1 3.525-.565q.435-.614.748-1.298A5 5 0 0 1 18 12c0-1.26.47-2.437 1.273-3.334a8 8 0 0 0-.75-1.298A5 5 0 0 1 15 6.804a5 5 0 0 1-2.25-2.77q-.75-.071-1.499-.001A5 5 0 0 1 9 6.803a5 5 0 0 1-3.525.565 8 8 0 0 0-.748 1.298A5 5 0 0 1 6 12a5 5 0 0 1-1.273 3.334 8 8 0 0 0 .75 1.298A5 5 0 0 1 9 17.196M12 15a3 3 0 1 1 0-6 3 3 0 0 1 0 6m0-2a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/></svg>

Before

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -1,5 +0,0 @@
import auth from "./src/auth";
const Auth = auth;
export { Auth };

View File

@@ -1,20 +0,0 @@
import { defineComponent, Fragment } from "vue";
import { hasAuth } from "@/router/utils";
export default defineComponent({
name: "Auth",
props: {
value: {
type: undefined,
default: []
}
},
setup(props, { slots }) {
return () => {
if (!slots) return null;
return hasAuth(props.value) ? (
<Fragment>{slots.default?.()}</Fragment>
) : null;
};
}
});

View File

@@ -1,29 +0,0 @@
import { ElCol } from "element-plus";
import { h, defineComponent } from "vue";
// 封装element-plus的el-col组件
export default defineComponent({
name: "ReCol",
props: {
value: {
type: Number,
default: 24
}
},
render() {
const attrs = this.$attrs;
const val = this.value;
return h(
ElCol,
{
xs: val,
sm: val,
md: val,
lg: val,
xl: val,
...attrs
},
{ default: () => this.$slots.default() }
);
}
});

View File

@@ -1,69 +0,0 @@
import { ref } from "vue";
import reDialog from "./index.vue";
import { useTimeoutFn } from "@vueuse/core";
import { withInstall } from "@pureadmin/utils";
import type {
EventType,
ArgsType,
DialogProps,
ButtonProps,
DialogOptions
} from "./type";
const dialogStore = ref<Array<DialogOptions>>([]);
/** 打开弹框 */
const addDialog = (options: DialogOptions) => {
const open = () =>
dialogStore.value.push(Object.assign(options, { visible: true }));
if (options?.openDelay) {
useTimeoutFn(() => {
open();
}, options.openDelay);
} else {
open();
}
};
/** 关闭弹框 */
const closeDialog = (options: DialogOptions, index: number, args?: any) => {
dialogStore.value[index].visible = false;
options.closeCallBack && options.closeCallBack({ options, index, args });
const closeDelay = options?.closeDelay ?? 200;
useTimeoutFn(() => {
dialogStore.value.splice(index, 1);
}, closeDelay);
};
/**
* @description 更改弹框自身属性值
* @param value 属性值
* @param key 属性,默认`title`
* @param index 弹框索引(默认`0`,代表只有一个弹框,对于嵌套弹框要改哪个弹框的属性值就把该弹框索引赋给`index`
*/
const updateDialog = (value: any, key = "title", index = 0) => {
dialogStore.value[index][key] = value;
};
/** 关闭所有弹框 */
const closeAllDialog = () => {
dialogStore.value = [];
};
/** 千万别忘了在下面这三处引入并注册下,放心注册,不使用`addDialog`调用就不会被挂载
* https://github.com/pure-admin/vue-pure-admin/blob/main/src/App.vue#L4
* https://github.com/pure-admin/vue-pure-admin/blob/main/src/App.vue#L12
* https://github.com/pure-admin/vue-pure-admin/blob/main/src/App.vue#L22
*/
const ReDialog = withInstall(reDialog);
export type { EventType, ArgsType, DialogProps, ButtonProps, DialogOptions };
export {
ReDialog,
dialogStore,
addDialog,
closeDialog,
updateDialog,
closeAllDialog
};

View File

@@ -1,188 +0,0 @@
<script setup lang="ts">
import {
type EventType,
type ButtonProps,
type DialogOptions,
closeDialog,
dialogStore
} from "./index";
import { ref, computed } from "vue";
import { isFunction } from "@pureadmin/utils";
import Fullscreen from "@iconify-icons/ri/fullscreen-fill";
import ExitFullscreen from "@iconify-icons/ri/fullscreen-exit-fill";
defineOptions({
name: "ReDialog"
});
const fullscreen = ref(false);
const footerButtons = computed(() => {
return (options: DialogOptions) => {
return options?.footerButtons?.length > 0
? options.footerButtons
: ([
{
label: "取消",
text: true,
bg: true,
btnClick: ({ dialog: { options, index } }) => {
const done = () =>
closeDialog(options, index, { command: "cancel" });
if (options?.beforeCancel && isFunction(options?.beforeCancel)) {
options.beforeCancel(done, { options, index });
} else {
done();
}
}
},
{
label: "确定",
type: "primary",
text: true,
bg: true,
popconfirm: options?.popconfirm,
btnClick: ({ dialog: { options, index } }) => {
const done = () =>
closeDialog(options, index, { command: "sure" });
if (options?.beforeSure && isFunction(options?.beforeSure)) {
options.beforeSure(done, { options, index });
} else {
done();
}
}
}
] as Array<ButtonProps>);
};
});
const fullscreenClass = computed(() => {
return [
"el-icon",
"el-dialog__close",
"-translate-x-2",
"cursor-pointer",
"hover:!text-[red]"
];
});
function eventsCallBack(
event: EventType,
options: DialogOptions,
index: number,
isClickFullScreen = false
) {
if (!isClickFullScreen) fullscreen.value = options?.fullscreen ?? false;
if (options?.[event] && isFunction(options?.[event])) {
return options?.[event]({ options, index });
}
}
function handleClose(
options: DialogOptions,
index: number,
args = { command: "close" }
) {
closeDialog(options, index, args);
eventsCallBack("close", options, index);
}
</script>
<template>
<el-dialog
v-for="(options, index) in dialogStore"
:key="index"
v-bind="options"
v-model="options.visible"
class="pure-dialog"
:fullscreen="fullscreen ? true : options?.fullscreen ? true : false"
@closed="handleClose(options, index)"
@opened="eventsCallBack('open', options, index)"
@openAutoFocus="eventsCallBack('openAutoFocus', options, index)"
@closeAutoFocus="eventsCallBack('closeAutoFocus', options, index)"
>
<!-- header -->
<template
v-if="options?.fullscreenIcon || options?.headerRenderer"
#header="{ close, titleId, titleClass }"
>
<div
v-if="options?.fullscreenIcon"
class="flex items-center justify-between"
>
<span :id="titleId" :class="titleClass">{{ options?.title }}</span>
<i
v-if="!options?.fullscreen"
:class="fullscreenClass"
@click="
() => {
fullscreen = !fullscreen;
eventsCallBack(
'fullscreenCallBack',
{ ...options, fullscreen },
index,
true
);
}
"
>
<IconifyIconOffline
class="pure-dialog-svg"
:icon="
options?.fullscreen
? ExitFullscreen
: fullscreen
? ExitFullscreen
: Fullscreen
"
/>
</i>
</div>
<component
:is="options?.headerRenderer({ close, titleId, titleClass })"
v-else
/>
</template>
<component
v-bind="options?.props"
:is="options.contentRenderer({ options, index })"
@close="args => handleClose(options, index, args)"
/>
<!-- footer -->
<template v-if="!options?.hideFooter" #footer>
<template v-if="options?.footerRenderer">
<component :is="options?.footerRenderer({ options, index })" />
</template>
<span v-else>
<template v-for="(btn, key) in footerButtons(options)" :key="key">
<el-popconfirm
v-if="btn.popconfirm"
v-bind="btn.popconfirm"
@confirm="
btn.btnClick({
dialog: { options, index },
button: { btn, index: key }
})
"
>
<template #reference>
<el-button v-bind="btn">{{ btn?.label }}</el-button>
</template>
</el-popconfirm>
<el-button
v-else
v-bind="btn"
@click="
btn.btnClick({
dialog: { options, index },
button: { btn, index: key }
})
"
>
{{ btn?.label }}
</el-button>
</template>
</span>
</template>
</el-dialog>
</template>

View File

@@ -1,270 +0,0 @@
import type { CSSProperties, VNode, Component } from "vue";
type DoneFn = (cancel?: boolean) => void;
type EventType =
| "open"
| "close"
| "openAutoFocus"
| "closeAutoFocus"
| "fullscreenCallBack";
type ArgsType = {
/** `cancel` 点击取消按钮、`sure` 点击确定按钮、`close` 点击右上角关闭按钮或空白页或按下了esc键 */
command: "cancel" | "sure" | "close";
};
type ButtonType =
| "primary"
| "success"
| "warning"
| "danger"
| "info"
| "text";
/** https://element-plus.org/zh-CN/component/dialog.html#attributes */
type DialogProps = {
/** `Dialog` 的显示与隐藏 */
visible?: boolean;
/** `Dialog` 的标题 */
title?: string;
/** `Dialog` 的宽度,默认 `50%` */
width?: string | number;
/** 是否为全屏 `Dialog`(会一直处于全屏状态,除非弹框关闭),默认 `false``fullscreen` 和 `fullscreenIcon` 都传时只有 `fullscreen` 会生效 */
fullscreen?: boolean;
/** 是否显示全屏操作图标,默认 `false``fullscreen` 和 `fullscreenIcon` 都传时只有 `fullscreen` 会生效 */
fullscreenIcon?: boolean;
/** `Dialog CSS` 中的 `margin-top` 值,默认 `15vh` */
top?: string;
/** 是否需要遮罩层,默认 `true` */
modal?: boolean;
/** `Dialog` 自身是否插入至 `body` 元素上。嵌套的 `Dialog` 必须指定该属性并赋值为 `true`,默认 `false` */
appendToBody?: boolean;
/** 是否在 `Dialog` 出现时将 `body` 滚动锁定,默认 `true` */
lockScroll?: boolean;
/** `Dialog` 的自定义类名 */
class?: string;
/** `Dialog` 的自定义样式 */
style?: CSSProperties;
/** `Dialog` 打开的延时时间,单位毫秒,默认 `0` */
openDelay?: number;
/** `Dialog` 关闭的延时时间,单位毫秒,默认 `0` */
closeDelay?: number;
/** 是否可以通过点击 `modal` 关闭 `Dialog`,默认 `true` */
closeOnClickModal?: boolean;
/** 是否可以通过按下 `ESC` 关闭 `Dialog`,默认 `true` */
closeOnPressEscape?: boolean;
/** 是否显示关闭按钮,默认 `true` */
showClose?: boolean;
/** 关闭前的回调,会暂停 `Dialog` 的关闭. 回调函数内执行 `done` 参数方法的时候才是真正关闭对话框的时候 */
beforeClose?: (done: DoneFn) => void;
/** 为 `Dialog` 启用可拖拽功能,默认 `false` */
draggable?: boolean;
/** 是否让 `Dialog` 的 `header` 和 `footer` 部分居中排列,默认 `false` */
center?: boolean;
/** 是否水平垂直对齐对话框,默认 `false` */
alignCenter?: boolean;
/** 当关闭 `Dialog` 时,销毁其中的元素,默认 `false` */
destroyOnClose?: boolean;
};
//element-plus.org/zh-CN/component/popconfirm.html#attributes
type Popconfirm = {
/** 标题 */
title?: string;
/** 确认按钮文字 */
confirmButtonText?: string;
/** 取消按钮文字 */
cancelButtonText?: string;
/** 确认按钮类型,默认 `primary` */
confirmButtonType?: ButtonType;
/** 取消按钮类型,默认 `text` */
cancelButtonType?: ButtonType;
/** 自定义图标,默认 `QuestionFilled` */
icon?: string | Component;
/** `Icon` 颜色,默认 `#f90` */
iconColor?: string;
/** 是否隐藏 `Icon`,默认 `false` */
hideIcon?: boolean;
/** 关闭时的延迟,默认 `200` */
hideAfter?: number;
/** 是否将 `popover` 的下拉列表插入至 `body` 元素,默认 `true` */
teleported?: boolean;
/** 当 `popover` 组件长时间不触发且 `persistent` 属性设置为 `false` 时, `popover` 将会被删除,默认 `false` */
persistent?: boolean;
/** 弹层宽度,最小宽度 `150px`,默认 `150` */
width?: string | number;
};
type BtnClickDialog = {
options?: DialogOptions;
index?: number;
};
type BtnClickButton = {
btn?: ButtonProps;
index?: number;
};
/** https://element-plus.org/zh-CN/component/button.html#button-attributes */
type ButtonProps = {
/** 按钮文字 */
label: string;
/** 按钮尺寸 */
size?: "large" | "default" | "small";
/** 按钮类型 */
type?: "primary" | "success" | "warning" | "danger" | "info";
/** 是否为朴素按钮,默认 `false` */
plain?: boolean;
/** 是否为文字按钮,默认 `false` */
text?: boolean;
/** 是否显示文字按钮背景颜色,默认 `false` */
bg?: boolean;
/** 是否为链接按钮,默认 `false` */
link?: boolean;
/** 是否为圆角按钮,默认 `false` */
round?: boolean;
/** 是否为圆形按钮,默认 `false` */
circle?: boolean;
/** 确认按钮的 `Popconfirm` 气泡确认框相关配置 */
popconfirm?: Popconfirm;
/** 是否为加载中状态,默认 `false` */
loading?: boolean;
/** 自定义加载中状态图标组件 */
loadingIcon?: string | Component;
/** 按钮是否为禁用状态,默认 `false` */
disabled?: boolean;
/** 图标组件 */
icon?: string | Component;
/** 是否开启原生 `autofocus` 属性,默认 `false` */
autofocus?: boolean;
/** 原生 `type` 属性,默认 `button` */
nativeType?: "button" | "submit" | "reset";
/** 自动在两个中文字符之间插入空格 */
autoInsertSpace?: boolean;
/** 自定义按钮颜色, 并自动计算 `hover` 和 `active` 触发后的颜色 */
color?: string;
/** `dark` 模式, 意味着自动设置 `color` 为 `dark` 模式的颜色,默认 `false` */
dark?: boolean;
/** 自定义元素标签 */
tag?: string | Component;
/** 点击按钮后触发的回调 */
btnClick?: ({
dialog,
button
}: {
/** 当前 `Dialog` 信息 */
dialog: BtnClickDialog;
/** 当前 `button` 信息 */
button: BtnClickButton;
}) => void;
};
interface DialogOptions extends DialogProps {
/** 内容区组件的 `props`,可通过 `defineProps` 接收 */
props?: any;
/** 是否隐藏 `Dialog` 按钮操作区的内容 */
hideFooter?: boolean;
/** 确认按钮的 `Popconfirm` 气泡确认框相关配置 */
popconfirm?: Popconfirm;
/**
* @description 自定义对话框标题的内容渲染器
* @see {@link https://element-plus.org/zh-CN/component/dialog.html#%E8%87%AA%E5%AE%9A%E4%B9%89%E5%A4%B4%E9%83%A8}
*/
headerRenderer?: ({
close,
titleId,
titleClass
}: {
close: Function;
titleId: string;
titleClass: string;
}) => VNode | Component;
/** 自定义内容渲染器 */
contentRenderer?: ({
options,
index
}: {
options: DialogOptions;
index: number;
}) => VNode | Component;
/** 自定义按钮操作区的内容渲染器,会覆盖`footerButtons`以及默认的 `取消` 和 `确定` 按钮 */
footerRenderer?: ({
options,
index
}: {
options: DialogOptions;
index: number;
}) => VNode | Component;
/** 自定义底部按钮操作 */
footerButtons?: Array<ButtonProps>;
/** `Dialog` 打开后的回调 */
open?: ({
options,
index
}: {
options: DialogOptions;
index: number;
}) => void;
/** `Dialog` 关闭后的回调只有点击右上角关闭按钮或空白页或按下了esc键关闭页面时才会触发 */
close?: ({
options,
index
}: {
options: DialogOptions;
index: number;
}) => void;
/** `Dialog` 关闭后的回调。 `args` 返回的 `command` 值解析:`cancel` 点击取消按钮、`sure` 点击确定按钮、`close` 点击右上角关闭按钮或空白页或按下了esc键 */
closeCallBack?: ({
options,
index,
args
}: {
options: DialogOptions;
index: number;
args: any;
}) => void;
/** 点击全屏按钮时的回调 */
fullscreenCallBack?: ({
options,
index
}: {
options: DialogOptions;
index: number;
}) => void;
/** 输入焦点聚焦在 `Dialog` 内容时的回调 */
openAutoFocus?: ({
options,
index
}: {
options: DialogOptions;
index: number;
}) => void;
/** 输入焦点从 `Dialog` 内容失焦时的回调 */
closeAutoFocus?: ({
options,
index
}: {
options: DialogOptions;
index: number;
}) => void;
/** 点击底部取消按钮的回调,会暂停 `Dialog` 的关闭. 回调函数内执行 `done` 参数方法的时候才是真正关闭对话框的时候 */
beforeCancel?: (
done: Function,
{
options,
index
}: {
options: DialogOptions;
index: number;
}
) => void;
/** 点击底部确定按钮的回调,会暂停 `Dialog` 的关闭. 回调函数内执行 `done` 参数方法的时候才是真正关闭对话框的时候 */
beforeSure?: (
done: Function,
{
options,
index
}: {
options: DialogOptions;
index: number;
}
) => void;
}
export type { EventType, ArgsType, DialogProps, ButtonProps, DialogOptions };

View File

@@ -1,12 +0,0 @@
import iconifyIconOffline from "./src/iconifyIconOffline";
import iconifyIconOnline from "./src/iconifyIconOnline";
import fontIcon from "./src/iconfont";
/** 本地图标组件 */
const IconifyIconOffline = iconifyIconOffline;
/** 在线图标组件 */
const IconifyIconOnline = iconifyIconOnline;
/** `iconfont`组件 */
const FontIcon = fontIcon;
export { IconifyIconOffline, IconifyIconOnline, FontIcon };

View File

@@ -1,61 +0,0 @@
import type { iconType } from "./types";
import { h, defineComponent, type Component } from "vue";
import { IconifyIconOnline, IconifyIconOffline, FontIcon } from "../index";
/**
* 支持 `iconfont`、自定义 `svg` 以及 `iconify` 中所有的图标
* @see 点击查看文档图标篇 {@link https://pure-admin.github.io/pure-admin-doc/pages/icon/}
* @param icon 必传 图标
* @param attrs 可选 iconType 属性
* @returns Component
*/
export function useRenderIcon(icon: any, attrs?: iconType): Component {
// iconfont
const ifReg = /^IF-/;
// typeof icon === "function" 属于SVG
if (ifReg.test(icon)) {
// iconfont
const name = icon.split(ifReg)[1];
const iconName = name.slice(
0,
name.indexOf(" ") == -1 ? name.length : name.indexOf(" ")
);
const iconType = name.slice(name.indexOf(" ") + 1, name.length);
return defineComponent({
name: "FontIcon",
render() {
return h(FontIcon, {
icon: iconName,
iconType,
...attrs
});
}
});
} else if (typeof icon === "function" || typeof icon?.render === "function") {
// svg
return attrs ? h(icon, { ...attrs }) : icon;
} else if (typeof icon === "object") {
return defineComponent({
name: "OfflineIcon",
render() {
return h(IconifyIconOffline, {
icon: icon,
...attrs
});
}
});
} else {
// 通过是否存在 : 符号来判断是在线还是本地图标,存在即是在线图标,反之
return defineComponent({
name: "Icon",
render() {
const IconifyIcon =
icon && icon.includes(":") ? IconifyIconOnline : IconifyIconOffline;
return h(IconifyIcon, {
icon: icon,
...attrs
});
}
});
}
}

View File

@@ -1,48 +0,0 @@
import { h, defineComponent } from "vue";
// 封装iconfont组件默认`font-class`引用模式,支持`unicode`引用、`font-class`引用、`symbol`引用 https://www.iconfont.cn/help/detail?spm=a313x.7781069.1998910419.20&helptype=code
export default defineComponent({
name: "FontIcon",
props: {
icon: {
type: String,
default: ""
}
},
render() {
const attrs = this.$attrs;
if (Object.keys(attrs).includes("uni") || attrs?.iconType === "uni") {
return h(
"i",
{
class: "iconfont",
...attrs
},
this.icon
);
} else if (
Object.keys(attrs).includes("svg") ||
attrs?.iconType === "svg"
) {
return h(
"svg",
{
class: "icon-svg",
"aria-hidden": true
},
{
default: () => [
h("use", {
"xlink:href": `#${this.icon}`
})
]
}
);
} else {
return h("i", {
class: `iconfont ${this.icon}`,
...attrs
});
}
}
});

View File

@@ -1,30 +0,0 @@
import { h, defineComponent } from "vue";
import { Icon as IconifyIcon, addIcon } from "@iconify/vue/dist/offline";
// Iconify Icon在Vue里本地使用用于内网环境
export default defineComponent({
name: "IconifyIconOffline",
components: { IconifyIcon },
props: {
icon: {
default: null
}
},
render() {
if (typeof this.icon === "object") addIcon(this.icon, this.icon);
const attrs = this.$attrs;
return h(
IconifyIcon,
{
icon: this.icon,
style: attrs?.style
? Object.assign(attrs.style, { outline: "none" })
: { outline: "none" },
...attrs
},
{
default: () => []
}
);
}
});

View File

@@ -1,30 +0,0 @@
import { h, defineComponent } from "vue";
import { Icon as IconifyIcon } from "@iconify/vue";
// Iconify Icon在Vue里在线使用用于外网环境
export default defineComponent({
name: "IconifyIconOnline",
components: { IconifyIcon },
props: {
icon: {
type: String,
default: ""
}
},
render() {
const attrs = this.$attrs;
return h(
IconifyIcon,
{
icon: `${this.icon}`,
style: attrs?.style
? Object.assign(attrs.style, { outline: "none" })
: { outline: "none" },
...attrs
},
{
default: () => []
}
);
}
});

View File

@@ -1,14 +0,0 @@
// 这里存放本地图标,在 src/layout/index.vue 文件中加载,避免在首启动加载
import { addIcon } from "@iconify/vue/dist/offline";
// 本地菜单图标,后端在路由的 icon 中返回对应的图标字符串并且前端在此处使用 addIcon 添加即可渲染菜单图标
// @iconify-icons/ep
import Lollipop from "@iconify-icons/ep/lollipop";
import HomeFilled from "@iconify-icons/ep/home-filled";
addIcon("ep:lollipop", Lollipop);
addIcon("ep:home-filled", HomeFilled);
// @iconify-icons/ri
import Search from "@iconify-icons/ri/search-line";
import InformationLine from "@iconify-icons/ri/information-line";
addIcon("ri:search-line", Search);
addIcon("ri:information-line", InformationLine);

View File

@@ -1,20 +0,0 @@
export interface iconType {
// iconify (https://docs.iconify.design/icon-components/vue/#properties)
inline?: boolean;
width?: string | number;
height?: string | number;
horizontalFlip?: boolean;
verticalFlip?: boolean;
flip?: string;
rotate?: number | string;
color?: string;
horizontalAlign?: boolean;
verticalAlign?: boolean;
align?: string;
onLoad?: Function;
includes?: Function;
// svg 需要什么SVG属性自行添加
fill?: string;
// all icon
style?: object;
}

View File

@@ -1,5 +0,0 @@
import pureTableBar from "./src/bar";
import { withInstall } from "@pureadmin/utils";
/** 配合 `@pureadmin/table` 实现快速便捷的表格操作 https://github.com/pure-admin/pure-admin-table */
export const PureTableBar = withInstall(pureTableBar);

View File

@@ -1,366 +0,0 @@
import Sortable from "sortablejs";
import { useEpThemeStoreHook } from "@/store/modules/epTheme";
import {
type PropType,
ref,
unref,
computed,
nextTick,
defineComponent,
getCurrentInstance
} from "vue";
import {
delay,
cloneDeep,
isBoolean,
isFunction,
getKeyList
} from "@pureadmin/utils";
import DragIcon from "@/assets/table-bar/drag.svg?component";
import ExpandIcon from "@/assets/table-bar/expand.svg?component";
import RefreshIcon from "@/assets/table-bar/refresh.svg?component";
import SettingIcon from "@/assets/table-bar/settings.svg?component";
import CollapseIcon from "@/assets/table-bar/collapse.svg?component";
const props = {
/** 头部最左边的标题 */
title: {
type: String,
default: "列表"
},
/** 对于树形表格如果想启用展开和折叠功能传入当前表格的ref即可 */
tableRef: {
type: Object as PropType<any>
},
/** 需要展示的列 */
columns: {
type: Array as PropType<TableColumnList>,
default: () => []
},
isExpandAll: {
type: Boolean,
default: true
},
tableKey: {
type: [String, Number] as PropType<string | number>,
default: "0"
}
};
export default defineComponent({
name: "PureTableBar",
props,
emits: ["refresh"],
setup(props, { emit, slots, attrs }) {
const size = ref("default");
const loading = ref(false);
const checkAll = ref(true);
const isIndeterminate = ref(false);
const instance = getCurrentInstance()!;
const isExpandAll = ref(props.isExpandAll);
const filterColumns = cloneDeep(props?.columns).filter(column =>
isBoolean(column?.hide)
? !column.hide
: !(isFunction(column?.hide) && column?.hide())
);
let checkColumnList = getKeyList(cloneDeep(props?.columns), "label");
const checkedColumns = ref(getKeyList(cloneDeep(filterColumns), "label"));
const dynamicColumns = ref(cloneDeep(props?.columns));
const getDropdownItemStyle = computed(() => {
return s => {
return {
background:
s === size.value ? useEpThemeStoreHook().epThemeColor : "",
color: s === size.value ? "#fff" : "var(--el-text-color-primary)"
};
};
});
const iconClass = computed(() => {
return [
"text-black",
"dark:text-white",
"duration-100",
"hover:!text-primary",
"cursor-pointer",
"outline-none"
];
});
const topClass = computed(() => {
return [
"flex",
"justify-between",
"pt-[3px]",
"px-[11px]",
"border-b-[1px]",
"border-solid",
"border-[#dcdfe6]",
"dark:border-[#303030]"
];
});
function onReFresh() {
loading.value = true;
emit("refresh");
delay(500).then(() => (loading.value = false));
}
function onExpand() {
isExpandAll.value = !isExpandAll.value;
toggleRowExpansionAll(props.tableRef.data, isExpandAll.value);
}
function toggleRowExpansionAll(data, isExpansion) {
data.forEach(item => {
props.tableRef.toggleRowExpansion(item, isExpansion);
if (item.children !== undefined && item.children !== null) {
toggleRowExpansionAll(item.children, isExpansion);
}
});
}
function handleCheckAllChange(val: boolean) {
checkedColumns.value = val ? checkColumnList : [];
isIndeterminate.value = false;
dynamicColumns.value.map(column =>
val ? (column.hide = false) : (column.hide = true)
);
}
function handleCheckedColumnsChange(value: string[]) {
checkedColumns.value = value;
const checkedCount = value.length;
checkAll.value = checkedCount === checkColumnList.length;
isIndeterminate.value =
checkedCount > 0 && checkedCount < checkColumnList.length;
}
function handleCheckColumnListChange(val: boolean, label: string) {
dynamicColumns.value.filter(item => item.label === label)[0].hide = !val;
}
async function onReset() {
checkAll.value = true;
isIndeterminate.value = false;
dynamicColumns.value = cloneDeep(props?.columns);
checkColumnList = [];
checkColumnList = await getKeyList(cloneDeep(props?.columns), "label");
checkedColumns.value = getKeyList(cloneDeep(filterColumns), "label");
}
const dropdown = {
dropdown: () => (
<el-dropdown-menu class="translation">
<el-dropdown-item
style={getDropdownItemStyle.value("large")}
onClick={() => (size.value = "large")}
>
</el-dropdown-item>
<el-dropdown-item
style={getDropdownItemStyle.value("default")}
onClick={() => (size.value = "default")}
>
</el-dropdown-item>
<el-dropdown-item
style={getDropdownItemStyle.value("small")}
onClick={() => (size.value = "small")}
>
</el-dropdown-item>
</el-dropdown-menu>
)
};
/** 列展示拖拽排序 */
const rowDrop = (event: { preventDefault: () => void }) => {
event.preventDefault();
nextTick(() => {
const wrapper: HTMLElement = (
instance?.proxy?.$refs[`GroupRef${unref(props.tableKey)}`] as any
).$el.firstElementChild;
Sortable.create(wrapper, {
animation: 300,
handle: ".drag-btn",
onEnd: ({ newIndex, oldIndex, item }) => {
const targetThElem = item;
const wrapperElem = targetThElem.parentNode as HTMLElement;
const oldColumn = dynamicColumns.value[oldIndex];
const newColumn = dynamicColumns.value[newIndex];
if (oldColumn?.fixed || newColumn?.fixed) {
// 当前列存在fixed属性 则不可拖拽
const oldThElem = wrapperElem.children[oldIndex] as HTMLElement;
if (newIndex > oldIndex) {
wrapperElem.insertBefore(targetThElem, oldThElem);
} else {
wrapperElem.insertBefore(
targetThElem,
oldThElem ? oldThElem.nextElementSibling : oldThElem
);
}
return;
}
const currentRow = dynamicColumns.value.splice(oldIndex, 1)[0];
dynamicColumns.value.splice(newIndex, 0, currentRow);
}
});
});
};
const isFixedColumn = (label: string) => {
return dynamicColumns.value.filter(item => item.label === label)[0].fixed
? true
: false;
};
const rendTippyProps = (content: string) => {
// https://vue-tippy.netlify.app/props
return {
content,
offset: [0, 18],
duration: [300, 0],
followCursor: true,
hideOnClick: "toggle"
};
};
const reference = {
reference: () => (
<SettingIcon
class={["w-[16px]", iconClass.value]}
v-tippy={rendTippyProps("列设置")}
/>
)
};
return () => (
<>
<div {...attrs} class="w-[99/100] mt-2 px-2 pb-2 bg-bg_color">
<div class="flex justify-between w-full h-[60px] p-4">
{slots?.title ? (
slots.title()
) : (
<p class="font-bold truncate">{props.title}</p>
)}
<div class="flex items-center justify-around">
{slots?.buttons ? (
<div class="flex mr-4">{slots.buttons()}</div>
) : null}
{props.tableRef?.size ? (
<>
<ExpandIcon
class={["w-[16px]", iconClass.value]}
style={{
transform: isExpandAll.value ? "none" : "rotate(-90deg)"
}}
v-tippy={rendTippyProps(
isExpandAll.value ? "折叠" : "展开"
)}
onClick={() => onExpand()}
/>
<el-divider direction="vertical" />
</>
) : null}
<RefreshIcon
class={[
"w-[16px]",
iconClass.value,
loading.value ? "animate-spin" : ""
]}
v-tippy={rendTippyProps("刷新")}
onClick={() => onReFresh()}
/>
<el-divider direction="vertical" />
<el-dropdown
v-slots={dropdown}
trigger="click"
v-tippy={rendTippyProps("密度")}
>
<CollapseIcon class={["w-[16px]", iconClass.value]} />
</el-dropdown>
<el-divider direction="vertical" />
<el-popover
v-slots={reference}
placement="bottom-start"
popper-style={{ padding: 0 }}
width="200"
trigger="click"
>
<div class={[topClass.value]}>
<el-checkbox
class="!-mr-1"
label="列展示"
v-model={checkAll.value}
indeterminate={isIndeterminate.value}
onChange={value => handleCheckAllChange(value)}
/>
<el-button type="primary" link onClick={() => onReset()}>
</el-button>
</div>
<div class="pt-[6px] pl-[11px]">
<el-scrollbar max-height="36vh">
<el-checkbox-group
ref={`GroupRef${unref(props.tableKey)}`}
modelValue={checkedColumns.value}
onChange={value => handleCheckedColumnsChange(value)}
>
<el-space
direction="vertical"
alignment="flex-start"
size={0}
>
{checkColumnList.map((item, index) => {
return (
<div class="flex items-center">
<DragIcon
class={[
"drag-btn w-[16px] mr-2",
isFixedColumn(item)
? "!cursor-no-drop"
: "!cursor-grab"
]}
onMouseenter={(event: {
preventDefault: () => void;
}) => rowDrop(event)}
/>
<el-checkbox
key={index}
label={item}
value={item}
onChange={value =>
handleCheckColumnListChange(value, item)
}
>
<span
title={item}
class="inline-block w-[120px] truncate hover:text-text_color_primary"
>
{item}
</span>
</el-checkbox>
</div>
);
})}
</el-space>
</el-checkbox-group>
</el-scrollbar>
</div>
</el-popover>
</div>
</div>
{slots.default({
size: size.value,
dynamicColumns: dynamicColumns.value
})}
</div>
</>
);
}
});

View File

@@ -1,8 +0,0 @@
import reSegmented from "./src/index";
import { withInstall } from "@pureadmin/utils";
/** 分段控制器组件 */
export const ReSegmented = withInstall(reSegmented);
export default ReSegmented;
export type { OptionsType } from "./src/type";

View File

@@ -1,157 +0,0 @@
.pure-segmented {
--pure-control-padding-horizontal: 12px;
--pure-control-padding-horizontal-sm: 8px;
--pure-segmented-track-padding: 2px;
--pure-segmented-line-width: 1px;
--pure-segmented-border-radius-small: 4px;
--pure-segmented-border-radius-base: 6px;
--pure-segmented-border-radius-large: 8px;
box-sizing: border-box;
display: inline-block;
padding: var(--pure-segmented-track-padding);
font-size: var(--el-font-size-base);
color: rgba(0, 0, 0, 0.65);
background-color: rgb(0 0 0 / 4%);
border-radius: var(--pure-segmented-border-radius-base);
}
.pure-segmented-block {
display: flex;
}
.pure-segmented-block .pure-segmented-item {
flex: 1;
min-width: 0;
}
.pure-segmented-block .pure-segmented-item > .pure-segmented-item-label > span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* small */
.pure-segmented.pure-segmented--small {
border-radius: var(--pure-segmented-border-radius-small);
}
.pure-segmented.pure-segmented--small .pure-segmented-item {
border-radius: var(--el-border-radius-small);
}
.pure-segmented.pure-segmented--small .pure-segmented-item > div {
min-height: calc(
var(--el-component-size-small) - var(--pure-segmented-track-padding) * 2
);
line-height: calc(
var(--el-component-size-small) - var(--pure-segmented-track-padding) * 2
);
padding: 0
calc(
var(--pure-control-padding-horizontal-sm) -
var(--pure-segmented-line-width)
);
}
/* large */
.pure-segmented.pure-segmented--large {
border-radius: var(--pure-segmented-border-radius-large);
}
.pure-segmented.pure-segmented--large .pure-segmented-item {
border-radius: calc(
var(--el-border-radius-base) + var(--el-border-radius-small)
);
}
.pure-segmented.pure-segmented--large .pure-segmented-item > div {
min-height: calc(
var(--el-component-size-large) - var(--pure-segmented-track-padding) * 2
);
line-height: calc(
var(--el-component-size-large) - var(--pure-segmented-track-padding) * 2
);
padding: 0
calc(
var(--pure-control-padding-horizontal) - var(--pure-segmented-line-width)
);
font-size: var(--el-font-size-medium);
}
/* default */
.pure-segmented-item {
position: relative;
text-align: center;
cursor: pointer;
border-radius: var(--el-border-radius-base);
transition: all 0.1s cubic-bezier(0.645, 0.045, 0.355, 1);
}
.pure-segmented .pure-segmented-item > div {
min-height: calc(
var(--el-component-size) - var(--pure-segmented-track-padding) * 2
);
line-height: calc(
var(--el-component-size) - var(--pure-segmented-track-padding) * 2
);
padding: 0
calc(
var(--pure-control-padding-horizontal) - var(--pure-segmented-line-width)
);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: 0.1s;
}
.pure-segmented-group {
position: relative;
display: flex;
align-items: stretch;
justify-items: flex-start;
width: 100%;
}
.pure-segmented-item-selected {
position: absolute;
top: 0;
left: 0;
box-sizing: border-box;
display: none;
width: 0;
height: 100%;
padding: 4px 0;
background-color: #fff;
border-radius: 4px;
box-shadow:
0 2px 8px -2px rgb(0 0 0 / 5%),
0 1px 4px -1px rgb(0 0 0 / 7%),
0 0 1px rgb(0 0 0 / 7%);
transition:
transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1),
width 0.5s cubic-bezier(0.645, 0.045, 0.355, 1);
will-change: transform, width;
}
.pure-segmented-item > input {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.pure-segmented-item-label {
display: flex;
align-items: center;
justify-content: center;
}
.pure-segmented-item-icon svg {
width: 16px;
height: 16px;
}
.pure-segmented-item-disabled {
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
}

View File

@@ -1,216 +0,0 @@
import "./index.css";
import type { OptionsType } from "./type";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import {
useDark,
isNumber,
isFunction,
useResizeObserver
} from "@pureadmin/utils";
import {
type PropType,
h,
ref,
toRef,
watch,
nextTick,
defineComponent,
getCurrentInstance
} from "vue";
const props = {
options: {
type: Array<OptionsType>,
default: () => []
},
/** 默认选中,按照第一个索引为 `0` 的模式,可选(`modelValue`只有传`number`类型时才为响应式) */
modelValue: {
type: undefined,
require: false,
default: "0"
},
/** 将宽度调整为父元素宽度 */
block: {
type: Boolean,
default: false
},
/** 控件尺寸 */
size: {
type: String as PropType<"small" | "default" | "large">
},
/** 是否全局禁用,默认 `false` */
disabled: {
type: Boolean,
default: false
},
/** 当内容发生变化时,设置 `resize` 可使其自适应容器位置 */
resize: {
type: Boolean,
default: false
}
};
export default defineComponent({
name: "ReSegmented",
props,
emits: ["change", "update:modelValue"],
setup(props, { emit }) {
const width = ref(0);
const translateX = ref(0);
const { isDark } = useDark();
const initStatus = ref(false);
const curMouseActive = ref(-1);
const segmentedItembg = ref("");
const instance = getCurrentInstance()!;
const curIndex = isNumber(props.modelValue)
? toRef(props, "modelValue")
: ref(0);
function handleChange({ option, index }, event: Event) {
if (props.disabled || option.disabled) return;
event.preventDefault();
isNumber(props.modelValue)
? emit("update:modelValue", index)
: (curIndex.value = index);
segmentedItembg.value = "";
emit("change", { index, option });
}
function handleMouseenter({ option, index }, event: Event) {
if (props.disabled) return;
event.preventDefault();
curMouseActive.value = index;
if (option.disabled || curIndex.value === index) {
segmentedItembg.value = "";
} else {
segmentedItembg.value = isDark.value
? "#1f1f1f"
: "rgba(0, 0, 0, 0.06)";
}
}
function handleMouseleave(_, event: Event) {
if (props.disabled) return;
event.preventDefault();
curMouseActive.value = -1;
}
function handleInit(index = curIndex.value) {
nextTick(() => {
const curLabelRef = instance?.proxy?.$refs[`labelRef${index}`] as ElRef;
if (!curLabelRef) return;
width.value = curLabelRef.clientWidth;
translateX.value = curLabelRef.offsetLeft;
initStatus.value = true;
});
}
function handleResizeInit() {
useResizeObserver(".pure-segmented", () => {
nextTick(() => {
handleInit(curIndex.value);
});
});
}
(props.block || props.resize) && handleResizeInit();
watch(
() => curIndex.value,
index => {
nextTick(() => {
handleInit(index);
});
},
{
immediate: true
}
);
watch(() => props.size, handleResizeInit, {
immediate: true
});
const rendLabel = () => {
return props.options.map((option, index) => {
return (
<label
ref={`labelRef${index}`}
class={[
"pure-segmented-item",
(props.disabled || option?.disabled) &&
"pure-segmented-item-disabled"
]}
style={{
background:
curMouseActive.value === index ? segmentedItembg.value : "",
color: props.disabled
? null
: !option.disabled &&
(curIndex.value === index || curMouseActive.value === index)
? isDark.value
? "rgba(255, 255, 255, 0.85)"
: "rgba(0,0,0,.88)"
: ""
}}
onMouseenter={event => handleMouseenter({ option, index }, event)}
onMouseleave={event => handleMouseleave({ option, index }, event)}
onClick={event => handleChange({ option, index }, event)}
>
<input type="radio" name="segmented" />
<div
class="pure-segmented-item-label"
v-tippy={{
content: option?.tip,
zIndex: 41000
}}
>
{option.icon && !isFunction(option.label) ? (
<span
class="pure-segmented-item-icon"
style={{ marginRight: option.label ? "6px" : 0 }}
>
{h(
useRenderIcon(option.icon, {
...option?.iconAttrs
})
)}
</span>
) : null}
{option.label ? (
isFunction(option.label) ? (
h(option.label)
) : (
<span>{option.label}</span>
)
) : null}
</div>
</label>
);
});
};
return () => (
<div
class={{
"pure-segmented": true,
"pure-segmented-block": props.block,
"pure-segmented--large": props.size === "large",
"pure-segmented--small": props.size === "small"
}}
>
<div class="pure-segmented-group">
<div
class="pure-segmented-item-selected"
style={{
width: `${width.value}px`,
transform: `translateX(${translateX.value}px)`,
display: initStatus.value ? "block" : "none"
}}
></div>
{rendLabel()}
</div>
</div>
);
}
});

View File

@@ -1,20 +0,0 @@
import type { VNode, Component } from "vue";
import type { iconType } from "@/components/ReIcon/src/types.ts";
export interface OptionsType {
/** 文字 */
label?: string | (() => VNode | Component);
/**
* @description 图标,采用平台内置的 `useRenderIcon` 函数渲染
* @see {@link 用法参考 https://pure-admin.github.io/pure-admin-doc/pages/icon/#%E9%80%9A%E7%94%A8%E5%9B%BE%E6%A0%87-userendericon-hooks }
*/
icon?: string | Component;
/** 图标属性、样式配置 */
iconAttrs?: iconType;
/** 值 */
value?: any;
/** 是否禁用 */
disabled?: boolean;
/** `tooltip` 提示 */
tip?: string;
}

View File

@@ -1,7 +0,0 @@
import reText from "./src/index.vue";
import { withInstall } from "@pureadmin/utils";
/** 支持`Tooltip`提示的文本省略组件 */
export const ReText = withInstall(reText);
export default ReText;

View File

@@ -1,66 +0,0 @@
<script lang="ts" setup>
import { h, onMounted, ref, useSlots } from "vue";
import { type TippyOptions, useTippy } from "vue-tippy";
defineOptions({
name: "ReText"
});
const props = defineProps({
// 行数
lineClamp: {
type: [String, Number]
},
tippyProps: {
type: Object as PropType<TippyOptions>,
default: () => ({})
}
});
const $slots = useSlots();
const textRef = ref();
const tippyFunc = ref();
const isTextEllipsis = (el: HTMLElement) => {
if (!props.lineClamp) {
// 单行省略判断
return el.scrollWidth > el.clientWidth;
} else {
// 多行省略判断
return el.scrollHeight > el.clientHeight;
}
};
const getTippyProps = () => ({
content: h($slots.content || $slots.default),
...props.tippyProps
});
function handleHover(event: MouseEvent) {
if (isTextEllipsis(event.target as HTMLElement)) {
tippyFunc.value.setProps(getTippyProps());
tippyFunc.value.enable();
} else {
tippyFunc.value.disable();
}
}
onMounted(() => {
tippyFunc.value = useTippy(textRef.value?.$el, getTippyProps());
});
</script>
<template>
<el-text
v-bind="{
truncated: !lineClamp,
lineClamp,
...$attrs
}"
ref="textRef"
@mouseover.self="handleHover"
>
<slot />
</el-text>
</template>

View File

@@ -1,55 +0,0 @@
import axios from "axios";
import type { App } from "vue";
let config: object = {};
const { VITE_PUBLIC_PATH } = import.meta.env;
const setConfig = (cfg?: unknown) => {
config = Object.assign(config, cfg);
};
const getConfig = (key?: string): PlatformConfigs => {
if (typeof key === "string") {
const arr = key.split(".");
if (arr && arr.length) {
let data = config;
arr.forEach(v => {
if (data && typeof data[v] !== "undefined") {
data = data[v];
} else {
data = null;
}
});
return data;
}
}
return config;
};
/** 获取项目动态全局配置 */
export const getPlatformConfig = async (app: App): Promise<undefined> => {
app.config.globalProperties.$config = getConfig();
return axios({
method: "get",
url: `${VITE_PUBLIC_PATH}platform-config.json`
})
.then(({ data: config }) => {
let $config = app.config.globalProperties.$config;
// 自动注入系统配置
if (app && $config && typeof config === "object") {
$config = Object.assign($config, config);
app.config.globalProperties.$config = $config;
// 设置全局配置
setConfig($config);
}
return $config;
})
.catch(() => {
throw "请在public文件夹下添加platform-config.json配置文件";
});
};
/** 本地响应式存储的命名空间 */
const responsiveStorageNameSpace = () => getConfig().ResponsiveStorageNameSpace;
export { getConfig, setConfig, responsiveStorageNameSpace };

View File

@@ -1,15 +0,0 @@
import { hasAuth } from "@/router/utils";
import type { Directive, DirectiveBinding } from "vue";
export const auth: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<string | Array<string>>) {
const { value } = binding;
if (value) {
!hasAuth(value) && el.parentNode?.removeChild(el);
} else {
throw new Error(
"[Directive: auth]: need auths! Like v-auth=\"['btn.add','btn.edit']\""
);
}
}
};

View File

@@ -1,33 +0,0 @@
import { message } from "@/utils/message";
import { useEventListener } from "@vueuse/core";
import { copyTextToClipboard } from "@pureadmin/utils";
import type { Directive, DirectiveBinding } from "vue";
export interface CopyEl extends HTMLElement {
copyValue: string;
}
/** 文本复制指令(默认双击复制) */
export const copy: Directive = {
mounted(el: CopyEl, binding: DirectiveBinding<string>) {
const { value } = binding;
if (value) {
el.copyValue = value;
const arg = binding.arg ?? "dblclick";
// Register using addEventListener on mounted, and removeEventListener automatically on unmounted
useEventListener(el, arg, () => {
const success = copyTextToClipboard(el.copyValue);
success
? message("复制成功", { type: "success" })
: message("复制失败", { type: "error" });
});
} else {
throw new Error(
'[Directive: copy]: need value! Like v-copy="modelValue"'
);
}
},
updated(el: CopyEl, binding: DirectiveBinding) {
el.copyValue = binding.value;
}
};

View File

@@ -1,5 +0,0 @@
export * from "./auth";
export * from "./copy";
export * from "./longpress";
export * from "./optimize";
export * from "./ripple";

View File

@@ -1,63 +0,0 @@
import { useEventListener } from "@vueuse/core";
import type { Directive, DirectiveBinding } from "vue";
import { subBefore, subAfter, isFunction } from "@pureadmin/utils";
export const longpress: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<Function>) {
const cb = binding.value;
if (cb && isFunction(cb)) {
let timer = null;
let interTimer = null;
let num = 500;
let interNum = null;
const isInter = binding?.arg?.includes(":") ?? false;
if (isInter) {
num = Number(subBefore(binding.arg, ":"));
interNum = Number(subAfter(binding.arg, ":"));
} else if (binding.arg) {
num = Number(binding.arg);
}
const clear = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
if (interTimer) {
clearInterval(interTimer);
interTimer = null;
}
};
const onDownInter = (ev: PointerEvent) => {
ev.preventDefault();
if (interTimer === null) {
interTimer = setInterval(() => cb(), interNum);
}
};
const onDown = (ev: PointerEvent) => {
clear();
ev.preventDefault();
if (timer === null) {
timer = isInter
? setTimeout(() => {
cb();
onDownInter(ev);
}, num)
: setTimeout(() => cb(), num);
}
};
// Register using addEventListener on mounted, and removeEventListener automatically on unmounted
useEventListener(el, "pointerdown", onDown);
useEventListener(el, "pointerup", clear);
useEventListener(el, "pointerleave", clear);
} else {
throw new Error(
'[Directive: longpress]: need callback and callback must be a function! Like v-longpress="callback"'
);
}
}
};

View File

@@ -1,68 +0,0 @@
import {
isArray,
throttle,
debounce,
isObject,
isFunction
} from "@pureadmin/utils";
import { useEventListener } from "@vueuse/core";
import type { Directive, DirectiveBinding } from "vue";
export interface OptimizeOptions {
/** 事件名 */
event: string;
/** 事件触发的方法 */
fn: (...params: any) => any;
/** 是否立即执行 */
immediate?: boolean;
/** 防抖或节流的延迟时间(防抖默认:`200`毫秒、节流默认:`1000`毫秒) */
timeout?: number;
/** 传递的参数 */
params?: any;
}
/** 防抖v-optimize或v-optimize:debounce、节流v-optimize:throttle指令 */
export const optimize: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<OptimizeOptions>) {
const { value } = binding;
const optimizeType = binding.arg ?? "debounce";
const type = ["debounce", "throttle"].find(t => t === optimizeType);
if (type) {
if (value && value.event && isFunction(value.fn)) {
let params = value?.params;
if (params) {
if (isArray(params) || isObject(params)) {
params = isObject(params) ? Array.of(params) : params;
} else {
throw new Error(
"[Directive: optimize]: `params` must be an array or object"
);
}
}
// Register using addEventListener on mounted, and removeEventListener automatically on unmounted
useEventListener(
el,
value.event,
type === "debounce"
? debounce(
params ? () => value.fn(...params) : value.fn,
value?.timeout ?? 200,
value?.immediate ?? false
)
: throttle(
params ? () => value.fn(...params) : value.fn,
value?.timeout ?? 1000
)
);
} else {
throw new Error(
"[Directive: optimize]: `event` and `fn` are required, and `fn` must be a function"
);
}
} else {
throw new Error(
"[Directive: optimize]: only `debounce` and `throttle` are supported"
);
}
}
};

View File

@@ -1,48 +0,0 @@
/* stylelint-disable-next-line scss/dollar-variable-colon-space-after */
$ripple-animation-transition-in:
transform 0.4s cubic-bezier(0, 0, 0.2, 1),
opacity 0.2s cubic-bezier(0, 0, 0.2, 1) !default;
$ripple-animation-transition-out: opacity 0.5s cubic-bezier(0, 0, 0.2, 1) !default;
$ripple-animation-visible-opacity: 0.25 !default;
.v-ripple {
&__container {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
overflow: hidden;
pointer-events: none;
border-radius: inherit;
contain: strict;
}
&__animation {
position: absolute;
top: 0;
left: 0;
overflow: hidden;
pointer-events: none;
background: currentcolor;
border-radius: 50%;
opacity: 0;
will-change: transform, opacity;
&--enter {
opacity: 0;
transition: none;
}
&--in {
opacity: $ripple-animation-visible-opacity;
transition: $ripple-animation-transition-in;
}
&--out {
opacity: 0;
transition: $ripple-animation-transition-out;
}
}
}

View File

@@ -1,229 +0,0 @@
import "./index.scss";
import { isObject } from "@pureadmin/utils";
import type { Directive, DirectiveBinding } from "vue";
export interface RippleOptions {
/** 自定义`ripple`颜色,支持`tailwindcss` */
class?: string;
/** 是否从中心扩散 */
center?: boolean;
circle?: boolean;
}
export interface RippleDirectiveBinding
extends Omit<DirectiveBinding, "modifiers" | "value"> {
value?: boolean | { class: string };
modifiers: {
center?: boolean;
circle?: boolean;
};
}
function transform(el: HTMLElement, value: string) {
el.style.transform = value;
el.style.webkitTransform = value;
}
const calculate = (
e: PointerEvent,
el: HTMLElement,
value: RippleOptions = {}
) => {
const offset = el.getBoundingClientRect();
// 获取点击位置距离 el 的垂直和水平距离
let localX = e.clientX - offset.left;
let localY = e.clientY - offset.top;
let radius = 0;
let scale = 0.3;
// 计算点击位置到 el 顶点最远距离,即为圆的最大半径(勾股定理)
if (el._ripple?.circle) {
scale = 0.15;
radius = el.clientWidth / 2;
radius = value.center
? radius
: radius + Math.sqrt((localX - radius) ** 2 + (localY - radius) ** 2) / 4;
} else {
radius = Math.sqrt(el.clientWidth ** 2 + el.clientHeight ** 2) / 2;
}
// 中心点坐标
const centerX = `${(el.clientWidth - radius * 2) / 2}px`;
const centerY = `${(el.clientHeight - radius * 2) / 2}px`;
// 点击位置坐标
const x = value.center ? centerX : `${localX - radius}px`;
const y = value.center ? centerY : `${localY - radius}px`;
return { radius, scale, x, y, centerX, centerY };
};
const ripples = {
show(e: PointerEvent, el: HTMLElement, value: RippleOptions = {}) {
if (!el?._ripple?.enabled) {
return;
}
// 创建 ripple 元素和 ripple 父元素
const container = document.createElement("span");
const animation = document.createElement("span");
container.appendChild(animation);
container.className = "v-ripple__container";
if (value.class) {
container.className += ` ${value.class}`;
}
const { radius, scale, x, y, centerX, centerY } = calculate(e, el, value);
// ripple 圆大小
const size = `${radius * 2}px`;
animation.className = "v-ripple__animation";
animation.style.width = size;
animation.style.height = size;
el.appendChild(container);
// 获取目标元素样式表
const computed = window.getComputedStyle(el);
// 防止 position 被覆盖导致 ripple 位置有问题
if (computed && computed.position === "static") {
el.style.position = "relative";
el.dataset.previousPosition = "static";
}
animation.classList.add("v-ripple__animation--enter");
animation.classList.add("v-ripple__animation--visible");
transform(
animation,
`translate(${x}, ${y}) scale3d(${scale},${scale},${scale})`
);
animation.dataset.activated = String(performance.now());
setTimeout(() => {
animation.classList.remove("v-ripple__animation--enter");
animation.classList.add("v-ripple__animation--in");
transform(animation, `translate(${centerX}, ${centerY}) scale3d(1,1,1)`);
}, 0);
},
hide(el: HTMLElement | null) {
if (!el?._ripple?.enabled) return;
const ripples = el.getElementsByClassName("v-ripple__animation");
if (ripples.length === 0) return;
const animation = ripples[ripples.length - 1] as HTMLElement;
if (animation.dataset.isHiding) return;
else animation.dataset.isHiding = "true";
const diff = performance.now() - Number(animation.dataset.activated);
const delay = Math.max(250 - diff, 0);
setTimeout(() => {
animation.classList.remove("v-ripple__animation--in");
animation.classList.add("v-ripple__animation--out");
setTimeout(() => {
const ripples = el.getElementsByClassName("v-ripple__animation");
if (ripples.length === 1 && el.dataset.previousPosition) {
el.style.position = el.dataset.previousPosition;
delete el.dataset.previousPosition;
}
if (animation.parentNode?.parentNode === el)
el.removeChild(animation.parentNode);
}, 300);
}, delay);
}
};
function isRippleEnabled(value: any): value is true {
return typeof value === "undefined" || !!value;
}
function rippleShow(e: PointerEvent) {
const value: RippleOptions = {};
const element = e.currentTarget as HTMLElement | undefined;
if (!element?._ripple || element._ripple.touched) return;
value.center = element._ripple.centered;
if (element._ripple.class) {
value.class = element._ripple.class;
}
ripples.show(e, element, value);
}
function rippleHide(e: Event) {
const element = e.currentTarget as HTMLElement | null;
if (!element?._ripple) return;
window.setTimeout(() => {
if (element._ripple) {
element._ripple.touched = false;
}
});
ripples.hide(element);
}
function updateRipple(
el: HTMLElement,
binding: RippleDirectiveBinding,
wasEnabled: boolean
) {
const { value, modifiers } = binding;
const enabled = isRippleEnabled(value);
if (!enabled) {
ripples.hide(el);
}
el._ripple = el._ripple ?? {};
el._ripple.enabled = enabled;
el._ripple.centered = modifiers.center;
el._ripple.circle = modifiers.circle;
if (isObject(value) && value.class) {
el._ripple.class = value.class;
}
if (enabled && !wasEnabled) {
el.addEventListener("pointerdown", rippleShow);
el.addEventListener("pointerup", rippleHide);
} else if (!enabled && wasEnabled) {
removeListeners(el);
}
}
function removeListeners(el: HTMLElement) {
el.removeEventListener("pointerdown", rippleShow);
el.removeEventListener("pointerup", rippleHide);
}
function mounted(el: HTMLElement, binding: RippleDirectiveBinding) {
updateRipple(el, binding, false);
}
function unmounted(el: HTMLElement) {
delete el._ripple;
removeListeners(el);
}
function updated(el: HTMLElement, binding: RippleDirectiveBinding) {
if (binding.value === binding.oldValue) {
return;
}
const wasEnabled = isRippleEnabled(binding.oldValue);
updateRipple(el, binding, wasEnabled);
}
export const Ripple: Directive = {
mounted,
unmounted,
updated
};

View File

@@ -1,213 +0,0 @@
<script setup lang="ts">
import LayFrame from "../lay-frame/index.vue";
import LayFooter from "../lay-footer/index.vue";
import { useTags } from "@/layout/hooks/useTag";
import { useGlobal, isNumber } from "@pureadmin/utils";
import BackTopIcon from "@/assets/svg/back_top.svg?component";
import { h, computed, Transition, defineComponent } from "vue";
import { usePermissionStoreHook } from "@/store/modules/permission";
const props = defineProps({
fixedHeader: Boolean
});
const { showModel } = useTags();
const { $storage, $config } = useGlobal<GlobalPropertiesApi>();
const isKeepAlive = computed(() => {
return $config?.KeepAlive;
});
const transitions = computed(() => {
return route => {
return route.meta.transition;
};
});
const hideTabs = computed(() => {
return $storage?.configure.hideTabs;
});
const hideFooter = computed(() => {
return $storage?.configure.hideFooter;
});
const stretch = computed(() => {
return $storage?.configure.stretch;
});
const layout = computed(() => {
return $storage?.layout.layout === "vertical";
});
const getMainWidth = computed(() => {
return isNumber(stretch.value)
? stretch.value + "px"
: stretch.value
? "1440px"
: "100%";
});
const getSectionStyle = computed(() => {
return [
hideTabs.value && layout ? "padding-top: 48px;" : "",
!hideTabs.value && layout
? showModel.value == "chrome"
? "padding-top: 85px;"
: "padding-top: 81px;"
: "",
hideTabs.value && !layout.value ? "padding-top: 48px;" : "",
!hideTabs.value && !layout.value
? showModel.value == "chrome"
? "padding-top: 85px;"
: "padding-top: 81px;"
: "",
props.fixedHeader
? ""
: `padding-top: 0;${
hideTabs.value
? "min-height: calc(100vh - 48px);"
: "min-height: calc(100vh - 86px);"
}`
];
});
const transitionMain = defineComponent({
props: {
route: {
type: undefined,
required: true
}
},
render() {
const transitionName =
transitions.value(this.route)?.name || "fade-transform";
const enterTransition = transitions.value(this.route)?.enterTransition;
const leaveTransition = transitions.value(this.route)?.leaveTransition;
return h(
Transition,
{
name: enterTransition ? "pure-classes-transition" : transitionName,
enterActiveClass: enterTransition
? `animate__animated ${enterTransition}`
: undefined,
leaveActiveClass: leaveTransition
? `animate__animated ${leaveTransition}`
: undefined,
mode: "out-in",
appear: true
},
{
default: () => [this.$slots.default()]
}
);
}
});
</script>
<template>
<section
:class="[fixedHeader ? 'app-main' : 'app-main-nofixed-header']"
:style="getSectionStyle"
>
<router-view>
<template #default="{ Component, route }">
<LayFrame :currComp="Component" :currRoute="route">
<template #default="{ Comp, fullPath, frameInfo }">
<el-scrollbar
v-if="fixedHeader"
:wrap-style="{
display: 'flex',
'flex-wrap': 'wrap',
'max-width': getMainWidth,
margin: '0 auto',
transition: 'all 300ms cubic-bezier(0.4, 0, 0.2, 1)'
}"
:view-style="{
display: 'flex',
flex: 'auto',
overflow: 'hidden',
'flex-direction': 'column'
}"
>
<el-backtop
title="回到顶部"
target=".app-main .el-scrollbar__wrap"
>
<BackTopIcon />
</el-backtop>
<div class="grow">
<transitionMain :route="route">
<keep-alive
v-if="isKeepAlive"
:include="usePermissionStoreHook().cachePageList"
>
<component
:is="Comp"
:key="fullPath"
:frameInfo="frameInfo"
class="main-content"
/>
</keep-alive>
<component
:is="Comp"
v-else
:key="fullPath"
:frameInfo="frameInfo"
class="main-content"
/>
</transitionMain>
</div>
<LayFooter v-if="!hideFooter" />
</el-scrollbar>
<div v-else class="grow">
<transitionMain :route="route">
<keep-alive
v-if="isKeepAlive"
:include="usePermissionStoreHook().cachePageList"
>
<component
:is="Comp"
:key="fullPath"
:frameInfo="frameInfo"
class="main-content"
/>
</keep-alive>
<component
:is="Comp"
v-else
:key="fullPath"
:frameInfo="frameInfo"
class="main-content"
/>
</transitionMain>
</div>
</template>
</LayFrame>
</template>
</router-view>
<!-- 页脚 -->
<LayFooter v-if="!hideFooter && !fixedHeader" />
</section>
</template>
<style scoped>
.app-main {
position: relative;
width: 100%;
height: 100vh;
overflow-x: hidden;
}
.app-main-nofixed-header {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
}
.main-content {
margin: 24px;
}
</style>

View File

@@ -1,31 +0,0 @@
<script lang="ts" setup>
import { getConfig } from "@/config";
const TITLE = getConfig("Title");
</script>
<template>
<footer
class="layout-footer text-[rgba(0,0,0,0.6)] dark:text-[rgba(220,220,242,0.8)]"
>
Copyright © 2020-present
<a
class="hover:text-primary"
href="https://github.com/pure-admin"
target="_blank"
>
&nbsp;{{ TITLE }}
</a>
</footer>
</template>
<style lang="scss" scoped>
.layout-footer {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 0 0 8px;
font-size: 14px;
}
</style>

View File

@@ -1,79 +0,0 @@
<script setup lang="ts">
import { getConfig } from "@/config";
import { useMultiFrame } from "@/layout/hooks/useMultiFrame";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import { type Component, shallowRef, watch, computed } from "vue";
import { type RouteRecordRaw, RouteLocationNormalizedLoaded } from "vue-router";
const props = defineProps<{
currRoute: RouteLocationNormalizedLoaded;
currComp: Component;
}>();
const compList = shallowRef([]);
const { setMap, getMap, MAP, delMap } = useMultiFrame();
const keep = computed(() => {
return (
getConfig().KeepAlive &&
props.currRoute.meta?.keepAlive &&
!!props.currRoute.meta?.frameSrc
);
});
// 避免重新渲染 LayFrame
const normalComp = computed(() => !keep.value && props.currComp);
watch(useMultiTagsStoreHook().multiTags, (tags: any) => {
if (!Array.isArray(tags) || !keep.value) {
return;
}
const iframeTags = tags.filter(i => i.meta?.frameSrc);
// tags必须是小于MAP才是做了关闭动作因为MAP插入的顺序在tags变化后发生
if (iframeTags.length < MAP.size) {
for (const i of MAP.keys()) {
if (!tags.some(s => s.path === i)) {
delMap(i);
compList.value = getMap();
}
}
}
});
watch(
() => props.currRoute.fullPath,
path => {
const multiTags = useMultiTagsStoreHook().multiTags as RouteRecordRaw[];
const iframeTags = multiTags.filter(i => i.meta?.frameSrc);
if (keep.value) {
if (iframeTags.length !== MAP.size) {
const sameKey = [...MAP.keys()].find(i => path === i);
if (!sameKey) {
// 添加缓存
setMap(path, props.currComp);
}
}
}
if (MAP.size > 0) {
compList.value = getMap();
}
},
{
immediate: true
}
);
</script>
<template>
<template v-for="[fullPath, Comp] in compList" :key="fullPath">
<div v-show="fullPath === currRoute.fullPath" class="w-full h-full">
<slot
:fullPath="fullPath"
:Comp="Comp"
:frameInfo="{ frameSrc: currRoute.meta?.frameSrc, fullPath }"
/>
</div>
</template>
<div v-show="!keep" class="w-full h-full">
<slot :Comp="normalComp" :fullPath="currRoute.fullPath" frameInfo />
</div>
</template>

View File

@@ -1,135 +0,0 @@
<script setup lang="ts">
import { useNav } from "@/layout/hooks/useNav";
import LaySearch from "../lay-search/index.vue";
import LayNotice from "../lay-notice/index.vue";
import LayNavMix from "../lay-sidebar/NavMix.vue";
import LaySidebarFullScreen from "../lay-sidebar/components/SidebarFullScreen.vue";
import LaySidebarBreadCrumb from "../lay-sidebar/components/SidebarBreadCrumb.vue";
import LaySidebarTopCollapse from "../lay-sidebar/components/SidebarTopCollapse.vue";
import LogoutCircleRLine from "@iconify-icons/ri/logout-circle-r-line";
import Setting from "@iconify-icons/ri/settings-3-line";
const {
layout,
device,
logout,
onPanel,
pureApp,
username,
userAvatar,
avatarsStyle,
toggleSideBar
} = useNav();
</script>
<template>
<div class="navbar bg-[#fff] shadow-sm shadow-[rgba(0,21,41,0.08)]">
<LaySidebarTopCollapse
v-if="device === 'mobile'"
class="hamburger-container"
:is-active="pureApp.sidebar.opened"
@toggleClick="toggleSideBar"
/>
<LaySidebarBreadCrumb
v-if="layout !== 'mix' && device !== 'mobile'"
class="breadcrumb-container"
/>
<LayNavMix v-if="layout === 'mix'" />
<div v-if="layout === 'vertical'" class="vertical-header-right">
<!-- 菜单搜索 -->
<LaySearch id="header-search" />
<!-- 全屏 -->
<LaySidebarFullScreen id="full-screen" />
<!-- 消息通知 -->
<LayNotice id="header-notice" />
<!-- 退出登录 -->
<el-dropdown trigger="click">
<span class="el-dropdown-link navbar-bg-hover select-none">
<img :src="userAvatar" :style="avatarsStyle" />
<p v-if="username" class="dark:text-white">{{ username }}</p>
</span>
<template #dropdown>
<el-dropdown-menu class="logout">
<el-dropdown-item @click="logout">
<IconifyIconOffline
:icon="LogoutCircleRLine"
style="margin: 5px"
/>
退出系统
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<span
class="set-icon navbar-bg-hover"
title="打开系统配置"
@click="onPanel"
>
<IconifyIconOffline :icon="Setting" />
</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.navbar {
width: 100%;
height: 48px;
overflow: hidden;
.hamburger-container {
float: left;
height: 100%;
line-height: 48px;
cursor: pointer;
}
.vertical-header-right {
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 280px;
height: 48px;
color: #000000d9;
.el-dropdown-link {
display: flex;
align-items: center;
justify-content: space-around;
height: 48px;
padding: 10px;
color: #000000d9;
cursor: pointer;
p {
font-size: 14px;
}
img {
width: 22px;
height: 22px;
border-radius: 50%;
}
}
}
.breadcrumb-container {
float: left;
margin-left: 16px;
}
}
.logout {
width: 120px;
::v-deep(.el-dropdown-menu__item) {
display: inline-flex;
flex-wrap: wrap;
min-width: 100%;
}
}
</style>

View File

@@ -1,177 +0,0 @@
<script setup lang="ts">
import { ListItem } from "../data";
import { ref, PropType, nextTick } from "vue";
import { useNav } from "@/layout/hooks/useNav";
import { deviceDetection } from "@pureadmin/utils";
defineProps({
noticeItem: {
type: Object as PropType<ListItem>,
default: () => {}
}
});
const titleRef = ref(null);
const titleTooltip = ref(false);
const descriptionRef = ref(null);
const descriptionTooltip = ref(false);
const { tooltipEffect } = useNav();
const isMobile = deviceDetection();
function hoverTitle() {
nextTick(() => {
titleRef.value?.scrollWidth > titleRef.value?.clientWidth
? (titleTooltip.value = true)
: (titleTooltip.value = false);
});
}
function hoverDescription(event, description) {
// currentWidth 为文本在页面中所占的宽度创建标签加入到页面获取currentWidth ,最后在移除
const tempTag = document.createElement("span");
tempTag.innerText = description;
tempTag.className = "getDescriptionWidth";
document.querySelector("body").appendChild(tempTag);
const currentWidth = (
document.querySelector(".getDescriptionWidth") as HTMLSpanElement
).offsetWidth;
document.querySelector(".getDescriptionWidth").remove();
// cellWidth为容器的宽度
const cellWidth = event.target.offsetWidth;
// 当文本宽度大于容器宽度两倍时,代表文本显示超过两行
currentWidth > 2 * cellWidth
? (descriptionTooltip.value = true)
: (descriptionTooltip.value = false);
}
</script>
<template>
<div
class="notice-container border-b-[1px] border-solid border-[#f0f0f0] dark:border-[#303030]"
>
<el-avatar
v-if="noticeItem.avatar"
:size="30"
:src="noticeItem.avatar"
class="notice-container-avatar"
/>
<div class="notice-container-text">
<div class="notice-text-title text-[#000000d9] dark:text-white">
<el-tooltip
popper-class="notice-title-popper"
:effect="tooltipEffect"
:disabled="!titleTooltip"
:content="noticeItem.title"
placement="top-start"
:enterable="!isMobile"
>
<div
ref="titleRef"
class="notice-title-content"
@mouseover="hoverTitle"
>
{{ noticeItem.title }}
</div>
</el-tooltip>
<el-tag
v-if="noticeItem?.extra"
:type="noticeItem?.status"
size="small"
class="notice-title-extra"
>
{{ noticeItem?.extra }}
</el-tag>
</div>
<el-tooltip
popper-class="notice-title-popper"
:effect="tooltipEffect"
:disabled="!descriptionTooltip"
:content="noticeItem.description"
placement="top-start"
>
<div
ref="descriptionRef"
class="notice-text-description"
@mouseover="hoverDescription($event, noticeItem.description)"
>
{{ noticeItem.description }}
</div>
</el-tooltip>
<div class="notice-text-datetime text-[#00000073] dark:text-white">
{{ noticeItem.datetime }}
</div>
</div>
</div>
</template>
<style>
.notice-title-popper {
max-width: 238px;
}
</style>
<style scoped lang="scss">
.notice-container {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 12px 0;
// border-bottom: 1px solid #f0f0f0;
.notice-container-avatar {
margin-right: 16px;
background: #fff;
}
.notice-container-text {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
.notice-text-title {
display: flex;
margin-bottom: 8px;
font-size: 14px;
font-weight: 400;
line-height: 1.5715;
cursor: pointer;
.notice-title-content {
flex: 1;
width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-title-extra {
float: right;
margin-top: -1.5px;
font-weight: 400;
}
}
.notice-text-description,
.notice-text-datetime {
font-size: 12px;
line-height: 1.5715;
}
.notice-text-description {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.notice-text-datetime {
margin-top: 4px;
}
}
}
</style>

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
import { PropType } from "vue";
import { ListItem } from "../data";
import NoticeItem from "./NoticeItem.vue";
defineProps({
list: {
type: Array as PropType<Array<ListItem>>,
default: () => []
},
emptyText: {
type: String,
default: ""
}
});
</script>
<template>
<div v-if="list.length">
<NoticeItem v-for="(item, index) in list" :key="index" :noticeItem="item" />
</div>
<el-empty v-else :description="emptyText" />
</template>

View File

@@ -1,97 +0,0 @@
export interface ListItem {
avatar: string;
title: string;
datetime: string;
type: string;
description: string;
status?: "primary" | "success" | "warning" | "info" | "danger";
extra?: string;
}
export interface TabItem {
key: string;
name: string;
list: ListItem[];
emptyText: string;
}
export const noticesData: TabItem[] = [
{
key: "1",
name: "通知",
list: [],
emptyText: "暂无通知"
},
{
key: "2",
name: "消息",
list: [
{
avatar: "https://xiaoxian521.github.io/hyperlink/svg/smile1.svg",
title: "小铭 评论了你",
description: "诚在于心,信在于行,诚信在于心行合一。",
datetime: "今天",
type: "2"
},
{
avatar: "https://xiaoxian521.github.io/hyperlink/svg/smile2.svg",
title: "李白 回复了你",
description: "长风破浪会有时,直挂云帆济沧海。",
datetime: "昨天",
type: "2"
},
{
avatar: "https://xiaoxian521.github.io/hyperlink/svg/smile5.svg",
title: "标题",
description:
"请将鼠标移动到此处以便测试超长的消息在此处将如何处理。本例中设置的描述最大行数为2超过2行的描述内容将被省略并且可以通过tooltip查看完整内容",
datetime: "时间",
type: "2"
}
],
emptyText: "暂无消息"
},
{
key: "3",
name: "待办",
list: [
{
avatar: "",
title: "第三方紧急代码变更",
description:
"小林提交于 2024-05-10需在 2024-05-11 前完成代码变更任务",
datetime: "",
extra: "马上到期",
status: "danger",
type: "3"
},
{
avatar: "",
title: "版本发布",
description: "指派小铭于 2024-06-18 前完成更新并发布",
datetime: "",
extra: "已耗时 8 天",
status: "warning",
type: "3"
},
{
avatar: "",
title: "新功能开发",
description: "开发多租户管理",
datetime: "",
extra: "进行中",
type: "3"
},
{
avatar: "",
title: "任务名称",
description: "任务需要在 2030-10-30 10:00 前启动",
datetime: "",
extra: "未开始",
status: "info",
type: "3"
}
],
emptyText: "暂无待办"
}
];

View File

@@ -1,96 +0,0 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { noticesData } from "./data";
import NoticeList from "./components/NoticeList.vue";
import BellIcon from "@iconify-icons/ep/bell";
const noticesNum = ref(0);
const notices = ref(noticesData);
const activeKey = ref(noticesData[0]?.key);
notices.value.map(v => (noticesNum.value += v.list.length));
const getLabel = computed(
() => item =>
item.name + (item.list.length > 0 ? `(${item.list.length})` : "")
);
</script>
<template>
<el-dropdown trigger="click" placement="bottom-end">
<span
:class="[
'dropdown-badge',
'navbar-bg-hover',
'select-none',
Number(noticesNum) !== 0 && 'mr-[10px]'
]"
>
<el-badge :value="Number(noticesNum) === 0 ? '' : noticesNum" :max="99">
<span class="header-notice-icon">
<IconifyIconOffline :icon="BellIcon" />
</span>
</el-badge>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-tabs
v-model="activeKey"
:stretch="true"
class="dropdown-tabs"
:style="{ width: notices.length === 0 ? '200px' : '330px' }"
>
<el-empty
v-if="notices.length === 0"
description="暂无消息"
:image-size="60"
/>
<span v-else>
<template v-for="item in notices" :key="item.key">
<el-tab-pane :label="getLabel(item)" :name="`${item.key}`">
<el-scrollbar max-height="330px">
<div class="noticeList-container">
<NoticeList :list="item.list" :emptyText="item.emptyText" />
</div>
</el-scrollbar>
</el-tab-pane>
</template>
</span>
</el-tabs>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<style lang="scss" scoped>
.dropdown-badge {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 48px;
cursor: pointer;
.header-notice-icon {
font-size: 18px;
}
}
.dropdown-tabs {
.noticeList-container {
padding: 15px 24px 0;
}
:deep(.el-tabs__header) {
margin: 0;
}
:deep(.el-tabs__nav-wrap)::after {
height: 1px;
}
:deep(.el-tabs__nav-wrap) {
padding: 0 36px;
}
}
</style>

View File

@@ -1,145 +0,0 @@
<script setup lang="ts">
import { emitter } from "@/utils/mitt";
import { onClickOutside } from "@vueuse/core";
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
import CloseIcon from "@iconify-icons/ep/close";
const target = ref(null);
const show = ref<Boolean>(false);
const iconClass = computed(() => {
return [
"w-[22px]",
"h-[22px]",
"flex",
"justify-center",
"items-center",
"outline-none",
"rounded-[4px]",
"cursor-pointer",
"transition-colors",
"hover:bg-[#0000000f]",
"dark:hover:bg-[#ffffff1f]",
"dark:hover:text-[#ffffffd9]"
];
});
const { onReset } = useDataThemeChange();
onClickOutside(target, (event: any) => {
if (event.clientX > target.value.offsetLeft) return;
show.value = false;
});
onMounted(() => {
emitter.on("openPanel", () => {
show.value = true;
});
});
onBeforeUnmount(() => {
// 解绑`openPanel`公共事件,防止多次触发
emitter.off("openPanel");
});
</script>
<template>
<div :class="{ show }">
<div class="right-panel-background" />
<div ref="target" class="right-panel bg-bg_color">
<div
class="project-configuration border-b-[1px] border-solid border-[var(--pure-border-color)]"
>
<h4 class="dark:text-white">系统配置</h4>
<span
v-tippy="{
content: '关闭配置',
placement: 'bottom-start',
zIndex: 41000
}"
:class="iconClass"
>
<IconifyIconOffline
class="dark:text-white"
width="18px"
height="18px"
:icon="CloseIcon"
@click="show = !show"
/>
</span>
</div>
<el-scrollbar>
<slot />
</el-scrollbar>
<div
class="flex justify-end p-3 border-t-[1px] border-solid border-[var(--pure-border-color)]"
>
<el-button
v-tippy="{
content: '清空缓存并返回登录页',
placement: 'left-start',
zIndex: 41000
}"
type="danger"
text
bg
@click="onReset"
>
清空缓存
</el-button>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
:deep(.el-scrollbar) {
height: calc(100vh - 110px);
}
.right-panel-background {
position: fixed;
top: 0;
left: 0;
z-index: -1;
background: rgb(0 0 0 / 20%);
opacity: 0;
transition: opacity 0.3s cubic-bezier(0.7, 0.3, 0.1, 1);
}
.right-panel {
position: fixed;
top: 0;
right: 0;
z-index: 40000;
width: 100%;
max-width: 280px;
box-shadow: 0 0 15px 0 rgb(0 0 0 / 5%);
transition: all 0.25s cubic-bezier(0.7, 0.3, 0.1, 1);
transform: translate(100%);
}
.show {
transition: all 0.3s cubic-bezier(0.7, 0.3, 0.1, 1);
.right-panel-background {
z-index: 20000;
width: 100%;
height: 100%;
opacity: 1;
}
.right-panel {
transform: translate(0);
}
}
.project-configuration {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
}
</style>

View File

@@ -1,61 +0,0 @@
<script setup lang="ts">
import { useNav } from "@/layout/hooks/useNav";
import MdiKeyboardEsc from "@/assets/svg/keyboard_esc.svg?component";
import EnterOutlined from "@/assets/svg/enter_outlined.svg?component";
import ArrowUpLine from "@iconify-icons/ri/arrow-up-line";
import ArrowDownLine from "@iconify-icons/ri/arrow-down-line";
withDefaults(defineProps<{ total: number }>(), {
total: 0
});
const { device } = useNav();
</script>
<template>
<div class="search-footer text-[#333] dark:text-white">
<span class="search-footer-item">
<EnterOutlined class="icon" />
确认
</span>
<span class="search-footer-item">
<IconifyIconOffline :icon="ArrowUpLine" class="icon" />
<IconifyIconOffline :icon="ArrowDownLine" class="icon" />
切换
</span>
<span class="search-footer-item">
<MdiKeyboardEsc class="icon" />
关闭
</span>
<p v-if="device !== 'mobile' && total > 0" class="search-footer-total">
{{ ` ${total} ` }}
</p>
</div>
</template>
<style lang="scss" scoped>
.search-footer {
display: flex;
.search-footer-item {
display: flex;
align-items: center;
margin-right: 14px;
}
.icon {
padding: 2px;
margin-right: 3px;
font-size: 20px;
box-shadow:
inset 0 -2px #cdcde6,
inset 0 0 1px 1px #fff,
0 1px 2px 1px #1e235a66;
}
.search-footer-total {
position: absolute;
right: 20px;
}
}
</style>

View File

@@ -1,198 +0,0 @@
<script setup lang="ts">
import Sortable from "sortablejs";
import SearchHistoryItem from "./SearchHistoryItem.vue";
import type { optionsItem, dragItem, Props } from "../types";
import { useEpThemeStoreHook } from "@/store/modules/epTheme";
import { useResizeObserver, isArray, delay } from "@pureadmin/utils";
import { ref, watch, nextTick, computed, getCurrentInstance } from "vue";
interface Emits {
(e: "update:value", val: string): void;
(e: "enter"): void;
(e: "collect", val: optionsItem): void;
(e: "delete", val: optionsItem): void;
(e: "drag", val: dragItem): void;
}
const historyRef = ref();
const innerHeight = ref();
/** 判断是否停止鼠标移入事件处理 */
const stopMouseEvent = ref(false);
const emit = defineEmits<Emits>();
const instance = getCurrentInstance()!;
const props = withDefaults(defineProps<Props>(), {});
const itemStyle = computed(() => {
return item => {
return {
background:
item?.path === active.value ? useEpThemeStoreHook().epThemeColor : "",
color: item.path === active.value ? "#fff" : "",
fontSize: item.path === active.value ? "16px" : "14px"
};
};
});
const titleStyle = computed(() => {
return {
color: useEpThemeStoreHook().epThemeColor,
fontWeight: 500
};
});
const active = computed({
get() {
return props.value;
},
set(val: string) {
emit("update:value", val);
}
});
watch(
() => props.value,
newValue => {
if (newValue) {
if (stopMouseEvent.value) {
delay(100).then(() => (stopMouseEvent.value = false));
}
}
}
);
const historyList = computed(() => {
return props.options.filter(item => item.type === "history");
});
const collectList = computed(() => {
return props.options.filter(item => item.type === "collect");
});
function handleCollect(item) {
emit("collect", item);
}
function handleDelete(item) {
stopMouseEvent.value = true;
emit("delete", item);
}
/** 鼠标移入 */
async function handleMouse(item) {
if (!stopMouseEvent.value) active.value = item.path;
}
function handleTo() {
emit("enter");
}
function resizeResult() {
// el-scrollbar max-height="calc(90vh - 140px)"
innerHeight.value = window.innerHeight - window.innerHeight / 10 - 140;
}
useResizeObserver(historyRef, resizeResult);
function handleScroll(index: number) {
const curInstance = instance?.proxy?.$refs[`historyItemRef${index}`];
if (!curInstance) return 0;
const curRef = isArray(curInstance)
? (curInstance[0] as ElRef)
: (curInstance as ElRef);
const scrollTop = curRef.offsetTop + 128; // 128 两个history-item56px+56px=112px高度加上下margin8px+8px=16px
return scrollTop > innerHeight.value ? scrollTop - innerHeight.value : 0;
}
const handleChangeIndex = (evt): void => {
emit("drag", { oldIndex: evt.oldIndex, newIndex: evt.newIndex });
};
let sortableInstance = null;
watch(
collectList,
val => {
if (val.length > 1) {
nextTick(() => {
const wrapper: HTMLElement =
document.querySelector(".collect-container");
if (!wrapper || sortableInstance) return;
sortableInstance = Sortable.create(wrapper, {
animation: 160,
onStart: event => {
event.item.style.cursor = "move";
},
onEnd: event => {
event.item.style.cursor = "pointer";
},
onUpdate: handleChangeIndex
});
resizeResult();
});
}
},
{ deep: true, immediate: true }
);
defineExpose({ handleScroll });
</script>
<template>
<div ref="historyRef" class="history">
<template v-if="historyList.length">
<div :style="titleStyle">搜索历史</div>
<div
v-for="(item, index) in historyList"
:key="item.path"
:ref="'historyItemRef' + index"
class="history-item dark:bg-[#1d1d1d]"
:style="itemStyle(item)"
@click="handleTo"
@mouseenter="handleMouse(item)"
>
<SearchHistoryItem
:item="item"
@delete-item="handleDelete"
@collect-item="handleCollect"
/>
</div>
</template>
<template v-if="collectList.length">
<div :style="titleStyle">
{{ `收藏${collectList.length > 1 ? "(可拖拽排序)" : ""}` }}
</div>
<div class="collect-container">
<div
v-for="(item, index) in collectList"
:key="item.path"
:ref="'historyItemRef' + (index + historyList.length)"
class="history-item dark:bg-[#1d1d1d]"
:style="itemStyle(item)"
@click="handleTo"
@mouseenter="handleMouse(item)"
>
<SearchHistoryItem :item="item" @delete-item="handleDelete" />
</div>
</div>
</template>
</div>
</template>
<style lang="scss" scoped>
.history {
padding-bottom: 12px;
&-item {
display: flex;
align-items: center;
height: 56px;
padding: 14px;
margin: 8px auto 10px;
cursor: pointer;
border: 0.1px solid #ccc;
border-radius: 4px;
transition: font-size 0.16s;
}
}
</style>

View File

@@ -1,52 +0,0 @@
<script setup lang="ts">
import type { optionsItem } from "../types";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import StarIcon from "@iconify-icons/ep/star";
import CloseIcon from "@iconify-icons/ep/close";
interface Props {
item: optionsItem;
}
interface Emits {
(e: "collectItem", val: optionsItem): void;
(e: "deleteItem", val: optionsItem): void;
}
const emit = defineEmits<Emits>();
withDefaults(defineProps<Props>(), {});
function handleCollect(item) {
emit("collectItem", item);
}
function handleDelete(item) {
emit("deleteItem", item);
}
</script>
<template>
<component :is="useRenderIcon(item.meta?.icon)" />
<span class="history-item-title">
{{ item.meta?.title }}
</span>
<IconifyIconOffline
v-show="item.type === 'history'"
:icon="StarIcon"
class="w-[18px] h-[18px] mr-2 hover:text-[#d7d5d4]"
@click.stop="handleCollect(item)"
/>
<IconifyIconOffline
:icon="CloseIcon"
class="w-[18px] h-[18px] hover:text-[#d7d5d4] cursor-pointer"
@click.stop="handleDelete(item)"
/>
</template>
<style lang="scss" scoped>
.history-item-title {
display: flex;
flex: 1;
margin-left: 5px;
}
</style>

View File

@@ -1,334 +0,0 @@
<script setup lang="ts">
import { match } from "pinyin-pro";
import { getConfig } from "@/config";
import { useRouter } from "vue-router";
import SearchResult from "./SearchResult.vue";
import SearchFooter from "./SearchFooter.vue";
import { useNav } from "@/layout/hooks/useNav";
import SearchHistory from "./SearchHistory.vue";
import type { optionsItem, dragItem } from "../types";
import { ref, computed, shallowRef, watch } from "vue";
import { useDebounceFn, onKeyStroke } from "@vueuse/core";
import { usePermissionStoreHook } from "@/store/modules/permission";
import { cloneDeep, isAllEmpty, storageLocal } from "@pureadmin/utils";
import SearchIcon from "@iconify-icons/ri/search-line";
interface Props {
/** 弹窗显隐 */
value: boolean;
}
interface Emits {
(e: "update:value", val: boolean): void;
}
const { device } = useNav();
const emit = defineEmits<Emits>();
const props = withDefaults(defineProps<Props>(), {});
const router = useRouter();
const HISTORY_TYPE = "history";
const COLLECT_TYPE = "collect";
const LOCALEHISTORYKEY = "menu-search-history";
const LOCALECOLLECTKEY = "menu-search-collect";
const keyword = ref("");
const resultRef = ref();
const historyRef = ref();
const scrollbarRef = ref();
const activePath = ref("");
const historyPath = ref("");
const resultOptions = shallowRef([]);
const historyOptions = shallowRef([]);
const handleSearch = useDebounceFn(search, 300);
const historyNum = getConfig().MenuSearchHistory;
const inputRef = ref<HTMLInputElement | null>(null);
/** 菜单树形结构 */
const menusData = computed(() => {
return cloneDeep(usePermissionStoreHook().wholeMenus);
});
const show = computed({
get() {
return props.value;
},
set(val: boolean) {
emit("update:value", val);
}
});
watch(
() => props.value,
newValue => {
if (newValue) getHistory();
}
);
const showSearchResult = computed(() => {
return keyword.value && resultOptions.value.length > 0;
});
const showSearchHistory = computed(() => {
return !keyword.value && historyOptions.value.length > 0;
});
const showEmpty = computed(() => {
return (
(!keyword.value && historyOptions.value.length === 0) ||
(keyword.value && resultOptions.value.length === 0)
);
});
function getStorageItem(key) {
return storageLocal().getItem<optionsItem[]>(key) || [];
}
function setStorageItem(key, value) {
storageLocal().setItem(key, value);
}
/** 将菜单树形结构扁平化为一维数组,用于菜单查询 */
function flatTree(arr) {
const res = [];
function deep(arr) {
arr.forEach(item => {
res.push(item);
item.children && deep(item.children);
});
}
deep(arr);
return res;
}
/** 查询 */
function search() {
const flatMenusData = flatTree(menusData.value);
resultOptions.value = flatMenusData.filter(menu =>
keyword.value
? menu.meta?.title
.toLocaleLowerCase()
.includes(keyword.value.toLocaleLowerCase().trim()) ||
!isAllEmpty(
match(
menu.meta?.title.toLocaleLowerCase(),
keyword.value.toLocaleLowerCase().trim()
)
)
: false
);
activePath.value =
resultOptions.value?.length > 0 ? resultOptions.value[0].path : "";
}
function handleClose() {
show.value = false;
/** 延时处理防止用户看到某些操作 */
setTimeout(() => {
resultOptions.value = [];
historyPath.value = "";
keyword.value = "";
}, 200);
}
function scrollTo(index) {
const ref = resultOptions.value.length ? resultRef.value : historyRef.value;
const scrollTop = ref.handleScroll(index);
scrollbarRef.value.setScrollTop(scrollTop);
}
/** 获取当前选项和路径 */
function getCurrentOptionsAndPath() {
const isResultOptions = resultOptions.value.length > 0;
const options = isResultOptions ? resultOptions.value : historyOptions.value;
const currentPath = isResultOptions ? activePath.value : historyPath.value;
return { options, currentPath, isResultOptions };
}
/** 更新路径并滚动到指定项 */
function updatePathAndScroll(newIndex, isResultOptions) {
if (isResultOptions) {
activePath.value = resultOptions.value[newIndex].path;
} else {
historyPath.value = historyOptions.value[newIndex].path;
}
scrollTo(newIndex);
}
/** key up */
function handleUp() {
const { options, currentPath, isResultOptions } = getCurrentOptionsAndPath();
if (options.length === 0) return;
const index = options.findIndex(item => item.path === currentPath);
const prevIndex = (index - 1 + options.length) % options.length;
updatePathAndScroll(prevIndex, isResultOptions);
}
/** key down */
function handleDown() {
const { options, currentPath, isResultOptions } = getCurrentOptionsAndPath();
if (options.length === 0) return;
const index = options.findIndex(item => item.path === currentPath);
const nextIndex = (index + 1) % options.length;
updatePathAndScroll(nextIndex, isResultOptions);
}
/** key enter */
function handleEnter() {
const { options, currentPath, isResultOptions } = getCurrentOptionsAndPath();
if (options.length === 0 || currentPath === "") return;
const index = options.findIndex(item => item.path === currentPath);
if (index === -1) return;
if (isResultOptions) {
saveHistory();
} else {
updateHistory();
}
router.push(options[index].path);
handleClose();
}
/** 删除历史记录 */
function handleDelete(item) {
const key = item.type === HISTORY_TYPE ? LOCALEHISTORYKEY : LOCALECOLLECTKEY;
let list = getStorageItem(key);
list = list.filter(listItem => listItem.path !== item.path);
setStorageItem(key, list);
getHistory();
}
/** 收藏历史记录 */
function handleCollect(item) {
let searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
let searchCollectList = getStorageItem(LOCALECOLLECTKEY);
searchHistoryList = searchHistoryList.filter(
historyItem => historyItem.path !== item.path
);
setStorageItem(LOCALEHISTORYKEY, searchHistoryList);
if (!searchCollectList.some(collectItem => collectItem.path === item.path)) {
searchCollectList.unshift({ ...item, type: COLLECT_TYPE });
setStorageItem(LOCALECOLLECTKEY, searchCollectList);
}
getHistory();
}
/** 存储搜索记录 */
function saveHistory() {
const { path, meta } = resultOptions.value.find(
item => item.path === activePath.value
);
const searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
const searchCollectList = getStorageItem(LOCALECOLLECTKEY);
const isCollected = searchCollectList.some(item => item.path === path);
const existingIndex = searchHistoryList.findIndex(item => item.path === path);
if (!isCollected) {
if (existingIndex !== -1) searchHistoryList.splice(existingIndex, 1);
if (searchHistoryList.length >= historyNum) searchHistoryList.pop();
searchHistoryList.unshift({ path, meta, type: HISTORY_TYPE });
storageLocal().setItem(LOCALEHISTORYKEY, searchHistoryList);
}
}
/** 更新存储的搜索记录 */
function updateHistory() {
let searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
const historyIndex = searchHistoryList.findIndex(
item => item.path === historyPath.value
);
if (historyIndex !== -1) {
const [historyItem] = searchHistoryList.splice(historyIndex, 1);
searchHistoryList.unshift(historyItem);
setStorageItem(LOCALEHISTORYKEY, searchHistoryList);
}
}
/** 获取本地历史记录 */
function getHistory() {
const searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
const searchCollectList = getStorageItem(LOCALECOLLECTKEY);
historyOptions.value = [...searchHistoryList, ...searchCollectList];
historyPath.value = historyOptions.value[0]?.path;
}
/** 拖拽改变收藏顺序 */
function handleDrag(item: dragItem) {
const searchCollectList = getStorageItem(LOCALECOLLECTKEY);
const [reorderedItem] = searchCollectList.splice(item.oldIndex, 1);
searchCollectList.splice(item.newIndex, 0, reorderedItem);
storageLocal().setItem(LOCALECOLLECTKEY, searchCollectList);
historyOptions.value = [
...getStorageItem(LOCALEHISTORYKEY),
...getStorageItem(LOCALECOLLECTKEY)
];
historyPath.value = reorderedItem.path;
}
onKeyStroke("Enter", handleEnter);
onKeyStroke("ArrowUp", handleUp);
onKeyStroke("ArrowDown", handleDown);
</script>
<template>
<el-dialog
v-model="show"
top="5vh"
class="pure-search-dialog"
:show-close="false"
:width="device === 'mobile' ? '80vw' : '40vw'"
:before-close="handleClose"
:style="{
borderRadius: '6px'
}"
append-to-body
@opened="inputRef.focus()"
@closed="inputRef.blur()"
>
<el-input
ref="inputRef"
v-model="keyword"
size="large"
clearable
placeholder="搜索菜单(支持拼音搜索)"
@input="handleSearch"
>
<template #prefix>
<IconifyIconOffline
:icon="SearchIcon"
class="text-primary w-[24px] h-[24px]"
/>
</template>
</el-input>
<div class="search-content">
<el-scrollbar ref="scrollbarRef" max-height="calc(90vh - 140px)">
<el-empty v-if="showEmpty" description="暂无搜索结果" />
<SearchHistory
v-if="showSearchHistory"
ref="historyRef"
v-model:value="historyPath"
:options="historyOptions"
@click="handleEnter"
@delete="handleDelete"
@collect="handleCollect"
@drag="handleDrag"
/>
<SearchResult
v-if="showSearchResult"
ref="resultRef"
v-model:value="activePath"
:options="resultOptions"
@click="handleEnter"
/>
</el-scrollbar>
</div>
<template #footer>
<SearchFooter :total="resultOptions.length" />
</template>
</el-dialog>
</template>
<style lang="scss" scoped>
.search-content {
margin-top: 12px;
}
</style>

View File

@@ -1,113 +0,0 @@
<script setup lang="ts">
import type { Props } from "../types";
import { useResizeObserver } from "@pureadmin/utils";
import { useEpThemeStoreHook } from "@/store/modules/epTheme";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import { ref, computed, getCurrentInstance, onMounted } from "vue";
import EnterOutlined from "@/assets/svg/enter_outlined.svg?component";
interface Emits {
(e: "update:value", val: string): void;
(e: "enter"): void;
}
const resultRef = ref();
const innerHeight = ref();
const emit = defineEmits<Emits>();
const instance = getCurrentInstance()!;
const props = withDefaults(defineProps<Props>(), {});
const itemStyle = computed(() => {
return item => {
return {
background:
item?.path === active.value ? useEpThemeStoreHook().epThemeColor : "",
color: item.path === active.value ? "#fff" : "",
fontSize: item.path === active.value ? "16px" : "14px"
};
};
});
const active = computed({
get() {
return props.value;
},
set(val: string) {
emit("update:value", val);
}
});
/** 鼠标移入 */
async function handleMouse(item) {
active.value = item.path;
}
function handleTo() {
emit("enter");
}
function resizeResult() {
// el-scrollbar max-height="calc(90vh - 140px)"
innerHeight.value = window.innerHeight - window.innerHeight / 10 - 140;
}
useResizeObserver(resultRef, resizeResult);
function handleScroll(index: number) {
const curInstance = instance?.proxy?.$refs[`resultItemRef${index}`];
if (!curInstance) return 0;
const curRef = curInstance[0] as ElRef;
const scrollTop = curRef.offsetTop + 128; // 128 两个result-item56px+56px=112px高度加上下margin8px+8px=16px
return scrollTop > innerHeight.value ? scrollTop - innerHeight.value : 0;
}
onMounted(() => {
resizeResult();
});
defineExpose({ handleScroll });
</script>
<template>
<div ref="resultRef" class="result">
<div
v-for="(item, index) in options"
:key="item.path"
:ref="'resultItemRef' + index"
class="result-item dark:bg-[#1d1d1d]"
:style="itemStyle(item)"
@click="handleTo"
@mouseenter="handleMouse(item)"
>
<component :is="useRenderIcon(item.meta?.icon)" />
<span class="result-item-title">
{{ item.meta?.title }}
</span>
<EnterOutlined />
</div>
</div>
</template>
<style lang="scss" scoped>
.result {
padding-bottom: 12px;
&-item {
display: flex;
align-items: center;
height: 56px;
padding: 14px;
margin-top: 8px;
cursor: pointer;
border: 0.1px solid #ccc;
border-radius: 4px;
transition: font-size 0.16s;
&-title {
display: flex;
flex: 1;
margin-left: 5px;
}
}
}
</style>

View File

@@ -1,21 +0,0 @@
<script setup lang="ts">
import { useBoolean } from "../../hooks/useBoolean";
import SearchModal from "./components/SearchModal.vue";
const { bool: show, toggle } = useBoolean();
function handleSearch() {
toggle();
}
</script>
<template>
<div>
<div
class="search-container w-[40px] h-[48px] flex-c cursor-pointer navbar-bg-hover"
@click="handleSearch"
>
<IconifyIconOffline icon="ri:search-line" />
</div>
<SearchModal v-model:value="show" />
</div>
</template>

View File

@@ -1,20 +0,0 @@
interface optionsItem {
path: string;
type: "history" | "collect";
meta: {
icon?: string;
title?: string;
};
}
interface dragItem {
oldIndex: number;
newIndex: number;
}
interface Props {
value: string;
options: Array<optionsItem>;
}
export type { optionsItem, dragItem, Props };

View File

@@ -1,636 +0,0 @@
<script setup lang="ts">
import {
ref,
unref,
watch,
reactive,
computed,
nextTick,
onUnmounted,
onBeforeMount
} from "vue";
import { emitter } from "@/utils/mitt";
import LayPanel from "../lay-panel/index.vue";
import { useNav } from "@/layout/hooks/useNav";
import { useAppStoreHook } from "@/store/modules/app";
import { toggleTheme } from "@pureadmin/theme/dist/browser-utils";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import Segmented, { type OptionsType } from "@/components/ReSegmented";
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
import { useDark, useGlobal, debounce, isNumber } from "@pureadmin/utils";
import Check from "@iconify-icons/ep/check";
import LeftArrow from "@iconify-icons/ri/arrow-left-s-line";
import RightArrow from "@iconify-icons/ri/arrow-right-s-line";
import DayIcon from "@/assets/svg/day.svg?component";
import DarkIcon from "@/assets/svg/dark.svg?component";
import SystemIcon from "@/assets/svg/system.svg?component";
const { device } = useNav();
const { isDark } = useDark();
const { $storage } = useGlobal<GlobalPropertiesApi>();
const mixRef = ref();
const verticalRef = ref();
const horizontalRef = ref();
const {
dataTheme,
overallStyle,
layoutTheme,
themeColors,
toggleClass,
dataThemeChange,
setLayoutThemeColor
} = useDataThemeChange();
/* body添加layout属性作用于src/style/sidebar.scss */
if (unref(layoutTheme)) {
const layout = unref(layoutTheme).layout;
const theme = unref(layoutTheme).theme;
toggleTheme({
scopeName: `layout-theme-${theme}`
});
setLayoutModel(layout);
}
/** 默认灵动模式 */
const markValue = ref($storage.configure?.showModel ?? "smart");
const logoVal = ref($storage.configure?.showLogo ?? true);
const settings = reactive({
greyVal: $storage.configure.grey,
weakVal: $storage.configure.weak,
tabsVal: $storage.configure.hideTabs,
showLogo: $storage.configure.showLogo,
showModel: $storage.configure.showModel,
hideFooter: $storage.configure.hideFooter,
multiTagsCache: $storage.configure.multiTagsCache,
stretch: $storage.configure.stretch
});
const getThemeColorStyle = computed(() => {
return color => {
return { background: color };
};
});
/** 当网页整体为暗色风格时不显示亮白色主题配色切换选项 */
const showThemeColors = computed(() => {
return themeColor => {
return themeColor === "light" && isDark.value ? false : true;
};
});
function storageConfigureChange<T>(key: string, val: T): void {
const storageConfigure = $storage.configure;
storageConfigure[key] = val;
$storage.configure = storageConfigure;
}
/** 灰色模式设置 */
const greyChange = (value): void => {
const htmlEl = document.querySelector("html");
toggleClass(settings.greyVal, "html-grey", htmlEl);
storageConfigureChange("grey", value);
};
/** 色弱模式设置 */
const weekChange = (value): void => {
const htmlEl = document.querySelector("html");
toggleClass(settings.weakVal, "html-weakness", htmlEl);
storageConfigureChange("weak", value);
};
/** 隐藏标签页设置 */
const tagsChange = () => {
const showVal = settings.tabsVal;
storageConfigureChange("hideTabs", showVal);
emitter.emit("tagViewsChange", showVal as unknown as string);
};
/** 隐藏页脚设置 */
const hideFooterChange = () => {
const hideFooter = settings.hideFooter;
storageConfigureChange("hideFooter", hideFooter);
};
/** 标签页持久化设置 */
const multiTagsCacheChange = () => {
const multiTagsCache = settings.multiTagsCache;
storageConfigureChange("multiTagsCache", multiTagsCache);
useMultiTagsStoreHook().multiTagsCacheChange(multiTagsCache);
};
function onChange({ option }) {
const { value } = option;
markValue.value = value;
storageConfigureChange("showModel", value);
emitter.emit("tagViewsShowModel", value);
}
/** 侧边栏Logo */
function logoChange() {
unref(logoVal)
? storageConfigureChange("showLogo", true)
: storageConfigureChange("showLogo", false);
emitter.emit("logoChange", unref(logoVal));
}
function setFalse(Doms): any {
Doms.forEach(v => {
toggleClass(false, "is-select", unref(v));
});
}
/** 页宽 */
const stretchTypeOptions = computed<Array<OptionsType>>(() => {
return [
{
label: "固定",
tip: "紧凑页面,轻松找到所需信息",
value: "fixed"
},
{
label: "自定义",
tip: "最小1280、最大1600",
value: "custom"
}
];
});
const setStretch = value => {
settings.stretch = value;
storageConfigureChange("stretch", value);
};
const stretchTypeChange = ({ option }) => {
const { value } = option;
value === "custom" ? setStretch(1440) : setStretch(false);
};
/** 主题色 激活选择项 */
const getThemeColor = computed(() => {
return current => {
if (
current === layoutTheme.value.theme &&
layoutTheme.value.theme !== "light"
) {
return "#fff";
} else if (
current === layoutTheme.value.theme &&
layoutTheme.value.theme === "light"
) {
return "#1d2b45";
} else {
return "transparent";
}
};
});
const pClass = computed(() => {
return ["mb-[12px]", "font-medium", "text-sm", "dark:text-white"];
});
const themeOptions = computed<Array<OptionsType>>(() => {
return [
{
label: "浅色",
icon: DayIcon,
theme: "light",
tip: "清新启航,点亮舒适的工作界面",
iconAttrs: { fill: isDark.value ? "#fff" : "#000" }
},
{
label: "深色",
icon: DarkIcon,
theme: "dark",
tip: "月光序曲,沉醉于夜的静谧雅致",
iconAttrs: { fill: isDark.value ? "#fff" : "#000" }
},
{
label: "自动",
icon: SystemIcon,
theme: "system",
tip: "同步时光,界面随晨昏自然呼应",
iconAttrs: { fill: isDark.value ? "#fff" : "#000" }
}
];
});
const markOptions = computed<Array<OptionsType>>(() => {
return [
{
label: "灵动",
tip: "灵动标签,添趣生辉",
value: "smart"
},
{
label: "卡片",
tip: "卡片标签,高效浏览",
value: "card"
},
{
label: "谷歌",
tip: "谷歌风格,经典美观",
value: "chrome"
}
];
});
/** 设置导航模式 */
function setLayoutModel(layout: string) {
layoutTheme.value.layout = layout;
window.document.body.setAttribute("layout", layout);
$storage.layout = {
layout,
theme: layoutTheme.value.theme,
darkMode: $storage.layout?.darkMode,
sidebarStatus: $storage.layout?.sidebarStatus,
epThemeColor: $storage.layout?.epThemeColor,
themeColor: $storage.layout?.themeColor,
overallStyle: $storage.layout?.overallStyle
};
useAppStoreHook().setLayout(layout);
}
watch($storage, ({ layout }) => {
switch (layout["layout"]) {
case "vertical":
toggleClass(true, "is-select", unref(verticalRef));
debounce(setFalse([horizontalRef]), 50);
debounce(setFalse([mixRef]), 50);
break;
case "horizontal":
toggleClass(true, "is-select", unref(horizontalRef));
debounce(setFalse([verticalRef]), 50);
debounce(setFalse([mixRef]), 50);
break;
case "mix":
toggleClass(true, "is-select", unref(mixRef));
debounce(setFalse([verticalRef]), 50);
debounce(setFalse([horizontalRef]), 50);
break;
}
});
const mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
/** 根据操作系统主题设置平台整体风格 */
function updateTheme() {
if (overallStyle.value !== "system") return;
if (mediaQueryList.matches) {
dataTheme.value = true;
} else {
dataTheme.value = false;
}
dataThemeChange(overallStyle.value);
}
function removeMatchMedia() {
mediaQueryList.removeEventListener("change", updateTheme);
}
/** 监听操作系统主题改变 */
function watchSystemThemeChange() {
updateTheme();
removeMatchMedia();
mediaQueryList.addEventListener("change", updateTheme);
}
onBeforeMount(() => {
/* 初始化系统配置 */
nextTick(() => {
watchSystemThemeChange();
settings.greyVal &&
document.querySelector("html")?.classList.add("html-grey");
settings.weakVal &&
document.querySelector("html")?.classList.add("html-weakness");
settings.tabsVal && tagsChange();
settings.hideFooter && hideFooterChange();
});
});
onUnmounted(() => removeMatchMedia);
</script>
<template>
<LayPanel>
<div class="p-5">
<p :class="pClass">整体风格</p>
<Segmented
resize
class="select-none"
:modelValue="overallStyle === 'system' ? 2 : dataTheme ? 1 : 0"
:options="themeOptions"
@change="
theme => {
theme.index === 1 && theme.index !== 2
? (dataTheme = true)
: (dataTheme = false);
overallStyle = theme.option.theme;
dataThemeChange(theme.option.theme);
theme.index === 2 && watchSystemThemeChange();
}
"
/>
<p :class="['mt-5', pClass]">主题色</p>
<ul class="theme-color">
<li
v-for="(item, index) in themeColors"
v-show="showThemeColors(item.themeColor)"
:key="index"
:style="getThemeColorStyle(item.color)"
@click="setLayoutThemeColor(item.themeColor)"
>
<el-icon
style="margin: 0.1em 0.1em 0 0"
:size="17"
:color="getThemeColor(item.themeColor)"
>
<IconifyIconOffline :icon="Check" />
</el-icon>
</li>
</ul>
<p :class="['mt-5', pClass]">导航模式</p>
<ul class="pure-theme">
<li
ref="verticalRef"
v-tippy="{
content: '左侧菜单,亲切熟悉',
zIndex: 41000
}"
:class="layoutTheme.layout === 'vertical' ? 'is-select' : ''"
@click="setLayoutModel('vertical')"
>
<div />
<div />
</li>
<li
v-if="device !== 'mobile'"
ref="horizontalRef"
v-tippy="{
content: '顶部菜单,简洁概览',
zIndex: 41000
}"
:class="layoutTheme.layout === 'horizontal' ? 'is-select' : ''"
@click="setLayoutModel('horizontal')"
>
<div />
<div />
</li>
<li
v-if="device !== 'mobile'"
ref="mixRef"
v-tippy="{
content: '混合菜单,灵活多变',
zIndex: 41000
}"
:class="layoutTheme.layout === 'mix' ? 'is-select' : ''"
@click="setLayoutModel('mix')"
>
<div />
<div />
</li>
</ul>
<span v-if="useAppStoreHook().getViewportWidth > 1280">
<p :class="['mt-5', pClass]">页宽</p>
<Segmented
resize
class="mb-2 select-none"
:modelValue="isNumber(settings.stretch) ? 1 : 0"
:options="stretchTypeOptions"
@change="stretchTypeChange"
/>
<el-input-number
v-if="isNumber(settings.stretch)"
v-model="settings.stretch as number"
:min="1280"
:max="1600"
controls-position="right"
@change="value => setStretch(value)"
/>
<button
v-else
v-ripple="{ class: 'text-gray-300' }"
class="bg-transparent flex-c w-full h-20 rounded-md border border-[var(--pure-border-color)]"
@click="setStretch(!settings.stretch)"
>
<div
class="flex-bc transition-all duration-300"
:class="[settings.stretch ? 'w-[24%]' : 'w-[50%]']"
style="color: var(--el-color-primary)"
>
<IconifyIconOffline
:icon="settings.stretch ? RightArrow : LeftArrow"
height="20"
/>
<div
class="flex-grow border-b border-dashed"
style="border-color: var(--el-color-primary)"
/>
<IconifyIconOffline
:icon="settings.stretch ? LeftArrow : RightArrow"
height="20"
/>
</div>
</button>
</span>
<p :class="['mt-4', pClass]">页签风格</p>
<Segmented
resize
class="select-none"
:modelValue="markValue === 'smart' ? 0 : markValue === 'card' ? 1 : 2"
:options="markOptions"
@change="onChange"
/>
<p class="mt-5 font-medium text-sm dark:text-white">界面显示</p>
<ul class="setting">
<li>
<span class="dark:text-white">灰色模式</span>
<el-switch
v-model="settings.greyVal"
inline-prompt
active-text="开"
inactive-text="关"
@change="greyChange"
/>
</li>
<li>
<span class="dark:text-white">色弱模式</span>
<el-switch
v-model="settings.weakVal"
inline-prompt
active-text="开"
inactive-text="关"
@change="weekChange"
/>
</li>
<li>
<span class="dark:text-white">隐藏标签页</span>
<el-switch
v-model="settings.tabsVal"
inline-prompt
active-text="开"
inactive-text="关"
@change="tagsChange"
/>
</li>
<li>
<span class="dark:text-white">隐藏页脚</span>
<el-switch
v-model="settings.hideFooter"
inline-prompt
active-text="开"
inactive-text="关"
@change="hideFooterChange"
/>
</li>
<li>
<span class="dark:text-white">Logo</span>
<el-switch
v-model="logoVal"
inline-prompt
:active-value="true"
:inactive-value="false"
active-text="开"
inactive-text="关"
@change="logoChange"
/>
</li>
<li>
<span class="dark:text-white">页签持久化</span>
<el-switch
v-model="settings.multiTagsCache"
inline-prompt
active-text="开"
inactive-text="关"
@change="multiTagsCacheChange"
/>
</li>
</ul>
</div>
</LayPanel>
</template>
<style lang="scss" scoped>
:deep(.el-divider__text) {
font-size: 16px;
font-weight: 700;
}
:deep(.el-switch__core) {
--el-switch-off-color: var(--pure-switch-off-color);
min-width: 36px;
height: 18px;
}
:deep(.el-switch__core .el-switch__action) {
height: 14px;
}
.theme-color {
height: 20px;
li {
float: left;
height: 20px;
margin-right: 8px;
cursor: pointer;
border-radius: 4px;
&:nth-child(1) {
border: 1px solid #ddd;
}
}
}
.pure-theme {
display: flex;
gap: 12px;
li {
position: relative;
width: 46px;
height: 36px;
overflow: hidden;
cursor: pointer;
background: #f0f2f5;
border-radius: 4px;
box-shadow: 0 1px 2.5px 0 rgb(0 0 0 / 18%);
&:nth-child(1) {
div {
&:nth-child(1) {
width: 30%;
height: 100%;
background: #1b2a47;
}
&:nth-child(2) {
position: absolute;
top: 0;
right: 0;
width: 70%;
height: 30%;
background: #fff;
box-shadow: 0 0 1px #888;
}
}
}
&:nth-child(2) {
div {
&:nth-child(1) {
width: 100%;
height: 30%;
background: #1b2a47;
box-shadow: 0 0 1px #888;
}
}
}
&:nth-child(3) {
div {
&:nth-child(1) {
width: 100%;
height: 30%;
background: #1b2a47;
box-shadow: 0 0 1px #888;
}
&:nth-child(2) {
position: absolute;
bottom: 0;
left: 0;
width: 30%;
height: 70%;
background: #fff;
box-shadow: 0 0 1px #888;
}
}
}
}
}
.is-select {
border: 2px solid var(--el-color-primary);
}
.setting {
li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 0;
font-size: 14px;
}
}
</style>

View File

@@ -1,110 +0,0 @@
<script setup lang="ts">
import { isAllEmpty } from "@pureadmin/utils";
import { ref, nextTick, computed } from "vue";
import { useNav } from "@/layout/hooks/useNav";
import LaySearch from "../lay-search/index.vue";
import LayNotice from "../lay-notice/index.vue";
import { usePermissionStoreHook } from "@/store/modules/permission";
import LaySidebarItem from "../lay-sidebar/components/SidebarItem.vue";
import LaySidebarFullScreen from "../lay-sidebar/components/SidebarFullScreen.vue";
import LogoutCircleRLine from "@iconify-icons/ri/logout-circle-r-line";
import Setting from "@iconify-icons/ri/settings-3-line";
const menuRef = ref();
const {
route,
title,
logout,
onPanel,
getLogo,
username,
userAvatar,
backTopMenu,
avatarsStyle
} = useNav();
const defaultActive = computed(() =>
!isAllEmpty(route.meta?.activePath) ? route.meta.activePath : route.path
);
nextTick(() => {
menuRef.value?.handleResize();
});
</script>
<template>
<div
v-loading="usePermissionStoreHook().wholeMenus.length === 0"
class="horizontal-header"
>
<div class="horizontal-header-left" @click="backTopMenu">
<img :src="getLogo()" alt="logo" />
<span>{{ title }}</span>
</div>
<el-menu
ref="menuRef"
mode="horizontal"
popper-class="pure-scrollbar"
class="horizontal-header-menu"
:default-active="defaultActive"
>
<LaySidebarItem
v-for="route in usePermissionStoreHook().wholeMenus"
:key="route.path"
:item="route"
:base-path="route.path"
/>
</el-menu>
<div class="horizontal-header-right">
<!-- 菜单搜索 -->
<LaySearch id="header-search" />
<!-- 全屏 -->
<LaySidebarFullScreen id="full-screen" />
<!-- 消息通知 -->
<LayNotice id="header-notice" />
<!-- 退出登录 -->
<el-dropdown trigger="click">
<span class="el-dropdown-link navbar-bg-hover">
<img :src="userAvatar" :style="avatarsStyle" />
<p v-if="username" class="dark:text-white">{{ username }}</p>
</span>
<template #dropdown>
<el-dropdown-menu class="logout">
<el-dropdown-item @click="logout">
<IconifyIconOffline
:icon="LogoutCircleRLine"
style="margin: 5px"
/>
退出系统
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<span
class="set-icon navbar-bg-hover"
title="打开系统配置"
@click="onPanel"
>
<IconifyIconOffline :icon="Setting" />
</span>
</div>
</div>
</template>
<style lang="scss" scoped>
:deep(.el-loading-mask) {
opacity: 0.45;
}
.logout {
width: 120px;
::v-deep(.el-dropdown-menu__item) {
display: inline-flex;
flex-wrap: wrap;
min-width: 100%;
}
}
</style>

View File

@@ -1,143 +0,0 @@
<script setup lang="ts">
import { isAllEmpty } from "@pureadmin/utils";
import { useNav } from "@/layout/hooks/useNav";
import LaySearch from "../lay-search/index.vue";
import LayNotice from "../lay-notice/index.vue";
import { ref, toRaw, watch, onMounted, nextTick } from "vue";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import { getParentPaths, findRouteByPath } from "@/router/utils";
import { usePermissionStoreHook } from "@/store/modules/permission";
import LaySidebarExtraIcon from "../lay-sidebar/components/SidebarExtraIcon.vue";
import LaySidebarFullScreen from "../lay-sidebar/components/SidebarFullScreen.vue";
import LogoutCircleRLine from "@iconify-icons/ri/logout-circle-r-line";
import Setting from "@iconify-icons/ri/settings-3-line";
const menuRef = ref();
const defaultActive = ref(null);
const {
route,
device,
logout,
onPanel,
resolvePath,
username,
userAvatar,
getDivStyle,
avatarsStyle
} = useNav();
function getDefaultActive(routePath) {
const wholeMenus = usePermissionStoreHook().wholeMenus;
/** 当前路由的父级路径 */
const parentRoutes = getParentPaths(routePath, wholeMenus)[0];
defaultActive.value = !isAllEmpty(route.meta?.activePath)
? route.meta.activePath
: findRouteByPath(parentRoutes, wholeMenus)?.children[0]?.path;
}
onMounted(() => {
getDefaultActive(route.path);
});
nextTick(() => {
menuRef.value?.handleResize();
});
watch(
() => [route.path, usePermissionStoreHook().wholeMenus],
() => {
getDefaultActive(route.path);
}
);
</script>
<template>
<div
v-if="device !== 'mobile'"
v-loading="usePermissionStoreHook().wholeMenus.length === 0"
class="horizontal-header"
>
<el-menu
ref="menuRef"
router
mode="horizontal"
popper-class="pure-scrollbar"
class="horizontal-header-menu"
:default-active="defaultActive"
>
<el-menu-item
v-for="route in usePermissionStoreHook().wholeMenus"
:key="route.path"
:index="resolvePath(route) || route.redirect"
>
<template #title>
<div
v-if="toRaw(route.meta.icon)"
:class="['sub-menu-icon', route.meta.icon]"
>
<component
:is="useRenderIcon(route.meta && toRaw(route.meta.icon))"
/>
</div>
<div :style="getDivStyle">
<span class="select-none">
{{ route.meta.title }}
</span>
<LaySidebarExtraIcon :extraIcon="route.meta.extraIcon" />
</div>
</template>
</el-menu-item>
</el-menu>
<div class="horizontal-header-right">
<!-- 菜单搜索 -->
<LaySearch id="header-search" />
<!-- 全屏 -->
<LaySidebarFullScreen id="full-screen" />
<!-- 消息通知 -->
<LayNotice id="header-notice" />
<!-- 退出登录 -->
<el-dropdown trigger="click">
<span class="el-dropdown-link navbar-bg-hover select-none">
<img :src="userAvatar" :style="avatarsStyle" />
<p v-if="username" class="dark:text-white">{{ username }}</p>
</span>
<template #dropdown>
<el-dropdown-menu class="logout">
<el-dropdown-item @click="logout">
<IconifyIconOffline
:icon="LogoutCircleRLine"
style="margin: 5px"
/>
退出系统
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<span
class="set-icon navbar-bg-hover"
title="打开系统配置"
@click="onPanel"
>
<IconifyIconOffline :icon="Setting" />
</span>
</div>
</div>
</template>
<style lang="scss" scoped>
:deep(.el-loading-mask) {
opacity: 0.45;
}
.logout {
width: 120px;
::v-deep(.el-dropdown-menu__item) {
display: inline-flex;
flex-wrap: wrap;
min-width: 100%;
}
}
</style>

View File

@@ -1,137 +0,0 @@
<script setup lang="ts">
import { useRoute } from "vue-router";
import { emitter } from "@/utils/mitt";
import { useNav } from "@/layout/hooks/useNav";
import { responsiveStorageNameSpace } from "@/config";
import { storageLocal, isAllEmpty } from "@pureadmin/utils";
import { findRouteByPath, getParentPaths } from "@/router/utils";
import { usePermissionStoreHook } from "@/store/modules/permission";
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import LaySidebarLogo from "../lay-sidebar/components/SidebarLogo.vue";
import LaySidebarItem from "../lay-sidebar/components/SidebarItem.vue";
import LaySidebarLeftCollapse from "../lay-sidebar/components/SidebarLeftCollapse.vue";
import LaySidebarCenterCollapse from "../lay-sidebar/components/SidebarCenterCollapse.vue";
const route = useRoute();
const isShow = ref(false);
const showLogo = ref(
storageLocal().getItem<StorageConfigs>(
`${responsiveStorageNameSpace()}configure`
)?.showLogo ?? true
);
const {
device,
pureApp,
isCollapse,
tooltipEffect,
menuSelect,
toggleSideBar
} = useNav();
const subMenuData = ref([]);
const menuData = computed(() => {
return pureApp.layout === "mix" && device.value !== "mobile"
? subMenuData.value
: usePermissionStoreHook().wholeMenus;
});
const loading = computed(() =>
pureApp.layout === "mix" ? false : menuData.value.length === 0 ? true : false
);
const defaultActive = computed(() =>
!isAllEmpty(route.meta?.activePath) ? route.meta.activePath : route.path
);
function getSubMenuData() {
let path = "";
path = defaultActive.value;
subMenuData.value = [];
// path的上级路由组成的数组
const parentPathArr = getParentPaths(
path,
usePermissionStoreHook().wholeMenus
);
// 当前路由的父级路由信息
const parenetRoute = findRouteByPath(
parentPathArr[0] || path,
usePermissionStoreHook().wholeMenus
);
if (!parenetRoute?.children) return;
subMenuData.value = parenetRoute?.children;
}
watch(
() => [route.path, usePermissionStoreHook().wholeMenus],
() => {
if (route.path.includes("/redirect")) return;
getSubMenuData();
menuSelect(route.path);
}
);
onMounted(() => {
getSubMenuData();
emitter.on("logoChange", key => {
showLogo.value = key;
});
});
onBeforeUnmount(() => {
// 解绑`logoChange`公共事件,防止多次触发
emitter.off("logoChange");
});
</script>
<template>
<div
v-loading="loading"
:class="['sidebar-container', showLogo ? 'has-logo' : 'no-logo']"
@mouseenter.prevent="isShow = true"
@mouseleave.prevent="isShow = false"
>
<LaySidebarLogo v-if="showLogo" :collapse="isCollapse" />
<el-scrollbar
wrap-class="scrollbar-wrapper"
:class="[device === 'mobile' ? 'mobile' : 'pc']"
>
<el-menu
unique-opened
mode="vertical"
popper-class="pure-scrollbar"
class="outer-most select-none"
:collapse="isCollapse"
:collapse-transition="false"
:popper-effect="tooltipEffect"
:default-active="defaultActive"
>
<LaySidebarItem
v-for="routes in menuData"
:key="routes.path"
:item="routes"
:base-path="routes.path"
class="outer-most select-none"
/>
</el-menu>
</el-scrollbar>
<LaySidebarCenterCollapse
v-if="device !== 'mobile' && (isShow || isCollapse)"
:is-active="pureApp.sidebar.opened"
@toggleClick="toggleSideBar"
/>
<LaySidebarLeftCollapse
v-if="device !== 'mobile'"
:is-active="pureApp.sidebar.opened"
@toggleClick="toggleSideBar"
/>
</div>
</template>
<style scoped>
:deep(.el-loading-mask) {
opacity: 0.45;
}
</style>

View File

@@ -1,120 +0,0 @@
<script setup lang="ts">
import { isEqual } from "@pureadmin/utils";
import { useRoute, useRouter } from "vue-router";
import { ref, watch, onMounted, toRaw } from "vue";
import { getParentPaths, findRouteByPath } from "@/router/utils";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
const route = useRoute();
const levelList = ref([]);
const router = useRouter();
const routes: any = router.options.routes;
const multiTags: any = useMultiTagsStoreHook().multiTags;
const getBreadcrumb = (): void => {
// 当前路由信息
let currentRoute;
if (Object.keys(route.query).length > 0) {
multiTags.forEach(item => {
if (isEqual(route.query, item?.query)) {
currentRoute = toRaw(item);
}
});
} else if (Object.keys(route.params).length > 0) {
multiTags.forEach(item => {
if (isEqual(route.params, item?.params)) {
currentRoute = toRaw(item);
}
});
} else {
currentRoute = findRouteByPath(router.currentRoute.value.path, routes);
}
// 当前路由的父级路径组成的数组
const parentRoutes = getParentPaths(
router.currentRoute.value.name as string,
routes,
"name"
);
// 存放组成面包屑的数组
const matched = [];
// 获取每个父级路径对应的路由信息
parentRoutes.forEach(path => {
if (path !== "/") matched.push(findRouteByPath(path, routes));
});
matched.push(currentRoute);
matched.forEach((item, index) => {
if (currentRoute?.query || currentRoute?.params) return;
if (item?.children) {
item.children.forEach(v => {
if (v?.meta?.title === item?.meta?.title) {
matched.splice(index, 1);
}
});
}
});
levelList.value = matched.filter(
item => item?.meta && item?.meta.title !== false
);
};
const handleLink = item => {
const { redirect, name, path } = item;
if (redirect) {
router.push(redirect as any);
} else {
if (name) {
if (item.query) {
router.push({
name,
query: item.query
});
} else if (item.params) {
router.push({
name,
params: item.params
});
} else {
router.push({ name });
}
} else {
router.push({ path });
}
}
};
onMounted(() => {
getBreadcrumb();
});
watch(
() => route.path,
() => {
getBreadcrumb();
},
{
deep: true
}
);
</script>
<template>
<el-breadcrumb class="!leading-[50px] select-none" separator="/">
<transition-group name="breadcrumb">
<el-breadcrumb-item
v-for="item in levelList"
:key="item.path"
class="!inline !items-stretch"
>
<a @click.prevent="handleLink(item)">
{{ item.meta.title }}
</a>
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</template>

View File

@@ -1,70 +0,0 @@
<script setup lang="ts">
import { computed } from "vue";
import { useGlobal } from "@pureadmin/utils";
import { useNav } from "@/layout/hooks/useNav";
import ArrowLeft from "@iconify-icons/ri/arrow-left-double-fill";
interface Props {
isActive: boolean;
}
withDefaults(defineProps<Props>(), {
isActive: false
});
const { tooltipEffect } = useNav();
const iconClass = computed(() => {
return ["w-[16px]", "h-[16px]"];
});
const { $storage } = useGlobal<GlobalPropertiesApi>();
const themeColor = computed(() => $storage.layout?.themeColor);
const emit = defineEmits<{
(e: "toggleClick"): void;
}>();
const toggleClick = () => {
emit("toggleClick");
};
</script>
<template>
<div
v-tippy="{
content: isActive ? '点击折叠' : '点击展开',
theme: tooltipEffect,
hideOnClick: 'toggle',
placement: 'right'
}"
class="center-collapse"
@click="toggleClick"
>
<IconifyIconOffline
:icon="ArrowLeft"
:class="[iconClass, themeColor === 'light' ? '' : 'text-primary']"
:style="{ transform: isActive ? 'none' : 'rotateY(180deg)' }"
/>
</div>
</template>
<style lang="scss" scoped>
.center-collapse {
position: absolute;
top: 50%;
right: 2px;
z-index: 1002;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 34px;
cursor: pointer;
background: var(--el-bg-color);
border: 1px solid var(--pure-border-color);
border-radius: 4px;
transform: translate(12px, -50%);
}
</style>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import { toRaw } from "vue";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
defineProps({
extraIcon: {
type: String,
default: ""
}
});
</script>
<template>
<div v-if="extraIcon" class="flex justify-center items-center">
<component
:is="useRenderIcon(toRaw(extraIcon))"
class="w-[30px] h-[30px]"
/>
</div>
</template>

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { useNav } from "@/layout/hooks/useNav";
const screenIcon = ref();
const { toggle, isFullscreen, Fullscreen, ExitFullscreen } = useNav();
isFullscreen.value = !!(
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement
);
watch(
isFullscreen,
full => {
screenIcon.value = full ? ExitFullscreen : Fullscreen;
},
{
immediate: true
}
);
</script>
<template>
<span class="fullscreen-icon navbar-bg-hover" @click="toggle">
<IconifyIconOffline :icon="screenIcon" />
</span>
</template>

View File

@@ -1,222 +0,0 @@
<script setup lang="ts">
import path from "path";
import { getConfig } from "@/config";
import { menuType } from "@/layout/types";
import { ReText } from "@/components/ReText";
import { useNav } from "@/layout/hooks/useNav";
import SidebarLinkItem from "./SidebarLinkItem.vue";
import SidebarExtraIcon from "./SidebarExtraIcon.vue";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import {
type PropType,
type CSSProperties,
ref,
toRaw,
computed,
useAttrs
} from "vue";
import ArrowUp from "@iconify-icons/ep/arrow-up-bold";
import EpArrowDown from "@iconify-icons/ep/arrow-down-bold";
import ArrowLeft from "@iconify-icons/ep/arrow-left-bold";
import ArrowRight from "@iconify-icons/ep/arrow-right-bold";
const attrs = useAttrs();
const { layout, isCollapse, tooltipEffect, getDivStyle } = useNav();
const props = defineProps({
item: {
type: Object as PropType<menuType>
},
isNest: {
type: Boolean,
default: false
},
basePath: {
type: String,
default: ""
}
});
const getNoDropdownStyle = computed((): CSSProperties => {
return {
width: "100%",
display: "flex",
alignItems: "center"
};
});
const getSubMenuIconStyle = computed((): CSSProperties => {
return {
display: "flex",
justifyContent: "center",
alignItems: "center",
margin:
layout.value === "horizontal"
? "0 5px 0 0"
: isCollapse.value
? "0 auto"
: "0 5px 0 0"
};
});
const expandCloseIcon = computed(() => {
if (!getConfig()?.MenuArrowIconNoTransition) return "";
return {
"expand-close-icon": useRenderIcon(EpArrowDown),
"expand-open-icon": useRenderIcon(ArrowUp),
"collapse-close-icon": useRenderIcon(ArrowRight),
"collapse-open-icon": useRenderIcon(ArrowLeft)
};
});
const onlyOneChild: menuType = ref(null);
function hasOneShowingChild(children: menuType[] = [], parent: menuType) {
const showingChildren = children.filter((item: any) => {
onlyOneChild.value = item;
return true;
});
if (showingChildren[0]?.meta?.showParent) {
return false;
}
if (showingChildren.length === 1) {
return true;
}
if (showingChildren.length === 0) {
onlyOneChild.value = { ...parent, path: "", noShowingChildren: true };
return true;
}
return false;
}
function resolvePath(routePath) {
const httpReg = /^http(s?):\/\//;
if (httpReg.test(routePath) || httpReg.test(props.basePath)) {
return routePath || props.basePath;
} else {
// 使用path.posix.resolve替代path.resolve 避免windows环境下使用electron出现盘符问题
return path.posix.resolve(props.basePath, routePath);
}
}
</script>
<template>
<SidebarLinkItem
v-if="
hasOneShowingChild(item.children, item) &&
(!onlyOneChild.children || onlyOneChild.noShowingChildren)
"
:to="item"
>
<el-menu-item
:index="resolvePath(onlyOneChild.path)"
:class="{ 'submenu-title-noDropdown': !isNest }"
:style="getNoDropdownStyle"
v-bind="attrs"
>
<div
v-if="toRaw(item.meta.icon)"
class="sub-menu-icon"
:style="getSubMenuIconStyle"
>
<component
:is="
useRenderIcon(
toRaw(onlyOneChild.meta.icon) ||
(item.meta && toRaw(item.meta.icon))
)
"
/>
</div>
<el-text
v-if="
(!item?.meta.icon &&
isCollapse &&
layout === 'vertical' &&
item?.pathList?.length === 1) ||
(!onlyOneChild.meta.icon &&
isCollapse &&
layout === 'mix' &&
item?.pathList?.length === 2)
"
truncated
class="!w-full !pl-4 !text-inherit"
>
{{ onlyOneChild.meta.title }}
</el-text>
<template #title>
<div :style="getDivStyle">
<ReText
:tippyProps="{
offset: [0, -10],
theme: tooltipEffect
}"
class="!w-full !text-inherit"
>
{{ onlyOneChild.meta.title }}
</ReText>
<SidebarExtraIcon :extraIcon="onlyOneChild.meta.extraIcon" />
</div>
</template>
</el-menu-item>
</SidebarLinkItem>
<el-sub-menu
v-else
ref="subMenu"
teleported
:index="resolvePath(item.path)"
v-bind="expandCloseIcon"
>
<template #title>
<div
v-if="toRaw(item.meta.icon)"
:style="getSubMenuIconStyle"
class="sub-menu-icon"
>
<component :is="useRenderIcon(item.meta && toRaw(item.meta.icon))" />
</div>
<ReText
v-if="
layout === 'mix' && toRaw(item.meta.icon)
? !isCollapse || item?.pathList?.length !== 2
: !(
layout === 'vertical' &&
isCollapse &&
toRaw(item.meta.icon) &&
item.parentId === null
)
"
:tippyProps="{
offset: [0, -10],
theme: tooltipEffect
}"
:class="{
'!w-full': true,
'!text-inherit': true,
'!pl-4':
layout !== 'horizontal' &&
isCollapse &&
!toRaw(item.meta.icon) &&
item.parentId === null
}"
>
{{ item.meta.title }}
</ReText>
<SidebarExtraIcon v-if="!isCollapse" :extraIcon="item.meta.extraIcon" />
</template>
<sidebar-item
v-for="child in item.children"
:key="child.path"
:is-nest="true"
:item="child"
:base-path="resolvePath(child.path)"
class="nest-menu"
/>
</el-sub-menu>
</template>

View File

@@ -1,69 +0,0 @@
<script setup lang="ts">
import { computed } from "vue";
import { useGlobal } from "@pureadmin/utils";
import { useNav } from "@/layout/hooks/useNav";
import MenuFold from "@iconify-icons/ri/menu-fold-fill";
interface Props {
isActive: boolean;
}
withDefaults(defineProps<Props>(), {
isActive: false
});
const { tooltipEffect } = useNav();
const iconClass = computed(() => {
return [
"ml-4",
"mb-1",
"w-[16px]",
"h-[16px]",
"inline-block",
"align-middle",
"cursor-pointer",
"duration-[100ms]"
];
});
const { $storage } = useGlobal<GlobalPropertiesApi>();
const themeColor = computed(() => $storage.layout?.themeColor);
const emit = defineEmits<{
(e: "toggleClick"): void;
}>();
const toggleClick = () => {
emit("toggleClick");
};
</script>
<template>
<div class="left-collapse">
<IconifyIconOffline
v-tippy="{
content: isActive ? '点击折叠' : '点击展开',
theme: tooltipEffect,
hideOnClick: 'toggle',
placement: 'right'
}"
:icon="MenuFold"
:class="[iconClass, themeColor === 'light' ? '' : 'text-primary']"
:style="{ transform: isActive ? 'none' : 'rotateY(180deg)' }"
@click="toggleClick"
/>
</div>
</template>
<style lang="scss" scoped>
.left-collapse {
position: absolute;
bottom: 0;
width: 100%;
height: 40px;
line-height: 40px;
box-shadow: 0 0 6px -3px var(--el-color-primary);
}
</style>

View File

@@ -1,32 +0,0 @@
<script setup lang="ts">
import { computed } from "vue";
import { isUrl } from "@pureadmin/utils";
import { menuType } from "@/layout/types";
const props = defineProps<{
to: menuType;
}>();
const isExternalLink = computed(() => isUrl(props.to.name));
const getLinkProps = (item: menuType) => {
if (isExternalLink.value) {
return {
href: item.name,
target: "_blank",
rel: "noopener"
};
}
return {
to: item
};
};
</script>
<template>
<component
:is="isExternalLink ? 'a' : 'router-link'"
v-bind="getLinkProps(to)"
>
<slot />
</component>
</template>

View File

@@ -1,72 +0,0 @@
<script setup lang="ts">
import { getTopMenu } from "@/router/utils";
import { useNav } from "@/layout/hooks/useNav";
defineProps({
collapse: Boolean
});
const { title, getLogo } = useNav();
</script>
<template>
<div class="sidebar-logo-container" :class="{ collapses: collapse }">
<transition name="sidebarLogoFade">
<router-link
v-if="collapse"
key="collapse"
:title="title"
class="sidebar-logo-link"
:to="getTopMenu()?.path ?? '/'"
>
<img :src="getLogo()" alt="logo" />
<span class="sidebar-title">{{ title }}</span>
</router-link>
<router-link
v-else
key="expand"
:title="title"
class="sidebar-logo-link"
:to="getTopMenu()?.path ?? '/'"
>
<img :src="getLogo()" alt="logo" />
<span class="sidebar-title">{{ title }}</span>
</router-link>
</transition>
</div>
</template>
<style lang="scss" scoped>
.sidebar-logo-container {
position: relative;
width: 100%;
height: 48px;
overflow: hidden;
.sidebar-logo-link {
display: flex;
flex-wrap: nowrap;
align-items: center;
height: 100%;
padding-left: 10px;
img {
display: inline-block;
height: 32px;
}
.sidebar-title {
display: inline-block;
height: 32px;
margin: 2px 0 0 12px;
overflow: hidden;
font-size: 18px;
font-weight: 600;
line-height: 32px;
color: $subMenuActiveText;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>

View File

@@ -1,33 +0,0 @@
<script setup lang="ts">
import MenuFold from "@iconify-icons/ri/menu-fold-fill";
import MenuUnfold from "@iconify-icons/ri/menu-unfold-fill";
interface Props {
isActive: boolean;
}
withDefaults(defineProps<Props>(), {
isActive: false
});
const emit = defineEmits<{
(e: "toggleClick"): void;
}>();
const toggleClick = () => {
emit("toggleClick");
};
</script>
<template>
<div
class="px-3 mr-1 navbar-bg-hover"
:title="isActive ? '点击折叠' : '点击展开'"
@click="toggleClick"
>
<IconifyIconOffline
:icon="isActive ? MenuFold : MenuUnfold"
class="inline-block align-middle hover:text-primary dark:hover:!text-white"
/>
</div>
</template>

View File

@@ -1,33 +0,0 @@
<template>
<svg class="w-full h-full">
<defs>
<symbol id="geometry-left" viewBox="0 0 214 36">
<path d="M17 0h197v36H0v-2c4.5 0 9-3.5 9-8V8c0-4.5 3.5-8 8-8z" />
</symbol>
<symbol id="geometry-right" viewBox="0 0 214 36">
<use xlink:href="#geometry-left" />
</symbol>
<clipPath>
<rect width="100%" height="100%" x="0" />
</clipPath>
</defs>
<svg width="51%" height="100%">
<use
xlink:href="#geometry-left"
width="214"
height="36"
fill="currentColor"
/>
</svg>
<g transform="scale(-1, 1)">
<svg width="51%" height="100%" x="-100%" y="0">
<use
xlink:href="#geometry-right"
width="214"
height="36"
fill="currentColor"
/>
</svg>
</g>
</svg>
</template>

View File

@@ -1,371 +0,0 @@
@keyframes schedule-in-width {
from {
width: 0;
}
to {
width: 100%;
}
}
@keyframes schedule-out-width {
from {
width: 100%;
}
to {
width: 0;
}
}
.tags-view {
position: relative;
display: flex;
align-items: center;
width: 100%;
font-size: 14px;
color: var(--el-text-color-primary);
background: #fff;
box-shadow: 0 0 1px #888;
.scroll-item {
position: relative;
display: inline-block;
height: 34px;
padding-left: 6px;
line-height: 34px;
cursor: pointer;
transition: all 0.4s;
&:not(:first-child) {
padding-right: 24px;
}
&.chrome-item {
padding-right: 0;
padding-left: 0;
margin-right: -18px;
box-shadow: none;
}
.el-icon-close {
position: absolute;
top: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: var(--el-color-primary);
cursor: pointer;
border-radius: 4px;
transition:
background-color 0.12s,
color 0.12s;
transform: translate(0, -50%);
&:hover {
color: rgb(0 0 0 / 88%) !important;
background-color: rgb(0 0 0 / 6%);
}
}
}
.tag-title {
padding: 0 4px;
color: var(--el-text-color-primary);
text-decoration: none;
}
.scroll-container {
position: relative;
flex: 1;
overflow: hidden;
white-space: nowrap;
&.chrome-scroll-container {
padding-top: 4px;
.fixed-tag {
padding: 0 !important;
}
}
.tab {
position: relative;
float: left;
overflow: visible;
white-space: nowrap;
list-style: none;
.scroll-item {
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
&:nth-child(1) {
padding: 0 12px;
}
&.chrome-item {
&:nth-child(1) {
padding: 0;
}
}
}
.fixed-tag {
padding: 0 12px;
}
}
}
/* 右键菜单 */
.contextmenu {
position: absolute;
padding: 5px 0;
margin: 0;
font-size: 13px;
font-weight: normal;
color: var(--el-text-color-primary);
white-space: nowrap;
list-style-type: none;
background: #fff;
border-radius: 4px;
outline: 0;
box-shadow: 0 2px 8px rgb(0 0 0 / 15%);
li {
display: flex;
align-items: center;
width: 100%;
padding: 7px 12px;
margin: 0;
cursor: pointer;
&:hover {
color: var(--el-color-primary);
}
svg {
display: block;
margin-right: 0.5em;
}
}
}
}
.el-dropdown-menu {
li {
display: flex;
align-items: center;
width: 100%;
margin: 0;
cursor: pointer;
svg {
display: block;
margin-right: 0.5em;
}
}
}
.el-dropdown-menu__item:not(.is-disabled):hover {
color: #606266;
background: #f0f0f0;
}
:deep(.el-dropdown-menu__item) i {
margin-right: 10px;
}
:deep(.el-dropdown-menu__item--divided) {
margin: 1px 0;
}
.el-dropdown-menu__item--divided::before {
margin: 0;
}
.el-dropdown-menu__item.is-disabled {
cursor: not-allowed;
}
.scroll-item.is-active {
position: relative;
color: #fff;
box-shadow: 0 0 0.7px #888;
.chrome-tab {
z-index: 10;
}
.chrome-tab__bg {
color: var(--el-color-primary-light-9) !important;
}
.tag-title {
color: var(--el-color-primary) !important;
}
.chrome-close-btn {
color: var(--el-color-primary);
&:hover {
background-color: var(--el-color-primary);
}
}
.chrome-tab-divider {
opacity: 0;
}
}
.arrow-left,
.arrow-right,
.arrow-down {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 34px;
color: var(--el-text-color-primary);
svg {
width: 20px;
height: 20px;
}
}
.arrow-left {
box-shadow: 5px 0 5px -6px #ccc;
&:hover {
cursor: w-resize;
}
}
.arrow-right {
border-right: 0.5px solid #ccc;
box-shadow: -5px 0 5px -6px #ccc;
&:hover {
cursor: e-resize;
}
}
/* 卡片模式下鼠标移入显示蓝色边框 */
.card-in {
color: var(--el-color-primary);
.tag-title {
color: var(--el-color-primary);
}
}
/* 卡片模式下鼠标移出隐藏蓝色边框 */
.card-out {
color: #666;
border: none;
.tag-title {
color: #666;
}
}
/* 灵动模式 */
.schedule-active {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--el-color-primary);
}
/* 灵动模式下鼠标移入显示蓝色进度条 */
.schedule-in {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--el-color-primary);
animation: schedule-in-width 200ms ease-in;
}
/* 灵动模式下鼠标移出隐藏蓝色进度条 */
.schedule-out {
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 2px;
background: var(--el-color-primary);
animation: schedule-out-width 200ms ease-in;
}
/* 谷歌风格的页签 */
.chrome-tab {
position: relative;
display: inline-flex;
gap: 16px;
align-items: center;
justify-content: center;
padding: 0 24px;
white-space: nowrap;
cursor: pointer;
.tag-title {
padding: 0;
}
.chrome-tab-divider {
position: absolute;
right: 7px;
width: 1px;
height: 14px;
background-color: #2b2d2f;
}
&:hover {
z-index: 10;
.chrome-tab__bg {
color: #dee1e6;
}
.tag-title {
color: #1f1f1f;
}
.chrome-tab-divider {
opacity: 0;
}
}
.chrome-tab__bg {
position: absolute;
top: 0;
left: 0;
z-index: -10;
width: 100%;
height: 100%;
color: transparent;
pointer-events: none;
}
.chrome-close-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: #666;
border-radius: 50%;
&:hover {
color: white;
background-color: #b1b3b8;
}
}
}

View File

@@ -1,683 +0,0 @@
<script setup lang="ts">
import { emitter } from "@/utils/mitt";
import { RouteConfigs } from "../../types";
import { useTags } from "../../hooks/useTag";
import { routerArrays } from "@/layout/types";
import { onClickOutside } from "@vueuse/core";
import TagChrome from "./components/TagChrome.vue";
import { handleAliveRoute, getTopMenu } from "@/router/utils";
import { useSettingStoreHook } from "@/store/modules/settings";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import { usePermissionStoreHook } from "@/store/modules/permission";
import { ref, watch, unref, toRaw, nextTick, onBeforeUnmount } from "vue";
import {
delay,
isEqual,
isAllEmpty,
useResizeObserver
} from "@pureadmin/utils";
import ExitFullscreen from "@iconify-icons/ri/fullscreen-exit-fill";
import Fullscreen from "@iconify-icons/ri/fullscreen-fill";
import ArrowDown from "@iconify-icons/ri/arrow-down-s-line";
import ArrowRightSLine from "@iconify-icons/ri/arrow-right-s-line";
import ArrowLeftSLine from "@iconify-icons/ri/arrow-left-s-line";
const {
Close,
route,
router,
visible,
showTags,
instance,
multiTags,
tagsViews,
buttonTop,
buttonLeft,
showModel,
translateX,
isFixedTag,
pureSetting,
activeIndex,
getTabStyle,
isScrolling,
iconIsActive,
linkIsActive,
currentSelect,
scheduleIsActive,
getContextMenuStyle,
closeMenu,
onMounted,
onMouseenter,
onMouseleave,
onContentFullScreen
} = useTags();
const tabDom = ref();
const containerDom = ref();
const scrollbarDom = ref();
const contextmenuRef = ref();
const isShowArrow = ref(false);
const topPath = getTopMenu()?.path;
const { VITE_HIDE_HOME } = import.meta.env;
const fixedTags = [
...routerArrays,
...usePermissionStoreHook().flatteningRoutes.filter(v => v?.meta?.fixedTag)
];
const dynamicTagView = async () => {
await nextTick();
const index = multiTags.value.findIndex(item => {
if (!isAllEmpty(route.query)) {
return isEqual(route.query, item.query);
} else if (!isAllEmpty(route.params)) {
return isEqual(route.params, item.params);
} else {
return route.path === item.path;
}
});
moveToView(index);
};
const moveToView = async (index: number): Promise<void> => {
await nextTick();
const tabNavPadding = 10;
if (!instance.refs["dynamic" + index]) return;
const tabItemEl = instance.refs["dynamic" + index][0];
const tabItemElOffsetLeft = (tabItemEl as HTMLElement)?.offsetLeft;
const tabItemOffsetWidth = (tabItemEl as HTMLElement)?.offsetWidth;
// 标签页导航栏可视长度(不包含溢出部分)
const scrollbarDomWidth = scrollbarDom.value
? scrollbarDom.value?.offsetWidth
: 0;
// 已有标签页总长度(包含溢出部分)
const tabDomWidth = tabDom.value ? tabDom.value?.offsetWidth : 0;
scrollbarDomWidth <= tabDomWidth
? (isShowArrow.value = true)
: (isShowArrow.value = false);
if (tabDomWidth < scrollbarDomWidth || tabItemElOffsetLeft === 0) {
translateX.value = 0;
} else if (tabItemElOffsetLeft < -translateX.value) {
// 标签在可视区域左侧
translateX.value = -tabItemElOffsetLeft + tabNavPadding;
} else if (
tabItemElOffsetLeft > -translateX.value &&
tabItemElOffsetLeft + tabItemOffsetWidth <
-translateX.value + scrollbarDomWidth
) {
// 标签在可视区域
translateX.value = Math.min(
0,
scrollbarDomWidth -
tabItemOffsetWidth -
tabItemElOffsetLeft -
tabNavPadding
);
} else {
// 标签在可视区域右侧
translateX.value = -(
tabItemElOffsetLeft -
(scrollbarDomWidth - tabNavPadding - tabItemOffsetWidth)
);
}
};
const handleScroll = (offset: number): void => {
const scrollbarDomWidth = scrollbarDom.value
? scrollbarDom.value?.offsetWidth
: 0;
const tabDomWidth = tabDom.value ? tabDom.value.offsetWidth : 0;
if (offset > 0) {
translateX.value = Math.min(0, translateX.value + offset);
} else {
if (scrollbarDomWidth < tabDomWidth) {
if (translateX.value >= -(tabDomWidth - scrollbarDomWidth)) {
translateX.value = Math.max(
translateX.value + offset,
scrollbarDomWidth - tabDomWidth
);
}
} else {
translateX.value = 0;
}
}
isScrolling.value = false;
};
const handleWheel = (event: WheelEvent): void => {
isScrolling.value = true;
const scrollIntensity = Math.abs(event.deltaX) + Math.abs(event.deltaY);
let offset = 0;
if (event.deltaX < 0) {
offset = scrollIntensity > 0 ? scrollIntensity : 100;
} else {
offset = scrollIntensity > 0 ? -scrollIntensity : -100;
}
smoothScroll(offset);
};
const smoothScroll = (offset: number): void => {
// 每帧滚动的距离
const scrollAmount = 20;
let remaining = Math.abs(offset);
const scrollStep = () => {
const scrollOffset = Math.sign(offset) * Math.min(scrollAmount, remaining);
handleScroll(scrollOffset);
remaining -= Math.abs(scrollOffset);
if (remaining > 0) {
requestAnimationFrame(scrollStep);
}
};
requestAnimationFrame(scrollStep);
};
function dynamicRouteTag(value: string): void {
const hasValue = multiTags.value.some(item => {
return item.path === value;
});
function concatPath(arr: object[], value: string) {
if (!hasValue) {
arr.forEach((arrItem: any) => {
if (arrItem.path === value) {
useMultiTagsStoreHook().handleTags("push", {
path: value,
meta: arrItem.meta,
name: arrItem.name
});
} else {
if (arrItem.children && arrItem.children.length > 0) {
concatPath(arrItem.children, value);
}
}
});
}
}
concatPath(router.options.routes as any, value);
}
/** 刷新路由 */
function onFresh() {
const { fullPath, query } = unref(route);
router.replace({
path: "/redirect" + fullPath,
query
});
handleAliveRoute(route as ToRouteType, "refresh");
}
function deleteDynamicTag(obj: any, current: any, tag?: string) {
const valueIndex: number = multiTags.value.findIndex((item: any) => {
if (item.query) {
if (item.path === obj.path) {
return item.query === obj.query;
}
} else if (item.params) {
if (item.path === obj.path) {
return item.params === obj.params;
}
} else {
return item.path === obj.path;
}
});
const spliceRoute = (
startIndex?: number,
length?: number,
other?: boolean
): void => {
if (other) {
useMultiTagsStoreHook().handleTags(
"equal",
[
VITE_HIDE_HOME === "false" ? fixedTags : toRaw(getTopMenu()),
obj
].flat()
);
} else {
useMultiTagsStoreHook().handleTags("splice", "", {
startIndex,
length
}) as any;
}
dynamicTagView();
};
if (tag === "other") {
spliceRoute(1, 1, true);
} else if (tag === "left") {
spliceRoute(fixedTags.length, valueIndex - 1, true);
} else if (tag === "right") {
spliceRoute(valueIndex + 1, multiTags.value.length);
} else {
// 从当前匹配到的路径中删除
spliceRoute(valueIndex, 1);
}
const newRoute = useMultiTagsStoreHook().handleTags("slice");
if (current === route.path) {
// 如果删除当前激活tag就自动切换到最后一个tag
if (tag === "left") return;
if (newRoute[0]?.query) {
router.push({ name: newRoute[0].name, query: newRoute[0].query });
} else if (newRoute[0]?.params) {
router.push({ name: newRoute[0].name, params: newRoute[0].params });
} else {
router.push({ path: newRoute[0].path });
}
} else {
if (!multiTags.value.length) return;
if (multiTags.value.some(item => item.path === route.path)) return;
if (newRoute[0]?.query) {
router.push({ name: newRoute[0].name, query: newRoute[0].query });
} else if (newRoute[0]?.params) {
router.push({ name: newRoute[0].name, params: newRoute[0].params });
} else {
router.push({ path: newRoute[0].path });
}
}
}
function deleteMenu(item, tag?: string) {
deleteDynamicTag(item, item.path, tag);
handleAliveRoute(route as ToRouteType);
}
function onClickDrop(key, item, selectRoute?: RouteConfigs) {
if (item && item.disabled) return;
let selectTagRoute;
if (selectRoute) {
selectTagRoute = {
path: selectRoute.path,
meta: selectRoute.meta,
name: selectRoute.name,
query: selectRoute?.query,
params: selectRoute?.params
};
} else {
selectTagRoute = { path: route.path, meta: route.meta };
}
// 当前路由信息
switch (key) {
case 0:
// 刷新路由
onFresh();
break;
case 1:
// 关闭当前标签页
deleteMenu(selectTagRoute);
break;
case 2:
// 关闭左侧标签页
deleteMenu(selectTagRoute, "left");
break;
case 3:
// 关闭右侧标签页
deleteMenu(selectTagRoute, "right");
break;
case 4:
// 关闭其他标签页
deleteMenu(selectTagRoute, "other");
break;
case 5:
// 关闭全部标签页
useMultiTagsStoreHook().handleTags("splice", "", {
startIndex: fixedTags.length,
length: multiTags.value.length
});
router.push(topPath);
// router.push(fixedTags[fixedTags.length - 1]?.path);
handleAliveRoute(route as ToRouteType);
break;
case 6:
// 内容区全屏
onContentFullScreen();
setTimeout(() => {
if (pureSetting.hiddenSideBar) {
tagsViews[6].icon = ExitFullscreen;
tagsViews[6].text = "内容区退出全屏";
} else {
tagsViews[6].icon = Fullscreen;
tagsViews[6].text = "内容区全屏";
}
}, 100);
break;
}
setTimeout(() => {
showMenuModel(route.fullPath, route.query);
});
}
function handleCommand(command: any) {
const { key, item } = command;
onClickDrop(key, item);
}
/** 触发右键中菜单的点击事件 */
function selectTag(key, item) {
closeMenu();
onClickDrop(key, item, currentSelect.value);
}
function showMenus(value: boolean) {
Array.of(1, 2, 3, 4, 5).forEach(v => {
tagsViews[v].show = value;
});
}
function disabledMenus(value: boolean, fixedTag = false) {
Array.of(1, 2, 3, 4, 5).forEach(v => {
tagsViews[v].disabled = value;
});
if (fixedTag) {
tagsViews[2].show = false;
tagsViews[2].disabled = true;
}
}
/** 检查当前右键的菜单两边是否存在别的菜单,如果左侧的菜单是顶级菜单,则不显示关闭左侧标签页,如果右侧没有菜单,则不显示关闭右侧标签页 */
function showMenuModel(
currentPath: string,
query: object = {},
refresh = false
) {
const allRoute = multiTags.value;
const routeLength = multiTags.value.length;
let currentIndex = -1;
if (isAllEmpty(query)) {
currentIndex = allRoute.findIndex(v => v.path === currentPath);
} else {
currentIndex = allRoute.findIndex(v => isEqual(v.query, query));
}
function fixedTagDisabled() {
if (allRoute[currentIndex]?.meta?.fixedTag) {
Array.of(1, 2, 3, 4, 5).forEach(v => {
tagsViews[v].disabled = true;
});
}
}
showMenus(true);
if (refresh) {
tagsViews[0].show = true;
}
/**
* currentIndex为1时左侧的菜单顶级菜单则不显示关闭左侧标签页
* 如果currentIndex等于routeLength-1右侧没有菜单则不显示关闭右侧标签页
*/
if (currentIndex === 1 && routeLength !== 2) {
// 左侧的菜单是顶级菜单,右侧存在别的菜单
tagsViews[2].show = false;
Array.of(1, 3, 4, 5).forEach(v => {
tagsViews[v].disabled = false;
});
tagsViews[2].disabled = true;
fixedTagDisabled();
} else if (currentIndex === 1 && routeLength === 2) {
disabledMenus(false);
// 左侧的菜单是顶级菜单,右侧不存在别的菜单
Array.of(2, 3, 4).forEach(v => {
tagsViews[v].show = false;
tagsViews[v].disabled = true;
});
fixedTagDisabled();
} else if (routeLength - 1 === currentIndex && currentIndex !== 0) {
// 当前路由是所有路由中的最后一个
tagsViews[3].show = false;
Array.of(1, 2, 4, 5).forEach(v => {
tagsViews[v].disabled = false;
});
tagsViews[3].disabled = true;
if (allRoute[currentIndex - 1]?.meta?.fixedTag) {
tagsViews[2].show = false;
tagsViews[2].disabled = true;
}
fixedTagDisabled();
} else if (currentIndex === 0 || currentPath === `/redirect${topPath}`) {
// 当前路由为顶级菜单
disabledMenus(true);
} else {
disabledMenus(false, allRoute[currentIndex - 1]?.meta?.fixedTag);
fixedTagDisabled();
}
}
function openMenu(tag, e) {
closeMenu();
if (tag.path === topPath || tag?.meta?.fixedTag) {
// 右键菜单为顶级菜单或拥有 fixedTag 属性,只显示刷新
showMenus(false);
tagsViews[0].show = true;
} else if (route.path !== tag.path && route.name !== tag.name) {
// 右键菜单不匹配当前路由,隐藏刷新
tagsViews[0].show = false;
showMenuModel(tag.path, tag.query);
} else if (multiTags.value.length === 2 && route.path !== tag.path) {
showMenus(true);
// 只有两个标签时不显示关闭其他标签页
tagsViews[4].show = false;
} else if (route.path === tag.path) {
// 右键当前激活的菜单
showMenuModel(tag.path, tag.query, true);
}
currentSelect.value = tag;
const menuMinWidth = 140;
const offsetLeft = unref(containerDom).getBoundingClientRect().left;
const offsetWidth = unref(containerDom).offsetWidth;
const maxLeft = offsetWidth - menuMinWidth;
const left = e.clientX - offsetLeft + 5;
if (left > maxLeft) {
buttonLeft.value = maxLeft;
} else {
buttonLeft.value = left;
}
useSettingStoreHook().hiddenSideBar
? (buttonTop.value = e.clientY)
: (buttonTop.value = e.clientY - 40);
nextTick(() => {
visible.value = true;
});
}
/** 触发tags标签切换 */
function tagOnClick(item) {
const { name, path } = item;
if (name) {
if (item.query) {
router.push({
name,
query: item.query
});
} else if (item.params) {
router.push({
name,
params: item.params
});
} else {
router.push({ name });
}
} else {
router.push({ path });
}
}
onClickOutside(contextmenuRef, closeMenu, {
detectIframe: true
});
watch(route, () => {
activeIndex.value = -1;
dynamicTagView();
});
onMounted(() => {
if (!instance) return;
// 根据当前路由初始化操作标签页的禁用状态
showMenuModel(route.fullPath);
// 触发隐藏标签页
emitter.on("tagViewsChange", (key: any) => {
if (unref(showTags as any) === key) return;
(showTags as any).value = key;
});
// 改变标签风格
emitter.on("tagViewsShowModel", key => {
showModel.value = key;
});
// 接收侧边栏切换传递过来的参数
emitter.on("changLayoutRoute", indexPath => {
dynamicRouteTag(indexPath);
setTimeout(() => {
showMenuModel(indexPath);
});
});
useResizeObserver(scrollbarDom, dynamicTagView);
delay().then(() => dynamicTagView());
});
onBeforeUnmount(() => {
// 解绑`tagViewsChange`、`tagViewsShowModel`、`changLayoutRoute`公共事件,防止多次触发
emitter.off("tagViewsChange");
emitter.off("tagViewsShowModel");
emitter.off("changLayoutRoute");
});
</script>
<template>
<div v-if="!showTags" ref="containerDom" class="tags-view">
<span v-show="isShowArrow" class="arrow-left">
<IconifyIconOffline :icon="ArrowLeftSLine" @click="handleScroll(200)" />
</span>
<div
ref="scrollbarDom"
class="scroll-container"
:class="showModel === 'chrome' && 'chrome-scroll-container'"
@wheel.prevent="handleWheel"
>
<div ref="tabDom" class="tab select-none" :style="getTabStyle">
<div
v-for="(item, index) in multiTags"
:ref="'dynamic' + index"
:key="index"
:class="[
'scroll-item is-closable',
linkIsActive(item),
showModel === 'chrome' && 'chrome-item',
isFixedTag(item) && 'fixed-tag'
]"
@contextmenu.prevent="openMenu(item, $event)"
@mouseenter.prevent="onMouseenter(index)"
@mouseleave.prevent="onMouseleave(index)"
@click="tagOnClick(item)"
>
<template v-if="showModel !== 'chrome'">
<span
class="tag-title dark:!text-text_color_primary dark:hover:!text-primary"
>
{{ item.meta.title }}
</span>
<span
v-if="
isFixedTag(item)
? false
: iconIsActive(item, index) ||
(index === activeIndex && index !== 0)
"
class="el-icon-close"
@click.stop="deleteMenu(item)"
>
<IconifyIconOffline :icon="Close" />
</span>
<span
v-if="showModel !== 'card'"
:ref="'schedule' + index"
:class="[scheduleIsActive(item)]"
/>
</template>
<div v-else class="chrome-tab">
<div class="chrome-tab__bg">
<TagChrome />
</div>
<span class="tag-title">
{{ item.meta.title }}
</span>
<span
v-if="isFixedTag(item) ? false : index !== 0"
class="chrome-close-btn"
@click.stop="deleteMenu(item)"
>
<IconifyIconOffline :icon="Close" />
</span>
<span class="chrome-tab-divider" />
</div>
</div>
</div>
</div>
<span v-show="isShowArrow" class="arrow-right">
<IconifyIconOffline :icon="ArrowRightSLine" @click="handleScroll(-200)" />
</span>
<!-- 右键菜单按钮 -->
<transition name="el-zoom-in-top">
<ul
v-show="visible"
ref="contextmenuRef"
:key="Math.random()"
:style="getContextMenuStyle"
class="contextmenu"
>
<div
v-for="(item, key) in tagsViews.slice(0, 6)"
:key="key"
style="display: flex; align-items: center"
>
<li v-if="item.show" @click="selectTag(key, item)">
<IconifyIconOffline :icon="item.icon" />
{{ item.text }}
</li>
</div>
</ul>
</transition>
<!-- 右侧功能按钮 -->
<el-dropdown
trigger="click"
placement="bottom-end"
@command="handleCommand"
>
<span class="arrow-down">
<IconifyIconOffline :icon="ArrowDown" class="dark:text-white" />
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(item, key) in tagsViews"
:key="key"
:command="{ key, item }"
:divided="item.divided"
:disabled="item.disabled"
>
<IconifyIconOffline :icon="item.icon" />
{{ item.text }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<style lang="scss" scoped>
@import url("./index.scss");
</style>

View File

@@ -1,91 +0,0 @@
<script setup lang="ts">
import { useRoute } from "vue-router";
import { ref, unref, watch, onMounted, nextTick } from "vue";
defineOptions({
name: "LayFrame"
});
const props = defineProps<{
frameInfo?: {
frameSrc?: string;
fullPath?: string;
};
}>();
const loading = ref(true);
const currentRoute = useRoute();
const frameSrc = ref<string>("");
const frameRef = ref<HTMLElement | null>(null);
if (unref(currentRoute.meta)?.frameSrc) {
frameSrc.value = unref(currentRoute.meta)?.frameSrc as string;
}
unref(currentRoute.meta)?.frameLoading === false && hideLoading();
function hideLoading() {
loading.value = false;
}
function init() {
nextTick(() => {
const iframe = unref(frameRef);
if (!iframe) return;
const _frame = iframe as any;
if (_frame.attachEvent) {
_frame.attachEvent("onload", () => {
hideLoading();
});
} else {
iframe.onload = () => {
hideLoading();
};
}
});
}
watch(
() => currentRoute.fullPath,
path => {
if (
currentRoute.name === "Redirect" &&
path.includes(props.frameInfo?.fullPath)
) {
frameSrc.value = path; // redirect时置换成任意值待重定向后 重新赋值
loading.value = true;
}
// 重新赋值
if (props.frameInfo?.fullPath === path) {
frameSrc.value = props.frameInfo?.frameSrc;
}
}
);
onMounted(() => {
init();
});
</script>
<template>
<div v-loading="loading" class="frame" element-loading-text="加载中...">
<iframe ref="frameRef" :src="frameSrc" class="frame-iframe" />
</div>
</template>
<style lang="scss" scoped>
.frame {
position: absolute;
inset: 0;
.frame-iframe {
box-sizing: border-box;
width: 100%;
height: 100%;
overflow: hidden;
border: 0;
}
}
.main-content {
margin: 2px 0 0 !important;
}
</style>

View File

@@ -1,26 +0,0 @@
import { ref } from "vue";
export function useBoolean(initValue = false) {
const bool = ref(initValue);
function setBool(value: boolean) {
bool.value = value;
}
function setTrue() {
setBool(true);
}
function setFalse() {
setBool(false);
}
function toggle() {
setBool(!bool.value);
}
return {
bool,
setBool,
setTrue,
setFalse,
toggle
};
}

View File

@@ -1,145 +0,0 @@
import { ref } from "vue";
import { getConfig } from "@/config";
import { useLayout } from "./useLayout";
import { removeToken } from "@/utils/auth";
import { routerArrays } from "@/layout/types";
import { router, resetRouter } from "@/router";
import type { themeColorsType } from "../types";
import { useAppStoreHook } from "@/store/modules/app";
import { useGlobal, storageLocal } from "@pureadmin/utils";
import { useEpThemeStoreHook } from "@/store/modules/epTheme";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import {
darken,
lighten,
toggleTheme
} from "@pureadmin/theme/dist/browser-utils";
export function useDataThemeChange() {
const { layoutTheme, layout } = useLayout();
const themeColors = ref<Array<themeColorsType>>([
/* 亮白色 */
{ color: "#ffffff", themeColor: "light" },
/* 道奇蓝 */
{ color: "#1b2a47", themeColor: "default" },
/* 深紫罗兰色 */
{ color: "#722ed1", themeColor: "saucePurple" },
/* 深粉色 */
{ color: "#eb2f96", themeColor: "pink" },
/* 猩红色 */
{ color: "#f5222d", themeColor: "dusk" },
/* 橙红色 */
{ color: "#fa541c", themeColor: "volcano" },
/* 绿宝石 */
{ color: "#13c2c2", themeColor: "mingQing" },
/* 酸橙绿 */
{ color: "#52c41a", themeColor: "auroraGreen" }
]);
const { $storage } = useGlobal<GlobalPropertiesApi>();
const dataTheme = ref<boolean>($storage?.layout?.darkMode);
const overallStyle = ref<string>($storage?.layout?.overallStyle);
const body = document.documentElement as HTMLElement;
function toggleClass(flag: boolean, clsName: string, target?: HTMLElement) {
const targetEl = target || document.body;
let { className } = targetEl;
className = className.replace(clsName, "").trim();
targetEl.className = flag ? `${className} ${clsName}` : className;
}
/** 设置导航主题色 */
function setLayoutThemeColor(
theme = getConfig().Theme ?? "light",
isClick = true
) {
layoutTheme.value.theme = theme;
toggleTheme({
scopeName: `layout-theme-${theme}`
});
// 如果非isClick保留之前的themeColor
const storageThemeColor = $storage.layout.themeColor;
$storage.layout = {
layout: layout.value,
theme,
darkMode: dataTheme.value,
sidebarStatus: $storage.layout?.sidebarStatus,
epThemeColor: $storage.layout?.epThemeColor,
themeColor: isClick ? theme : storageThemeColor,
overallStyle: overallStyle.value
};
if (theme === "default" || theme === "light") {
setEpThemeColor(getConfig().EpThemeColor);
} else {
const colors = themeColors.value.find(v => v.themeColor === theme);
setEpThemeColor(colors.color);
}
}
function setPropertyPrimary(mode: string, i: number, color: string) {
document.documentElement.style.setProperty(
`--el-color-primary-${mode}-${i}`,
dataTheme.value ? darken(color, i / 10) : lighten(color, i / 10)
);
}
/** 设置 `element-plus` 主题色 */
const setEpThemeColor = (color: string) => {
useEpThemeStoreHook().setEpThemeColor(color);
document.documentElement.style.setProperty("--el-color-primary", color);
for (let i = 1; i <= 2; i++) {
setPropertyPrimary("dark", i, color);
}
for (let i = 1; i <= 9; i++) {
setPropertyPrimary("light", i, color);
}
};
/** 浅色、深色整体风格切换 */
function dataThemeChange(overall?: string) {
overallStyle.value = overall;
if (useEpThemeStoreHook().epTheme === "light" && dataTheme.value) {
setLayoutThemeColor("default", false);
} else {
setLayoutThemeColor(useEpThemeStoreHook().epTheme, false);
}
if (dataTheme.value) {
document.documentElement.classList.add("dark");
} else {
if ($storage.layout.themeColor === "light") {
setLayoutThemeColor("light", false);
}
document.documentElement.classList.remove("dark");
}
}
/** 清空缓存并返回登录页 */
function onReset() {
removeToken();
storageLocal().clear();
const { Grey, Weak, MultiTagsCache, EpThemeColor, Layout } = getConfig();
useAppStoreHook().setLayout(Layout);
setEpThemeColor(EpThemeColor);
useMultiTagsStoreHook().multiTagsCacheChange(MultiTagsCache);
toggleClass(Grey, "html-grey", document.querySelector("html"));
toggleClass(Weak, "html-weakness", document.querySelector("html"));
router.push("/login");
useMultiTagsStoreHook().handleTags("equal", [...routerArrays]);
resetRouter();
}
return {
body,
dataTheme,
overallStyle,
layoutTheme,
themeColors,
onReset,
toggleClass,
dataThemeChange,
setEpThemeColor,
setLayoutThemeColor
};
}

View File

@@ -1,58 +0,0 @@
import { computed } from "vue";
import { routerArrays } from "../types";
import { useGlobal } from "@pureadmin/utils";
import { useMultiTagsStore } from "@/store/modules/multiTags";
export function useLayout() {
const { $storage, $config } = useGlobal<GlobalPropertiesApi>();
const initStorage = () => {
/** 路由 */
if (
useMultiTagsStore().multiTagsCache &&
(!$storage.tags || $storage.tags.length === 0)
) {
$storage.tags = routerArrays;
}
/** 导航 */
if (!$storage.layout) {
$storage.layout = {
layout: $config?.Layout ?? "vertical",
theme: $config?.Theme ?? "light",
darkMode: $config?.DarkMode ?? false,
sidebarStatus: $config?.SidebarStatus ?? true,
epThemeColor: $config?.EpThemeColor ?? "#409EFF",
themeColor: $config?.Theme ?? "light",
overallStyle: $config?.OverallStyle ?? "light"
};
}
/** 灰色模式、色弱模式、隐藏标签页 */
if (!$storage.configure) {
$storage.configure = {
grey: $config?.Grey ?? false,
weak: $config?.Weak ?? false,
hideTabs: $config?.HideTabs ?? false,
hideFooter: $config.HideFooter ?? true,
showLogo: $config?.ShowLogo ?? true,
showModel: $config?.ShowModel ?? "smart",
multiTagsCache: $config?.MultiTagsCache ?? false,
stretch: $config?.Stretch ?? false
};
}
};
/** 清空缓存后从platform-config.json读取默认配置并赋值到storage中 */
const layout = computed(() => {
return $storage?.layout.layout;
});
const layoutTheme = computed(() => {
return $storage.layout;
});
return {
layout,
layoutTheme,
initStorage
};
}

View File

@@ -1,25 +0,0 @@
const MAP = new Map();
export const useMultiFrame = () => {
function setMap(path, Comp) {
MAP.set(path, Comp);
}
function getMap(path?) {
if (path) {
return MAP.get(path);
}
return [...MAP.entries()];
}
function delMap(path) {
MAP.delete(path);
}
return {
setMap,
getMap,
delMap,
MAP
};
};

View File

@@ -1,157 +0,0 @@
import { storeToRefs } from "pinia";
import { getConfig } from "@/config";
import { emitter } from "@/utils/mitt";
import Avatar from "@/assets/user.jpg";
import { getTopMenu } from "@/router/utils";
import { useFullscreen } from "@vueuse/core";
import type { routeMetaType } from "../types";
import { useRouter, useRoute } from "vue-router";
import { router, remainingPaths } from "@/router";
import { computed, type CSSProperties } from "vue";
import { useAppStoreHook } from "@/store/modules/app";
import { useUserStoreHook } from "@/store/modules/user";
import { useGlobal, isAllEmpty } from "@pureadmin/utils";
import { usePermissionStoreHook } from "@/store/modules/permission";
import ExitFullscreen from "@iconify-icons/ri/fullscreen-exit-fill";
import Fullscreen from "@iconify-icons/ri/fullscreen-fill";
const errorInfo =
"The current routing configuration is incorrect, please check the configuration";
export function useNav() {
const route = useRoute();
const pureApp = useAppStoreHook();
const routers = useRouter().options.routes;
const { isFullscreen, toggle } = useFullscreen();
const { wholeMenus } = storeToRefs(usePermissionStoreHook());
/** 平台`layout`中所有`el-tooltip`的`effect`配置,默认`light` */
const tooltipEffect = getConfig()?.TooltipEffect ?? "light";
const getDivStyle = computed((): CSSProperties => {
return {
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
overflow: "hidden"
};
});
/** 头像(如果头像为空则使用 src/assets/user.jpg */
const userAvatar = computed(() => {
return isAllEmpty(useUserStoreHook()?.avatar)
? Avatar
: useUserStoreHook()?.avatar;
});
/** 昵称(如果昵称为空则显示用户名) */
const username = computed(() => {
return isAllEmpty(useUserStoreHook()?.nickname)
? useUserStoreHook()?.username
: useUserStoreHook()?.nickname;
});
const avatarsStyle = computed(() => {
return username.value ? { marginRight: "10px" } : "";
});
const isCollapse = computed(() => {
return !pureApp.getSidebarStatus;
});
const device = computed(() => {
return pureApp.getDevice;
});
const { $storage, $config } = useGlobal<GlobalPropertiesApi>();
const layout = computed(() => {
return $storage?.layout?.layout;
});
const title = computed(() => {
return $config.Title;
});
/** 动态title */
function changeTitle(meta: routeMetaType) {
const Title = getConfig().Title;
if (Title) document.title = `${meta.title} | ${Title}`;
else document.title = meta.title;
}
/** 退出登录 */
function logout() {
useUserStoreHook().logOut();
}
function backTopMenu() {
router.push(getTopMenu()?.path);
}
function onPanel() {
emitter.emit("openPanel");
}
function toggleSideBar() {
pureApp.toggleSideBar();
}
function handleResize(menuRef) {
menuRef?.handleResize();
}
function resolvePath(route) {
if (!route.children) return console.error(errorInfo);
const httpReg = /^http(s?):\/\//;
const routeChildPath = route.children[0]?.path;
if (httpReg.test(routeChildPath)) {
return route.path + "/" + routeChildPath;
} else {
return routeChildPath;
}
}
function menuSelect(indexPath: string) {
if (wholeMenus.value.length === 0 || isRemaining(indexPath)) return;
emitter.emit("changLayoutRoute", indexPath);
}
/** 判断路径是否参与菜单 */
function isRemaining(path: string) {
return remainingPaths.includes(path);
}
/** 获取`logo` */
function getLogo() {
return new URL("/logo.svg", import.meta.url).href;
}
return {
route,
title,
device,
layout,
logout,
routers,
$storage,
isFullscreen,
Fullscreen,
ExitFullscreen,
toggle,
backTopMenu,
onPanel,
getDivStyle,
changeTitle,
toggleSideBar,
menuSelect,
handleResize,
resolvePath,
getLogo,
isCollapse,
pureApp,
username,
userAvatar,
avatarsStyle,
tooltipEffect
};
}

View File

@@ -1,245 +0,0 @@
import {
ref,
unref,
computed,
reactive,
onMounted,
type CSSProperties,
getCurrentInstance
} from "vue";
import type { tagsViewsType } from "../types";
import { useRoute, useRouter } from "vue-router";
import { responsiveStorageNameSpace } from "@/config";
import { useSettingStoreHook } from "@/store/modules/settings";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import {
isEqual,
isBoolean,
storageLocal,
toggleClass,
hasClass
} from "@pureadmin/utils";
import Fullscreen from "@iconify-icons/ri/fullscreen-fill";
import CloseAllTags from "@iconify-icons/ri/subtract-line";
import CloseOtherTags from "@iconify-icons/ri/text-spacing";
import CloseRightTags from "@iconify-icons/ri/text-direction-l";
import CloseLeftTags from "@iconify-icons/ri/text-direction-r";
import RefreshRight from "@iconify-icons/ep/refresh-right";
import Close from "@iconify-icons/ep/close";
export function useTags() {
const route = useRoute();
const router = useRouter();
const instance = getCurrentInstance();
const pureSetting = useSettingStoreHook();
const buttonTop = ref(0);
const buttonLeft = ref(0);
const translateX = ref(0);
const visible = ref(false);
const activeIndex = ref(-1);
// 当前右键选中的路由信息
const currentSelect = ref({});
const isScrolling = ref(false);
/** 显示模式,默认灵动模式 */
const showModel = ref(
storageLocal().getItem<StorageConfigs>(
`${responsiveStorageNameSpace()}configure`
)?.showModel || "smart"
);
/** 是否隐藏标签页,默认显示 */
const showTags =
ref(
storageLocal().getItem<StorageConfigs>(
`${responsiveStorageNameSpace()}configure`
).hideTabs
) ?? ref("false");
const multiTags: any = computed(() => {
return useMultiTagsStoreHook().multiTags;
});
const tagsViews = reactive<Array<tagsViewsType>>([
{
icon: RefreshRight,
text: "重新加载",
divided: false,
disabled: false,
show: true
},
{
icon: Close,
text: "关闭当前标签页",
divided: false,
disabled: multiTags.value.length > 1 ? false : true,
show: true
},
{
icon: CloseLeftTags,
text: "关闭左侧标签页",
divided: true,
disabled: multiTags.value.length > 1 ? false : true,
show: true
},
{
icon: CloseRightTags,
text: "关闭右侧标签页",
divided: false,
disabled: multiTags.value.length > 1 ? false : true,
show: true
},
{
icon: CloseOtherTags,
text: "关闭其他标签页",
divided: true,
disabled: multiTags.value.length > 2 ? false : true,
show: true
},
{
icon: CloseAllTags,
text: "关闭全部标签页",
divided: false,
disabled: multiTags.value.length > 1 ? false : true,
show: true
},
{
icon: Fullscreen,
text: "内容区全屏",
divided: true,
disabled: false,
show: true
}
]);
function conditionHandle(item, previous, next) {
if (isBoolean(route?.meta?.showLink) && route?.meta?.showLink === false) {
if (Object.keys(route.query).length > 0) {
return isEqual(route.query, item.query) ? previous : next;
} else {
return isEqual(route.params, item.params) ? previous : next;
}
} else {
return route.path === item.path ? previous : next;
}
}
const isFixedTag = computed(() => {
return item => {
return isBoolean(item?.meta?.fixedTag) && item?.meta?.fixedTag === true;
};
});
const iconIsActive = computed(() => {
return (item, index) => {
if (index === 0) return;
return conditionHandle(item, true, false);
};
});
const linkIsActive = computed(() => {
return item => {
return conditionHandle(item, "is-active", "");
};
});
const scheduleIsActive = computed(() => {
return item => {
return conditionHandle(item, "schedule-active", "");
};
});
const getTabStyle = computed((): CSSProperties => {
return {
transform: `translateX(${translateX.value}px)`,
transition: isScrolling.value ? "none" : "transform 0.5s ease-in-out"
};
});
const getContextMenuStyle = computed((): CSSProperties => {
return { left: buttonLeft.value + "px", top: buttonTop.value + "px" };
});
const closeMenu = () => {
visible.value = false;
};
/** 鼠标移入添加激活样式 */
function onMouseenter(index) {
if (index) activeIndex.value = index;
if (unref(showModel) === "smart") {
if (hasClass(instance.refs["schedule" + index][0], "schedule-active"))
return;
toggleClass(true, "schedule-in", instance.refs["schedule" + index][0]);
toggleClass(false, "schedule-out", instance.refs["schedule" + index][0]);
} else {
if (hasClass(instance.refs["dynamic" + index][0], "is-active")) return;
toggleClass(true, "card-in", instance.refs["dynamic" + index][0]);
toggleClass(false, "card-out", instance.refs["dynamic" + index][0]);
}
}
/** 鼠标移出恢复默认样式 */
function onMouseleave(index) {
activeIndex.value = -1;
if (unref(showModel) === "smart") {
if (hasClass(instance.refs["schedule" + index][0], "schedule-active"))
return;
toggleClass(false, "schedule-in", instance.refs["schedule" + index][0]);
toggleClass(true, "schedule-out", instance.refs["schedule" + index][0]);
} else {
if (hasClass(instance.refs["dynamic" + index][0], "is-active")) return;
toggleClass(false, "card-in", instance.refs["dynamic" + index][0]);
toggleClass(true, "card-out", instance.refs["dynamic" + index][0]);
}
}
function onContentFullScreen() {
pureSetting.hiddenSideBar
? pureSetting.changeSetting({ key: "hiddenSideBar", value: false })
: pureSetting.changeSetting({ key: "hiddenSideBar", value: true });
}
onMounted(() => {
if (!showModel.value) {
const configure = storageLocal().getItem<StorageConfigs>(
`${responsiveStorageNameSpace()}configure`
);
configure.showModel = "card";
storageLocal().setItem(
`${responsiveStorageNameSpace()}configure`,
configure
);
}
});
return {
Close,
route,
router,
visible,
showTags,
instance,
multiTags,
showModel,
tagsViews,
buttonTop,
buttonLeft,
translateX,
isFixedTag,
pureSetting,
activeIndex,
getTabStyle,
isScrolling,
iconIsActive,
linkIsActive,
currentSelect,
scheduleIsActive,
getContextMenuStyle,
closeMenu,
onMounted,
onMouseenter,
onMouseleave,
onContentFullScreen
};
}

View File

@@ -1,235 +0,0 @@
<script setup lang="ts">
import "animate.css";
// 引入 src/components/ReIcon/src/offlineIcon.ts 文件中所有使用addIcon添加过的本地图标
import "@/components/ReIcon/src/offlineIcon";
import { setType } from "./types";
import { useLayout } from "./hooks/useLayout";
import { useAppStoreHook } from "@/store/modules/app";
import { useSettingStoreHook } from "@/store/modules/settings";
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
import {
h,
ref,
reactive,
computed,
onMounted,
onBeforeMount,
defineComponent
} from "vue";
import {
useDark,
useGlobal,
deviceDetection,
useResizeObserver
} from "@pureadmin/utils";
import LayTag from "./components/lay-tag/index.vue";
import LayNavbar from "./components/lay-navbar/index.vue";
import LayContent from "./components/lay-content/index.vue";
import LaySetting from "./components/lay-setting/index.vue";
import NavVertical from "./components/lay-sidebar/NavVertical.vue";
import NavHorizontal from "./components/lay-sidebar/NavHorizontal.vue";
import BackTopIcon from "@/assets/svg/back_top.svg?component";
const appWrapperRef = ref();
const { isDark } = useDark();
const { layout } = useLayout();
const isMobile = deviceDetection();
const pureSetting = useSettingStoreHook();
const { $storage } = useGlobal<GlobalPropertiesApi>();
const set: setType = reactive({
sidebar: computed(() => {
return useAppStoreHook().sidebar;
}),
device: computed(() => {
return useAppStoreHook().device;
}),
fixedHeader: computed(() => {
return pureSetting.fixedHeader;
}),
classes: computed(() => {
return {
hideSidebar: !set.sidebar.opened,
openSidebar: set.sidebar.opened,
withoutAnimation: set.sidebar.withoutAnimation,
mobile: set.device === "mobile"
};
}),
hideTabs: computed(() => {
return $storage?.configure.hideTabs;
})
});
function setTheme(layoutModel: string) {
window.document.body.setAttribute("layout", layoutModel);
$storage.layout = {
layout: `${layoutModel}`,
theme: $storage.layout?.theme,
darkMode: $storage.layout?.darkMode,
sidebarStatus: $storage.layout?.sidebarStatus,
epThemeColor: $storage.layout?.epThemeColor,
themeColor: $storage.layout?.themeColor,
overallStyle: $storage.layout?.overallStyle
};
}
function toggle(device: string, bool: boolean) {
useAppStoreHook().toggleDevice(device);
useAppStoreHook().toggleSideBar(bool, "resize");
}
// 判断是否可自动关闭菜单栏
let isAutoCloseSidebar = true;
useResizeObserver(appWrapperRef, entries => {
if (isMobile) return;
const entry = entries[0];
const [{ inlineSize: width, blockSize: height }] = entry.borderBoxSize;
useAppStoreHook().setViewportSize({ width, height });
width <= 760 ? setTheme("vertical") : setTheme(useAppStoreHook().layout);
/** width app-wrapper类容器宽度
* 0 < width <= 760 隐藏侧边栏
* 760 < width <= 990 折叠侧边栏
* width > 990 展开侧边栏
*/
if (width > 0 && width <= 760) {
toggle("mobile", false);
isAutoCloseSidebar = true;
} else if (width > 760 && width <= 990) {
if (isAutoCloseSidebar) {
toggle("desktop", false);
isAutoCloseSidebar = false;
}
} else if (width > 990 && !set.sidebar.isClickCollapse) {
toggle("desktop", true);
isAutoCloseSidebar = true;
} else {
toggle("desktop", false);
isAutoCloseSidebar = false;
}
});
onMounted(() => {
if (isMobile) {
toggle("mobile", false);
}
});
onBeforeMount(() => {
useDataThemeChange().dataThemeChange($storage.layout?.overallStyle);
});
const LayHeader = defineComponent({
name: "LayHeader",
render() {
return h(
"div",
{
class: { "fixed-header": set.fixedHeader },
style: [
set.hideTabs && layout.value.includes("horizontal")
? isDark.value
? "box-shadow: 0 1px 4px #0d0d0d"
: "box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08)"
: ""
]
},
{
default: () => [
!pureSetting.hiddenSideBar &&
(layout.value.includes("vertical") || layout.value.includes("mix"))
? h(LayNavbar)
: null,
!pureSetting.hiddenSideBar && layout.value.includes("horizontal")
? h(NavHorizontal)
: null,
h(LayTag)
]
}
);
}
});
</script>
<template>
<div ref="appWrapperRef" :class="['app-wrapper', set.classes]">
<div
v-show="
set.device === 'mobile' &&
set.sidebar.opened &&
layout.includes('vertical')
"
class="app-mask"
@click="useAppStoreHook().toggleSideBar()"
/>
<NavVertical
v-show="
!pureSetting.hiddenSideBar &&
(layout.includes('vertical') || layout.includes('mix'))
"
/>
<div
:class="[
'main-container',
pureSetting.hiddenSideBar ? 'main-hidden' : ''
]"
>
<div v-if="set.fixedHeader">
<LayHeader />
<!-- 主体内容 -->
<LayContent :fixed-header="set.fixedHeader" />
</div>
<el-scrollbar v-else>
<el-backtop
title="回到顶部"
target=".main-container .el-scrollbar__wrap"
>
<BackTopIcon />
</el-backtop>
<LayHeader />
<!-- 主体内容 -->
<LayContent :fixed-header="set.fixedHeader" />
</el-scrollbar>
</div>
<!-- 系统设置 -->
<LaySetting />
</div>
</template>
<style lang="scss" scoped>
.app-wrapper {
position: relative;
width: 100%;
height: 100%;
&::after {
display: table;
clear: both;
content: "";
}
&.mobile.openSidebar {
position: fixed;
top: 0;
}
}
.app-mask {
position: absolute;
top: 0;
z-index: 999;
width: 100%;
height: 100%;
background: #000;
opacity: 0.3;
}
.re-screen {
margin-top: 12px;
}
</style>

View File

@@ -1,24 +0,0 @@
<script setup lang="ts">
import { unref } from "vue";
import { useRouter } from "vue-router";
defineOptions({
name: "Redirect"
});
const { currentRoute, replace } = useRouter();
const { params, query } = unref(currentRoute);
const { path } = params;
const _path = Array.isArray(path) ? path.join("/") : path;
replace({
path: "/" + _path,
query
});
</script>
<template>
<div />
</template>

View File

@@ -1,129 +0,0 @@
/**
* @description ⚠️:此文件仅供主题插件使用,请不要在此文件中导出别的工具函数(仅在页面加载前运行)
*/
import type { multipleScopeVarsOptions } from "@pureadmin/theme";
/** 预设主题色 */
const themeColors = {
/* 亮白色 */
light: {
subMenuActiveText: "#000000d9",
menuBg: "#fff",
menuHover: "#f6f6f6",
subMenuBg: "#fff",
subMenuActiveBg: "#e0ebf6",
menuText: "rgb(0 0 0 / 60%)",
sidebarLogo: "#fff",
menuTitleHover: "#000",
menuActiveBefore: "#4091f7"
},
/* 道奇蓝 */
default: {
subMenuActiveText: "#fff",
menuBg: "#001529",
menuHover: "rgb(64 145 247 / 15%)",
subMenuBg: "#0f0303",
subMenuActiveBg: "#4091f7",
menuText: "rgb(254 254 254 / 65%)",
sidebarLogo: "#002140",
menuTitleHover: "#fff",
menuActiveBefore: "#4091f7"
},
/* 深紫罗兰色 */
saucePurple: {
subMenuActiveText: "#fff",
menuBg: "#130824",
menuHover: "rgb(105 58 201 / 15%)",
subMenuBg: "#000",
subMenuActiveBg: "#693ac9",
menuText: "#7a80b4",
sidebarLogo: "#1f0c38",
menuTitleHover: "#fff",
menuActiveBefore: "#693ac9"
},
/* 深粉色 */
pink: {
subMenuActiveText: "#fff",
menuBg: "#28081a",
menuHover: "rgb(216 68 147 / 15%)",
subMenuBg: "#000",
subMenuActiveBg: "#d84493",
menuText: "#7a80b4",
sidebarLogo: "#3f0d29",
menuTitleHover: "#fff",
menuActiveBefore: "#d84493"
},
/* 猩红色 */
dusk: {
subMenuActiveText: "#fff",
menuBg: "#2a0608",
menuHover: "rgb(225 60 57 / 15%)",
subMenuBg: "#000",
subMenuActiveBg: "#e13c39",
menuText: "rgb(254 254 254 / 65.1%)",
sidebarLogo: "#42090c",
menuTitleHover: "#fff",
menuActiveBefore: "#e13c39"
},
/* 橙红色 */
volcano: {
subMenuActiveText: "#fff",
menuBg: "#2b0e05",
menuHover: "rgb(232 95 51 / 15%)",
subMenuBg: "#0f0603",
subMenuActiveBg: "#e85f33",
menuText: "rgb(254 254 254 / 65%)",
sidebarLogo: "#441708",
menuTitleHover: "#fff",
menuActiveBefore: "#e85f33"
},
/* 绿宝石 */
mingQing: {
subMenuActiveText: "#fff",
menuBg: "#032121",
menuHover: "rgb(89 191 193 / 15%)",
subMenuBg: "#000",
subMenuActiveBg: "#59bfc1",
menuText: "#7a80b4",
sidebarLogo: "#053434",
menuTitleHover: "#fff",
menuActiveBefore: "#59bfc1"
},
/* 酸橙绿 */
auroraGreen: {
subMenuActiveText: "#fff",
menuBg: "#0b1e15",
menuHover: "rgb(96 172 128 / 15%)",
subMenuBg: "#000",
subMenuActiveBg: "#60ac80",
menuText: "#7a80b4",
sidebarLogo: "#112f21",
menuTitleHover: "#fff",
menuActiveBefore: "#60ac80"
}
};
/**
* @description 将预设主题色处理成主题插件所需格式
*/
export const genScssMultipleScopeVars = (): multipleScopeVarsOptions[] => {
const result = [] as multipleScopeVarsOptions[];
Object.keys(themeColors).forEach(key => {
result.push({
scopeName: `layout-theme-${key}`,
varsContent: `
$subMenuActiveText: ${themeColors[key].subMenuActiveText} !default;
$menuBg: ${themeColors[key].menuBg} !default;
$menuHover: ${themeColors[key].menuHover} !default;
$subMenuBg: ${themeColors[key].subMenuBg} !default;
$subMenuActiveBg: ${themeColors[key].subMenuActiveBg} !default;
$menuText: ${themeColors[key].menuText} !default;
$sidebarLogo: ${themeColors[key].sidebarLogo} !default;
$menuTitleHover: ${themeColors[key].menuTitleHover} !default;
$menuActiveBefore: ${themeColors[key].menuActiveBefore} !default;
`
} as multipleScopeVarsOptions);
});
return result;
};

Some files were not shown because too many files have changed in this diff Show More