1
0

feat: initial commit

This commit is contained in:
gin
2026-05-07 18:39:00 +08:00
commit cdee21ee8e
653 changed files with 63946 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
<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 zhCn from "element-plus/lib/locale/lang/zh-cn";
import { ReDialog } from "@/components/ReDialog";
export default defineComponent({
name: "app",
components: {
[ElConfigProvider.name]: ElConfigProvider,
ReDialog
},
computed: {
currentLocale() {
return zhCn;
}
}
});
</script>
+164
View File
@@ -0,0 +1,164 @@
import { http } from "@/utils/http";
import { RouteRecordRaw } from "vue-router";
export type CaptchaDTO = {
/** 验证码的base64图片 */
captchaCodeImg: string;
/** 验证码对应的缓存key */
captchaCodeKey: string;
};
export type ConfigDTO = {
/** 验证码开关 */
isCaptchaOn: boolean;
/** 系统字典配置(下拉选项之类的) */
dictionary: Record<string, Array<DictionaryData>>;
};
export type LoginByPasswordDTO = {
/** 用户名 */
username: string;
/** 密码 */
password: string;
/** 验证码 */
captchaCode: string;
/** 验证码对应的缓存key */
captchaCodeKey: string;
};
/**
* 后端token实现
*/
export type TokenDTO = {
/** token */
token: string;
/** 当前登录的用户 */
currentUser: CurrentLoginUserDTO;
};
export type CurrentLoginUserDTO = {
userInfo: CurrentUserInfoDTO;
roleKey: string;
permissions: Set<string>;
};
/**
* 当前User
*/
export interface CurrentUserInfoDTO {
avatar?: string;
createTime?: Date;
creatorId?: number;
creatorName?: string;
deptId?: number;
deptName?: string;
email?: string;
loginDate?: Date;
loginIp?: string;
nickName?: string;
phoneNumber?: string;
postId?: number;
postName?: string;
remark?: string;
roleId?: number;
roleName?: string;
sex?: number;
status?: number;
updaterId?: number;
updaterName?: string;
updateTime?: Date;
userId?: number;
username?: string;
userType?: number;
}
export type DictionaryData = {
label: string;
value: number;
cssTag: string;
};
/** 获取系统配置接口 */
export const getConfig = () => {
return http.request<ResponseData<ConfigDTO>>("get", "/getConfig");
};
/** 验证码接口 */
export const getCaptchaCode = () => {
return http.request<ResponseData<CaptchaDTO>>("get", "/captchaImage");
};
/** 登录接口 */
export const loginByPassword = (data: LoginByPasswordDTO) => {
return http.request<ResponseData<TokenDTO>>("post", "/login", { data });
};
/** 获取当前登录用户接口 */
export const getLoginUserInfo = () => {
return http.request<ResponseData<TokenDTO>>("get", "/getLoginUserInfo");
};
export interface RouteMeta {
id: string;
title: string;
icon?: string;
showLink?: boolean;
showParent?: boolean;
auths?: string[];
rank?: number;
frameSrc?: string;
isFrameSrcInternal?: boolean;
}
export type RouteItem = RouteRecordRaw & {
name?: string;
path: string;
meta: RouteMeta;
children?: RouteItem[];
};
type AsyncRoutesResponse = {
code: number;
msg: string;
data: RouteItem[];
};
/**
* 为后端返回的路由添加唯一id,后面我们在构建菜单树的层级结构时需要用到
* 这里我们假设 name + path 是唯一的,若日后有 name + path 不唯一的情况,
* 则需要修改此处的逻辑
*/
const addUniqueId = (routes: RouteItem[]): RouteItem[] => {
return routes.map(route => {
const id = `${route.name || ""}${route.path}`;
if (route.children && route.children.length > 0) {
route.children = addUniqueId(route.children);
}
return {
...route,
meta: {
...route.meta,
id
}
};
});
};
function withId(result: AsyncRoutesResponse) {
if (result.data) {
result.data = addUniqueId(result.data);
}
return result;
}
/**
* 获取动态菜单
* TODO:对于开发环境下此处可以对路由数据做一些校验,比如说 name 是否重复,name+path 是否重复等等
*/
export const getAsyncRoutes = async () => {
const result = await http.request<AsyncRoutesResponse>("get", "/getRouters");
return withId(result);
};
+76
View File
@@ -0,0 +1,76 @@
import { http } from "@/utils/http";
export interface ConfigQuery extends BasePageQuery {
/**
* 配置key
*/
configKey?: string;
/**
* 配置名称
*/
configName?: string;
/**
* 是否允许更改配置
*/
isAllowChange?: string;
}
/**
* ConfigDTO, 配置信息
*/
export interface ConfigDTO {
configId?: string;
configKey?: string;
configName?: string;
configOptions?: string[];
configValue?: string;
createTime?: Date;
isAllowChange?: string;
isAllowChangeStr?: string;
remark?: string;
}
/**
* ConfigUpdateCommand
*/
export interface UpdateConfigRequest {
configValue: string;
}
/** 获取配置列表 */
export const getConfigListApi = (params?: ConfigQuery) => {
return http.request<ResponseData<PageDTO<ConfigDTO>>>(
"get",
"/system/configs",
{
params
}
);
};
/** 获取配置信息 */
export const getConfigInfoApi = (configId: string) => {
return http.request<ResponseData<ConfigDTO>>(
"get",
`/system/config/${configId}`
);
};
/** 刷新配置缓存 */
export const updateConfigApi = (
configId: number,
data: UpdateConfigRequest
) => {
return http.request<ResponseData<PageDTO<ConfigDTO>>>(
"put",
`/system/config/${configId}`,
{
data
}
);
};
/** 刷新配置缓存 */
export const refreshConfigCacheApi = () => {
return http.request<ResponseData<void>>("delete", "/system/configs/cache");
};
+83
View File
@@ -0,0 +1,83 @@
import { http } from "@/utils/http";
export interface DeptQuery extends BaseQuery {
// TODO 目前不需要这个参数
deptId?: number;
parentId?: number;
}
/**
* DeptDTO
*/
export interface DeptDTO {
createTime?: Date;
id?: number;
deptName?: string;
email?: string;
leaderName?: string;
orderNum?: number;
parentId?: number;
phone?: string;
status?: number;
statusStr?: string;
}
/**
* AddDeptCommand
*/
export interface DeptRequest {
deptName: string;
email?: string;
leaderName?: string;
orderNum: number;
parentId: number;
phone?: string;
status: number;
}
export interface DeptTreeDTO {
id: number;
parentId: number;
label: string;
children: [DeptTreeDTO];
}
/** 获取部门列表 */
export const getDeptListApi = (params?: DeptQuery) => {
return http.request<ResponseData<Array<DeptDTO>>>("get", "/system/depts", {
params
});
};
/** 新增部门 */
export const addDeptApi = (data: DeptRequest) => {
console.log(data);
return http.request<ResponseData<void>>("post", "/system/dept", {
data
});
};
/** 部门详情 */
export const getDeptInfoApi = (deptId: string) => {
return http.request<ResponseData<DeptDTO>>("get", `/system/dept/${deptId}`);
};
/** 修改部门 */
export const updateDeptApi = (deptId: string, data: DeptRequest) => {
return http.request<ResponseData<void>>("put", `/system/dept/${deptId}`, {
data
});
};
/** 删除部门 */
export const deleteDeptApi = (deptId: string) => {
return http.request<ResponseData<void>>("delete", `/system/dept/${deptId}`);
};
/** 获取部门树级结构 */
export const getDeptTree = () => {
return http.request<ResponseData<DeptTreeDTO>>(
"get",
"/system/depts/dropdown"
);
};
+116
View File
@@ -0,0 +1,116 @@
import { http } from "@/utils/http";
export interface OperationLogsQuery extends BasePageQuery {
businessType?: string;
requestModule?: string;
status?: string;
username?: string;
}
export interface OperationLogDTO {
businessType?: number;
businessTypeStr?: string;
calledMethod?: string;
deptId?: number;
deptName?: string;
errorStack?: string;
operationId?: number;
operationParam?: string;
operationResult?: string;
operationTime?: Date;
operatorIp?: string;
operatorLocation?: string;
operatorType?: number;
operatorTypeStr?: string;
requestMethod?: string;
requestModule?: string;
requestUrl?: string;
status?: number;
statusStr?: string;
userId?: number;
username?: string;
}
/** 获取操作日志列表 */
export const getOperationLogListApi = (params?: OperationLogsQuery) => {
return http.request<ResponseData<PageDTO<OperationLogDTO>>>(
"get",
"/logs/operationLogs",
{
params
}
);
};
export const exportOperationLogExcelApi = (
params: OperationLogsQuery,
fileName: string
) => {
return http.download("/logs/operationLogs/excel", fileName, {
params
});
};
export const deleteOperationLogApi = (data: Array<number>) => {
return http.request<ResponseData<void>>("delete", "/logs/operationLogs", {
params: {
// 需要将数组转换为字符串 否则Axios会将参数变成 noticeIds[0]:1 noticeIds[1]:2 这种格式,后端接收参数不成功
operationIds: data.toString()
}
});
};
/** 登录日志查询类 */
export interface LoginLogQuery extends BasePageQuery {
beginTime?: string;
endTime?: string;
ipAddress?: string;
status?: string;
username?: string;
}
/**
* 登录日志信息
*/
export interface LoginLogsDTO {
browser?: string;
infoId?: string;
ipAddress?: string;
loginLocation?: string;
loginTime?: Date;
msg?: string;
operationSystem?: string;
/** TODO 这个登录状态的设计很奇怪 需要重构掉 */
status?: number;
statusStr?: string;
username?: string;
}
/** 获取操作日志列表 */
export const getLoginLogListApi = (params?: LoginLogQuery) => {
return http.request<ResponseData<PageDTO<LoginLogsDTO>>>(
"get",
"/logs/loginLogs",
{
params
}
);
};
export const exportLoginLogExcelApi = (
params: LoginLogQuery,
fileName: string
) => {
return http.download("/logs/loginLogs/excel", fileName, {
params
});
};
export const deleteLoginLogApi = (data: Array<number>) => {
return http.request<ResponseData<void>>("delete", "/logs/loginLogs", {
params: {
// 需要将数组转换为字符串 否则Axios会将参数变成 noticeIds[0]:1 noticeIds[1]:2 这种格式,后端接收参数不成功
ids: data.toString()
}
});
};
+118
View File
@@ -0,0 +1,118 @@
import { http } from "@/utils/http";
import { Tree } from "@/utils/tree";
export interface MenuQuery {
isButton: boolean;
}
/**
* MenuDTO
*/
export interface MenuDTO extends Tree {
createTime?: Date;
isButton?: number;
id?: number;
menuName?: string;
parentId?: number;
menuType: number;
menuTypeStr: string;
path?: string;
permission?: string;
routerName?: string;
status?: number;
statusStr?: string;
}
/**
* MenuDetailDTO
*/
export interface MenuDetailDTO extends MenuDTO {
meta: MetaDTO;
permission?: string;
}
/**
* AddMenuCommand
*/
export interface MenuRequest {
id: number;
parentId: number;
menuName: string;
routerName?: string;
path?: string;
permission?: string;
status: number;
isButton: boolean;
menuType: number;
meta: MetaDTO;
}
/**
* MetaDTO
*/
export interface MetaDTO {
auths?: string[];
dynamicLevel?: number;
extraIcon?: ExtraIconDTO;
frameLoading?: boolean;
frameSrc?: string;
hiddenTag?: boolean;
icon?: string;
isFrameSrcInternal?: boolean;
keepAlive?: boolean;
rank?: number;
roles?: string[];
showLink?: boolean;
showParent?: boolean;
title?: string;
transition?: TransitionDTO;
}
/**
* ExtraIconDTO
*/
export interface ExtraIconDTO {
name?: string;
svg?: boolean;
}
/**
* TransitionDTO
*/
export interface TransitionDTO {
enterTransition?: string;
leaveTransition?: string;
name?: string;
}
/** 获取菜单列表 */
export const getMenuListApi = (params: MenuQuery) => {
return http.request<ResponseData<Array<MenuDTO>>>("get", "/system/menus", {
params
});
};
/** 添加菜单 */
export const addMenuApi = (data: MenuRequest) => {
return http.request<ResponseData<void>>("post", "/system/menus", { data });
};
/** 修改菜单 */
export const updateMenuApi = (menuId: string, data: MenuRequest) => {
return http.request<ResponseData<void>>("put", `/system/menus/${menuId}`, {
data
});
};
/** 删除菜单 */
export const deleteMenuApi = (menuId: string) => {
return http.request<ResponseData<void>>("delete", `/system/menus/${menuId}`);
};
/** 菜单详情 */
export const getMenuInfoApi = (menuId: string) => {
return http.request<ResponseData<MenuDetailDTO>>(
"get",
`/system/menus/${menuId}`
);
};
+137
View File
@@ -0,0 +1,137 @@
import { http } from "@/utils/http";
export interface OnlineUserQuery {
ipAddress: string;
username: string;
}
export interface OnlineUserInfo {
browser?: string;
deptName?: string;
ipAddress?: string;
loginLocation?: string;
loginTime?: number;
operationSystem?: string;
tokenId?: string;
username?: string;
}
/** 获取操作日志列表 */
export const getOnlineUserListApi = (params?: OnlineUserQuery) => {
return http.request<ResponseData<PageDTO<OnlineUserInfo>>>(
"get",
"/monitor/onlineUsers",
{
params
}
);
};
/** 强制登出用户 */
export const logoutOnlineUserApi = (tokenId: string) => {
return http.request<ResponseData<void>>(
"delete",
`/monitor/onlineUser/${tokenId}`
);
};
/**
* ServerInfo
*/
export interface ServerInfo {
cpuInfo?: CpuInfo;
diskInfos?: DiskInfo[];
jvmInfo?: JvmInfo;
memoryInfo?: MemoryInfo;
systemInfo?: SystemInfo;
}
/**
* CpuInfo
*/
export interface CpuInfo {
cpuNum?: number;
free?: number;
sys?: number;
total?: number;
used?: number;
wait?: number;
}
/**
* DiskInfo
*/
export interface DiskInfo {
dirName?: string;
free?: string;
sysTypeName?: string;
total?: string;
typeName?: string;
usage?: number;
used?: string;
}
/**
* JvmInfo
*/
export interface JvmInfo {
free?: number;
home?: string;
inputArgs?: string;
max?: number;
name?: string;
runTime?: string;
startTime?: string;
total?: number;
usage?: number;
used?: number;
version?: string;
}
/**
* MemoryInfo
*/
export interface MemoryInfo {
free?: number;
total?: number;
usage?: number;
used?: number;
}
/**
* SystemInfo
*/
export interface SystemInfo {
computerIp?: string;
computerName?: string;
osArch?: string;
osName?: string;
userDir?: string;
}
/** 获取服务器信息 */
export const getServerInfoApi = () => {
return http.request<ResponseData<ServerInfo>>("get", "/monitor/serverInfo");
};
/**
* RedisCacheInfoDTO
*/
export interface RedisCacheInfoDTO {
commandStats?: CommandStatusDTO[];
dbSize?: number;
info?: { [key: string]: string };
}
/**
* CommandStatusDTO
*/
export interface CommandStatusDTO {
name?: string;
value?: string;
}
/** 获取Redis信息 */
export const getCacheInfoApi = () => {
return http.request<ResponseData<ServerInfo>>("get", "/monitor/cacheInfo");
};
+64
View File
@@ -0,0 +1,64 @@
import { http } from "@/utils/http";
export interface SystemNoticeQuery extends BasePageQuery {
noticeType: string;
noticeTitle: string;
creatorName: string;
}
type SystemNoticeDTO = {
noticeId: string;
noticeTitle: string;
noticeType: number;
noticeContent: string;
status: number;
createTime: Date;
creatorName: string;
};
export type SystemNoticeRequest = {
noticeId?: number;
noticeTitle: string;
noticeType: number;
noticeContent: string;
status: number;
};
/** 获取系统通知列表 */
export const getSystemNoticeListApi = (params?: SystemNoticeQuery) => {
return http.request<ResponseData<PageDTO<SystemNoticeDTO>>>(
"get",
"/system/notices",
{
params
}
);
};
/** 添加系统通知 */
export const addSystemNoticeApi = (data: SystemNoticeRequest) => {
return http.request<ResponseData<void>>("post", "/system/notices", {
data
});
};
/** 修改系统通知 */
export const updateSystemNoticeApi = (data: SystemNoticeRequest) => {
return http.request<ResponseData<void>>(
"put",
`/system/notices/${data.noticeId}`,
{
data
}
);
};
/** 删除系统通知 */
export const deleteSystemNoticeApi = (data: Array<number>) => {
return http.request<ResponseData<void>>("delete", "/system/notices", {
params: {
// 需要将数组转换为字符串 否则Axios会将参数变成 noticeIds[0]:1 noticeIds[1]:2 这种格式,后端接收参数不成功
noticeIds: data.toString()
}
});
};
+70
View File
@@ -0,0 +1,70 @@
import { http } from "@/utils/http";
export interface PostListCommand extends BasePageQuery {
postCode?: string;
postName?: string;
status?: number;
}
export interface PostPageResponse {
createTime: string;
postCode: string;
postId: number;
postName: string;
postSort: number;
remark: string;
status: number;
statusStr: string;
}
export function getPostListApi(params: PostListCommand) {
return http.request<ResponseData<PageDTO<PostPageResponse>>>(
"get",
"/system/post/list",
{
params
}
);
}
export const exportPostExcelApi = (
params: PostListCommand,
fileName: string
) => {
return http.download("/system/post/excel", fileName, {
params
});
};
export const deletePostApi = (data: Array<number>) => {
return http.request<ResponseData<void>>("delete", "/system/post", {
params: {
// 需要将数组转换为字符串 否则Axios会将参数变成 noticeIds[0]:1 noticeIds[1]:2 这种格式,后端接收参数不成功
ids: data.toString()
}
});
};
export interface AddPostCommand {
postCode: string;
postName: string;
postSort: number;
remark?: string;
status?: string;
}
export const addPostApi = (data: AddPostCommand) => {
return http.request<ResponseData<void>>("post", "/system/post", {
data
});
};
export interface UpdatePostCommand extends AddPostCommand {
postId: number;
}
export const updatePostApi = (data: UpdatePostCommand) => {
return http.request<ResponseData<void>>("put", "/system/post", {
data
});
};
+65
View File
@@ -0,0 +1,65 @@
import { http } from "@/utils/http";
export interface RoleQuery extends BasePageQuery {
roleKey?: string;
roleName?: string;
status?: string;
timeRangeColumn?: string;
}
export interface RoleDTO {
createTime: Date;
dataScope: number;
remark: string;
roleId: number;
roleKey: string;
roleName: string;
roleSort: number;
selectedDeptList: number[];
selectedMenuList: number[];
status: number;
}
export function getRoleListApi(params: RoleQuery) {
return http.request<ResponseData<PageDTO<RoleDTO>>>(
"get",
"/system/role/list",
{
params
}
);
}
export function getRoleInfoApi(roleId: number) {
return http.request<ResponseData<RoleDTO>>("get", "/system/role/" + roleId);
}
export interface AddRoleCommand {
dataScope?: string;
menuIds: number[];
remark?: string;
roleKey: string;
roleName: string;
roleSort: number;
status?: string;
}
export function addRoleApi(data: AddRoleCommand) {
return http.request<void>("post", "/system/role", {
data
});
}
export interface UpdateRoleCommand extends AddRoleCommand {
roleId: number;
}
export function updateRoleApi(data: UpdateRoleCommand) {
return http.request<void>("put", "/system/role", {
data
});
}
export function deleteRoleApi(roleId: number) {
return http.request<void>("delete", "/system/role/" + roleId);
}
+176
View File
@@ -0,0 +1,176 @@
import { http } from "@/utils/http";
export interface UserQuery extends BasePageQuery {
deptId?: number;
phoneNumber?: string;
status?: number;
userId?: number;
username?: string;
}
/**
* UserDTO
*/
export interface UserDTO {
avatar?: string;
createTime?: Date;
creatorId?: number;
creatorName?: string;
deptId?: number;
deptName?: string;
email?: string;
loginDate?: Date;
loginIp?: string;
nickname?: string;
phoneNumber?: string;
postId?: number;
remark?: string;
roleId?: number;
roleName?: string;
sex?: number;
status?: number;
updaterId?: number;
updaterName?: string;
updateTime?: Date;
userId?: number;
username?: string;
userType?: number;
}
/**
* AddUserCommand
*/
export interface UserRequest {
userId: number;
avatar?: string;
deptId?: number;
email?: string;
nickname?: string;
phoneNumber?: string;
password: string;
postId?: number;
remark?: string;
roleId?: number;
sex?: number;
status?: number;
username?: string;
}
/**
* UpdateProfileCommand
*/
export interface UserProfileRequest {
email?: string;
nickName?: string;
phoneNumber?: string;
sex?: number;
userId?: number;
}
/**
* ResetPasswordCommand
*/
export interface ResetPasswordRequest {
newPassword?: string;
oldPassword?: string;
userId?: number;
}
/**
* 修改密码
*/
export interface PasswordRequest {
userId: number;
password: string;
}
/** 获取用户列表 */
export const getUserListApi = (params?: UserQuery) => {
return http.request<ResponseData<PageDTO<UserDTO>>>("get", "/system/users", {
params
});
};
/** 新增用户 */
export const addUserApi = (data?: UserRequest) => {
return http.request<ResponseData<void>>("post", "/system/users", {
data
});
};
/** 编辑用户 */
export const updateUserApi = (userId: number, data?: UserRequest) => {
return http.request<ResponseData<void>>("put", `/system/users/${userId}`, {
data
});
};
/** 更改用户密码 */
export const updateUserPasswordApi = (data?: PasswordRequest) => {
return http.request<ResponseData<void>>(
"put",
`/system/users/${data.userId}/password`,
{
data
}
);
};
/** 删除用户 */
export const deleteUserApi = (userId: number) => {
return http.request<ResponseData<void>>("delete", `/system/users/${userId}`);
};
/** 修改用户状态 */
export const updateUserStatusApi = (userId: number, status: number) => {
return http.request<ResponseData<PageDTO<UserDTO>>>(
"put",
`/system/users/${userId}/status`,
{
data: {
status: status
}
}
);
};
/** 批量导出用户 */
export const exportUserExcelApi = (params: UserQuery, fileName: string) => {
return http.download("/system/users/excel", fileName, {
params
});
};
/** 用户头像上传 */
export const uploadUserAvatarApi = data => {
return http.request<ResponseData<void>>(
"post",
"/system/user/profile/avatar",
{
data
},
{
headers: {
"Content-Type": "multipart/form-data"
}
}
);
};
/** 更改用户资料 */
export const updateUserProfileApi = (data?: UserProfileRequest) => {
return http.request<ResponseData<void>>("put", "/system/user/profile", {
data
});
};
/** 更改当前用户密码 */
export const updateCurrentUserPasswordApi = (data?: ResetPasswordRequest) => {
return http.request<ResponseData<void>>(
"put",
"/system/user/profile/password",
{
data
}
);
};
@@ -0,0 +1,26 @@
@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
@@ -0,0 +1,30 @@
{
"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.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<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.109z"/><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.665z"/><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.114 323.114 0 0 1-107.769-242.852z"/></svg>

After

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20"><g fill="none"><path d="M3 5a2 2 0 0 1 2-2h2a.5.5 0 0 1 0 1H5a1 1 0 0 0-1 1v2a.5.5 0 0 1-1 0V5zm9.5-1.5A.5.5 0 0 1 13 3h2a2 2 0 0 1 2 2v2a.5.5 0 0 1-1 0V5a1 1 0 0 0-1-1h-2a.5.5 0 0 1-.5-.5zm-9 9a.5.5 0 0 1 .5.5v2a1 1 0 0 0 1 1h2a.5.5 0 0 1 0 1H5a2 2 0 0 1-2-2v-2a.5.5 0 0 1 .5-.5zm13 0a.5.5 0 0 1 .5.5v2a2 2 0 0 1-2 2h-2a.5.5 0 0 1 0-1h2a1 1 0 0 0 1-1v-2a.5.5 0 0 1 .5-.5z" fill="currentColor"/></g></svg>

After

Width:  |  Height:  |  Size: 508 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none"><path d="M8.5 3.75a.75.75 0 0 0-1.5 0v2.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 0 0 1.5h2.5A2.25 2.25 0 0 0 8.5 6.25v-2.5zm0 16.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 0-.75-.75h-2.5a.75.75 0 0 1 0-1.5h2.5a2.25 2.25 0 0 1 2.25 2.25v2.5zM16.25 3a.75.75 0 0 0-.75.75v2.5a2.25 2.25 0 0 0 2.25 2.25h2.5a.75.75 0 0 0 0-1.5h-2.5a.75.75 0 0 1-.75-.75v-2.5a.75.75 0 0 0-.75-.75zm-.75 17.25a.75.75 0 0 0 1.5 0v-2.5a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 0 0-1.5h-2.5a2.25 2.25 0 0 0-2.25 2.25v2.5z" fill="currentColor"/></g></svg>

After

Width:  |  Height:  |  Size: 631 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M2.88 18.054a35.897 35.897 0 0 1 8.531-16.32.8.8 0 0 1 1.178 0c.166.18.304.332.413.455a35.897 35.897 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.019 44.019 0 0 1-6.584-.874zm6.698-1.123 1.157.066L12 19.527l1.265-2.53 1.157-.066a42.137 42.137 0 0 0 4.227-.454A33.913 33.913 0 0 0 12 4.09a33.913 33.913 0 0 0-6.649 12.387c1.395.222 2.805.374 4.227.454zM12 15a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-2a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></svg>

After

Width:  |  Height:  |  Size: 588 B

+1
View File
@@ -0,0 +1 @@
<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.981z"/></svg>

After

Width:  |  Height:  |  Size: 263 B

+1
View File
@@ -0,0 +1 @@
<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 12zM11 1h2v3h-2V1zm0 19h2v3h-2v-3zM3.515 4.929l1.414-1.414L7.05 5.636 5.636 7.05 3.515 4.93zM16.95 18.364l1.414-1.414 2.121 2.121-1.414 1.414-2.121-2.121zm2.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.414 2.121-2.121zM23 11v2h-3v-2h3zM4 11v2H1v-2h3z"/></svg>

After

Width:  |  Height:  |  Size: 480 B

@@ -0,0 +1 @@
<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-8z"/></svg>

After

Width:  |  Height:  |  Size: 352 B

@@ -0,0 +1 @@
<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.5l-.5.5zM13 3V1h-1v2.5l.5.5H15V3h-2zm-1 9.5V15h1v-2h2v-1h-2.5l-.5.5zM1 12v1h2v2h1v-2.5l-.5-.5H1zm11-1.5-.5.5h-7l-.5-.5v-5l.5-.5h7l.5.5v5zM10 7H6v2h4V7z"/></svg>

After

Width:  |  Height:  |  Size: 348 B

@@ -0,0 +1 @@
<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 12h10V4H3v8zm2-6h6v4H5V6zM2 6H1V2.5l.5-.5H5v1H2v3zm13-3.5V6h-1V3h-3V2h3.5l.5.5zM14 10h1v3.5l-.5.5H11v-1h3v-3zM2 13h3v1H1.5l-.5-.5V10h1v3z"/></svg>

After

Width:  |  Height:  |  Size: 318 B

@@ -0,0 +1 @@
<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 7h6v2H3v2h4v2H3v2h4v2H1V7m10 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-2Z"/></svg>

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,5 @@
import auth from "./src/auth";
const Auth = auth;
export { Auth };
@@ -0,0 +1,20 @@
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;
};
}
});
@@ -0,0 +1,29 @@
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() }
);
}
});
@@ -0,0 +1,7 @@
import reCropper from "./src";
import { withInstall } from "@pureadmin/utils";
/** 图片裁剪组件 */
export const ReCropper = withInstall(reCropper);
export default ReCropper;
@@ -0,0 +1,11 @@
@import "cropperjs/dist/cropper.css";
@import "tippy.js/dist/tippy.css";
@import "tippy.js/themes/light.css";
@import "tippy.js/animations/perspective.css";
.re-circled {
.cropper-view-box,
.cropper-face {
border-radius: 50%;
}
}
@@ -0,0 +1,439 @@
import "./circled.css";
import Cropper from "cropperjs";
import { ElUpload } from "element-plus";
import type { CSSProperties } from "vue";
import { useResizeObserver } from "@vueuse/core";
import { longpress } from "@/directives/longpress";
import { useTippy, directive as tippy } from "vue-tippy";
import { delay, debounce, isArray, downloadByBase64 } from "@pureadmin/utils";
import {
ref,
unref,
computed,
PropType,
onMounted,
onUnmounted,
defineComponent
} from "vue";
import {
Reload,
Upload,
ArrowH,
ArrowV,
ArrowUp,
ArrowDown,
ArrowLeft,
ChangeIcon,
ArrowRight,
RotateLeft,
SearchPlus,
RotateRight,
SearchMinus,
DownloadIcon
} from "./svg";
type Options = Cropper.Options;
const defaultOptions: Options = {
aspectRatio: 1,
zoomable: true,
zoomOnTouch: true,
zoomOnWheel: true,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: true,
autoCrop: true,
background: true,
highlight: true,
center: true,
responsive: true,
restore: true,
checkCrossOrigin: true,
checkOrientation: true,
scalable: true,
modal: true,
guides: true,
movable: true,
rotatable: true
};
const props = {
src: { type: String, required: true },
alt: { type: String },
circled: { type: Boolean, default: false },
realTimePreview: { type: Boolean, default: true },
height: { type: [String, Number], default: "360px" },
crossorigin: {
type: String as PropType<"" | "anonymous" | "use-credentials" | undefined>,
default: undefined
},
imageStyle: { type: Object as PropType<CSSProperties>, default: () => ({}) },
options: { type: Object as PropType<Options>, default: () => ({}) }
};
export default defineComponent({
name: "ReCropper",
props,
setup(props, { attrs, emit }) {
const tippyElRef = ref<ElRef<HTMLImageElement>>();
const imgElRef = ref<ElRef<HTMLImageElement>>();
const cropper = ref<Nullable<Cropper>>();
const isReady = ref(false);
const imgBase64 = ref();
const inCircled = ref(props.circled);
const inSrc = ref(props.src);
let scaleX = 1;
let scaleY = 1;
const debounceRealTimeCroppered = debounce(realTimeCroppered, 80);
const getImageStyle = computed((): CSSProperties => {
return {
height: props.height,
maxWidth: "100%",
...props.imageStyle
};
});
const getClass = computed(() => {
return [
attrs.class,
{
["re-circled"]: inCircled.value
}
];
});
const iconClass = computed(() => {
return [
"p-[6px]",
"h-[30px]",
"w-[30px]",
"outline-none",
"rounded-[4px]",
"cursor-pointer",
"hover:bg-[rgba(0,0,0,0.06)]"
];
});
const getWrapperStyle = computed((): CSSProperties => {
return { height: `${props.height}`.replace(/px/, "") + "px" };
});
onMounted(init);
onUnmounted(() => {
cropper.value?.destroy();
});
useResizeObserver(tippyElRef, () => {
handCropper("reset");
});
async function init() {
const imgEl = unref(imgElRef);
if (!imgEl) return;
cropper.value = new Cropper(imgEl, {
...defaultOptions,
ready: () => {
isReady.value = true;
realTimeCroppered();
delay(400).then(() => emit("readied", cropper.value));
},
crop() {
debounceRealTimeCroppered();
},
zoom() {
debounceRealTimeCroppered();
},
cropmove() {
debounceRealTimeCroppered();
},
...props.options
});
}
function realTimeCroppered() {
props.realTimePreview && croppered();
}
function croppered() {
if (!cropper.value) return;
const canvas = inCircled.value
? getRoundedCanvas()
: cropper.value.getCroppedCanvas();
// https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLCanvasElement/toBlob
canvas.toBlob(blob => {
if (!blob) return;
const fileReader: FileReader = new FileReader();
fileReader.readAsDataURL(blob);
fileReader.onloadend = e => {
if (!e.target?.result || !blob) return;
imgBase64.value = e.target.result;
emit("cropper", {
base64: e.target.result,
blob,
info: { size: blob.size, ...cropper.value.getData() }
});
};
fileReader.onerror = () => {
emit("error");
};
});
}
function getRoundedCanvas() {
const sourceCanvas = cropper.value!.getCroppedCanvas();
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;
const width = sourceCanvas.width;
const height = sourceCanvas.height;
canvas.width = width;
canvas.height = height;
context.imageSmoothingEnabled = true;
context.drawImage(sourceCanvas, 0, 0, width, height);
context.globalCompositeOperation = "destination-in";
context.beginPath();
context.arc(
width / 2,
height / 2,
Math.min(width, height) / 2,
0,
2 * Math.PI,
true
);
context.fill();
return canvas;
}
function handCropper(event: string, arg?: number | Array<number>) {
if (event === "scaleX") {
scaleX = arg = scaleX === -1 ? 1 : -1;
}
if (event === "scaleY") {
scaleY = arg = scaleY === -1 ? 1 : -1;
}
arg && isArray(arg)
? cropper.value?.[event]?.(...arg)
: cropper.value?.[event]?.(arg);
}
function beforeUpload(file) {
const reader = new FileReader();
reader.readAsDataURL(file);
inSrc.value = "";
reader.onload = e => {
inSrc.value = e.target?.result as string;
};
reader.onloadend = () => {
init();
};
return false;
}
const menuContent = defineComponent({
directives: {
tippy,
longpress
},
setup() {
return () => (
<div class="flex flex-wrap w-[60px] justify-between">
<ElUpload
accept="image/*"
show-file-list={false}
before-upload={beforeUpload}
>
<Upload
class={iconClass.value}
v-tippy={{
content: "上传",
placement: "left-start"
}}
/>
</ElUpload>
<DownloadIcon
class={iconClass.value}
v-tippy={{
content: "下载",
placement: "right-start"
}}
onClick={() => downloadByBase64(imgBase64.value, "cropping.png")}
/>
<ChangeIcon
class={iconClass.value}
v-tippy={{
content: "圆形、矩形裁剪",
placement: "left-start"
}}
onClick={() => {
inCircled.value = !inCircled.value;
realTimeCroppered();
}}
/>
<Reload
class={iconClass.value}
v-tippy={{
content: "重置",
placement: "right-start"
}}
onClick={() => handCropper("reset")}
/>
<ArrowUp
class={iconClass.value}
v-tippy={{
content: "上移(可长按)",
placement: "left-start"
}}
v-longpress={[() => handCropper("move", [0, -10]), "0:100"]}
/>
<ArrowDown
class={iconClass.value}
v-tippy={{
content: "下移(可长按)",
placement: "right-start"
}}
v-longpress={[() => handCropper("move", [0, 10]), "0:100"]}
/>
<ArrowLeft
class={iconClass.value}
v-tippy={{
content: "左移(可长按)",
placement: "left-start"
}}
v-longpress={[() => handCropper("move", [-10, 0]), "0:100"]}
/>
<ArrowRight
class={iconClass.value}
v-tippy={{
content: "右移(可长按)",
placement: "right-start"
}}
v-longpress={[() => handCropper("move", [10, 0]), "0:100"]}
/>
<ArrowH
class={iconClass.value}
v-tippy={{
content: "水平翻转",
placement: "left-start"
}}
onClick={() => handCropper("scaleX", -1)}
/>
<ArrowV
class={iconClass.value}
v-tippy={{
content: "垂直翻转",
placement: "right-start"
}}
onClick={() => handCropper("scaleY", -1)}
/>
<RotateLeft
class={iconClass.value}
v-tippy={{
content: "逆时针旋转",
placement: "left-start"
}}
onClick={() => handCropper("rotate", -45)}
/>
<RotateRight
class={iconClass.value}
v-tippy={{
content: "顺时针旋转",
placement: "right-start"
}}
onClick={() => handCropper("rotate", 45)}
/>
<SearchPlus
class={iconClass.value}
v-tippy={{
content: "放大(可长按)",
placement: "left-start"
}}
v-longpress={[() => handCropper("zoom", 0.1), "0:100"]}
/>
<SearchMinus
class={iconClass.value}
v-tippy={{
content: "缩小(可长按)",
placement: "right-start"
}}
v-longpress={[() => handCropper("zoom", -0.1), "0:100"]}
/>
</div>
);
}
});
function onContextmenu(event) {
event.preventDefault();
const { show, setProps } = useTippy(tippyElRef, {
content: menuContent,
arrow: false,
theme: "light",
trigger: "manual",
interactive: true,
appendTo: "parent",
// hideOnClick: false,
animation: "perspective",
placement: "bottom-start"
});
setProps({
getReferenceClientRect: () => ({
width: 0,
height: 0,
top: event.clientY,
bottom: event.clientY,
left: event.clientX,
right: event.clientX
})
});
show();
}
return {
inSrc,
props,
imgElRef,
tippyElRef,
getClass,
getWrapperStyle,
getImageStyle,
isReady,
croppered,
onContextmenu
};
},
render() {
const {
inSrc,
isReady,
getClass,
getImageStyle,
onContextmenu,
getWrapperStyle
} = this;
const { alt, crossorigin } = this.props;
return inSrc ? (
<div
ref="tippyElRef"
class={getClass}
style={getWrapperStyle}
onContextmenu={event => onContextmenu(event)}
>
<img
v-show={isReady}
ref="imgElRef"
style={getImageStyle}
src={inSrc}
alt={alt}
crossorigin={crossorigin}
/>
</div>
) : null;
}
});
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M862 465.3h-81c-4.6 0-9 2-12.1 5.5L550 723.1V160c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v563.1L255.1 470.8c-3-3.5-7.4-5.5-12.1-5.5h-81c-6.8 0-10.5 8.1-6 13.2L487.9 861a31.96 31.96 0 0 0 48.3 0L868 478.5c4.5-5.2.8-13.2-6-13.2z"/></svg>

After

Width:  |  Height:  |  Size: 347 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="icon" viewBox="0 0 1024 1024"><path d="m296.992 216.992-272 272L3.008 512l21.984 23.008 272 272 46.016-46.016L126.016 544h772L680.992 760.992l46.016 46.016 272-272L1020.992 512l-21.984-23.008-272-272-46.048 46.048L898.016 480h-772l216.96-216.992z"/></svg>

After

Width:  |  Height:  |  Size: 325 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M872 474H286.9l350.2-304c5.6-4.9 2.2-14-5.2-14h-88.5c-3.9 0-7.6 1.4-10.5 3.9L155 487.8a31.96 31.96 0 0 0 0 48.3L535.1 866c1.5 1.3 3.3 2 5.2 2h91.5c7.4 0 10.8-9.2 5.2-14L286.9 550H872c4.4 0 8-3.6 8-8v-60c0-4.4-3.6-8-8-8z"/></svg>

After

Width:  |  Height:  |  Size: 344 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M869 487.8 491.2 159.9c-2.9-2.5-6.6-3.9-10.5-3.9h-88.5c-7.4 0-10.8 9.2-5.2 14l350.2 304H152c-4.4 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h585.1L386.9 854c-5.6 4.9-2.2 14 5.2 14h91.5c1.9 0 3.8-.7 5.2-2L869 536.2a32.07 32.07 0 0 0 0-48.4z"/></svg>

After

Width:  |  Height:  |  Size: 351 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M868 545.5 536.1 163a31.96 31.96 0 0 0-48.3 0L156 545.5a7.97 7.97 0 0 0 6 13.2h81c4.6 0 9-2 12.1-5.5L474 300.9V864c0 4.4 3.6 8 8 8h60c4.4 0 8-3.6 8-8V300.9l218.9 252.3c3 3.5 7.4 5.5 12.1 5.5h81c6.8 0 10.5-8 6-13.2z"/></svg>

After

Width:  |  Height:  |  Size: 339 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="icon" viewBox="0 0 1024 1024"><path d="m512 67.008-23.008 21.984-256 256 46.048 46.048L480 190.016v644L279.008 632.96l-46.048 46.08 256 256 23.008 21.984 23.008-21.984 256-256-46.016-46.016L544 834.016v-644l200.992 200.96 46.016-45.984-256-256z"/></svg>

After

Width:  |  Height:  |  Size: 323 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="icon" viewBox="0 0 1024 1024"><path d="M956.8 988.8H585.6c-16 0-25.6-9.6-25.6-28.8V576c0-16 9.6-28.8 25.6-28.8h371.2c16 0 25.6 9.6 25.6 28.8v384c0 16-9.6 28.8-25.6 28.8zM608 937.6h326.4V598.4H608v339.2zm-121.6 44.8C262.4 982.4 144 848 144 595.2c0-19.2 9.6-28.8 25.6-28.8s25.6 12.8 25.6 28.8c0 220.8 96 326.4 288 326.4 16 0 25.6 12.8 25.6 28.8s-6.4 32-22.4 32z"/><path d="M262.4 694.4c-6.4 0-9.6-3.2-16-6.4L160 601.6c-9.6-9.6-9.6-22.4 0-28.8s22.4-9.6 28.8 0l86.4 86.4c9.6 9.6 9.6 22.4 0 28.8-3.2 3.2-6.4 6.4-12.8 6.4z"/><path d="M86.4 694.4c-6.4 0-9.6-3.2-16-6.4-9.6-9.6-9.6-22.4 0-28.8l86.4-86.4c9.6-9.6 22.4-9.6 28.8 0 9.6 9.6 9.6 22.4 0 28.8L99.2 688c-3.2 3.2-6.4 6.4-12.8 6.4zm790.4-249.6c-16 0-28.8-12.8-28.8-32 0-224-99.2-336-300.8-336-16 0-28.8-12.8-28.8-32s9.6-32 28.8-32c233.6 0 355.2 137.6 355.2 396.8 0 22.4-9.6 35.2-25.6 35.2z"/><path d="M876.8 448c-6.4 0-9.6-3.2-16-6.4l-86.4-86.4c-9.6-9.6-9.6-22.4 0-28.8s22.4-9.6 28.8 0l86.4 86.4c9.6 9.6 9.6 22.4 0 28.8 0 3.2-6.4 6.4-12.8 6.4z"/><path d="M876.8 448c-6.4 0-9.6-3.2-16-6.4-9.6-9.6-9.6-22.4 0-28.8l86.4-86.4c9.6-9.6 22.4-9.6 28.8 0s9.6 22.4 0 28.8l-86.4 86.4c-3.2 3.2-6.4 6.4-12.8 6.4zM288 524.8C156.8 524.8 48 416 48 278.4S156.8 35.2 288 35.2 528 144 528 281.6 419.2 524.8 288 524.8zm-3.2-432c-99.2 0-179.2 83.2-179.2 185.6S185.6 464 284.8 464 464 380.8 464 278.4 384 92.8 284.8 92.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M505.7 661a8 8 0 0 0 12.6 0l112-141.7c4.1-5.2.4-12.9-6.3-12.9h-74.1V168c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v338.3H400c-6.7 0-10.4 7.7-6.3 12.9l112 141.8zM878 626h-60c-4.4 0-8 3.6-8 8v154H214V634c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v198c0 17.7 14.3 32 32 32h684c17.7 0 32-14.3 32-32V634c0-4.4-3.6-8-8-8z"/></svg>

After

Width:  |  Height:  |  Size: 428 B

@@ -0,0 +1,31 @@
import Reload from "./reload.svg?component";
import Upload from "./upload.svg?component";
import ArrowH from "./arrow-h.svg?component";
import ArrowV from "./arrow-v.svg?component";
import ArrowUp from "./arrow-up.svg?component";
import ChangeIcon from "./change.svg?component";
import ArrowDown from "./arrow-down.svg?component";
import ArrowLeft from "./arrow-left.svg?component";
import DownloadIcon from "./download.svg?component";
import ArrowRight from "./arrow-right.svg?component";
import RotateLeft from "./rotate-left.svg?component";
import SearchPlus from "./search-plus.svg?component";
import RotateRight from "./rotate-right.svg?component";
import SearchMinus from "./search-minus.svg?component";
export {
Reload,
Upload,
ArrowH,
ArrowV,
ArrowUp,
ArrowDown,
ArrowLeft,
ChangeIcon,
ArrowRight,
RotateLeft,
SearchPlus,
RotateRight,
SearchMinus,
DownloadIcon
};
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M168 504.2c1-43.7 10-86.1 26.9-126 17.3-41 42.1-77.7 73.7-109.4S337 212.3 378 195c42.4-17.9 87.4-27 133.9-27s91.5 9.1 133.8 27A341.5 341.5 0 0 1 755 268.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 0 0 3 14.1l175.7 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c0-6.7-7.7-10.5-12.9-6.3l-56.4 44.1C765.8 155.1 646.2 92 511.8 92 282.7 92 96.3 275.6 92 503.8a8 8 0 0 0 8 8.2h60c4.4 0 7.9-3.5 8-7.8zm756 7.8h-60c-4.4 0-7.9 3.5-8 7.8-1 43.7-10 86.1-26.9 126-17.3 41-42.1 77.8-73.7 109.4A342.45 342.45 0 0 1 512.1 856a342.24 342.24 0 0 1-243.2-100.8c-9.9-9.9-19.2-20.4-27.8-31.4l60.2-47a8 8 0 0 0-3-14.1l-175.7-43c-5-1.2-9.9 2.6-9.9 7.7l-.7 181c0 6.7 7.7 10.5 12.9 6.3l56.4-44.1C258.2 868.9 377.8 932 512.2 932c229.2 0 415.5-183.7 419.8-411.8a8 8 0 0 0-8-8.2z"/></svg>

After

Width:  |  Height:  |  Size: 865 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M672 418H144c-17.7 0-32 14.3-32 32v414c0 17.7 14.3 32 32 32h528c17.7 0 32-14.3 32-32V450c0-17.7-14.3-32-32-32zm-44 402H188V494h440v326z"/><path fill="currentColor" d="M819.3 328.5c-78.8-100.7-196-153.6-314.6-154.2l-.2-64c0-6.5-7.6-10.1-12.6-6.1l-128 101c-4 3.1-3.9 9.1 0 12.3L492 318.6c5.1 4 12.7.4 12.6-6.1v-63.9c12.9.1 25.9.9 38.8 2.5 42.1 5.2 82.1 18.2 119 38.7 38.1 21.2 71.2 49.7 98.4 84.3 27.1 34.7 46.7 73.7 58.1 115.8 11 40.7 14 82.7 8.9 124.8-.7 5.4-1.4 10.8-2.4 16.1h74.9c14.8-103.6-11.3-213-81-302.3z"/></svg>

After

Width:  |  Height:  |  Size: 636 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M480.5 251.2c13-1.6 25.9-2.4 38.8-2.5v63.9c0 6.5 7.5 10.1 12.6 6.1L660 217.6c4-3.2 4-9.2 0-12.3l-128-101c-5.1-4-12.6-.4-12.6 6.1l-.2 64c-118.6.5-235.8 53.4-314.6 154.2-69.6 89.2-95.7 198.6-81.1 302.4h74.9c-.9-5.3-1.7-10.7-2.4-16.1-5.1-42.1-2.1-84.1 8.9-124.8 11.4-42.2 31-81.1 58.1-115.8 27.2-34.7 60.3-63.2 98.4-84.3 37-20.6 76.9-33.6 119.1-38.8z"/><path fill="currentColor" d="M880 418H352c-17.7 0-32 14.3-32 32v414c0 17.7 14.3 32 32 32h528c17.7 0 32-14.3 32-32V450c0-17.7-14.3-32-32-32zm-44 402H396V494h440v326z"/></svg>

After

Width:  |  Height:  |  Size: 639 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M637 443H325c-4.4 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h312c4.4 0 8-3.6 8-8v-60c0-4.4-3.6-8-8-8zm284 424L775 721c122.1-148.9 113.6-369.5-26-509-148-148.1-388.4-148.1-537 0-148.1 148.6-148.1 389 0 537 139.5 139.6 360.1 148.1 509 26l146 146c3.2 2.8 8.3 2.8 11 0l43-43c2.8-2.7 2.8-7.8 0-11zM696 696c-118.8 118.7-311.2 118.7-430 0-118.7-118.8-118.7-311.2 0-430 118.8-118.7 311.2-118.7 430 0 118.7 118.8 118.7 311.2 0 430z"/></svg>

After

Width:  |  Height:  |  Size: 535 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M637 443H519V309c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v134H325c-4.4 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h118v134c0 4.4 3.6 8 8 8h60c4.4 0 8-3.6 8-8V519h118c4.4 0 8-3.6 8-8v-60c0-4.4-3.6-8-8-8zm284 424L775 721c122.1-148.9 113.6-369.5-26-509-148-148.1-388.4-148.1-537 0-148.1 148.6-148.1 389 0 537 139.5 139.6 360.1 148.1 509 26l146 146c3.2 2.8 8.3 2.8 11 0l43-43c2.8-2.7 2.8-7.8 0-11zM696 696c-118.8 118.7-311.2 118.7-430 0-118.7-118.8-118.7-311.2 0-430 118.8-118.7 311.2-118.7 430 0 118.7 118.8 118.7 311.2 0 430z"/></svg>

After

Width:  |  Height:  |  Size: 631 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M400 317.7h73.9V656c0 4.4 3.6 8 8 8h60c4.4 0 8-3.6 8-8V317.7H624c6.7 0 10.4-7.7 6.3-12.9L518.3 163a8 8 0 0 0-12.6 0l-112 141.7c-4.1 5.3-.4 13 6.3 13zM878 626h-60c-4.4 0-8 3.6-8 8v154H214V634c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v198c0 17.7 14.3 32 32 32h684c17.7 0 32-14.3 32-32V634c0-4.4-3.6-8-8-8z"/></svg>

After

Width:  |  Height:  |  Size: 423 B

@@ -0,0 +1,64 @@
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.splice(index, 1);
options.closeCallBack && options.closeCallBack({ options, index, args });
};
/**
* @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#L13
* https://github.com/pure-admin/vue-pure-admin/blob/main/src/App.vue#L18
*/
const ReDialog = withInstall(reDialog);
export type { EventType, ArgsType, DialogProps, ButtonProps, DialogOptions };
export {
ReDialog,
dialogStore,
addDialog,
closeDialog,
updateDialog,
closeAllDialog
};
@@ -0,0 +1,157 @@
<script setup lang="ts">
import {
closeDialog,
dialogStore,
type EventType,
type ButtonProps,
type DialogOptions
} 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";
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,
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
) {
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
class="pure-dialog"
v-for="(options, index) in dialogStore"
:key="index"
v-bind="options"
v-model="options.visible"
:fullscreen="fullscreen ? true : options?.fullscreen ? true : false"
@close="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"
>
<IconifyIconOffline
class="pure-dialog-svg"
:icon="
options?.fullscreen
? ExitFullscreen
: fullscreen
? ExitFullscreen
: Fullscreen
"
/>
</i>
</div>
<component
v-else
:is="options?.headerRenderer({ close, titleId, titleClass })"
/>
</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>
<el-button
v-for="(btn, key) in footerButtons(options)"
:key="key"
v-bind="btn"
@click="
btn.btnClick({
dialog: { options, index },
button: { btn, index: key }
})
"
>
{{ btn?.label }}
</el-button>
</span>
</template>
</el-dialog>
</template>
@@ -0,0 +1,218 @@
import type { CSSProperties, VNode, Component } from "vue";
type DoneFn = (cancel?: boolean) => void;
type EventType = "open" | "close" | "openAutoFocus" | "closeAutoFocus";
type ArgsType = {
/** `cancel` 点击取消按钮、`sure` 点击确定按钮、`close` 点击右上角关闭按钮或者空白页 */
command: "cancel" | "sure" | "close";
};
/** 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;
};
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;
/** 是否为加载中状态,默认 `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;
/**
* @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` 关闭后的回调(只有点击右上角关闭按钮或者空白页关闭页面时才会触发) */
close?: ({
options,
index
}: {
options: DialogOptions;
index: number;
}) => void;
/** `Dialog` 关闭后的回调。 `args` 返回的 `command` 值解析:`cancel` 点击取消按钮、`sure` 点击确定按钮、`close` 点击右上角关闭按钮或者空白页 */
closeCallBack?: ({
options,
index,
args
}: {
options: DialogOptions;
index: number;
args: any;
}) => 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 };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
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 };
@@ -0,0 +1,237 @@
<script setup lang="ts">
import { cloneDeep } from "@pureadmin/utils";
import { IconJson } from "@/components/ReIcon/data";
import { ref, computed, CSSProperties, toRef, watch } from "vue";
type ParameterCSSProperties = (item?: string) => CSSProperties | undefined;
defineOptions({
name: "IconSelect"
});
const props = defineProps({
modelValue: {
require: false,
type: String
}
});
const emit = defineEmits<{ (e: "update:modelValue", v: string) }>();
const visible = ref(false);
const inputValue = toRef(props, "modelValue");
const iconList = ref(IconJson);
const icon = ref("add-location");
const currentActiveType = ref("ep:");
// 深拷贝图标数据,前端做搜索
const copyIconList = cloneDeep(iconList.value);
const pageSize = ref(96);
const currentPage = ref(1);
// 搜索条件
const filterValue = ref("");
const tabsList = [
{
label: "Element Plus",
name: "ep:"
},
{
label: "Font Awesome 4",
name: "fa:"
},
{
label: "Font Awesome 5 Solid",
name: "fa-solid:"
}
];
const pageList = computed(() => {
if (currentPage.value === 1) {
return copyIconList[currentActiveType.value]
.filter(v => v.includes(filterValue.value))
.slice(currentPage.value - 1, pageSize.value);
} else {
return copyIconList[currentActiveType.value]
.filter(v => v.includes(filterValue.value))
.slice(
pageSize.value * (currentPage.value - 1),
pageSize.value * (currentPage.value - 1) + pageSize.value
);
}
});
const iconItemStyle = computed((): ParameterCSSProperties => {
return item => {
if (inputValue.value === currentActiveType.value + item) {
return {
borderColor: "var(--el-color-primary)",
color: "var(--el-color-primary)"
};
}
};
});
function handleClick({ props }) {
currentPage.value = 1;
currentActiveType.value = props.name;
emit(
"update:modelValue",
currentActiveType.value + iconList.value[currentActiveType.value][0]
);
icon.value = iconList.value[currentActiveType.value][0];
}
function onChangeIcon(item) {
icon.value = item;
emit("update:modelValue", currentActiveType.value + item);
visible.value = false;
}
function onCurrentChange(page) {
currentPage.value = page;
}
watch(
() => {
return props.modelValue;
},
() => {
if (props.modelValue) {
currentActiveType.value = props.modelValue.substring(
0,
props.modelValue.indexOf(":") + 1
);
icon.value = props.modelValue.substring(
props.modelValue.indexOf(":") + 1
);
}
},
{ immediate: true }
);
watch(
() => {
return filterValue.value;
},
() => {
currentPage.value = 1;
}
);
</script>
<template>
<div class="selector w-[350px]">
<el-input v-model="inputValue" disabled>
<template #append>
<el-popover
:width="350"
trigger="click"
popper-class="pure-popper"
:popper-options="{
placement: 'auto'
}"
:visible="visible"
>
<template #reference>
<div
class="w-[40px] h-[32px] cursor-pointer flex justify-center items-center"
@click="visible = !visible"
>
<IconifyIconOnline :icon="currentActiveType + icon" />
</div>
</template>
<el-input
class="px-2 pt-2"
v-model="filterValue"
placeholder="搜索图标"
clearable
/>
<el-tabs v-model="currentActiveType" @tab-click="handleClick">
<el-tab-pane
v-for="(pane, index) in tabsList"
:key="index"
:label="pane.label"
:name="pane.name"
>
<el-scrollbar height="220px">
<ul class="flex flex-wrap px-2 ml-2">
<li
v-for="(item, key) in pageList"
:key="key"
:title="item"
class="icon-item p-2 cursor-pointer mr-2 mt-1 flex justify-center items-center border border-solid"
:style="iconItemStyle(item)"
@click="onChangeIcon(item)"
>
<IconifyIconOnline
:icon="currentActiveType + item"
width="20px"
height="20px"
/>
</li>
</ul>
</el-scrollbar>
</el-tab-pane>
</el-tabs>
<el-pagination
small
:total="copyIconList[currentActiveType].length"
:page-size="pageSize"
:current-page="currentPage"
background
layout="prev, pager, next"
class="flex items-center justify-center h-10"
@current-change="onCurrentChange"
/>
</el-popover>
</template>
</el-input>
</div>
</template>
<style lang="scss" scoped>
.icon-item {
&:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
transition: all 0.4s;
transform: scaleX(1.05);
}
}
:deep(.el-tabs__nav-next) {
font-size: 15px;
line-height: 32px;
box-shadow: -5px 0 5px -6px #ccc;
}
:deep(.el-tabs__nav-prev) {
font-size: 15px;
line-height: 32px;
box-shadow: 5px 0 5px -6px #ccc;
}
:deep(.el-input-group__append) {
padding: 0;
}
:deep(.el-tabs__item) {
height: 30px;
font-size: 12px;
font-weight: normal;
line-height: 30px;
}
:deep(.el-tabs__header),
:deep(.el-tabs__nav-wrap) {
position: static;
margin: 0;
box-shadow: 0 2px 5px rgb(0 0 0 / 6%);
}
:deep(.el-tabs__content) {
margin-top: 4px;
}
</style>
@@ -0,0 +1,61 @@
import { iconType } from "./types";
import { h, defineComponent, Component } from "vue";
import { IconifyIconOnline, IconifyIconOffline, FontIcon } from "../index";
/**
* 支持 `iconfont`、自定义 `svg` 以及 `iconify` 中所有的图标
* @see 点击查看文档图标篇 {@link https://yiming_chang.gitee.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 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
});
}
});
}
}
@@ -0,0 +1,48 @@
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
});
}
}
});
@@ -0,0 +1,30 @@
import { h, defineComponent } from "vue";
import { Icon as IconifyIcon, addIcon } from "@iconify/vue/dist/offline";
// Iconify Icon在Vue里本地使用(用于内网环境)https://docs.iconify.design/icon-components/vue/offline.html
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: () => []
}
);
}
});
@@ -0,0 +1,30 @@
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: () => []
}
);
}
});
@@ -0,0 +1,45 @@
import { addIcon } from "@iconify/vue/dist/offline";
/**
* 这里存放本地图标,在 src/layout/index.vue 文件中加载,避免在首启动加载
*/
// 本地菜单图标,后端在路由的icon中返回对应的图标字符串并且前端在此处使用addIcon添加即可渲染菜单图标
import UbuntuFill from "@iconify-icons/ri/ubuntu-fill";
import Menu from "@iconify-icons/ep/menu";
import Edit from "@iconify-icons/ep/edit";
import InformationLine from "@iconify-icons/ri/information-line";
import SetUp from "@iconify-icons/ep/set-up";
import TerminalWindowLine from "@iconify-icons/ri/terminal-window-line";
import Guide from "@iconify-icons/ep/guide";
import HomeFilled from "@iconify-icons/ep/home-filled";
import Card from "@iconify-icons/ri/bank-card-line";
import ListCheck from "@iconify-icons/ri/list-check";
import Histogram from "@iconify-icons/ep/histogram";
import Ppt from "@iconify-icons/ri/file-ppt-2-line";
import CheckboxCircleLine from "@iconify-icons/ri/checkbox-circle-line";
import FlUser from "@iconify-icons/ri/admin-line";
import Role from "@iconify-icons/ri/admin-fill";
import Setting from "@iconify-icons/ri/settings-3-line";
import Dept from "@iconify-icons/ri/git-branch-line";
import Lollipop from "@iconify-icons/ep/lollipop";
import Monitor from "@iconify-icons/ep/monitor";
addIcon("ubuntuFill", UbuntuFill);
addIcon("menu", Menu);
addIcon("edit", Edit);
addIcon("informationLine", InformationLine);
addIcon("setUp", SetUp);
addIcon("terminalWindowLine", TerminalWindowLine);
addIcon("guide", Guide);
addIcon("homeFilled", HomeFilled);
addIcon("card", Card);
addIcon("listCheck", ListCheck);
addIcon("histogram", Histogram);
addIcon("ppt", Ppt);
addIcon("checkboxCircleLine", CheckboxCircleLine);
addIcon("flUser", FlUser);
addIcon("role", Role);
addIcon("setting", Setting);
addIcon("dept", Dept);
addIcon("lollipop", Lollipop);
addIcon("monitor", Monitor);
@@ -0,0 +1,19 @@
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;
// all icon
style?: object;
}
@@ -0,0 +1,7 @@
import reImageVerify from "./src/index.vue";
import { withInstall } from "@pureadmin/utils";
/** 图形验证码组件 */
export const ReImageVerify = withInstall(reImageVerify);
export default ReImageVerify;
@@ -0,0 +1,86 @@
import { ref, onMounted } from "vue";
/**
* 绘制图形验证码
* @param width - 图形宽度
* @param height - 图形高度
*/
export const useImageVerify = (width = 120, height = 40) => {
const domRef = ref<HTMLCanvasElement>();
const imgCode = ref("");
function setImgCode(code: string) {
imgCode.value = code;
}
function getImgCode() {
if (!domRef.value) return;
imgCode.value = draw(domRef.value, width, height);
}
onMounted(() => {
getImgCode();
});
return {
domRef,
imgCode,
setImgCode,
getImgCode
};
};
/** 前端自己生成验证码 */
function randomNum(min: number, max: number) {
const num = Math.floor(Math.random() * (max - min) + min);
return num;
}
function randomColor(min: number, max: number) {
const r = randomNum(min, max);
const g = randomNum(min, max);
const b = randomNum(min, max);
return `rgb(${r},${g},${b})`;
}
function draw(dom: HTMLCanvasElement, width: number, height: number) {
let imgCode = "";
const NUMBER_STRING = "0123456789";
const ctx = dom.getContext("2d");
if (!ctx) return imgCode;
ctx.fillStyle = randomColor(180, 230);
ctx.fillRect(0, 0, width, height);
for (let i = 0; i < 4; i += 1) {
const text = NUMBER_STRING[randomNum(0, NUMBER_STRING.length)];
imgCode += text;
const fontSize = randomNum(18, 41);
const deg = randomNum(-30, 30);
ctx.font = `${fontSize}px Simhei`;
ctx.textBaseline = "top";
ctx.fillStyle = randomColor(80, 150);
ctx.save();
ctx.translate(30 * i + 15, 15);
ctx.rotate((deg * Math.PI) / 180);
ctx.fillText(text, -15 + 5, -15);
ctx.restore();
}
for (let i = 0; i < 5; i += 1) {
ctx.beginPath();
ctx.moveTo(randomNum(0, width), randomNum(0, height));
ctx.lineTo(randomNum(0, width), randomNum(0, height));
ctx.strokeStyle = randomColor(180, 230);
ctx.closePath();
ctx.stroke();
}
for (let i = 0; i < 41; i += 1) {
ctx.beginPath();
ctx.arc(randomNum(0, width), randomNum(0, height), 1, 0, 2 * Math.PI);
ctx.closePath();
ctx.fillStyle = randomColor(150, 200);
ctx.fill();
}
return imgCode;
}
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { watch } from "vue";
import { useImageVerify } from "./hooks";
defineOptions({
name: "ReImageVerify"
});
interface Props {
code?: string;
}
interface Emits {
(e: "update:code", code: string): void;
}
const props = withDefaults(defineProps<Props>(), {
code: ""
});
const emit = defineEmits<Emits>();
const { domRef, imgCode, setImgCode, getImgCode } = useImageVerify();
watch(
() => props.code,
newValue => {
setImgCode(newValue);
}
);
watch(imgCode, newValue => {
emit("update:code", newValue);
});
defineExpose({ getImgCode });
</script>
<template>
<canvas
ref="domRef"
width="120"
height="40"
class="cursor-pointer"
@click="getImgCode"
/>
</template>
@@ -0,0 +1,5 @@
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);
@@ -0,0 +1,355 @@
import { useEpThemeStoreHook } from "@/store/modules/epTheme";
import { defineComponent, ref, computed, type PropType, nextTick } from "vue";
import {
delay,
cloneDeep,
isBoolean,
isFunction,
getKeyList
} from "@pureadmin/utils";
import Sortable from "sortablejs";
import DragIcon from "./svg/drag.svg?component";
import ExpandIcon from "./svg/expand.svg?component";
import RefreshIcon from "./svg/refresh.svg?component";
import SettingIcon from "./svg/settings.svg?component";
import CollapseIcon from "./svg/collapse.svg?component";
const props = {
/** 头部最左边的标题 */
title: {
type: String,
default: "列表"
},
/** 对于树形表格,如果想启用展开和折叠功能,传入当前表格的ref即可 */
tableRef: {
type: Object as PropType<any>
},
/** 需要展示的列 */
columns: {
type: Array as PropType<TableColumnList>,
default: () => []
}
};
export default defineComponent({
name: "PureTableBar",
props,
emits: ["refresh"],
setup(props, { emit, slots, attrs }) {
const buttonRef = ref();
const size = ref("default");
const isExpandAll = ref(true);
const loading = ref(false);
const checkAll = ref(true);
const isIndeterminate = ref(false);
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[]) {
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 = document.querySelector(
".el-checkbox-group>div"
);
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 reference = {
reference: () => (
<SettingIcon
class={["w-[16px]", iconClass.value]}
onMouseover={e => (buttonRef.value = e.currentTarget)}
/>
)
};
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 ? (
<>
<el-tooltip
effect="dark"
content={isExpandAll.value ? "折叠" : "展开"}
placement="top"
>
<ExpandIcon
class={["w-[16px]", iconClass.value]}
style={{
transform: isExpandAll.value ? "none" : "rotate(-90deg)"
}}
onClick={() => onExpand()}
/>
</el-tooltip>
<el-divider direction="vertical" />
</>
) : null}
<el-tooltip effect="dark" content="刷新" placement="top">
<RefreshIcon
class={[
"w-[16px]",
iconClass.value,
loading.value ? "animate-spin" : ""
]}
onClick={() => onReFresh()}
/>
</el-tooltip>
<el-divider direction="vertical" />
<el-tooltip effect="dark" content="密度" placement="top">
<el-dropdown v-slots={dropdown} trigger="click">
<CollapseIcon class={["w-[16px]", iconClass.value]} />
</el-dropdown>
</el-tooltip>
<el-divider direction="vertical" />
<el-popover
v-slots={reference}
placement="bottom-start"
popper-style={{ padding: 0 }}
width="160"
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-checkbox-group
v-model={checkedColumns.value}
onChange={value => handleCheckedColumnsChange(value)}
>
<el-space
direction="vertical"
alignment="flex-start"
size={0}
>
{checkColumnList.map(item => {
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={item}
label={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>
</div>
</el-popover>
</div>
<el-tooltip
popper-options={{
modifiers: [
{
name: "computeStyles",
options: {
adaptive: false,
enabled: false
}
}
]
}}
placement="top"
virtual-ref={buttonRef.value}
virtual-triggering
trigger="hover"
content="列设置"
/>
</div>
{slots.default({
size: size.value,
dynamicColumns: dynamicColumns.value
})}
</div>
</>
);
}
});
@@ -0,0 +1 @@
<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 2Zm10 16H7a1 1 0 0 0 0 2h10a1 1 0 0 0 0-2Z"/></svg>

After

Width:  |  Height:  |  Size: 441 B

@@ -0,0 +1 @@
<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 97zm0 284a56 56 0 1 0 56-97 56 56 0 0 0-56 97zM640 228a56 56 0 1 0 112 0 56 56 0 0 0-112 0zm0 284a56 56 0 1 0 112 0 56 56 0 0 0-112 0zM300 844.5a56 56 0 1 0 56-97 56 56 0 0 0-56 97zM640 796a56 56 0 1 0 112 0 56 56 0 0 0-112 0z"/></svg>

After

Width:  |  Height:  |  Size: 398 B

@@ -0,0 +1 @@
<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.5V4h9Z"/></svg>

After

Width:  |  Height:  |  Size: 163 B

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 235 B

@@ -0,0 +1 @@
<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M3.34 17a10.018 10.018 0 0 1-.978-2.326 3 3 0 0 0 .002-5.347A9.99 9.99 0 0 1 4.865 4.99a3 3 0 0 0 4.631-2.674 9.99 9.99 0 0 1 5.007.002 3 3 0 0 0 4.632 2.672A9.99 9.99 0 0 1 20.66 7c.433.749.757 1.53.978 2.326a3 3 0 0 0-.002 5.347 9.99 9.99 0 0 1-2.501 4.337 3 3 0 0 0-4.631 2.674 9.99 9.99 0 0 1-5.007-.002 3 3 0 0 0-4.632-2.672A10.018 10.018 0 0 1 3.34 17zm5.66.196a4.993 4.993 0 0 1 2.25 2.77c.499.047 1 .048 1.499.001A4.993 4.993 0 0 1 15 17.197a4.993 4.993 0 0 1 3.525-.565c.29-.408.54-.843.748-1.298A4.993 4.993 0 0 1 18 12c0-1.26.47-2.437 1.273-3.334a8.126 8.126 0 0 0-.75-1.298A4.993 4.993 0 0 1 15 6.804a4.993 4.993 0 0 1-2.25-2.77c-.499-.047-1-.048-1.499-.001A4.993 4.993 0 0 1 9 6.803a4.993 4.993 0 0 1-3.525.565 7.99 7.99 0 0 0-.748 1.298A4.993 4.993 0 0 1 6 12a4.99 4.99 0 0 1-1.273 3.334 8.126 8.126 0 0 0 .75 1.298A4.993 4.993 0 0 1 9 17.196zM12 15a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-2a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></svg>

After

Width:  |  Height:  |  Size: 1011 B

@@ -0,0 +1,7 @@
import reQrcode from "./src/index";
import { withInstall } from "@pureadmin/utils";
/** 二维码组件 */
export const ReQrcode = withInstall(reQrcode);
export default ReQrcode;
@@ -0,0 +1,9 @@
.qrcode {
&--disabled {
background: rgb(255 255 255 / 95%);
& > div {
transform: translate(-50%, -50%);
}
}
}
@@ -0,0 +1,261 @@
import {
ref,
unref,
watch,
nextTick,
computed,
PropType,
defineComponent
} from "vue";
import "./index.scss";
import propTypes from "@/utils/propTypes";
import { isString, cloneDeep } from "@pureadmin/utils";
import QRCode, { QRCodeRenderersOptions } from "qrcode";
import RefreshRight from "@iconify-icons/ep/refresh-right";
interface QrcodeLogo {
src?: string;
logoSize?: number;
bgColor?: string;
borderSize?: number;
crossOrigin?: string;
borderRadius?: number;
logoRadius?: number;
}
const props = {
// img 或者 canvas,img不支持logo嵌套
tag: propTypes.string
.validate((v: string) => ["canvas", "img"].includes(v))
.def("canvas"),
// 二维码内容
text: {
type: [String, Array] as PropType<string | Recordable[]>,
default: null
},
// qrcode.js配置项
options: {
type: Object as PropType<QRCodeRenderersOptions>,
default: (): QRCodeRenderersOptions => ({})
},
// 宽度
width: propTypes.number.def(200),
// logo
logo: {
type: [String, Object] as PropType<Partial<QrcodeLogo> | string>,
default: (): QrcodeLogo | string => ""
},
// 是否过期
disabled: propTypes.bool.def(false),
// 过期提示内容
disabledText: propTypes.string.def("")
};
export default defineComponent({
name: "ReQrcode",
props,
emits: ["done", "click", "disabled-click"],
setup(props, { emit }) {
const { toCanvas, toDataURL } = QRCode;
const loading = ref(true);
const wrapRef = ref<Nullable<HTMLCanvasElement | HTMLImageElement>>(null);
const renderText = computed(() => String(props.text));
const wrapStyle = computed(() => {
return {
width: props.width + "px",
height: props.width + "px"
};
});
const initQrcode = async () => {
await nextTick();
const options = cloneDeep(props.options || {});
if (props.tag === "canvas") {
// 容错率,默认对内容少的二维码采用高容错率,内容多的二维码采用低容错率
options.errorCorrectionLevel =
options.errorCorrectionLevel ||
getErrorCorrectionLevel(unref(renderText));
const _width: number = await getOriginWidth(unref(renderText), options);
options.scale =
props.width === 0 ? undefined : (props.width / _width) * 4;
const canvasRef: any = await toCanvas(
unref(wrapRef) as HTMLCanvasElement,
unref(renderText),
options
);
if (props.logo) {
const url = await createLogoCode(canvasRef);
emit("done", url);
loading.value = false;
} else {
emit("done", canvasRef.toDataURL());
loading.value = false;
}
} else {
const url = await toDataURL(renderText.value, {
errorCorrectionLevel: "H",
width: props.width,
...options
});
(unref(wrapRef) as any).src = url;
emit("done", url);
loading.value = false;
}
};
watch(
() => renderText.value,
val => {
if (!val) return;
initQrcode();
},
{
deep: true,
immediate: true
}
);
const createLogoCode = (canvasRef: HTMLCanvasElement) => {
const canvasWidth = canvasRef.width;
const logoOptions: QrcodeLogo = Object.assign(
{
logoSize: 0.15,
bgColor: "#ffffff",
borderSize: 0.05,
crossOrigin: "anonymous",
borderRadius: 8,
logoRadius: 0
},
isString(props.logo) ? {} : props.logo
);
const {
logoSize = 0.15,
bgColor = "#ffffff",
borderSize = 0.05,
crossOrigin = "anonymous",
borderRadius = 8,
logoRadius = 0
} = logoOptions;
const logoSrc = isString(props.logo) ? props.logo : props.logo.src;
const logoWidth = canvasWidth * logoSize;
const logoXY = (canvasWidth * (1 - logoSize)) / 2;
const logoBgWidth = canvasWidth * (logoSize + borderSize);
const logoBgXY = (canvasWidth * (1 - logoSize - borderSize)) / 2;
const ctx = canvasRef.getContext("2d");
if (!ctx) return;
// logo 底色
canvasRoundRect(ctx)(
logoBgXY,
logoBgXY,
logoBgWidth,
logoBgWidth,
borderRadius
);
ctx.fillStyle = bgColor;
ctx.fill();
// logo
const image = new Image();
if (crossOrigin || logoRadius) {
image.setAttribute("crossOrigin", crossOrigin);
}
(image as any).src = logoSrc;
// 使用image绘制可以避免某些跨域情况
const drawLogoWithImage = (image: HTMLImageElement) => {
ctx.drawImage(image, logoXY, logoXY, logoWidth, logoWidth);
};
// 使用canvas绘制以获得更多的功能
const drawLogoWithCanvas = (image: HTMLImageElement) => {
const canvasImage = document.createElement("canvas");
canvasImage.width = logoXY + logoWidth;
canvasImage.height = logoXY + logoWidth;
const imageCanvas = canvasImage.getContext("2d");
if (!imageCanvas || !ctx) return;
imageCanvas.drawImage(image, logoXY, logoXY, logoWidth, logoWidth);
canvasRoundRect(ctx)(logoXY, logoXY, logoWidth, logoWidth, logoRadius);
if (!ctx) return;
const fillStyle = ctx.createPattern(canvasImage, "no-repeat");
if (fillStyle) {
ctx.fillStyle = fillStyle;
ctx.fill();
}
};
// 将 logo绘制到 canvas上
return new Promise((resolve: any) => {
image.onload = () => {
logoRadius ? drawLogoWithCanvas(image) : drawLogoWithImage(image);
resolve(canvasRef.toDataURL());
};
});
};
// 得到原QrCode的大小,以便缩放得到正确的QrCode大小
const getOriginWidth = async (
content: string,
options: QRCodeRenderersOptions
) => {
const _canvas = document.createElement("canvas");
await toCanvas(_canvas, content, options);
return _canvas.width;
};
// 对于内容少的QrCode,增大容错率
const getErrorCorrectionLevel = (content: string) => {
if (content.length > 36) {
return "M";
} else if (content.length > 16) {
return "Q";
} else {
return "H";
}
};
// 用于绘制圆角
const canvasRoundRect = (ctx: CanvasRenderingContext2D) => {
return (x: number, y: number, w: number, h: number, r: number) => {
const minSize = Math.min(w, h);
if (r > minSize / 2) {
r = minSize / 2;
}
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
return ctx;
};
};
const clickCode = () => {
emit("click");
};
const disabledClick = () => {
emit("disabled-click");
};
return () => (
<>
<div
v-loading={unref(loading)}
class="qrcode relative inline-block"
style={unref(wrapStyle)}
>
{props.tag === "canvas" ? (
<canvas ref={wrapRef} onClick={clickCode}></canvas>
) : (
<img ref={wrapRef} onClick={clickCode}></img>
)}
{props.disabled && (
<div
class="qrcode--disabled absolute top-0 left-0 flex w-full h-full items-center justify-center"
onClick={disabledClick}
>
<div class="absolute top-[50%] left-[50%] font-bold">
<iconify-icon-offline
class="cursor-pointer"
icon={RefreshRight}
width="30"
color="var(--el-color-primary)"
/>
<div>{props.disabledText}</div>
</div>
</div>
)}
</div>
</>
);
}
});
@@ -0,0 +1,44 @@
import { h, defineComponent } from "vue";
import TypeIt from "typeit";
// 打字机效果组件(只是简单的封装下,更多配置项参考 https://www.typeitjs.com/docs/vanilla/usage#options
export default defineComponent({
name: "TypeIt",
props: {
/** 打字速度,以每一步之间的毫秒数为单位,默认`200` */
speed: {
type: Number,
default: 200
},
values: {
type: Array,
defalut: []
},
className: {
type: String,
default: "type-it"
},
cursor: {
type: Boolean,
default: true
}
},
render() {
return h(
"span",
{
class: this.className
},
{
default: () => []
}
);
},
mounted() {
new TypeIt(`.${this.className}`, {
strings: this.values,
speed: this.speed,
cursor: this.cursor
}).go();
}
});
@@ -0,0 +1,173 @@
<template>
<el-dialog
v-model="visible"
:fullscreen="fullScreen"
class="v-dialog"
:class="dialogClazz"
:draggable="props.draggable"
:show-close="false"
v-bind="$attrs"
>
<template #header>
<slot name="header">
<div
style="
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
height: 24px;
font-weight: bold;
color: rgb(118 131 164);
"
>
<div v-text="props.title" />
<div
style="
position: absolute;
right: 0;
display: flex;
flex-direction: row;
justify-content: center;
"
>
<el-button
v-if="props.showFullScreen"
:icon="fullScreen ? FullScreenMinimize : FullScreenMaximize"
link
@click="requestFullScreen"
class="header-btn"
/>
<el-button
:icon="Close"
link
@click="handleCloseClick"
class="header-btn"
style="margin-left: 0"
/>
</div>
</div>
</slot>
</template>
<template v-if="useBodyScrolling">
<el-scrollbar :max-height="bodyHeight" always>
<slot name="default" />
</el-scrollbar>
</template>
<template v-else>
<slot name="default" />
</template>
<template #footer>
<slot name="footer" v-if="!props.hiddenFooter">
<div style="display: flex; justify-content: center">
<el-button
:loading="props.loading"
type="primary"
@click="handleConfirm"
>{{ props.confirmText }}</el-button
>
<el-button :loading="props.loading" @click="handleCancel">{{
cancelText
}}</el-button>
</div>
</slot>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import { ElDialog, ElButton, ElScrollbar } from "element-plus";
import { DialogEmits, DialogProps } from "./dialog";
import { Close } from "@element-plus/icons-vue";
import FullScreenMaximize from "@/assets/svg/FullScreenMaximize.svg?component";
import FullScreenMinimize from "@/assets/svg/FullScreenMinimize.svg?component";
const props = withDefaults(defineProps<DialogProps>(), {
fullScreen: undefined,
confirmText: "确定",
cancelText: "取消",
disableFooter: false,
useBodyScrolling: false,
fixedBodyHeight: true,
draggable: true,
loading: false
});
const emits = defineEmits<DialogEmits>();
const visible = computed<boolean>({
get: () => {
return props.modelValue;
},
set: v => emits("update:modelValue", v)
});
const fullScreenState = ref(!!props.initFullScreen);
const fullScreen = computed<boolean>({
get: () => {
console.log("fullScreen getter", props.fullScreen, fullScreenState.value);
// 非受控模式,状态完全由组件内部控制
if (props.fullScreen === undefined) {
return fullScreenState.value;
} else {
return props.fullScreen;
}
},
set: v => {
fullScreenState.value = v;
console.log("fullScreen setter", v, props.fullScreen);
// 受控模式,将状态更新到父组件
if (props.fullScreen !== undefined) {
emits("update:fullScreen", v);
}
}
});
// const fullScreen = ref<boolean>(false)
function requestFullScreen() {
fullScreen.value = !fullScreen.value;
}
const bodyHeight = computed(() => {
const footerHeight = props.hiddenFooter ? "0" : "52px";
if (props.fullScreen) {
// footerHeight=52,headerHeight=44,padding=12
return `calc(100dvh - ${footerHeight} - 44px)`;
} else {
return `calc(70dvh - ${footerHeight} - 44px)`;
}
});
const dialogClazz = computed(() => {
const classList: string[] = ["v-dialog"];
if (!props.fixedBodyHeight) {
classList.push("flex-body");
}
if (props.hiddenFooter) {
classList.push("hidden-footer");
}
return classList;
});
function handleConfirm() {
emits("confirm");
}
function handleCancel() {
emits("cancel");
}
function handleCloseClick() {
visible.value = false;
}
</script>
<style scoped>
.header-btn :deep(.el-icon),
.header-btn :deep(.el-icon svg) {
width: 24px;
height: 24px;
}
</style>
@@ -0,0 +1,62 @@
html.dark .v-dialog {
--header-bg-color: #171d1e;
--footer-bg-color: #171d1e;
}
.v-dialog {
--header-bg-color: #f5f7fa;
--footer-bg-color: #f5f7fa;
}
.v-dialog.el-dialog.hidden-footer .el-dialog__footer {
padding: 0;
border: none;
}
.el-dialog__footer {
padding: 10px;
/*border-top: 1px solid var(--el-border-color);*/
/*border-bottom: 1px solid var(--el-border-color);*/
box-sizing: border-box;
background-color: var(--header-bg-color);
position: relative; /* 防止被表单覆盖底部 */
z-index: calc(var(--el-index-normal) + 1);
}
.v-dialog.el-dialog {
box-sizing: border-box;
margin: 15dvh auto;
}
.v-dialog.el-dialog.is-fullscreen {
box-sizing: border-box;
margin: auto;
}
.v-dialog.el-dialog .el-dialog__header {
padding: 10px 16px;
/*border-bottom: 1px solid var(--el-border-color);*/
margin-right: 0;
box-sizing: border-box;
background-color: var(--header-bg-color);
}
.v-dialog.el-dialog--center .el-dialog__body,
.el-dialog__body {
padding: 16px 20px;
box-sizing: border-box;
}
.v-dialog.el-dialog.is-fullscreen .el-dialog__body {
height: calc(100dvh - 44px - 52px);
max-height: calc(100dvh - 44px - 52px);
}
.v-dialog.el-dialog .el-dialog__body {
height: calc(70dvh - 44px - 52px);
}
.v-dialog.el-dialog.flex-body:not(.is-fullscreen) .el-dialog__body {
height: initial;
max-height: calc(70dvh - 44px - 52px);
}
@@ -0,0 +1,47 @@
export interface DialogProps {
/**
* 标题
*/
title: string;
/**
* 显隐
*/
modelValue: boolean;
/**
* 初始化全屏状态
*/
initFullScreen?: boolean | undefined;
/**
* 展示全屏按钮
*/
showFullScreen?: boolean;
/**
* 全屏
*/
fullScreen?: boolean | undefined;
confirmText?: string;
cancelText?: string;
loading?: boolean;
/**
* 使用el-scrollbar包裹对话框body
*/
useBodyScrolling?: boolean;
/**
* 固定对话框body高度
*/
fixedBodyHeight?: boolean;
draggable?: boolean;
hiddenFooter?: boolean;
}
export type DialogEmits = {
"update:modelValue": [val: boolean];
"update:fullScreen": [val: boolean];
confirm: [];
cancel: [];
};
+55
View File
@@ -0,0 +1,55 @@
import { App } from "vue";
import axios from "axios";
import { configConver } from "@/utils/rootConver";
let config: object = {};
const setConfig = (cfg?: unknown) => {
config = Object.assign(config, cfg);
};
const getConfig = (key?: string): ServerConfigs => {
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 getServerConfig = async (app: App): Promise<undefined> => {
app.config.globalProperties.$config = getConfig();
return axios({
method: "get",
url: `${configConver()}serverConfig.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文件夹下添加serverConfig.json配置文件";
});
};
/** 本地响应式存储的命名空间 */
const responsiveStorageNameSpace = () => getConfig().ResponsiveStorageNameSpace;
export { getConfig, setConfig, responsiveStorageNameSpace };
+15
View File
@@ -0,0 +1,15 @@
import { hasAuth } from "@/router/utils";
import type { Directive, DirectiveBinding } from "vue";
export const auth: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
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']\""
);
}
}
};
+33
View File
@@ -0,0 +1,33 @@
import { message } from "@/utils/message";
import { useEventListener } from "@vueuse/core";
import { copyTextToClipboard } from "@pureadmin/utils";
import type { Directive, DirectiveBinding } from "vue";
interface CopyEl extends HTMLElement {
copyValue: string;
}
/** 文本复制指令(默认双击复制) */
export const copy: Directive = {
mounted(el: CopyEl, binding: DirectiveBinding) {
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;
}
};
+4
View File
@@ -0,0 +1,4 @@
export * from "./auth";
export * from "./copy";
export * from "./longpress";
export * from "./optimize";
@@ -0,0 +1,63 @@
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) {
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"'
);
}
}
};
@@ -0,0 +1,55 @@
import {
isFunction,
isObject,
isArray,
debounce,
throttle
} from "@pureadmin/utils";
import { useEventListener } from "@vueuse/core";
import type { Directive, DirectiveBinding } from "vue";
/** 防抖(v-optimize或v-optimize:debounce)、节流(v-optimize:throttle)指令 */
export const optimize: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
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"
);
}
}
};
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { useGlobal } from "@pureadmin/utils";
import backTop 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 { $storage, $config } = useGlobal<GlobalPropertiesApi>();
const keepAlive = computed(() => {
return $config?.KeepAlive;
});
const transitions = computed(() => {
return route => {
return route.meta.transition;
};
});
const hideTabs = computed(() => {
return $storage?.configure.hideTabs;
});
const layout = computed(() => {
return $storage?.layout.layout === "vertical";
});
const getSectionStyle = computed(() => {
return [
hideTabs.value && layout ? "padding-top: 48px;" : "",
!hideTabs.value && layout ? "padding-top: 85px;" : "",
hideTabs.value && !layout.value ? "padding-top: 48px" : "",
!hideTabs.value && !layout.value ? "padding-top: 85px;" : "",
props.fixedHeader ? "" : "padding-top: 0;"
];
});
const transitionMain = defineComponent({
render() {
return h(
Transition,
{
name:
transitions.value(this.route) &&
this.route.meta.transition.enterTransition
? "pure-classes-transition"
: (transitions.value(this.route) &&
this.route.meta.transition.name) ||
"fade-transform",
enterActiveClass:
transitions.value(this.route) &&
`animate__animated ${this.route.meta.transition.enterTransition}`,
leaveActiveClass:
transitions.value(this.route) &&
`animate__animated ${this.route.meta.transition.leaveTransition}`,
mode: "out-in",
appear: true
},
{
default: () => [this.$slots.default()]
}
);
},
props: {
route: {
type: undefined,
required: true
}
}
});
</script>
<template>
<section
:class="[props.fixedHeader ? 'app-main' : 'app-main-nofixed-header']"
:style="getSectionStyle"
>
<router-view>
<template #default="{ Component, route }">
<el-scrollbar v-if="props.fixedHeader">
<el-backtop title="回到顶部" target=".app-main .el-scrollbar__wrap">
<backTop />
</el-backtop>
<transitionMain :route="route">
<keep-alive
v-if="keepAlive"
:include="usePermissionStoreHook().cachePageList"
>
<component
:is="Component"
:key="route.fullPath"
class="main-content"
/>
</keep-alive>
<component
v-else
:is="Component"
:key="route.fullPath"
class="main-content"
/>
</transitionMain>
</el-scrollbar>
<div v-else>
<transitionMain :route="route">
<keep-alive
v-if="keepAlive"
:include="usePermissionStoreHook().cachePageList"
>
<component
:is="Component"
:key="route.fullPath"
class="main-content"
/>
</keep-alive>
<component
v-else
:is="Component"
:key="route.fullPath"
class="main-content"
/>
</transitionMain>
</div>
</template>
</router-view>
</section>
</template>
<style scoped>
.app-main {
position: relative;
width: 100%;
height: 100vh;
overflow-x: hidden;
}
.app-main-nofixed-header {
position: relative;
width: 100%;
min-height: 100vh;
}
.main-content {
margin: 24px;
}
</style>
@@ -0,0 +1,143 @@
<script setup lang="ts">
import Search from "./search/index.vue";
import Notice from "./notice/index.vue";
import mixNav from "./sidebar/mixNav.vue";
import { useNav } from "@/layout/hooks/useNav";
import Breadcrumb from "./sidebar/breadCrumb.vue";
import topCollapse from "./sidebar/topCollapse.vue";
import LogoutCircleRLine from "@iconify-icons/ri/logout-circle-r-line";
import Setting from "@iconify-icons/ri/settings-3-line";
const {
layout,
device,
logout,
userProfile,
onPanel,
pureApp,
username,
userAvatar,
avatarsStyle,
toggleSideBar
} = useNav();
</script>
<template>
<div
class="navbar bg-[#fff] shadow-sm shadow-[rgba(0, 21, 41, 0.08)] dark:shadow-[#0d0d0d]"
>
<topCollapse
v-if="device === 'mobile'"
class="hamburger-container"
:is-active="pureApp.sidebar.opened"
@toggleClick="toggleSideBar"
/>
<Breadcrumb
v-if="layout !== 'mix' && device !== 'mobile'"
class="breadcrumb-container"
/>
<mixNav v-if="layout === 'mix'" />
<div v-if="layout === 'vertical'" class="vertical-header-right">
<!-- 菜单搜索 -->
<Search />
<!-- 通知 -->
<Notice 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="userProfile">
<IconifyIconOffline
:icon="LogoutCircleRLine"
style="margin: 5px"
/>
个人中心
</el-dropdown-item>
</el-dropdown-menu>
<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 {
max-width: 120px;
::v-deep(.el-dropdown-menu__item) {
display: inline-flex;
flex-wrap: wrap;
min-width: 100%;
}
}
</style>
@@ -0,0 +1,146 @@
export interface ListItem {
avatar: string;
title: string;
datetime: string;
type: string;
description: string;
status?: "" | "success" | "warning" | "info" | "danger";
extra?: string;
}
export interface TabItem {
key: string;
name: string;
list: ListItem[];
}
export const noticesData: TabItem[] = [
{
key: "1",
name: "通知",
list: [
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
title: "你收到了 12 份新周报",
datetime: "一年前",
description: "",
type: "1"
},
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png",
title: "你推荐的 前端高手 已通过第三轮面试",
datetime: "一年前",
description: "",
type: "1"
},
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/kISTdvpyTAhtGxpovNWd.png",
title: "这种模板可以区分多种通知类型",
datetime: "一年前",
description: "",
type: "1"
},
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png",
title:
"展示标题内容超过一行后的处理方式,如果内容超过1行将自动截断并支持tooltip显示完整标题。",
datetime: "一年前",
description: "",
type: "1"
},
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png",
title: "左侧图标用于区分不同的类型",
datetime: "一年前",
description: "",
type: "1"
},
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png",
title: "左侧图标用于区分不同的类型",
datetime: "一年前",
description: "",
type: "1"
}
]
},
{
key: "2",
name: "消息",
list: [
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg",
title: "李白 评论了你",
description: "长风破浪会有时,直挂云帆济沧海",
datetime: "一年前",
type: "2"
},
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg",
title: "李白 回复了你",
description: "行路难,行路难,多歧路,今安在。",
datetime: "一年前",
type: "2"
},
{
avatar:
"https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg",
title: "标题",
description:
"请将鼠标移动到此处,以便测试超长的消息在此处将如何处理。本例中设置的描述最大行数为2,超过2行的描述内容将被省略并且可以通过tooltip查看完整内容",
datetime: "一年前",
type: "2"
}
]
},
{
key: "3",
name: "代办",
list: [
{
avatar: "",
title: "任务名称",
description: "任务需要在 2022-11-16 20:00 前启动",
datetime: "",
extra: "未开始",
status: "info",
type: "3"
},
{
avatar: "",
title: "第三方紧急代码变更",
description:
"一拳提交于 2022-11-16,需在 2022-11-18 前完成代码变更任务",
datetime: "",
extra: "马上到期",
status: "danger",
type: "3"
},
{
avatar: "",
title: "信息安全考试",
description: "指派小仙于 2022-12-12 前完成更新并发布",
datetime: "",
extra: "已耗时 8 天",
status: "warning",
type: "3"
},
{
avatar: "",
title: "vue-pure-admin 版本发布",
description: "vue-pure-admin 版本发布",
datetime: "",
extra: "进行中",
type: "3"
}
]
}
];
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref } from "vue";
import { noticesData } from "./data";
import NoticeList from "./noticeList.vue";
import Bell 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));
</script>
<template>
<el-dropdown trigger="click" placement="bottom-end">
<span class="dropdown-badge navbar-bg-hover select-none">
<el-badge :value="noticesNum" :max="99">
<span class="header-notice-icon">
<IconifyIconOffline :icon="Bell" />
</span>
</el-badge>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-tabs
:stretch="true"
v-model="activeKey"
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="`${item.name}(${item.list.length})`"
:name="`${item.key}`"
>
<el-scrollbar max-height="330px">
<div class="noticeList-container">
<NoticeList :list="item.list" />
</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;
margin-right: 10px;
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>
@@ -0,0 +1,177 @@
<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";
const props = 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="props.noticeItem.avatar"
:size="30"
:src="props.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="props.noticeItem.title"
placement="top-start"
:enterable="!isMobile"
>
<div
ref="titleRef"
class="notice-title-content"
@mouseover="hoverTitle"
>
{{ props.noticeItem.title }}
</div>
</el-tooltip>
<el-tag
v-if="props.noticeItem?.extra"
:type="props.noticeItem?.status"
size="small"
class="notice-title-extra"
>
{{ props.noticeItem?.extra }}
</el-tag>
</div>
<el-tooltip
popper-class="notice-title-popper"
:effect="tooltipEffect"
:disabled="!descriptionTooltip"
:content="props.noticeItem.description"
placement="top-start"
>
<div
ref="descriptionRef"
class="notice-text-description"
@mouseover="hoverDescription($event, props.noticeItem.description)"
>
{{ props.noticeItem.description }}
</div>
</el-tooltip>
<div class="notice-text-datetime text-[#00000073] dark:text-white">
{{ props.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>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { PropType } from "vue";
import { ListItem } from "./data";
import NoticeItem from "./noticeItem.vue";
const props = defineProps({
list: {
type: Array as PropType<Array<ListItem>>,
default: () => []
}
});
</script>
<template>
<div v-if="props.list.length">
<NoticeItem
v-for="(item, index) in props.list"
:noticeItem="item"
:key="index"
/>
</div>
<el-empty v-else description="暂无数据" />
</template>
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { emitter } from "@/utils/mitt";
import { onClickOutside } from "@vueuse/core";
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import Close from "@iconify-icons/ep/close";
const target = ref(null);
const show = ref<Boolean>(false);
const iconClass = computed(() => {
return [
"mr-[20px]",
"outline-none",
"width-[20px]",
"height-[20px]",
"rounded-[4px]",
"cursor-pointer",
"transition-colors",
"hover:bg-[#0000000f]",
"dark:hover:bg-[#ffffff1f]",
"dark:hover:text-[#ffffffd9]"
];
});
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: show }" class="right-panel-container">
<div class="right-panel-background" />
<div ref="target" class="right-panel bg-bg_color">
<div class="right-panel-items">
<div class="project-configuration">
<h4 class="dark:text-white">项目配置</h4>
<span title="关闭配置" :class="iconClass">
<IconifyIconOffline
class="dark:text-white"
width="20px"
height="20px"
:icon="Close"
@click="show = !show"
/>
</span>
</div>
<div
class="border-b-[1px] border-solid border-[#dcdfe6] dark:border-[#303030]"
/>
<slot />
</div>
</div>
</div>
</template>
<style>
.showright-panel {
position: relative;
width: calc(100% - 15px);
overflow: hidden;
}
</style>
<style lang="scss" scoped>
.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: 315px;
height: 100vh;
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);
}
}
.handle-button {
position: absolute;
top: 45%;
left: -48px;
z-index: 0;
width: 48px;
height: 48px;
font-size: 24px;
line-height: 48px;
color: #fff;
text-align: center;
pointer-events: auto;
cursor: pointer;
background: rgb(24 144 255);
border-radius: 6px 0 0 6px !important;
i {
font-size: 24px;
line-height: 48px;
}
}
.right-panel-items {
height: calc(100vh - 60px);
margin-top: 60px;
overflow-y: auto;
}
.project-configuration {
position: fixed;
top: 15px;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 30px;
margin-left: 10px;
}
:deep(.el-divider--horizontal) {
width: 90%;
margin: 20px auto 0;
}
</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import ArrowUpLine from "@iconify-icons/ri/arrow-up-line";
import ArrowDownLine from "@iconify-icons/ri/arrow-down-line";
import { useNav } from "@/layout/hooks/useNav";
import mdiKeyboardEsc from "@/assets/svg/keyboard_esc.svg?component";
import enterOutlined from "@/assets/svg/enter_outlined.svg?component";
const props = 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' && props.total > 0"
class="search-footer-total"
>
{{ props.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>
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { match } from "pinyin-pro";
import { useRouter } from "vue-router";
import SearchResult from "./SearchResult.vue";
import SearchFooter from "./SearchFooter.vue";
import { useNav } from "@/layout/hooks/useNav";
import { ref, computed, shallowRef } from "vue";
import { cloneDeep, isAllEmpty } from "@pureadmin/utils";
import { useDebounceFn, onKeyStroke } from "@vueuse/core";
import { usePermissionStoreHook } from "@/store/modules/permission";
import Search 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 keyword = ref("");
const scrollbarRef = ref();
const resultRef = ref();
const activePath = ref("");
const inputRef = ref<HTMLInputElement | null>(null);
const resultOptions = shallowRef([]);
const handleSearch = useDebounceFn(search, 300);
/** 菜单树形结构 */
const menusData = computed(() => {
return cloneDeep(usePermissionStoreHook().wholeMenus);
});
const show = computed({
get() {
return props.value;
},
set(val: boolean) {
emit("update:value", val);
}
});
/** 将菜单树形结构扁平化为一维数组,用于菜单查询 */
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
);
if (resultOptions.value?.length > 0) {
activePath.value = resultOptions.value[0].path;
} else {
activePath.value = "";
}
}
function handleClose() {
show.value = false;
/** 延时处理防止用户看到某些操作 */
setTimeout(() => {
resultOptions.value = [];
keyword.value = "";
}, 200);
}
function scrollTo(index) {
const scrollTop = resultRef.value.handleScroll(index);
scrollbarRef.value.setScrollTop(scrollTop);
}
/** key up */
function handleUp() {
const { length } = resultOptions.value;
if (length === 0) return;
const index = resultOptions.value.findIndex(
item => item.path === activePath.value
);
if (index === 0) {
activePath.value = resultOptions.value[length - 1].path;
scrollTo(resultOptions.value.length - 1);
} else {
activePath.value = resultOptions.value[index - 1].path;
scrollTo(index - 1);
}
}
/** key down */
function handleDown() {
const { length } = resultOptions.value;
if (length === 0) return;
const index = resultOptions.value.findIndex(
item => item.path === activePath.value
);
if (index + 1 === length) {
activePath.value = resultOptions.value[0].path;
} else {
activePath.value = resultOptions.value[index + 1].path;
}
scrollTo(index + 1);
}
/** key enter */
function handleEnter() {
const { length } = resultOptions.value;
if (length === 0 || activePath.value === "") return;
router.push(activePath.value);
handleClose();
}
onKeyStroke("Enter", handleEnter);
onKeyStroke("ArrowUp", handleUp);
onKeyStroke("ArrowDown", handleDown);
</script>
<template>
<el-dialog
top="5vh"
class="pure-search-dialog"
v-model="show"
: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"
size="large"
v-model="keyword"
clearable
placeholder="搜索菜单"
@input="handleSearch"
>
<template #prefix>
<IconifyIconOffline
:icon="Search"
class="text-primary w-[24px] h-[24px]"
/>
</template>
</el-input>
<div class="search-result-container">
<el-scrollbar ref="scrollbarRef" max-height="calc(90vh - 140px)">
<el-empty
v-if="resultOptions.length === 0"
description="暂无搜索结果"
/>
<SearchResult
v-else
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-result-container {
margin-top: 12px;
}
</style>
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { useResizeObserver } from "@vueuse/core";
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";
import Bookmark2Line from "@iconify-icons/ri/bookmark-2-line";
interface optionsItem {
path: string;
meta?: {
icon?: string;
title?: string;
};
}
interface Props {
value: string;
options: Array<optionsItem>;
}
interface Emits {
(e: "update:value", val: string): void;
(e: "enter"): void;
}
const resultRef = ref();
const innerHeight = ref();
const props = withDefaults(defineProps<Props>(), {});
const emit = defineEmits<Emits>();
const instance = getCurrentInstance()!;
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 ?? Bookmark2Line)" />
<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: all 0.3s;
&-title {
display: flex;
flex: 1;
margin-left: 5px;
}
}
}
</style>
@@ -0,0 +1,3 @@
import SearchModal from "./SearchModal.vue";
export { SearchModal };

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