This commit is contained in:
eibons
2025-08-15 20:58:57 +08:00
parent 694842a4a0
commit 49ac45ca05
730 changed files with 68015 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
# 项目背景
- 库typescript、javaScript、scss、vue、tailwind
- 框架cool-admin-vue
- 项目版本8.x
# 项目目录
├── .vscode(代码片段,根据关键字可以快速地生成代码)
├── public(静态资源文件)
├── packages(源码包:@cool-vue/crud、@cool-vue/vite-plugin)
├── build
│ └── cool()
│ │ └── eps.json(Eps 配置文件)
│ │ └── eps.d.ts(Eps 描述文件)
├── src
│ └── cool(核心文件)
│ └── modules(项目模块)
│ │ └── base(基础模块)
│ │ └── demo(示例模块)
│ │ └── dict(字典模块)
│ │ └── helper(辅助模块)
│ │ └── recycle(回收站模块)
│ │ └── space(cl-upload-space 文件空间模块)
│ │ └── task(任务模块)
│ │ └── user(用户模块)
│ └── plugins(项目插件)
│ │ └── crud(cl-crud、@cool-vue/crud)
│ │ └── distpicker(cl-distpicker、省市区选择器)
│ │ └── echarts(图标)
│ │ └── editor-preview(编辑器预览组件)
│ │ └── editor-wange(wang富文本编辑器)
│ │ └── element-ui(element-plus 组件)
│ │ └── excel(excel导入、导出组件)
│ │ └── i18n(多语言)
│ │ └── iconfont(iconfont 图标)
│ │ └── theme(cl-theme 主题组件)
│ │ └── upload(cl-upload 文件上传组件)
│ │ └── view(cl-view-group、cl-view-head 视图组件)
│ └── config
│ │ └── index.ts(默认配置)
│ │ └── dev.ts(开发环境)
│ │ └── prod.ts(生产环境)
│ │ └── proxy.ts(代理配置)
│ └── App.vue(入口文件)
│ └── main.ts(入口文件)
├── package.json(依赖管理,项目信息)
└── ...
模块、插件目录
├── modules/plugins
│ └── base(模块名)
│ │ └── components(全局组件)
│ │ └── directives(全局指令)
│ │ └── locales(国际化)
│ │ └── router(路由)
│ │ └── store(状态管理)
│ │ └── utils(工具函数)
│ │ └── views(视图)
│ │ └── config.ts(必须,模块的配置)
│ │ └── index.ts(模块导出)
# 其它
- 文件、组件命名用 - 连接student-info.vue
- service 的描述类型,查看 build/cool/eps.d.ts 描述文件
- 创建模块、插件代码需要读取.cursor/rules的module.mdc其它的rules根据需要进行参考
# import 引用别名
- "/@" 对应 "./src"
- "/$" 对应 "./src/modules"
- "/#" 对应 "./src/plugins"
- "/~" 对应 "./packages"

View File

@@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
dist-ssr
*.local

View File

@@ -0,0 +1,7 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
end_of_line = lf
indent_style = tab
indent_size = 4
trim_trailing_whitespace = true
insert_final_newline = true

5
cool-admin-vue/.env Normal file
View File

@@ -0,0 +1,5 @@
# 应用名称
VITE_NAME = "COOL-ADMIN"
# 网络超时请求时间
VITE_TIMEOUT = 30000

4
cool-admin-vue/.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
*.js text eol=lf
*.json text eol=lf
*.ts text eol=lf
*.vue text eol=lf

20
cool-admin-vue/.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
.DS_Store
node_modules/
/dist/
dist-ssr/
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.project
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
vite.config.ts.timestamp*

16
cool-admin-vue/.hintrc Normal file
View File

@@ -0,0 +1,16 @@
{
"extends": [
"development"
],
"hints": {
"meta-viewport": "off",
"axe/text-alternatives": [
"default",
{
"document-title": "off"
}
],
"disown-opener": "off",
"css-prefix-order": "off"
}
}

View File

@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": true,
"useTabs": true,
"tabWidth": 4,
"printWidth": 100,
"singleQuote": true,
"arrowParens": "avoid",
"trailingComma": "none"
}

14
cool-admin-vue/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:lts-alpine
WORKDIR /build
# 设置npm镜像
RUN npm config set registry https://registry.npmmirror.com
COPY package.json /build/package.json
RUN npm install
COPY ./ /build
RUN npm run build
FROM nginx
RUN mkdir /app
COPY --from=0 /build/dist /app
COPY --from=0 /build/nginx.conf /etc/nginx/nginx.conf
EXPOSE 80

33
cool-admin-vue/LICENSE Normal file
View File

@@ -0,0 +1,33 @@
MIT License
Copyright (c) [2025] [厦门闪酷科技开发有限公司]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
MIT 许可证
版权所有 (c) [2025] [厦门闪酷科技开发有限公司]
特此免费授予获得本软件及相关文档文件(“软件”)副本的任何人无限制地处理本软件的权限,包括但不限于使用、复制、修改、合并、发布、分发、再许可和/或销售软件的副本,并允许软件提供给其的人员这样做,但须符合以下条件:
上述版权声明和本许可声明应包含在软件的所有副本或主要部分中。
本软件按“原样”提供,不提供任何明示或暗示的担保,包括但不限于对适销性、特定用途适用性和非侵权的担保。在任何情况下,作者或版权持有人均不对因软件或软件使用或其他交易而产生的任何索赔、损害或其他责任负责,无论是在合同诉讼、侵权诉讼或其他诉讼中。

78
cool-admin-vue/README.md Normal file
View File

@@ -0,0 +1,78 @@
# cool-admin [vue3 - ts - vite]
<p align="center">
<a href="https://show.cool-admin.com/" target="blank"><img src="https://admin.cool-js.com/logo.png" width="200" alt="cool-admin Logo" /></a>
</p>
<p align="center">cool-admin 一个很酷的后台权限管理系统,开源免费,模块化、插件化、极速开发 CRUD方便快速构建迭代后台管理系统<a href="https://cool-js.com" target="_blank">文档</a> 进一步了解</p>
<p align="center">
<a href="https://github.com/cool-team-official/cool-admin-vue/blob/master/LICENSE" target="_blank"><img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="GitHub license" />
<a href=""><img src="https://img.shields.io/github/package-json/v/cool-team-official/cool-admin-vue?style=flat-square" alt="GitHub tag"></a>
<img src="https://img.shields.io/github/last-commit/cool-team-official/cool-admin-vue?style=flat-square" alt="GitHub tag"></a>
</p>
## 特性
Ai时代很多老旧的框架已经无法满足现代化的开发需求Cool-Admin开发了一系列的功能让开发变得更简单、更快速、更高效。
- **Ai编码**通过微调大模型学习框架特有写法实现简单功能从Api接口到前端页面的一键生成
- **流程编排**:通过拖拽编排方式,即可实现类似像智能客服这样的功能
- **模块化**:代码是模块化的,清晰明了,方便维护
- **插件化**:插件化的设计,可以通过安装插件的方式扩展如:支付、短信、邮件等功能
![](https://cool-show.oss-cn-shanghai.aliyuncs.com/admin/flow.png)
## 地址
- [📌 v7 版本](https://github.com/cool-team-official/cool-admin-vue/tree/7.x)
- [🌐 码云仓库](https://gitee.com/cool-team-official/cool-admin-vue)
## 视频教程
[官方 B 站视频教程](https://www.bilibili.com/video/BV1j1421R7aB)
## 演示
[https://show.cool-admin.com](https://show.cool-admin.com)
账户admin密码123456
<img src="https://cool-show.oss-cn-shanghai.aliyuncs.com/admin/home-mini.png" alt="Admin Home" ></a>
## 项目后端
[https://github.com/cool-team-official/cool-admin-midway](https://github.com/cool-team-official/cool-admin-midway)
[https://gitee.com/cool-team-official/cool-admin-midway](https://gitee.com/cool-team-official/cool-admin-midway)
[https://gitcode.com/cool_team/cool-admin-midway](https://gitcode.com/cool_team/cool-admin-midway)
## 微信群
<img width="260" src="https://cool-show.oss-cn-shanghai.aliyuncs.com/admin/wechat.jpeg" alt="Admin Wechat"></a>
## 安装项目依赖
推荐使用 `pnpm`
```shell
pnpm i
```
## 运行应用程序
安装过程完成后,运行以下命令启动服务。您可以在浏览器中预览网站 [http://localhost:9000](http://localhost:9000)
```shell
pnpm dev
```
### 低价服务器
[阿里云、腾讯云、华为云低价云服务器,不限新老](https://cool-js.com/service/cloud)

10
cool-admin-vue/env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_NAME: string;
readonly VITE_TIMEOUT: number;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,73 @@
import pluginVue from 'eslint-plugin-vue';
import vueTsEslintConfig from '@vue/eslint-config-typescript';
import prettier from 'eslint-plugin-prettier';
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting';
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: [
'**/dist/**',
'**/dist-ssr/**',
'**/coverage/**',
'**/packages/**',
'**/build/**',
],
},
...pluginVue.configs['flat/recommended'],
...vueTsEslintConfig(),
skipFormatting,
{
languageOptions: {
parserOptions: {
ecmaVersion: 2020,
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
'@typescript-eslint/ban-ts-ignore': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'space-before-function-paren': 'off',
'no-unused-vars': 'off',
'no-use-before-define': 'off',
'no-self-assign': 'off',
'vue/no-mutating-props': 'off',
'vue/no-template-shadow': 'off',
'vue/no-v-html': 'off',
'vue/component-name-in-template-casing': ['error', 'kebab-case'],
'vue/component-definition-name-casing': ['error', 'kebab-case'],
'vue/attributes-order': 'off',
'vue/one-component-per-file': 'off',
'vue/html-closing-bracket-newline': 'off',
'vue/max-attributes-per-line': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/multi-word-component-names': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/attribute-hyphenation': 'off',
'vue/html-self-closing': 'off',
'vue/require-default-prop': 'off',
'vue/v-on-event-hyphenation': 'off',
'vue/block-lang': 'off',
},
},
];

178
cool-admin-vue/index.html Normal file
View File

@@ -0,0 +1,178 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="referer" content="never" />
<meta name="renderer" content="webkit" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=0"
/>
<title></title>
<link rel="icon" href="./favicon.ico" />
<style>
html,
body,
#app {
height: 100%;
overflow: hidden;
}
* {
-webkit-font-smoothing: antialiased;
font-family:
'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
}
.preload__wrap {
display: flex;
flex-direction: column;
letter-spacing: 1px;
background-color: #2f3447;
position: fixed;
left: 0;
top: 0;
height: 100%;
width: 100%;
z-index: 9999;
transition: all 0.3s ease-in;
opacity: 1;
pointer-events: none;
}
.preload__wrap.is-hide {
opacity: 0;
}
.preload__container {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
width: 100%;
user-select: none;
-webkit-user-select: none;
flex-grow: 1;
}
.preload__name {
font-size: 30px;
color: #fff;
letter-spacing: 5px;
font-weight: bold;
margin-bottom: 30px;
min-height: 50px;
}
.preload__title {
color: #fff;
font-size: 14px;
margin: 30px 0 20px 0;
min-height: 20px;
}
.preload__sub-title {
color: #ababab;
font-size: 12px;
min-height: 20px;
}
.preload__name,
.preload__title,
.preload__sub-title {
animation: s 0.5s ease-in;
}
@keyframes s {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.preload__loading {
height: 44px;
width: 44px;
border-radius: 30px;
border: 7px solid currentColor;
border-bottom-color: #2f3447;
position: relative;
animation:
r 1s infinite cubic-bezier(0.17, 0.67, 0.83, 0.67),
bc 2s infinite ease-in;
transform: rotate(0deg);
box-sizing: border-box;
}
@keyframes r {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.preload__loading::after,
.preload__loading::before {
content: '';
display: inline-block;
position: absolute;
bottom: -2px;
height: 7px;
width: 7px;
border-radius: 10px;
background-color: currentColor;
}
.preload__loading::after {
left: -1px;
}
.preload__loading::before {
right: -1px;
}
@keyframes bc {
0% {
color: #689cc5;
}
25% {
color: #b3b7e2;
}
50% {
color: #93dbe9;
}
75% {
color: #abbd81;
}
100% {
color: #689cc5;
}
}
</style>
</head>
<body>
<div class="preload__wrap" id="Loading">
<div class="preload__container">
<div class="preload__name"></div>
<div class="preload__loading"></div>
<div class="preload__title"></div>
<div class="preload__sub-title"></div>
</div>
</div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

72
cool-admin-vue/nginx.conf Normal file
View File

@@ -0,0 +1,72 @@
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
upstream cool {
server midway:8001;
}
server {
listen 80;
server_name localhost;
location / {
root /app;
index index.html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://cool/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
#缓存相关配置
#proxy_cache cache_one;
#proxy_cache_key $host$request_uri$is_args$args;
#proxy_cache_valid 200 304 301 302 1h;
#持久化连接相关配置
proxy_connect_timeout 3000s;
proxy_read_timeout 86400s;
proxy_send_timeout 3000s;
#proxy_http_version 1.1;
#proxy_set_header Upgrade $http_upgrade;
#proxy_set_header Connection "upgrade";
add_header X-Cache $upstream_cache_status;
#expires 12h;
}
# socket需额外配置
location /socket {
proxy_pass http://cool/socket;
proxy_connect_timeout 3600s; #配置点1
proxy_read_timeout 3600s; #配置点2,如果没效,可以考虑这个时间配置长一点
proxy_send_timeout 3600s; #配置点3
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
#proxy_bind $remote_addr transparent;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
rewrite /socket/(.*) /$1 break;
proxy_redirect off;
}
}
}

8907
cool-admin-vue/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
{
"name": "cool-admin-vue",
"version": "8.0.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"build-static": "vite build --mode static",
"build-demo": "vite build --mode demo",
"preview": "vite preview",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"@cool-vue/crud": "^8.0.6",
"@element-plus/icons-vue": "^2.3.1",
"@vueuse/core": "^12.5.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.7.9",
"chardet": "^2.0.0",
"core-js": "^3.40.0",
"dayjs": "^1.11.13",
"echarts": "^5.6.0",
"element-plus": "2.10.2",
"file-saver": "^2.0.5",
"lodash-es": "^4.17.21",
"marked": "^14.1.3",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
"pinia": "^2.3.1",
"store": "^2.0.12",
"vue": "^3.5.13",
"vue-echarts": "^7.0.3",
"vue-i18n": "^11.0.1",
"vue-router": "^4.5.0",
"vuedraggable": "^4.1.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@cool-vue/vite-plugin": "^8.2.2",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@rushstack/eslint-patch": "^1.10.5",
"@tsconfig/node20": "^20.1.4",
"@types/file-saver": "^2.0.7",
"@types/lodash-es": "^4.17.12",
"@types/mockjs": "^1.0.10",
"@types/node": "^20.17.17",
"@types/nprogress": "^0.2.3",
"@types/store": "^2.0.5",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@vue/compiler-sfc": "^3.5.13",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.19.0",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-vue": "^9.32.0",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"rollup-plugin-visualizer": "^5.14.0",
"sass": "1.81.0",
"tailwindcss": "^3.4.17",
"terser": "^5.36.0",
"typescript": "~5.5.4",
"vite": "^5.4.14",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "^7.7.1",
"vue-tsc": "^2.2.0"
}
}

5655
cool-admin-vue/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- core-js
- es5-ext
- esbuild
- vue-demi

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -0,0 +1,8 @@
<template>
<router-view />
<cool />
</template>
<script setup lang="ts">
import Cool from '/@/cool/index.vue';
</script>

View File

@@ -0,0 +1,9 @@
import { host, value } from './proxy';
export default {
// 根地址
host,
// 请求地址
baseUrl: `/${value}`
};

View File

@@ -0,0 +1,62 @@
import { storage } from '../cool';
import dev from './dev';
import prod from './prod';
// 是否开发模式
export const isDev = import.meta.env.DEV;
// 配置
export const config = {
// 项目信息
app: {
name: import.meta.env.VITE_NAME,
// 菜单
menu: {
// 是否分组显示
isGroup: false,
// 自定义菜单列表
list: []
},
// 路由
router: {
// 模式
mode: import.meta.env.MODE == 'static' ? 'hash' : 'history',
// 转场动画
transition: 'slide'
}
},
// 国际化配置
i18n: {
locale: storage.get('locale') || 'zh-cn',
languages: [
{
label: '中文',
value: 'zh-cn'
},
{
label: '繁体中文',
value: 'zh-tw'
},
{
label: 'English',
value: 'en'
}
]
},
// 忽略规则
ignore: {
// 不显示请求进度条
NProgress: ['__cool_*'],
// 页面不需要登录验证
token: []
},
// 当前环境
...(isDev ? dev : prod)
};
export * from './proxy';

View File

@@ -0,0 +1,17 @@
import { proxy } from './proxy';
export default {
// 根地址
host: proxy['/prod/'].target,
// 请求地址
get baseUrl() {
const mode = import.meta.env.MODE;
if (mode == 'static') {
return location.origin;
} else {
return '/api';
}
}
};

View File

@@ -0,0 +1,18 @@
const proxy = {
'/dev/': {
target: 'http://127.0.0.1:8001',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/dev/, '')
},
'/prod/': {
target: 'https://show.cool-admin.com',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/prod/, '/api')
}
};
const value = 'dev';
const host = proxy[`/${value}/`]?.target;
export { proxy, host, value };

View File

@@ -0,0 +1,62 @@
import { merge } from 'lodash-es';
import { BaseService, service } from '../service';
import { isDev } from '/@/config';
import { eps } from 'virtual:eps';
import { hmr } from '../hooks';
export function createEps() {
// 设置 request 方法
function set(d: any) {
if (d.namespace) {
const a = new BaseService(d.namespace);
for (const i in d) {
const { path, method = 'get' } = d[i];
if (path) {
a.request = a.request;
a[i] = function (data?: any) {
return this.request({
url: path,
method,
[method.toLocaleLowerCase() == 'post' ? 'data' : 'params']: data
});
};
}
}
for (const i in a) {
d[i] = a[i];
}
} else {
for (const i in d) {
set(d[i]);
}
}
}
// 遍历每一个方法
set(eps.service);
// 合并 eps
merge(service, eps.service);
// 热更新处理
hmr.setData('service', service);
// 提示
if (isDev) {
console.log('[cool-eps] updated');
}
}
// 监听 vite 触发事件
if (import.meta.hot) {
import.meta.hot.on('eps-update', ({ service }) => {
if (service) {
eps.service = service;
}
createEps();
});
}

View File

@@ -0,0 +1,24 @@
import { createPinia } from 'pinia';
import { type App } from 'vue';
import { createModule } from './module';
import { router } from '../router';
import { Loading } from '../utils';
import { createEps } from './eps';
import 'virtual:svg-register';
export async function bootstrap(app: App) {
// pinia
app.use(createPinia());
// 路由
app.use(router);
// 模块
const { eventLoop } = createModule(app);
// eps
createEps();
// 加载
Loading.set([eventLoop()]);
}

View File

@@ -0,0 +1,126 @@
import { type App, type Directive } from 'vue';
import { assign, isFunction, orderBy, mergeWith } from 'lodash-es';
import { filename } from '../utils';
import { module } from '../module';
import { hmr } from '../hooks';
import { config } from '/@/config';
// 扫描文件
const files = import.meta.glob('/src/{modules,plugins}/*/{config.ts,service/**,directives/**}', {
eager: true,
import: 'default'
});
// 模块列表
module.list = hmr.getData('modules', []);
// 解析
for (const i in files) {
// 分割
const [, , type, name, action] = i.split('/');
// 文件名
const n = filename(i);
// 文件内容
const v = files[i];
// 模块是否存在
const m = module.get(name);
// 数据
const d = m || {
name,
type,
value: null,
services: [],
directives: []
};
// 配置
if (action == 'config.ts') {
d.value = v;
}
// 服务
else if (action == 'service') {
const s = new (v as any)();
if (s) {
d.services?.push({
path: s.namespace,
value: s
});
}
}
// 指令
else if (action == 'directives') {
d.directives?.push({ name: n, value: v as Directive });
}
if (!m) {
module.add(d);
}
}
// 创建
export function createModule(app: App) {
// 排序
module.list.forEach(e => {
const d = isFunction(e.value) ? e.value(app) : e.value;
if (d) {
assign(e, d);
}
if (!d.order) {
e.order = 0;
}
});
const list = orderBy(module.list, 'order', 'desc').map(e => {
if (e.enable !== false) {
// 初始化
e.install?.(app, e.options);
// 注册组件
e.components?.forEach(async (c: any) => {
const v = await (isFunction(c) ? c() : c);
const n = v.default || v;
if (n.name) {
app.component(n.name, n);
}
});
// 注册指令
e.directives?.forEach(v => {
app.directive(v.name, v.value);
});
// 合并忽略配置
config.ignore = mergeWith({}, config.ignore, e.ignore, (a, b) => a?.concat(b));
}
// 附加值
e.pages?.forEach(v => {
v.isPage = true;
});
return e;
});
return {
// 模块列表
list,
// 事件加载
async eventLoop() {
const events: any = {};
for (let i = 0; i < list.length; i++) {
if (list[i].onLoad) {
assign(events, await list[i]?.onLoad?.(events));
}
}
}
};
}

View File

@@ -0,0 +1,41 @@
import { useEventListener } from '@vueuse/core';
import { reactive, watch } from 'vue';
import { getBrowser } from '../utils';
// 使用 reactive 创建一个响应式的浏览器对象
const browser = reactive(getBrowser());
// 存储屏幕变化事件的回调函数数组
const events: (() => void)[] = [];
// 监听浏览器屏幕属性的变化
watch(
() => browser.screen, // 监听的属性
() => {
// 当屏幕属性变化时,执行所有注册的回调函数
events.forEach(ev => ev());
}
);
// 监听窗口的 resize 事件,并更新浏览器对象
useEventListener(window, 'resize', () => {
// 使用 Object.assign 更新响应式对象的属性
Object.assign(browser, getBrowser());
});
// 导出一个自定义的 hook
export function useBrowser() {
return {
browser, // 返回响应式的浏览器对象
// 注册屏幕变化的回调函数
onScreenChange(ev: () => void, immediate = true) {
// 将回调函数添加到事件数组中
events.push(ev);
// 如果 immediate 为 true立即执行回调函数
if (immediate) {
ev();
}
}
};
}

View File

@@ -0,0 +1,32 @@
// 解决热更新后数据失效问题
// 初始化数据对象,如果热更新数据存在则使用它
const data = import.meta.hot?.data.getData?.() || {};
// 检查是否支持热更新
if (import.meta.hot) {
// 将当前数据存储函数赋值给热更新数据对象
import.meta.hot.data.getData = () => {
return data;
};
}
// 导出一个热更新模块对象
export const hmr = {
data, // 当前数据对象
// 设置数据的方法
setData(key: string, value: any) {
// 将指定键值对存入数据对象
data[key] = value;
},
// 获取数据的方法
getData(key: string, defaultValue?: any) {
// 如果指定键不存在且提供了默认值,则设置默认值
if (defaultValue !== undefined && !data[key]) {
this.setData(key, defaultValue);
}
// 返回指定键的值
return data[key];
}
};

View File

@@ -0,0 +1,58 @@
import { getCurrentInstance, type Ref, reactive } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { service } from '../service';
import { useBrowser } from './browser';
import { useMitt } from './mitt';
// 创建一个响应式的 refs 对象,并提供设置 refs 的方法
export function useRefs() {
const refs = reactive<{ [key: string]: any }>({});
// 设置 refs 的方法,返回一个函数用于更新特定 ref
function setRefs(name: string) {
return (el: any) => {
refs[name] = el;
return () => refs[name]; // 返回一个函数用于获取当前 ref
};
}
return { refs, setRefs };
}
// 获取指定名称的父组件实例,并将其暴露的属性赋值给传入的 Ref
export function useParent(name: string, r: Ref) {
const instance = getCurrentInstance();
if (instance) {
let parent = instance.proxy?.$.parent;
// 遍历父组件链,直到找到匹配的组件名称
while (parent && parent.type?.name !== name) {
parent = parent?.parent;
}
// 如果找到匹配的父组件,将其暴露的属性赋值给 Ref
if (parent && parent.type.name === name) {
r.value = parent.exposed;
}
}
return r;
}
// 组合多个功能模块,返回一个包含服务、路由、事件总线等的对象
export function useCool() {
return {
service,
route: useRoute(),
router: useRouter(),
mitt: useMitt(),
...useBrowser(),
...useRefs()
};
}
// 导出其他模块的功能
export * from './browser';
export * from './hmr';
export * from './mitt';

View File

@@ -0,0 +1,9 @@
import Mitt, { type Emitter } from 'mitt';
import { hmr } from './hmr';
export const mitt: Emitter<any> = hmr.getData('mitt', Mitt());
// 返回 mitt 实例,用于在应用中进行事件的发布和订阅
export function useMitt() {
return mitt;
}

View File

@@ -0,0 +1,7 @@
export * from './service';
export * from './bootstrap';
export * from './hooks';
export * from './module';
export * from './router';
export * from './types';
export { storage } from './utils';

View File

@@ -0,0 +1,39 @@
<template>
<div class="cool">
<component v-for="item in list" :key="item.name" :is="item.component" />
</div>
</template>
<script setup lang="ts">
import { isFunction, orderBy } from 'lodash-es';
import { markRaw, onMounted, shallowRef } from 'vue';
import { module } from '/@/cool';
const list = shallowRef<any[]>([]);
async function refresh() {
const arr = orderBy(
module.list.filter(e => e.enable !== false && !!e.index).map(e => e.index),
'order'
);
list.value = await Promise.all(
arr
.filter(e => e?.component)
.map(async e => {
if (e) {
const c = await (isFunction(e.component) ? e.component() : e.component);
return {
...e,
component: markRaw(c.default || c)
};
}
})
);
}
onMounted(() => {
refresh();
});
</script>

View File

@@ -0,0 +1,43 @@
import type { Module } from '../types';
import { hmr } from '../hooks';
import { ctx } from 'virtual:ctx';
// 获取模块列表,若不存在则初始化为空数组
const list: Module[] = hmr.getData('modules', []);
// 定义模块对象
const module = {
// 模块列表
list,
// 模块目录
dirs: ctx.modules,
// 请求对象,初始化为已解决的 Promise
req: Promise.resolve(),
// 根据名称获取模块
get(name: string): Module {
// 使用 find 方法查找模块,假设模块名称是唯一的
return this.list.find(e => e.name == name)!;
},
// 获取模块的配置选项
config(name: string) {
// 如果模块存在,返回其配置选项,否则返回空对象
return this.get(name).options || {};
},
// 添加新模块到列表中
add(data: Module) {
this.list.push(data);
},
// 返回请求对象
wait() {
return this.req;
}
};
// 导出模块对象
export { module };

View File

@@ -0,0 +1,247 @@
import { ElMessage } from 'element-plus';
import {
createRouter,
createRouterMatcher,
createWebHashHistory,
createWebHistory,
type RouteRecordRaw
} from 'vue-router';
import { type Router, storage, module } from '/@/cool';
import { isArray } from 'lodash-es';
import { useBase } from '/$/base';
import { Loading } from '../utils';
import { config, isDev } from '/@/config';
// 基本路径
const baseUrl = import.meta.env.BASE_URL;
// 扫描文件
const files = import.meta.glob(['/src/modules/*/{views,pages}/**/*', '!**/components']);
// 默认路由
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'index',
component: () => import('/$/base/pages/main/index.vue'),
children: []
},
{
path: '/:catchAll(.*)',
name: '404',
component: () => import('/$/base/pages/error/404.vue')
}
];
// 创建路由器
const router = createRouter({
history:
config.app.router.mode == 'history'
? createWebHistory(baseUrl)
: createWebHashHistory(baseUrl),
routes
}) as Router;
// 组件加载后
router.beforeResolve(() => {
Loading.close();
});
let lock = false;
// 错误监听
router.onError((error: Error) => {
if (!lock) {
lock = true;
// 显示错误信息
ElMessage.error(`页面存在错误:${error.message}`);
console.error(error);
// 如果是动态加载模块失败的错误,且非开发环境,则刷新页面
if (error.message?.includes('Failed to fetch dynamically imported module')) {
if (!isDev) {
window.location.reload();
}
}
// 短暂延迟后解锁,允许后续错误处理
setTimeout(() => {
lock = false;
}, 0);
}
});
// 添加视图,页面路由
router.append = function (routeData) {
if (!routeData) {
return false; // 如果没有路由数据,直接返回
}
// 确保 routeData 是数组
const routeList = isArray(routeData) ? routeData : [routeData];
routeList.forEach(route => {
if (!route.meta) {
route.meta = {}; // 初始化 meta 对象
}
// 如果没有指定组件路径
if (!route.component) {
const viewPath = route.viewPath;
if (viewPath) {
if (viewPath.startsWith('http')) {
// 如果是外部链接,使用 iframe 组件
route.meta.iframeUrl = viewPath;
route.component = () => import('/$/base/views/frame.vue');
} else {
// 从文件系统中动态导入组件
route.component = files['/src/' + viewPath.replace('cool/', '')];
}
} else if (!route.redirect) {
// 如果没有组件路径且没有重定向,默认重定向到 404
route.redirect = '/404';
}
}
// 支持 props 接收参数
route.props = true;
// 标记为动态添加的路由
route.meta.dynamic = true;
// 判断是页面还是视图,并添加到相应的路由
if (route.isPage || route.viewPath?.includes('/pages/')) {
router.addRoute(route);
} else {
router.addRoute('index', route);
}
});
};
// 删除路由
router.del = function (routeName) {
const allRoutes = router.getRoutes();
allRoutes.forEach(route => {
if (route.name === routeName) {
router.removeRoute(routeName); // 移除指定名称的路由
}
});
};
// 清空路由
router.clear = function () {
const allRoutes = router.getRoutes();
allRoutes.forEach(route => {
if (route.name && route.meta?.dynamic) {
router.removeRoute(route.name); // 移除所有动态添加的路由
}
});
};
// 找路由
router.find = function (path: string) {
const { menu } = useBase();
// 获取已注册的路由
const registeredRoutes = router.getRoutes();
// 构建路由列表,包括已注册的路由、菜单配置和模块自定义路由
const routeList: any[] = [
...registeredRoutes.map(route => ({
...route,
isReg: true
})),
...menu.routes,
...module.list.flatMap(module => (module.views || []).concat(module.pages || []))
];
let isRegistered = false;
let matchedRoute: (typeof routeList)[number] | undefined;
// 创建路由匹配器
const matcher = createRouterMatcher(routeList, {});
// 查找匹配的路由
matcher.getRoutes().find(route => {
const routeRegex = new RegExp(route.re);
if (routeRegex.test(path)) {
if (path === '/') {
// 如果路径是根路径,查找标记为首页的路由
matchedRoute = routeList.find(route => route.meta?.isHome);
} else {
// 否则查找路径匹配且名称不是 'index' 的路由
matchedRoute = routeList.find(
r => r.path === route.record.path && r.name !== 'index'
);
}
if (matchedRoute) {
isRegistered = !!matchedRoute.isReg; // 检查路由是否已注册
}
return true;
}
return false;
});
return {
route: matchedRoute,
isReg: isRegistered
};
};
// 路由守卫
router.beforeEach(async (to, from, next) => {
// 等待应用配置加载完
await Loading.wait();
// 获取用户和进程数据
const { user, process } = useBase();
// 查找路由信息
const { isReg, route } = router.find(to.path);
// 如果路由不存在
if (!route) {
next(user.token ? '/404' : '/login'); // 根据用户登录状态重定向
return;
}
// 如果路由未注册
if (!isReg) {
router.append(route); // 注册路由
next(to.fullPath); // 重定向到原路径
return;
}
// 如果用户已登录
if (user.token) {
if (to.path.includes('/login')) {
// 如果在登录页且 Token 未过期,重定向到首页
if (!storage.isExpired('token')) {
next('/');
return;
}
} else {
process.add(to); // 添加路由进程
}
} else {
// 清除用户信息
user.clear();
// 如果路径不在忽略 Token 验证的列表中,重定向到登录页
if (!config.ignore.token.some(ignorePath => to.path === ignorePath)) {
next('/login');
return;
}
}
next(); // 继续导航
});
export { router };

View File

@@ -0,0 +1,86 @@
import { config } from '/@/config';
import { request } from './request';
import { AxiosRequestConfig } from 'axios';
export class BaseService {
namespace?: string;
constructor(namespace?: string) {
if (namespace) {
this.namespace = namespace;
}
}
// 发送请求
async request(options: AxiosRequestConfig = {}) {
let url = options.url;
if (url && url.indexOf('http') < 0) {
if (this.namespace) {
url = this.namespace + url;
}
if (options.proxy !== false) {
url = config.baseUrl + '/' + url;
}
}
return request({
...options,
url
});
}
// 获取列表
async list(data: any) {
return this.request({
url: '/list',
method: 'POST',
data
});
}
// 分页查询
async page(data: any) {
return this.request({
url: '/page',
method: 'POST',
data
});
}
// 获取信息
async info(params: any) {
return this.request({
url: '/info',
params
});
}
// 更新数据
async update(data: any) {
return this.request({
url: '/update',
method: 'POST',
data
});
}
// 删除数据
async delete(data: any) {
return this.request({
url: '/delete',
method: 'POST',
data
});
}
// 添加数据
async add(data: any) {
return this.request({
url: '/add',
method: 'POST',
data
});
}
}

View File

@@ -0,0 +1,10 @@
import { hmr } from '../hooks';
import { BaseService } from './base';
// service 数据集合
export const service: Eps.Service = hmr.getData('service', {
request: new BaseService().request
});
export * from './base';
export * from './stream';

View File

@@ -0,0 +1,168 @@
import axios from 'axios';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import { ElMessage } from 'element-plus';
import { endsWith } from 'lodash-es';
import { storage } from '/@/cool/utils';
import { useBase } from '/$/base';
import { router } from '../router';
import { config, isDev } from '/@/config';
// 创建 axios 实例
const request = axios.create({
timeout: import.meta.env.VITE_TIMEOUT, // 设置请求超时时间
withCredentials: false // 不携带凭证
});
// 配置 NProgress
NProgress.configure({
showSpinner: true // 显示加载指示器
});
// 请求队列,用于存储待处理的请求
let queue: Array<(token: string) => void> = [];
// 标识是否正在刷新 token
let isRefreshing = false;
// 请求拦截器
request.interceptors.request.use(
(req: any) => {
const { user } = useBase(); // 获取用户信息
if (req.url) {
// 控制请求进度条的显示
if (
!config.ignore.NProgress.some(e => req.url.match(new RegExp(`${e}.*`))) &&
(req.NProgress ?? true)
) {
NProgress.start();
}
}
// 在开发环境中打印请求信息
if (isDev) {
console.group(req.url);
console.log('method:', req.method);
console.table('data:', req.method == 'get' ? req.params : req.data);
console.groupEnd();
}
if (!req.headers) {
req.headers = {};
}
// 设置请求头中的语言
if (req.headers['language'] !== null) {
req.headers['language'] = config.i18n.locale;
}
// 验证 token
if (user.token) {
// 设置请求头中的 Authorization
if (req.headers['Authorization'] !== null) {
req.headers['Authorization'] = user.token;
}
// 忽略特定请求
if (['eps', 'refreshToken'].some(e => endsWith(req.url, e))) {
return req;
}
// 判断 token 是否过期
if (storage.isExpired('token')) {
// 判断 refreshToken 是否过期
if (storage.isExpired('refreshToken')) {
ElMessage.error('登录状态已失效,请重新登录');
user.logout();
} else {
// 如果不在刷新中,则刷新 token
if (!isRefreshing) {
isRefreshing = true;
user.refreshToken()
.then(token => {
queue.forEach(cb => cb(token)); // 处理队列中的请求
queue = [];
isRefreshing = false;
})
.catch(() => {
user.logout();
});
}
// 返回一个新的 Promise等待 token 刷新完成
return new Promise(resolve => {
queue.push(token => {
if (req.headers) {
req.headers['Authorization'] = token; // 重新设置 token
}
resolve(req);
});
});
}
}
}
return req;
},
error => {
return Promise.reject(error); // 请求错误处理
}
);
// 响应拦截器
request.interceptors.response.use(
res => {
NProgress.done(); // 结束进度条
if (!res?.data) {
return res;
}
const { code, data, message } = res.data;
if (!code) {
return res.data; // 返回数据
}
switch (code) {
case 1000:
return data; // 成功返回数据
default:
return Promise.reject({ code, message }); // 处理错误
}
},
async error => {
NProgress.done(); // 结束进度条
if (error.response) {
const { status } = error.response;
const { user } = useBase();
if (status == 401) {
user.logout(); // 未授权,登出用户
} else {
if (!isDev) {
switch (status) {
case 403:
router.push('/403'); // 禁止访问
break;
case 500:
router.push('/500'); // 服务器错误
break;
case 502:
router.push('/502'); // 网关错误
break;
}
}
}
}
return Promise.reject({ message: error.response?.data?.message || error.message }); // 返回错误信息
}
);
export { request };

View File

@@ -0,0 +1,103 @@
import { useBase } from '/$/base';
import { config } from '/@/config';
export function useStream() {
const { user } = useBase();
let abortController: AbortController | null = null;
// 调用
async function invoke({
url,
method = 'POST',
data,
cb
}: {
url: string;
method?: string;
data?: any;
cb?: (result: any) => void;
}) {
abortController = new AbortController();
let cacheText = '';
return fetch(config.baseUrl + url, {
method,
headers: {
Authorization: user.token,
'Content-Type': 'application/json'
},
body: JSON.stringify(data),
signal: abortController?.signal
})
.then(res => {
if (res.body) {
const reader = res.body.getReader();
const decoder = new TextDecoder('utf-8');
const stream = new ReadableStream({
start(controller) {
function push() {
reader.read().then(({ done, value }) => {
if (done) {
controller.close();
return;
}
let text = decoder.decode(value, { stream: true });
if (cb) {
if (cacheText) {
text = cacheText + text;
}
if (text.indexOf('data:') == 0) {
text = '\n\n' + text;
}
try {
const arr = text
.split(/\n\ndata:/g)
.filter(Boolean)
.map(e => JSON.parse(e));
arr.forEach(cb);
cacheText = '';
} catch (err) {
console.error('[parse text]', text);
cacheText = text;
}
}
controller.enqueue(text);
push();
});
}
push();
}
});
return new Response(stream);
}
return res;
})
.catch(err => {
console.error(err);
throw err;
});
}
// 取消
function cancel() {
if (abortController) {
abortController.abort();
abortController = null;
}
}
return {
invoke,
cancel
};
}

View File

@@ -0,0 +1,60 @@
import type { Component, Directive, App } from 'vue';
import type { Router as VueRouter, RouteRecordRaw } from 'vue-router';
export declare type Merge<A, B> = Omit<A, keyof B> & B;
export declare interface ModuleConfig {
enable?: boolean;
name?: string;
label?: string;
description?: string;
order?: number;
version?: string;
logo?: string;
author?: string;
updateTime?: string;
demo?: { name: string; component: Component }[] | string;
doc?: string;
ignore?: {
NProgress?: string[];
token?: string[];
};
options?: {
[key: string]: any;
};
toolbar?: {
order?: number;
pc?: boolean;
h5?: boolean;
component: any;
};
index?: {
component: any;
};
components?: Component[];
views?: RouteRecordRaw[];
pages?: (RouteRecordRaw & { isPage?: boolean })[];
install?(app: App, options?: any): any;
onLoad?(events: {
hasToken: (cb: () => Promise<any> | void) => Promise<any> | void;
[key: string]: any;
}): Promise<{ [key: string]: any }> | Promise<void> | void;
}
export declare interface Module extends ModuleConfig {
name: string;
options: {
[key: string]: any;
};
value?: any;
services?: { path: string; value: any }[];
directives?: { name: string; value: Directive }[];
[key: string]: any;
}
export declare interface Router extends VueRouter {
find(path: string): { route: RouteRecordRaw; isReg: boolean };
del(name: string): void;
clear(): void;
append(data: any | any[]): void;
}

View File

@@ -0,0 +1,301 @@
import { isArray, isNumber, isString, orderBy } from 'lodash-es';
import { resolveComponent } from 'vue';
import storage from './storage';
// 首字母大写
export function firstUpperCase(value: string): string {
return value.replace(/\b(\w)(\w*)/g, function ($0, $1, $2) {
return $1.toUpperCase() + $2;
});
}
// 获取方法名
export function getNames(value: any) {
return Object.getOwnPropertyNames(value.constructor.prototype);
}
// 获取地址栏参数
export function getUrlParam(name: string): string | null {
const reg = new RegExp('(^|&)' + name + '=([^&]*)(&|$)');
const r = window.location.search.substr(1).match(reg);
if (r != null) return decodeURIComponent(r[2]);
return null;
}
// 文件名
export function filename(path: string): string {
return basename(path.substring(0, path.lastIndexOf('.')));
}
// 路径名称
export function basename(path: string): string {
let index = path.lastIndexOf('/');
index = index > -1 ? index : path.lastIndexOf('\\');
if (index < 0) {
return path;
}
return path.substring(index + 1);
}
// 文件扩展名
export function extname(path: string): string {
return path.substring(path.lastIndexOf('.') + 1).split(/(\?|&)/)[0];
}
// 横杠转驼峰
export function toCamel(str: string): string {
return str.replace(/([^-])(?:-+([^-]))/g, function ($0, $1, $2) {
return $1 + $2.toUpperCase();
});
}
// uuid
export function uuid(separator = '-'): string {
const s: any[] = [];
const hexDigits = '0123456789abcdef';
for (let i = 0; i < 36; i++) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1);
}
s[14] = '4';
s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1);
s[8] = s[13] = s[18] = s[23] = separator;
return s.join('');
}
// 浏览器信息
export function getBrowser() {
const { clientHeight, clientWidth } = document.documentElement;
// 浏览器信息
const ua = navigator.userAgent.toLowerCase();
// 浏览器类型
let type = (ua.match(/firefox|chrome|safari|opera/g) || 'other')[0];
if ((ua.match(/msie|trident/g) || [])[0]) {
type = 'msie';
}
// 平台标签
let tag = '';
const isTocuh =
'ontouchstart' in window || ua.indexOf('touch') !== -1 || ua.indexOf('mobile') !== -1;
if (isTocuh) {
if (ua.indexOf('ipad') !== -1) {
tag = 'pad';
} else if (ua.indexOf('mobile') !== -1) {
tag = 'mobile';
} else if (ua.indexOf('android') !== -1) {
tag = 'androidPad';
} else {
tag = 'pc';
}
} else {
tag = 'pc';
}
// 浏览器内核
let prefix = '';
switch (type) {
case 'chrome':
case 'safari':
case 'mobile':
prefix = 'webkit';
break;
case 'msie':
prefix = 'ms';
break;
case 'firefox':
prefix = 'Moz';
break;
case 'opera':
prefix = 'O';
break;
default:
prefix = 'webkit';
break;
}
// 操作平台
const plat = ua.indexOf('android') > 0 ? 'android' : navigator.platform.toLowerCase();
// 屏幕信息
let screen = 'full';
if (clientWidth < 768) {
screen = 'xs';
} else if (clientWidth < 992) {
screen = 'sm';
} else if (clientWidth < 1200) {
screen = 'md';
} else if (clientWidth < 1920) {
screen = 'xl';
} else {
screen = 'full';
}
// 是否 ios
const isIOS = !!navigator.userAgent.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/);
// 是否 PC 端
const isPC = tag === 'pc';
// 是否移动端
const isMobile = isPC ? false : true;
// 是否移动端 + 屏幕宽过小
const isMini = screen === 'xs' || isMobile;
return {
height: clientHeight,
width: clientWidth,
type,
plat,
tag,
prefix,
isMobile,
isIOS,
isPC,
isMini,
screen
};
}
// 路径转数组
export function deepPaths(paths: string[], splitor?: string) {
const list: any[] = [];
paths.forEach(e => {
const arr: string[] = e.split(splitor || '/').filter(Boolean);
let c = list;
arr.forEach((a, i) => {
let d = c.find(e => e.label == a);
if (!d) {
d = {
label: a,
value: a,
children: arr[i + 1] ? [] : null
};
c.push(d);
}
if (d.children) {
c = d.children;
}
});
});
return list;
}
// 列表转树形
export function deepTree(list: any[], sort?: 'desc' | 'asc'): any[] {
const newList: any[] = [];
const map: any = {};
orderBy(list, 'orderNum', sort)
.map(e => {
map[e.id] = e;
return e;
})
.forEach(e => {
const parent = map[e.parentId];
if (parent) {
(parent.children || (parent.children = [])).push(e);
} else {
newList.push(e);
}
});
return newList;
}
// 树形转列表
export function revDeepTree(list: any[]) {
const arr: any[] = [];
let id = 0;
function deep(list: any[], parentId: number) {
list.forEach(e => {
if (!e.id) {
e.id = ++id;
}
if (!e.parentId) {
e.parentId = parentId;
}
arr.push(e);
if (e.children && isArray(e.children) && e.id) {
deep(e.children, e.id);
}
});
}
deep(list || [], 0);
return arr;
}
// 路径转对象
export function path2Obj(list: any[]) {
const data: any = {};
list.forEach(({ path, value }) => {
if (path) {
const arr: string[] = path.split('/');
const parents = arr.slice(0, arr.length - 1);
const name = basename(path).replace('.ts', '');
let curr = data;
parents.forEach(k => {
if (!curr[k]) {
curr[k] = {};
}
curr = curr[k];
});
curr[name] = value;
}
});
return data;
}
// 是否是组件
export function isComponent(name: string) {
return !isString(resolveComponent(name));
}
// 是否Promise
export function isPromise(val: any) {
return val && Object.prototype.toString.call(val) === '[object Promise]';
}
// 单位转换
export function parsePx(val: string | number) {
return isNumber(val) ? `${val}px` : val;
}
// 延迟
export function sleep(duration: number) {
return new Promise(resolve => {
setTimeout(() => {
resolve(true);
}, duration);
});
}
export { storage };
export * from './loading';

View File

@@ -0,0 +1,37 @@
export const Loading = {
resolve: null as (() => void) | null,
next: null as Promise<void> | null,
async set(list: Promise<any>[]) {
try {
await Promise.all(list);
} catch (e) {
console.error('[Loading] Error: ', e);
}
if (this.resolve) {
this.resolve();
}
},
async wait() {
if (this.next) {
return this.next;
}
return Promise.resolve();
},
close() {
const el = document.getElementById('Loading');
if (el) {
setTimeout(() => {
el.classList.add('is-hide');
}, 0);
}
}
};
Loading.next = new Promise<void>(resolve => {
Loading.resolve = resolve;
});

View File

@@ -0,0 +1,83 @@
import store from 'store';
export default {
// 后缀标识
suffix: '_deadtime',
/**
* 获取
* @param {string} key 关键字
*/
get(key: string) {
return store.get(key);
},
/**
* 获取全部
*/
info() {
const data: Record<string, any> = {};
store.each((value: any, key: any) => {
data[key] = value;
});
return data;
},
/**
* 设置
* @param {string} key 关键字
* @param {*} value 值
* @param {number} expires 过期时间
*/
set(key: string, value: any, expires?: number) {
store.set(key, value);
if (expires) {
const expirationTime = Date.now() + expires * 1000;
store.set(`${key}${this.suffix}`, expirationTime);
}
},
/**
* 是否过期
* @param {string} key 关键字
*/
isExpired(key: string) {
const expiration = this.getExpiration(key) || 0;
return expiration - Date.now() <= 2000;
},
/**
* 获取到期时间
* @param {string} key 关键字
*/
getExpiration(key: string) {
return this.get(key + this.suffix);
},
/**
* 移除
* @param {string} key 关键字
*/
remove(key: string) {
store.remove(key);
this.removeExpiration(key);
},
/**
* 移除到期时间
* @param {string} key 关键字
*/
removeExpiration(key: string) {
store.remove(key + this.suffix);
},
/**
* 清理
*/
clearAll() {
store.clearAll();
}
};

View File

@@ -0,0 +1,14 @@
import { createApp } from 'vue';
import App from './App.vue';
import { bootstrap } from './cool';
const app = createApp(App);
// 启动
bootstrap(app)
.then(() => {
app.mount('#app');
})
.catch(err => {
console.error('COOL-ADMIN 启动失败', err);
});

View File

@@ -0,0 +1,53 @@
import { defineComponent, type PropType } from 'vue';
import { UserFilled } from '@element-plus/icons-vue';
export default defineComponent({
name: 'cl-avatar',
props: {
modelValue: String,
src: String,
icon: {
type: null,
default: UserFilled
},
size: {
type: [String, Number] as PropType<'large' | 'default' | 'small' | number>,
default: 40
},
shape: {
type: String as PropType<'circle' | 'square'>,
default: 'square'
},
fit: {
type: String as PropType<'fill' | 'contain' | 'cover' | 'none' | 'scale-down'>,
default: 'cover'
}
},
setup(props) {
return () => {
const height = props.size + 'px';
return (
<div
class="cl-avatar"
style={{
height
}}
>
<el-avatar
style={{
height,
width: props.size + 'px'
}}
{...{
...props,
src: props.modelValue || props.src
}}
/>
</div>
);
};
}
});

View File

@@ -0,0 +1,156 @@
<template v-if="text">
<div class="cl-code-json__wrap" v-if="popover">
<el-popover
width="auto"
placement="right"
popper-class="cl-code-json__popper"
effect="dark"
>
<template #reference>
<span class="cl-code-json__text">{{ text }}</span>
</template>
<viewer />
</el-popover>
</div>
<viewer v-else>
<template #op>
<slot name="op"> </slot>
</template>
</viewer>
</template>
<script lang="tsx" setup>
defineOptions({
name: 'cl-code-json'
});
import { useClipboard } from '@vueuse/core';
import { ElMessage } from 'element-plus';
import { isObject, isString } from 'lodash-es';
import { computed, defineComponent } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
content: null,
modelValue: null,
popover: Boolean,
height: {
type: [Number, String],
default: '100%'
},
maxHeight: {
type: [Number, String],
default: 300
},
title: String
});
const { copy } = useClipboard();
const { t } = useI18n();
// 文本
const text = computed(() => {
const v = props.modelValue || props.content;
if (isString(v)) {
return v;
} else if (isObject(v)) {
return JSON.stringify(v, null, 4);
} else {
return String(v);
}
});
// 视图
const viewer = defineComponent({
setup(_, { slots }) {
function toCopy() {
copy(text.value);
ElMessage.success('复制成功');
}
return () => {
return (
<div class="cl-code-json">
<div class="cl-code-json__op">
{text.value != '{}' && (
<el-button type="success" size="small" onClick={toCopy}>
{t('复制')}
</el-button>
)}
{slots.op && slots.op()}
</div>
{props.title && <div class="cl-code-json__title">{props.title}</div>}
<el-scrollbar
class="cl-code-json__content"
max-height={props.maxHeight}
height={props.height}
>
<pre>
<code>{text.value}</code>
</pre>
</el-scrollbar>
</div>
);
};
}
});
</script>
<style lang="scss">
.cl-code-json {
position: relative;
min-width: 200px;
max-width: 500px;
font-size: 14px;
&__op {
position: absolute;
right: 8px;
top: 8px;
z-index: 9;
}
&__title {
padding: 10px;
font-size: 12px;
color: var(--el-text-color-regular);
& + .cl-code-json__content {
padding-top: 0;
}
}
&__content {
padding: 10px;
code {
white-space: pre-wrap;
}
}
&__wrap {
.cl-code-json__text {
display: block;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
&:hover {
color: var(--el-color-primary);
}
}
}
&__popper {
padding: 0 !important;
border-radius: 8px !important;
}
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<div class="cl-dept-check">
<div class="cl-dept-check__search">
<el-input v-model="keyword" :placeholder="$t('输入关键字进行过滤')" />
</div>
<div class="cl-dept-check__tree">
<el-scrollbar max-height="200px">
<el-tree
ref="Tree"
node-key="id"
show-checkbox
:data="list"
:props="{
label: 'name',
children: 'children'
}"
:filter-node-method="filterNode"
:check-strictly="checkStrictly"
@check="onCheckChange"
/>
</el-scrollbar>
</div>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'cl-dept-check'
});
import { ref, watch } from 'vue';
import { deepTree } from '/@/cool/utils';
import { useCool } from '/@/cool';
import { useUpsert } from '@cool-vue/crud';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
checkStrictly: Boolean
});
const emit = defineEmits(['update:modelValue']);
const { service } = useCool();
// el-tree
const Tree = ref();
// 树形列表
const list = ref();
// 关键字搜素
const keyword = ref('');
// 刷新树形列表
async function refresh() {
return service.base.sys.department.list().then(res => {
list.value = deepTree(res);
});
}
// 过滤节点
function filterNode(val: string, data: any) {
if (!val) return true;
return data.name.includes(val);
}
// 值改变
function onCheckChange(_: any, { checkedKeys }: any) {
emit('update:modelValue', checkedKeys);
}
// 监听过滤
watch(keyword, (val: string) => {
Tree.value?.filter(val);
});
useUpsert({
async onOpened() {
await refresh();
Tree.value?.setCheckedKeys(props.modelValue || []);
}
});
</script>
<style lang="scss" scoped>
.cl-dept-check {
&__search {
display: flex;
align-items: center;
.el-input {
flex: 1;
}
}
&__tree {
border: 1px solid var(--el-border-color);
margin-top: 5px;
border-radius: 4px;
box-sizing: border-box;
padding: 5px 0;
}
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div class="cl-dept-select">
<el-tree-select
v-model="value"
node-key="id"
:data="list"
:props="{
label: 'name',
value: 'id',
children: 'children'
}"
:multiple="multiple"
:check-strictly="checkStrictly"
:show-checkbox="multiple"
default-expand-all
@change="onChange"
@check="onCheckChange"
/>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'cl-dept-select'
});
import { ElMessage } from 'element-plus';
import { onMounted, ref, useModel } from 'vue';
import { useCool } from '/@/cool';
import { deepTree } from '/@/cool/utils';
const props = defineProps({
modelValue: [Array, Number, String],
multiple: Boolean,
checkStrictly: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['update:modelValue', 'change']);
const { service } = useCool();
const value = useModel(props, 'modelValue');
const list = ref();
// 单选改变
function onChange(val: string) {
if (!props.multiple) {
emit('update:modelValue', val);
}
}
// 多选改变
function onCheckChange(_: any, { checkedKeys }: any) {
if (props.multiple) {
emit('update:modelValue', checkedKeys);
}
}
// 刷新
function refresh() {
service.base.sys.department
.list()
.then(res => {
list.value = deepTree(res);
})
.catch(err => {
list.value = [];
ElMessage.error(err.message);
});
}
onMounted(() => {
refresh();
});
</script>
<style lang="scss" scoped>
.cl-dept-select {
:deep(.el-select) {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,44 @@
import { defineComponent, h, resolveComponent, ref, reactive, watch } from 'vue';
import { isComponent } from '/@/cool/utils';
import { assign } from 'lodash-es';
import { useI18n } from 'vue-i18n';
export default defineComponent({
name: 'cl-editor',
props: {
name: {
type: String,
required: true
}
},
setup(props, { slots, expose }) {
const Editor = ref();
const ex = reactive({});
const { t } = useI18n();
watch(Editor, v => {
if (v) {
assign(ex, v);
}
});
expose(ex);
return () => {
return isComponent(props.name) ? (
h(
resolveComponent(props.name),
{
...props,
ref: Editor
},
slots
)
) : (
<el-input type="textarea" rows={4} placeholder={t('请输入')} {...props} />
);
};
}
});

View File

@@ -0,0 +1,41 @@
<template>
<svg :class="svgClass" :style="style" aria-hidden="true">
<use :xlink:href="iconName" />
</svg>
</template>
<script lang="ts" setup>
defineOptions({
name: 'cl-svg'
});
import { computed, reactive } from 'vue';
import { parsePx } from '/@/cool/utils';
const props = defineProps({
name: String,
className: String,
color: String,
size: [String, Number]
});
const style = reactive({
fontSize: parsePx(props.size!),
fill: props.color
});
const iconName = computed(() => `#icon-${props.name}`);
const svgClass = computed(() => {
return ['cl-svg', `cl-svg__${props.name}`, String(props.className || '')];
});
</script>
<style lang="scss" scoped>
.cl-svg {
display: inline-block;
width: 1em;
height: 1em;
fill: currentColor;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<div
class="cl-image"
:style="{
height: style.h
}"
>
<el-image
:src="url"
:fit="fit"
:lazy="lazy"
:preview-src-list="preview ? urls : undefined"
:style="{
height: style.h,
width: style.w
}"
preview-teleported
>
<template #error>
<div class="cl-image__slot">
<el-icon :size="18"><picture-filled /></el-icon>
</div>
</template>
</el-image>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'cl-image'
});
import { computed, type PropType } from 'vue';
import { isArray, isNumber, isString } from 'lodash-es';
import { PictureFilled } from '@element-plus/icons-vue';
import { parsePx } from '/@/cool/utils';
const props = defineProps({
modelValue: [String, Array],
src: [String, Array],
size: {
type: [Number, Array],
default: 100
},
radius: {
type: [Number, String],
default: 0
},
lazy: Boolean,
fit: {
type: String as PropType<'' | 'contain' | 'cover' | 'none' | 'fill' | 'scale-down'>,
default: 'cover'
},
compress: String as PropType<'oss' | 'none'>,
preview: {
type: Boolean,
default: true
}
});
const urls = computed(() => {
const urls: any = props.modelValue || props.src;
if (isArray(urls)) {
return urls;
}
if (isString(urls)) {
if (urls.startsWith('data:image')) {
return [urls];
}
return (urls || '').split(',').filter(Boolean);
}
return [];
});
const style = computed(() => {
const [h, w]: any[] = isNumber(props.size) ? [props.size, props.size] : props.size;
return {
h: parsePx(h),
w: parsePx(w)
};
});
const url = computed(() => {
const v = urls.value[0];
return props.compress === 'oss'
? `${v}?x-oss-process=image/resize,m_fill,h_${style.value.h},w_${style.value.w}`
: v;
});
</script>
<style lang="scss" scoped>
.cl-image {
&__slot {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
border-radius: 6px;
background-color: var(--el-fill-color-lighter);
color: var(--el-text-color-regular);
}
:deep(.el-image__inner) {
border-radius: v-bind('parsePx(props.radius)');
}
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div class="cl-link">
<a v-for="item in urls" :key="item" class="cl-link__item" :href="item" :target="target">
<el-icon><icon-link /></el-icon>
<span>{{ text || filename(item) }}</span>
</a>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'cl-link'
});
import { computed } from 'vue';
import { isArray, isString, last } from 'lodash-es';
import { Link as IconLink } from '@element-plus/icons-vue';
const props = defineProps({
modelValue: [String, Array],
href: [String, Array],
text: String,
target: {
type: String,
default: '_blank'
}
});
const urls = computed(() => {
const urls: any = props.modelValue || props.href;
if (isArray(urls)) {
return urls;
}
if (isString(urls)) {
return (urls || '').split(',').filter(Boolean);
}
return [];
});
function filename(url: string) {
return last(url.split('/'));
}
</script>
<style lang="scss" scoped>
.cl-link {
&__item {
display: flex;
align-items: center;
color: var(--el-color-primary);
padding: 0 5px;
border-radius: 6px;
margin: 2px;
text-decoration: none;
span {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.el-icon {
margin-right: 4px;
}
&:hover {
text-decoration: underline;
}
}
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<div class="cl-menu-check">
<el-input v-model="keyword" :placeholder="$t('输入关键字进行过滤')" />
<div class="cl-menu-check__scroller">
<el-scrollbar max-height="200px">
<el-tree
ref="Tree"
node-key="id"
show-checkbox
:data="list"
:props="{
label: 'name',
children: 'children'
}"
:filter-node-method="filterNode"
@check="onCheckChange"
/>
</el-scrollbar>
</div>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'cl-menu-check'
});
import { ref, watch } from 'vue';
import { deepTree } from '/@/cool/utils';
import { useCool } from '/@/cool';
import { useUpsert } from '@cool-vue/crud';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
modelValue: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue']);
const { service } = useCool();
// el-tree 组件
const Tree = ref();
// 树形列表
const list = ref();
// 搜索关键字
const keyword = ref('');
// 刷新列表
async function refresh() {
return service.base.sys.menu.list().then(res => {
list.value = deepTree(res);
});
}
// 过滤节点
function filterNode(val: string, data: any) {
if (!val) return true;
return data.name.includes(val);
}
// 值改变
function onCheckChange(_: any, { checkedKeys, halfCheckedKeys }: any) {
emit('update:modelValue', [...checkedKeys, ...halfCheckedKeys]);
}
// 过滤监听
watch(keyword, (val: string) => {
Tree.value.filter(val);
});
useUpsert({
async onOpened() {
await refresh();
Tree.value?.setCheckedKeys(
(props.modelValue || []).filter(e => Tree.value.getNode(e)?.isLeaf)
);
}
});
</script>
<style lang="scss" scoped>
.cl-menu-check {
&__scroller {
border: 1px solid var(--el-border-color);
border-radius: 4px;
margin-top: 10px;
padding: 5px 0;
}
}
</style>

View File

@@ -0,0 +1,174 @@
<template>
<div class="cl-menu-file">
<el-tooltip :content="$t('自定义输入')">
<div
class="cl-menu-file__icon"
:class="{
'is-edit': isEdit
}"
@click="toggle()"
>
<cl-svg name="edit" />
</div>
</el-tooltip>
<template v-if="isEdit">
<el-input
v-model="text"
:placeholder="$t('请输入')"
@change="onTextChange"
:ref="setRefs('input')"
/>
</template>
<template v-else>
<el-cascader
v-model="path"
:options="data"
clearable
filterable
@change="onPathChange"
/>
</template>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'cl-menu-file'
});
import { nextTick, ref, watch } from 'vue';
import { deepPaths } from '/@/cool/utils';
import { useCool } from '/@/cool';
const props = defineProps({
modelValue: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:modelValue', 'change']);
const { refs, setRefs } = useCool();
// 扫描文件
function findFiles() {
const files = import.meta.glob(['/src/modules/*/{views,pages}/**/*', '!**/components']);
const list: string[] = [];
for (const i in files) {
if (!i.includes('base/pages')) {
list.push(i.substring(13));
}
}
return deepPaths(list);
}
// 路径
const path = ref();
// 文本
const text = ref();
// 是否编辑
const isEdit = ref(false);
// 数据列表
const data = ref(findFiles());
// 路径值改变
function onPathChange(arr: any) {
const v = 'modules/' + (arr || []).join('/');
emit('update:modelValue', v);
emit('change', v);
}
// 文本值改变
function onTextChange(v: string) {
emit('update:modelValue', v);
emit('change', v);
}
// 切换
function toggle() {
isEdit.value = !isEdit.value;
if (isEdit.value) {
nextTick(() => {
refs.input.focus();
});
}
}
watch(
() => props.modelValue,
val => {
if (val) {
if (val.includes('http')) {
text.value = val;
isEdit.value = true;
} else {
path.value = val.replace(/(modules\/|cool\/)/g, '').split('/');
}
}
},
{
immediate: true
}
);
</script>
<style lang="scss" scoped>
.cl-menu-file {
display: flex;
align-items: center;
width: 100%;
&__icon {
display: flex;
align-items: center;
justify-content: center;
margin-right: 5px;
border: 1px solid var(--el-border-color);
height: 32px;
width: 32px;
border-radius: var(--el-border-radius-base);
box-sizing: border-box;
flex-shrink: 0;
cursor: pointer;
.cl-svg {
font-size: 16px;
}
&.is-edit {
background-color: var(--el-color-primary);
color: var(--el-color-white);
border: 0;
}
&:hover:not(.is-edit) {
.cl-svg {
color: var(--el-color-primary);
}
}
}
:deep(.el-cascader) {
width: 100%;
}
.el-icon {
margin: 0 10px 0 0;
font-size: 18px;
cursor: pointer;
&:hover {
color: var(--el-color-primary);
}
}
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div class="cl-menu-icon">
<div class="cl-menu-icon__current" v-if="showIcon && modelValue">
<cl-svg :name="modelValue" />
</div>
<el-select v-model="value" filterable fit-input-width clearable>
<div class="cl-menu-icon__list">
<el-option v-for="item in list" :key="item" :value="item">
<cl-svg :name="item" />
</el-option>
</div>
</el-select>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'cl-menu-icon'
});
import { ref, useModel } from 'vue';
import { svgIcons } from 'virtual:svg-icons';
const props = defineProps({
modelValue: {
type: String,
default: ''
},
showIcon: Boolean
});
const emit = defineEmits(['update:modelValue']);
// 图标列表
const list = ref(svgIcons.filter(e => e.indexOf('icon-') === 0));
// 已选图标
const value = useModel(props, 'modelValue');
</script>
<style lang="scss" scoped>
.cl-menu-icon {
display: flex;
align-items: center;
&__current {
display: flex;
align-items: center;
justify-content: center;
margin-right: 5px;
border: 1px solid var(--el-border-color);
height: 32px;
width: 32px;
border-radius: var(--el-border-radius-base);
box-sizing: border-box;
flex-shrink: 0;
.cl-svg {
font-size: 16px;
}
}
&__list {
display: flex;
flex-wrap: wrap;
padding-left: 5px;
.el-select-dropdown__item {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
height: 50px;
width: 50px;
border-radius: 4px;
}
.cl-svg {
font-size: 18px;
}
}
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<div class="cl-menu-perms">
<el-cascader
v-model="value"
separator=":"
clearable
filterable
collapse-tags
collapse-tags-tooltip
:disabled="disabled"
:options="data"
:props="cascaderProps"
@change="onChange"
/>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'cl-menu-perms'
});
import { onMounted, ref, watch, reactive } from 'vue';
import { useCool } from '/@/cool';
import { deepPaths } from '/@/cool/utils';
const props = defineProps({
modelValue: {
type: String,
default: ''
},
disabled: Boolean
});
const emit = defineEmits(['update:modelValue']);
const { service } = useCool();
// 绑定值
const value = ref<string[][]>([]);
// 权限列表
const data = ref<any[]>([]);
// elm BUG
const cascaderProps = reactive({ multiple: true });
// 监听改变
function onChange(arr: any) {
emit('update:modelValue', arr.map((e: string[]) => e.join(':')).join(','));
}
// 监听值
watch(
() => props.modelValue,
val => {
value.value = val ? val.split(',').map(e => e.split(':')) : [];
},
{
immediate: true
}
);
onMounted(() => {
const list: any[] = [];
function deep(s: any) {
if (typeof s == 'object') {
for (const i in s) {
const { permission } = s[i];
if (permission) {
list.push(...Object.values(permission));
} else {
deep(s[i]);
}
}
}
}
deep(service);
data.value = deepPaths(list, ':');
});
</script>
<style lang="scss" scoped>
.cl-menu-perms {
line-height: 0;
:deep(.el-cascader) {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<div class="cl-menu-select">
<el-tree-select
v-model="value"
:data="tree"
:props="{
label: 'name',
value: 'id',
disabled: 'disabled',
children: 'children'
}"
clearable
default-expand-all
check-strictly
filterable
:size="size"
:placeholder="placeholder"
/>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'cl-menu-select'
});
import { useForm } from '@cool-vue/crud';
import { cloneDeep } from 'lodash-es';
import { computed, ref, useModel, onMounted } from 'vue';
import { useCool } from '/@/cool';
import { deepTree } from '/@/cool/utils';
const props = defineProps({
modelValue: [Number, String],
type: {
type: Number,
default: 1
},
placeholder: String,
size: String
});
const emit = defineEmits(['update:modelValue']);
const { service } = useCool();
const Form = useForm();
// 绑定值
const value = useModel(props, 'modelValue', {
get(val) {
return val ? Number(val) : val;
}
});
// 菜单列表
const list = ref<any[]>([]);
// 树形列表
const tree = computed(() => {
// 过滤掉自己和下级的数据
const data = list.value.filter(
e => e.id != Form.value?.form.id && (props.type === 0 ? e.type == 0 : props.type > e.type!)
);
return deepTree(cloneDeep(data)).filter(e => !e.parentId);
});
// 刷新列表
function refresh() {
service.base.sys.menu.list().then(res => {
list.value = res;
});
}
onMounted(() => {
refresh();
});
</script>
<style lang="scss" scoped>
.cl-menu-select {
:deep(.el-select) {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<span class="cl-number">
{{ text }} <span class="cl-number__suffix" v-if="suffix">{{ suffix }}</span>
</span>
</template>
<script setup lang="ts">
defineOptions({
name: 'cl-number'
});
import { useTransition } from '@vueuse/core';
import { computed, type PropType } from 'vue';
const props = defineProps({
modelValue: Number,
value: Number,
fixed: Number,
duration: {
type: Number,
default: 1000
},
type: {
type: String as PropType<'amount' | 'number'>,
default: 'number'
},
suffix: String
});
const transitionedValue = useTransition(
computed(() => props.modelValue || props.value! || 0),
{
duration: props.duration
}
);
const text = computed(() => {
const val = transitionedValue.value;
if (props.type === 'amount') {
// 若需要小数位数控制,可以使用 toFixed 再转数值:
const fixedVal = props.fixed !== undefined ? Number(val.toFixed(props.fixed)) : val;
// 利用 toLocaleString 增加千分位分隔符
return fixedVal.toLocaleString(undefined, {
minimumFractionDigits: props.fixed || 0,
maximumFractionDigits: props.fixed || 0
});
} else {
return val.toFixed(0);
}
});
</script>
<style lang="scss" scoped>
.cl-number {
&__suffix {
font-size: 14px;
}
}
</style>

View File

@@ -0,0 +1,94 @@
import { type ModuleConfig } from '/@/cool';
import { useStore } from './store';
import { config } from '/@/config';
import { t } from '/@/plugins/i18n';
import './static/css/index.scss';
export default (): ModuleConfig => {
return {
order: 99,
ignore: {
NProgress: [
'/base/open/eps',
'/base/comm/person',
'/base/comm/permmenu',
'/base/comm/upload',
'/base/comm/uploadMode'
],
token: ['/login', '/401', '/403', '/404', '/500', '/502']
},
components: Object.values(import.meta.glob('./components/**/*.{vue,tsx}')),
views: [
{
path: '/my/info',
meta: {
label: t('个人中心')
},
component: () => import('./views/info.vue')
}
],
pages: [
{
path: '/login',
component: () => import('./pages/login/index.vue')
},
...['401', '403', '404', '500', '502'].map(code => {
return {
path: `/${code}`,
meta: {
process: false
},
component: () => import(`./pages/error/${code}.vue`)
};
})
],
install() {
// 设置标题
document.title = config.app.name;
// 设置加载文案
const loading = document.querySelector('#Loading');
if (loading) {
const name = loading.querySelector('.preload__name');
const title = loading.querySelector('.preload__title');
const subTitle = loading.querySelector('.preload__sub-title');
if (name) {
name.innerHTML = config.app.name;
}
if (title) {
title.innerHTML = t('正在加载资源...');
}
if (subTitle) {
subTitle.innerHTML = t('初次加载资源可能需要较多时间,请耐心等待');
}
}
},
async onLoad() {
const { user, menu, app } = useStore();
// token 事件
async function hasToken(cb: () => Promise<any> | void) {
if (cb) {
app.addEvent('hasToken', cb);
if (user.token) {
await cb();
}
}
}
await hasToken(async () => {
// 获取用户信息
user.get();
// 获取菜单权限
await menu.get();
});
return {
hasToken
};
}
};
};

View File

@@ -0,0 +1,13 @@
import { checkPerm } from '../utils/permission';
function change(el: HTMLElement, binding: { value: any }) {
el.style.display = checkPerm(binding.value) ? el.getAttribute('_display') || '' : 'none';
}
export default {
created(el: HTMLElement, binding: { value: any }) {
el.setAttribute('_display', el.style.display || '');
change(el, binding);
},
updated: change
};

View File

@@ -0,0 +1,9 @@
import { useStore } from './store';
export function useBase() {
return {
...useStore()
};
}
export * from './utils';

View File

@@ -0,0 +1,143 @@
{
"删除": "Delete",
"新增成员": "Add Member",
"目录": "Directory",
"菜单": "Menu",
"权限": "Permission",
"是否显示": "Show/Hide",
"图标": "Icon",
"节点路由": "Node Route",
"路由缓存": "Route Cache",
"文件路径": "File Path",
"排序号": "Sorting Number",
"节点类型": "Node Type",
"节点名称": "Node Name",
"上级节点": "Parent Node",
"请输入节点路由,如:/test": "Please enter the node route, e.g.: /test",
"开启": "Enable",
"关闭": "Disable",
"请填写排序号": "Please fill in the sorting number",
"导入": "Import",
"如遇到问题无法导入菜单,请检查文件并尝试重新导入。": "If you encounter problems importing the menu, please check the file and try to import again.",
"角色标签": "Character Tag",
"请填写新密码": "Please fill in the new password",
"保存修改": "Save Changes",
"修改成功": "Modification Successful",
"拼命加载中": "Loading拼命",
"转移": "Transfer",
"搜索用户名、姓名": "Search Username, Name",
"用户列表": "User List",
"用户名": "Username",
"姓名": "Name",
"部门名称": "Department Name",
"角色": "Role",
"状态": "Status",
"手机号码": "Mobile Phone Number",
"选择头像": "Select Avatar",
"密码": "Password",
"密码长度在 6 到 16 个字符": "Password length should be between 6 and 16 characters",
"邮箱": "Email",
"启用": "Enable",
"禁用": "Disable",
"部门转移": "Department Transfer",
"请输入备注": "Please enter remarks",
"清空": "Clear",
"日志保存天数": "Log save days",
"搜索请求地址、用户昵称、ip": "Search request address, user nickname, IP",
"用户ID": "User ID",
"用户昵称": "User nickname",
"请求地址": "Request address",
"参数": "Parameter",
"请求时间": "Request time",
"保存成功": "Save successful",
"是否要清空日志?": "Do you want to clear the log?",
"提示": "Tip",
"清空成功": "Clear successful",
"基本信息": "Basic information",
"头像": "Avatar",
"昵称": "Nickname",
"请填写昵称": "Please fill in the nickname",
"原密码": "Original password",
"请填写原密码": "Please fill in the original password",
"新密码": "New password",
"菜单导入": "Menu Import",
"添加": "Add",
"导入成功": "Import Success",
"{file}文件格式错误:{error}": "{file} File Format Error: {error}",
"导出": "Export",
"选择菜单": "Select Menu",
"请先选择要导出的菜单": "Please select the menu to export first",
"菜单数据": "Menu Data",
"退出登录": "Log out",
"确定退出登录吗?": "Are you sure you want to log out?",
"搜索关键字": "Search Keyword",
"关闭当前": "Close Current",
"关闭其他": "Close Others",
"关闭所有": "Close All",
"{label} 没有子菜单,请先添加": "{label} has no sub-menus. Please add them first",
"快速开发后台权限管理系统": "Quick Development Background Permission Management System",
"请输入用户名": "Please enter your username",
"请输入密码": "Please enter your password",
"验证码": "Verification Code",
"登录": "Log in",
"用户名不能为空": "Username cannot be empty",
"密码不能为空": "Password cannot be empty",
"图片验证码不能为空": "Image verification code cannot be empty",
"验证码获取失败": "Failed to obtain verification code",
"马上回来": "Be right back",
"糟糕,出了点问题": "Oops, something went wrong",
"找不到您要查找的页面": "Page not found",
"您无权访问此页面": "You are not authorized to access this page",
"认证失败,请重新登录!": "Authentication failed, please log in again!",
"返回首页": "Return to home page",
"重新登录": "Log in again",
"返回登录页": "Return to login page",
"自定义输入": "Custom input",
"请输入": "Please enter",
"输入关键字进行过滤": "Enter keywords for filtering",
"复制": "Copy",
"行为": "Behavior",
"ip": "IP",
"数据类型 0-字符串 1-富文本 2-文件 ": "Data type 0 - String 1 - Rich text 2 - File",
"键": "Key",
"选择部门": "Select Department",
"请选择部门": "Please Select Department",
"转移到新部门,是否继续?": "Transfer to a new department. Continue?",
"转移成功": "Transfer Successful",
"组织架构": "Organization Structure",
"刷新": "Refresh",
"拖动排序": "Drag to Sort",
"编辑部门": "Edit Department",
"上级部门": "Superior Department",
"排序": "Sort",
"新增部门 “{name}” 成功": "Successfully added new department “{name}”",
"删除成功": "Delete Successful",
"“{name}” 部门的用户已成功转移到 “{parentName}” 部门。": "Users in the “{name}” department have been successfully transferred to the “{parentName}” department.",
"此操作将会删除 “{name}” 部门的所有用户,是否确认?": "This operation will delete all users in the “{name}” department. Are you sure?",
"直接删除": "Delete Directly",
"保留用户": "Keep Users",
"部门架构已发生改变,是否保存?": "The department structure has changed. Do you want to save?",
"更新排序成功": "Successfully updated sorting",
"新增": "Add",
"编辑": "Edit",
"个人中心": "Personal Center",
"正在加载资源...": "Loading resources...",
"初次加载资源可能需要较多时间,请耐心等待": "It may take some time for the initial resource loading. Please wait patiently.",
"搜索名称": "Search Name",
"是否关联上下级": "Whether to associate with superiors and subordinates",
"名称": "Name",
"标识": "Identifier",
"备注": "Remarks",
"功能权限": "Function Permissions",
"数据权限": "Data Permissions",
"创建时间": "Creation Time",
"更新时间": "Update Time",
"数据类型": "Data Type",
"搜索名称、keyName": "Search Name, keyName",
"字符串": "String",
"富文本": "Rich Text",
"文件": "File",
"请输入Key": "Please enter Key",
"类型": "Type",
"数据": "Data"
}

View File

@@ -0,0 +1,143 @@
{
"个人中心": "个人中心",
"正在加载资源...": "正在加载资源...",
"初次加载资源可能需要较多时间,请耐心等待": "初次加载资源可能需要较多时间,请耐心等待",
"搜索名称": "搜索名称",
"是否关联上下级": "是否关联上下级",
"名称": "名称",
"标识": "标识",
"备注": "备注",
"功能权限": "功能权限",
"数据权限": "数据权限",
"创建时间": "创建时间",
"更新时间": "更新时间",
"数据类型": "数据类型",
"搜索名称、keyName": "搜索名称、keyName",
"字符串": "字符串",
"富文本": "富文本",
"文件": "文件",
"请输入Key": "请输入Key",
"类型": "类型",
"数据": "数据",
"请输入备注": "请输入备注",
"清空": "清空",
"日志保存天数": "日志保存天数",
"搜索请求地址、用户昵称、ip": "搜索请求地址、用户昵称、ip",
"用户ID": "用户ID",
"用户昵称": "用户昵称",
"请求地址": "请求地址",
"参数": "参数",
"请求时间": "请求时间",
"保存成功": "保存成功",
"是否要清空日志?": "是否要清空日志?",
"提示": "提示",
"清空成功": "清空成功",
"基本信息": "基本信息",
"头像": "头像",
"昵称": "昵称",
"请填写昵称": "请填写昵称",
"原密码": "原密码",
"请填写原密码": "请填写原密码",
"新密码": "新密码",
"请填写新密码": "请填写新密码",
"保存修改": "保存修改",
"修改成功": "修改成功",
"拼命加载中": "拼命加载中",
"转移": "转移",
"搜索用户名、姓名": "搜索用户名、姓名",
"用户列表": "用户列表",
"用户名": "用户名",
"姓名": "姓名",
"部门名称": "部门名称",
"角色": "角色",
"状态": "状态",
"手机号码": "手机号码",
"选择头像": "选择头像",
"密码": "密码",
"密码长度在 6 到 16 个字符": "密码长度在 6 到 16 个字符",
"邮箱": "邮箱",
"启用": "启用",
"禁用": "禁用",
"部门转移": "部门转移",
"选择部门": "选择部门",
"请选择部门": "请选择部门",
"转移到新部门,是否继续?": "转移到新部门,是否继续?",
"转移成功": "转移成功",
"组织架构": "组织架构",
"刷新": "刷新",
"拖动排序": "拖动排序",
"编辑部门": "编辑部门",
"上级部门": "上级部门",
"排序": "排序",
"新增部门 “{name}” 成功": "新增部门 “{name}” 成功",
"删除成功": "删除成功",
"“{name}” 部门的用户已成功转移到 “{parentName}” 部门。": "“{name}” 部门的用户已成功转移到 “{parentName}” 部门。",
"此操作将会删除 “{name}” 部门的所有用户,是否确认?": "此操作将会删除 “{name}” 部门的所有用户,是否确认?",
"直接删除": "直接删除",
"保留用户": "保留用户",
"部门架构已发生改变,是否保存?": "部门架构已发生改变,是否保存?",
"更新排序成功": "更新排序成功",
"新增": "新增",
"编辑": "编辑",
"删除": "删除",
"新增成员": "新增成员",
"目录": "目录",
"菜单": "菜单",
"权限": "权限",
"是否显示": "是否显示",
"图标": "图标",
"节点路由": "节点路由",
"路由缓存": "路由缓存",
"文件路径": "文件路径",
"排序号": "排序号",
"节点类型": "节点类型",
"节点名称": "节点名称",
"上级节点": "上级节点",
"请输入节点路由,如:/test": "请输入节点路由,如:/test",
"开启": "开启",
"关闭": "关闭",
"请填写排序号": "请填写排序号",
"导入": "导入",
"如遇到问题无法导入菜单,请检查文件并尝试重新导入。": "如遇到问题无法导入菜单,请检查文件并尝试重新导入。",
"菜单导入": "菜单导入",
"添加": "添加",
"导入成功": "导入成功",
"{file}文件格式错误:{error}": "{file}文件格式错误:{error}",
"导出": "导出",
"选择菜单": "选择菜单",
"请先选择要导出的菜单": "请先选择要导出的菜单",
"菜单数据": "菜单数据",
"退出登录": "退出登录",
"确定退出登录吗?": "确定退出登录吗?",
"搜索关键字": "搜索关键字",
"关闭当前": "关闭当前",
"关闭其他": "关闭其他",
"关闭所有": "关闭所有",
"{label} 没有子菜单,请先添加": "{label} 没有子菜单,请先添加",
"快速开发后台权限管理系统": "快速开发后台权限管理系统",
"请输入用户名": "请输入用户名",
"请输入密码": "请输入密码",
"验证码": "验证码",
"登录": "登录",
"用户名不能为空": "用户名不能为空",
"密码不能为空": "密码不能为空",
"图片验证码不能为空": "图片验证码不能为空",
"验证码获取失败": "验证码获取失败",
"马上回来": "马上回来",
"糟糕,出了点问题": "糟糕,出了点问题",
"找不到您要查找的页面": "找不到您要查找的页面",
"您无权访问此页面": "您无权访问此页面",
"认证失败,请重新登录!": "认证失败,请重新登录!",
"返回首页": "返回首页",
"重新登录": "重新登录",
"返回登录页": "返回登录页",
"自定义输入": "自定义输入",
"请输入": "请输入",
"输入关键字进行过滤": "输入关键字进行过滤",
"复制": "复制",
"行为": "行为",
"ip": "ip",
"数据类型 0-字符串 1-富文本 2-文件 ": "数据类型 0-字符串 1-富文本 2-文件 ",
"键": "键",
"角色标签": "角色标签"
}

View File

@@ -0,0 +1,143 @@
{
"请填写新密码": "請填寫新密碼",
"保存修改": "保存修改",
"修改成功": "修改成功",
"拼命加载中": "拼命加載中",
"转移": "轉移",
"搜索用户名、姓名": "搜索用戶名、姓名",
"用户列表": "用戶列表",
"用户名": "用戶名",
"姓名": "姓名",
"部门名称": "部門名稱",
"角色": "角色",
"状态": "狀態",
"手机号码": "手機號碼",
"选择头像": "選擇頭像",
"密码": "密碼",
"密码长度在 6 到 16 个字符": "密碼長度在6到16個字符",
"邮箱": "郵箱",
"启用": "啟用",
"禁用": "禁用",
"部门转移": "部門轉移",
"请输入备注": "請輸入備註",
"清空": "清空",
"日志保存天数": "日誌保存天數",
"搜索请求地址、用户昵称、ip": "搜索請求地址、用戶暱稱、ip",
"用户ID": "用戶ID",
"用户昵称": "用戶暱稱",
"请求地址": "請求地址",
"参数": "參數",
"请求时间": "請求時間",
"保存成功": "保存成功",
"是否要清空日志?": "是否要清空日誌?",
"提示": "提示",
"清空成功": "清空成功",
"基本信息": "基本信息",
"头像": "頭像",
"昵称": "暱稱",
"请填写昵称": "請填寫暱稱",
"原密码": "原密碼",
"请填写原密码": "請填寫原密碼",
"新密码": "新密碼",
"角色标签": "角色標籤",
"个人中心": "個人中心",
"正在加载资源...": "正在加載資源...",
"初次加载资源可能需要较多时间,请耐心等待": "初次加載資源可能需要較多時間,請耐心等待",
"搜索名称": "搜索名稱",
"是否关联上下级": "是否關聯上下級",
"名称": "名稱",
"标识": "標識",
"备注": "備註",
"功能权限": "功能權限",
"数据权限": "數據權限",
"创建时间": "創建時間",
"更新时间": "更新時間",
"数据类型": "數據類型",
"搜索名称、keyName": "搜索名稱、keyName",
"字符串": "字串",
"富文本": "富文本",
"文件": "文件",
"请输入Key": "請輸入Key",
"类型": "類型",
"数据": "數據",
"删除": "刪除",
"新增成员": "新增成員",
"目录": "目錄",
"菜单": "菜單",
"权限": "權限",
"是否显示": "是否顯示",
"图标": "圖標",
"节点路由": "節點路由",
"路由缓存": "路由緩存",
"文件路径": "文件路徑",
"排序号": "排序號",
"节点类型": "節點類型",
"节点名称": "節點名稱",
"上级节点": "上級節點",
"请输入节点路由,如:/test": "請輸入節點路由,如:/test",
"开启": "開啟",
"关闭": "關閉",
"请填写排序号": "請填寫排序號",
"导入": "導入",
"如遇到问题无法导入菜单,请检查文件并尝试重新导入。": "如遇到問題無法導入菜單,請檢查文件並嘗試重新導入。",
"用户名不能为空": "用戶名不得為空",
"密码不能为空": "密碼不得為空",
"图片验证码不能为空": "圖片驗證碼不得為空",
"验证码获取失败": "驗證碼獲取失敗",
"马上回来": "馬上回來",
"糟糕,出了点问题": "糟糕,出了點問題",
"找不到您要查找的页面": "找不到您要查找的頁面",
"您无权访问此页面": "您無權訪問此頁面",
"认证失败,请重新登录!": "認證失敗,請重新登錄!",
"返回首页": "返回首頁",
"重新登录": "重新登錄",
"返回登录页": "返回登錄頁",
"自定义输入": "自定義輸入",
"请输入": "請輸入",
"输入关键字进行过滤": "輸入關鍵字進行過濾",
"复制": "複製",
"行为": "行為",
"ip": "IP",
"数据类型 0-字符串 1-富文本 2-文件 ": "數據類型 0-字串 1-富文本 2-文件 ",
"键": "鍵",
"菜单导入": "菜單導入",
"添加": "添加",
"导入成功": "導入成功",
"{file}文件格式错误:{error}": "{file}文件格式錯誤:{error}",
"导出": "導出",
"选择菜单": "選擇菜單",
"请先选择要导出的菜单": "請先選擇要導出的菜單",
"菜单数据": "菜單數據",
"退出登录": "退出登錄",
"确定退出登录吗?": "確定退出登錄嗎?",
"搜索关键字": "搜索關鍵字",
"关闭当前": "關閉當前",
"关闭其他": "關閉其他",
"关闭所有": "關閉所有",
"{label} 没有子菜单,请先添加": "{label} 沒有子菜單,請先添加",
"快速开发后台权限管理系统": "快速開發後台權限管理系統",
"请输入用户名": "請輸入用戶名",
"请输入密码": "請輸入密碼",
"验证码": "驗證碼",
"登录": "登錄",
"选择部门": "選擇部門",
"请选择部门": "請選擇部門",
"转移到新部门,是否继续?": "轉移到新部門,是否繼續?",
"转移成功": "轉移成功",
"组织架构": "組織架構",
"刷新": "刷新",
"拖动排序": "拖動排序",
"编辑部门": "編輯部門",
"上级部门": "上級部門",
"排序": "排序",
"新增部门 “{name}” 成功": "新增部門 “{name}” 成功",
"删除成功": "刪除成功",
"“{name}” 部门的用户已成功转移到 “{parentName}” 部门。": "“{name}” 部門的用戶已成功轉移到 “{parentName}” 部門。",
"此操作将会删除 “{name}” 部门的所有用户,是否确认?": "此操作將會刪除 “{name}” 部門的所有用戶,是否確認?",
"直接删除": "直接刪除",
"保留用户": "保留用戶",
"部门架构已发生改变,是否保存?": "部門架構已發生改變,是否保存?",
"更新排序成功": "更新排序成功",
"新增": "新增",
"编辑": "編輯"
}

View File

@@ -0,0 +1,11 @@
<template>
<error-page :code="401" :desc="$t('认证失败,请重新登录!')" />
</template>
<script lang="ts" setup>
defineOptions({
name: '401'
});
import ErrorPage from './components/error-page.vue';
</script>

View File

@@ -0,0 +1,11 @@
<template>
<error-page :code="403" :desc="$t('您无权访问此页面')" />
</template>
<script lang="ts" setup>
defineOptions({
name: '403'
});
import ErrorPage from './components/error-page.vue';
</script>

View File

@@ -0,0 +1,11 @@
<template>
<error-page :code="404" :desc="$t('找不到您要查找的页面')" />
</template>
<script lang="ts" setup>
defineOptions({
name: '404'
});
import ErrorPage from './components/error-page.vue';
</script>

View File

@@ -0,0 +1,11 @@
<template>
<error-page :code="500" :desc="$t('糟糕,出了点问题')" />
</template>
<script lang="ts" setup>
defineOptions({
name: '500'
});
import ErrorPage from './components/error-page.vue';
</script>

View File

@@ -0,0 +1,11 @@
<template>
<error-page :code="502" :desc="$t('马上回来')" />
</template>
<script lang="ts" setup>
defineOptions({
name: '502'
});
import ErrorPage from './components/error-page.vue';
</script>

View File

@@ -0,0 +1,140 @@
<template>
<div class="error-page">
<div class="error-page__wrap">
<h1 class="error-page__code">
<span v-for="c in codes" :key="c">
{{ c }}
</span>
</h1>
<p class="error-page__desc">{{ desc }}</p>
<template v-if="user.token || isLogout">
<div class="error-page__btns">
<el-button @click="home">{{ $t('返回首页') }}</el-button>
<el-button type="primary" @click="reLogin">{{ $t('重新登录') }}</el-button>
</div>
</template>
<template v-else>
<div class="error-page__btns">
<el-button type="primary" @click="toLogin">{{ $t('返回登录页') }}</el-button>
</div>
</template>
</div>
<div class="error-page__bg is-tl">
<cl-svg name="bg" />
</div>
<div class="error-page__bg is-br">
<cl-svg name="bg" />
</div>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'error-page'
});
import { computed, ref } from 'vue';
import { useCool } from '/@/cool';
import { useBase } from '/$/base';
const props = defineProps({
code: Number,
desc: String
});
const { router } = useCool();
const { user } = useBase();
const isLogout = ref(false);
const codes = computed(() => {
return (props.code || '').toString().split('');
});
function toLogin() {
router.push('/login');
}
async function reLogin() {
isLogout.value = true;
user.logout();
}
function home() {
router.push('/');
}
</script>
<style lang="scss" scoped>
.error-page {
background-color: #fff;
position: relative;
height: 100%;
&__wrap {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
position: relative;
z-index: 9;
}
&__bg {
position: absolute;
height: 100%;
width: 50%;
pointer-events: none;
transform: rotate(180deg) scaleY(-1);
.cl-svg {
height: 100%;
width: 100%;
fill: #2c3142;
}
&.is-tl {
left: 0;
top: 0;
transform: rotate(180deg) scaleY(-1);
}
&.is-br {
top: 0;
right: 0;
transform: scaleY(-1);
}
}
&__code {
font-size: 120px;
font-weight: normal;
color: #6c757d;
font-family: Consolas;
margin-top: -40px;
animation: dou 1s infinite linear;
position: relative;
}
&__desc {
font-size: 16px;
font-weight: 400;
color: #6c757d;
margin-top: 30px;
}
&__btns {
display: flex;
margin-top: 40px;
.el-button {
margin: 0 10px;
}
}
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="pic-captcha" @click="refresh">
<div v-if="svg" class="svg" v-html="svg" />
<img v-else-if="base64" class="base64" :src="base64" alt="" />
<template v-else>
<el-icon class="is-loading" :size="18">
<loading />
</el-icon>
</template>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'pic-captcha'
});
import { onMounted, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import { Loading } from '@element-plus/icons-vue';
import { useCool } from '/@/cool';
import { useI18n } from 'vue-i18n';
const emit = defineEmits(['update:modelValue', 'change']);
const { service } = useCool();
const { t } = useI18n();
// base64
const base64 = ref('');
// svg
const svg = ref('');
// 刷新
async function refresh() {
svg.value = '';
base64.value = '';
await service.base.open
.captcha({
height: 45,
width: 150,
color: '#2c3142'
})
.then(({ captchaId, data }) => {
if (data) {
if (data.includes(';base64,')) {
base64.value = data;
} else {
svg.value = data;
}
emit('update:modelValue', captchaId);
emit('change', {
base64,
svg,
captchaId
});
} else {
ElMessageBox.alert(t('验证码获取失败'), {
title: t('提示'),
type: 'error'
});
}
})
.catch(err => {
ElMessageBox.alert(err.message, {
title: t('提示'),
type: 'error'
});
});
}
onMounted(() => {
refresh();
});
defineExpose({
refresh
});
</script>
<style lang="scss" scoped>
.pic-captcha {
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
height: 45px;
width: 150px;
position: relative;
user-select: none;
.svg {
height: 100%;
position: relative;
}
.base64 {
height: 100%;
}
.is-loading {
position: absolute;
right: 15px;
}
}
</style>

View File

@@ -0,0 +1,307 @@
<template>
<div class="page-login">
<div class="box">
<div class="logo">
<div class="icon">
<img src="/logo.png" alt="Logo" />
</div>
<span>{{ app.info.name }}</span>
</div>
<p class="desc">{{ $t('快速开发后台权限管理系统') }}</p>
<div class="form">
<el-form label-position="top" class="form" :disabled="saving">
<el-form-item :label="$t('用户名')">
<el-input
v-model="form.username"
:placeholder="$t('请输入用户名')"
maxlength="20"
/>
</el-form-item>
<el-form-item :label="$t('密码')">
<el-input
v-model="form.password"
type="password"
:placeholder="$t('请输入密码')"
maxlength="20"
show-password
autocomplete="new-password"
/>
</el-form-item>
<el-form-item :label="$t('验证码')">
<el-input
v-model="form.verifyCode"
:placeholder="$t('验证码')"
maxlength="4"
@keyup.enter="toLogin"
>
<template #suffix>
<pic-captcha
:ref="setRefs('picCaptcha')"
v-model="form.captchaId"
@change="
() => {
form.verifyCode = '';
}
"
/>
</template>
</el-input>
</el-form-item>
<div class="op">
<el-button type="primary" :loading="saving" @click="toLogin">
{{ $t('登录') }}
</el-button>
</div>
</el-form>
</div>
</div>
<div class="bg">
<cl-svg name="bg"></cl-svg>
</div>
<a href="https://cool-js.com" class="copyright"> Copyright © COOL </a>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'login'
});
import { reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useCool } from '/@/cool';
import { useBase } from '/$/base';
import { storage } from '/@/cool/utils';
import { useI18n } from 'vue-i18n';
import PicCaptcha from './components/pic-captcha.vue';
const { refs, setRefs, router, service } = useCool();
const { user, app } = useBase();
const { t } = useI18n();
// 状态
const saving = ref(false);
// 表单数据
const form = reactive({
username: storage.get('username') || '',
password: '',
captchaId: '',
verifyCode: ''
});
// 演示模式
if (import.meta.env.MODE == 'demo') {
form.username = 'admin';
form.password = '123456';
}
// 登录
async function toLogin() {
if (!form.username) {
return ElMessage.error(t('用户名不能为空'));
}
if (!form.password) {
return ElMessage.error(t('密码不能为空'));
}
if (!form.verifyCode) {
return ElMessage.error(t('图片验证码不能为空'));
}
saving.value = true;
try {
// 登录
await service.base.open.login(form).then(user.setToken);
// token 事件
await Promise.all(app.events.hasToken.map(e => e()));
// 设置缓存
storage.set('username', form.username);
// 跳转首页
router.push('/');
} catch (err) {
// 刷新验证码
refs.picCaptcha.refresh();
// 提示错误
ElMessageBox.alert((err as Error).message, {
title: t('提示'),
type: 'error'
});
}
saving.value = false;
}
</script>
<style lang="scss" scoped>
$color: #2c3142;
.page-login {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
position: relative;
background-color: #fff;
color: $color;
.bg {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 90%;
pointer-events: none;
transform: rotate(180deg) scaleY(-1);
.cl-svg {
height: 100%;
width: 100%;
}
}
.copyright {
position: absolute;
bottom: 15px;
left: 0;
text-align: center;
width: 100%;
color: var(--el-color-info);
font-size: 14px;
user-select: none;
}
.box {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
width: 50%;
position: absolute;
right: 0;
top: 0;
z-index: 9;
.logo {
height: 50px;
margin-bottom: 20px;
display: flex;
align-items: center;
user-select: none;
.icon {
border-radius: 8px;
padding: 5px;
margin-right: 10px;
background-color: $color;
img {
height: 36px;
}
}
span {
font-size: 38px;
font-weight: bold;
line-height: 1;
letter-spacing: 3px;
}
}
.desc {
font-size: 15px;
letter-spacing: 1px;
margin-bottom: 50px;
user-select: none;
max-width: 80%;
text-align: center;
}
.form {
width: 300px;
:deep(.el-form) {
.el-form-item {
margin-bottom: 20px;
}
.el-form-item__label {
color: var(--el-color-info);
padding-left: 5px;
user-select: none;
}
.el-input {
box-sizing: border-box;
font-size: 15px;
border: 0;
border-radius: 0;
background-color: #f8f8f8;
padding: 0 5px;
border-radius: 8px;
position: relative;
&__wrapper {
box-shadow: none;
background-color: transparent;
}
&__inner {
height: 45px;
color: #333;
}
&:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px #f8f8f8 inset;
box-shadow: 0 0 0 1000px #f8f8f8 inset;
}
}
}
:deep(.pic-captcha) {
position: absolute;
right: -5px;
top: 0;
}
}
.op {
display: flex;
justify-content: center;
margin-top: 40px;
:deep(.el-button) {
height: 45px;
width: 100%;
font-size: 16px;
border-radius: 8px;
letter-spacing: 1px;
}
}
}
}
@media screen and (max-width: 1024px) {
.page-login {
.box {
width: 100%;
}
}
}
</style>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 500 350"
>
<g transform="">
<g transform="translate(628,-17) scale(100)" opacity="0.3">
<path
d="M4.10125 0 C4.10125 0.5525 4.3542 0.8338 4.1835 1.3593 S3.6427 1.9637 3.318 2.4107 S3.0325 3.2339 2.5855 3.5587 S1.7928 3.7298 1.2674 3.9005 S0.5525 4.3988 0 4.3988 S-0.7419 4.0713 -1.2674 3.9005 S-2.1385 3.8834 -2.5855 3.5587 S-2.9932 2.8576 -3.318 2.4107 S-4.0127 1.8847 -4.1835 1.3593 S-4.1013 0.5525 -4.1013 0 S-4.3542 -0.8338 -4.1835 -1.3593 S-3.6427 -1.9637 -3.318 -2.4107 S-3.0325 -3.2339 -2.5855 -3.5587 S-1.7928 -3.7298 -1.2674 -3.9005 S-0.5525 -4.3988 0 -4.3988 S0.7419 -4.0713 1.2674 -3.9005 S2.1385 -3.8834 2.5855 -3.5587 S2.9932 -2.8576 3.318 -2.4107 S4.0127 -1.8847 4.1835 -1.3593 S4.1013 -0.5525 4.1013 0"
stroke-width="0"
transform="rotate(19)"
>
<animateTransform
attributeName="transform"
type="rotate"
dur="10s"
repeatCount="indefinite"
values="0;36"
></animateTransform>
</path>
</g>
<g transform="translate(704,-56) scale(100)" opacity="0.9">
<path
d="M4.9215 0 C4.9215 0.663 5.225 1.0006 5.0202 1.6311 S4.3713 2.3564 3.9816 2.8928 S3.639 3.8807 3.1026 4.2704 S2.1514 4.4757 1.5208 4.6806 S0.663 5.2785 0 5.2785 S-0.8903 4.8855 -1.5208 4.6806 S-2.5662 4.6601 -3.1026 4.2704 S-3.5919 3.4292 -3.9816 2.8928 S-4.8153 2.2617 -5.0202 1.6311 S-4.9215 0.663 -4.9215 0 S-5.225 -1.0006 -5.0202 -1.6311 S-4.3713 -2.3564 -3.9816 -2.8928 S-3.639 -3.8807 -3.1026 -4.2704 S-2.1514 -4.4757 -1.5208 -4.6806 S-0.663 -5.2785 0 -5.2785 S0.8903 -4.8855 1.5208 -4.6806 S2.5662 -4.6601 3.1026 -4.2704 S3.5919 -3.4292 3.9816 -2.8928 S4.8153 -2.2617 5.0202 -1.6311 S4.9215 -0.663 4.9215 0"
stroke-width="0"
transform="rotate(2.04427)"
>
<animateTransform
attributeName="transform"
type="rotate"
dur="6s"
repeatCount="indefinite"
values="0;36"
></animateTransform>
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,151 @@
<template>
<div class="a-menu">
<div
class="a-menu__item"
v-for="(item, index) in list"
:key="item.id"
:class="{
'is-active': index == active
}"
@click="select(index)"
>
<cl-svg class="mr-3" :name="item.icon" :size="16" v-if="item.icon" />
<span class="text-[12px] tracking-wider whitespace-nowrap">{{ item.meta?.label }}</span>
</div>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'a-menu'
});
import { computed, ref, watch } from 'vue';
import { useBase } from '/$/base';
import { useCool } from '/@/cool';
import { ElMessage } from 'element-plus';
import { useI18n } from 'vue-i18n';
const { router, route } = useCool();
const { menu } = useBase();
const { t } = useI18n();
// 选中标识
const active = ref(0);
// 组列表
const list = computed(() => {
return menu.group.filter(e => e.isShow);
});
// 选择导航
function select(index: number) {
if (index == active.value) {
return false;
}
// 选中的组
const item = list.value[index];
// 获取第一个菜单地址
const url = menu.getPath(item);
if (url) {
// 设置左侧菜单
menu.setMenu(index);
// 跳转
router.push(url);
} else {
ElMessage.warning(t('{label} 没有子菜单,请先添加', { label: item.meta?.label }));
}
}
// 刷新
function refresh() {
let index = 0;
function deep(e: Menu.Item, i: number) {
switch (e.type) {
case 0:
if (e.children) {
e.children.forEach(e => {
deep(e, i);
});
}
break;
case 1:
if (route.path.includes(e.path)) {
index = i;
}
break;
default:
break;
}
}
// 遍历所有分组
list.value.forEach(deep);
// 确认选择
active.value = index;
// 设置该分组下的菜单
menu.setMenu(index);
}
// 监听变化
watch(
() => [route.path, menu.group.length],
() => {
refresh();
},
{
immediate: true
}
);
</script>
<style lang="scss" scoped>
.a-menu {
display: flex;
align-items: center;
flex: 1;
user-select: none;
&__item {
display: flex;
align-items: center;
height: 32px;
padding: 0 16px 0 12px;
border: 0;
color: var(--el-color-info);
position: relative;
background-color: transparent;
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
margin-right: 6px;
transition: all 0.3s;
&.is-active {
border-color: var(--el-color-primary-light-8);
color: var(--el-color-primary);
}
&.is-active,
&:hover {
background-color: var(--el-color-primary-light-9);
}
&:last-child {
margin-right: 0;
}
}
&__name {
margin-left: 8px;
}
}
</style>

View File

@@ -0,0 +1,153 @@
import { defineComponent, h, watch } from 'vue';
import { useBase } from '/$/base';
import { useCool } from '/@/cool';
import { debounce } from 'lodash-es';
export default defineComponent({
name: 'b-menu',
props: {
keyWord: String
},
setup(props) {
const { router, route, browser, refs, setRefs } = useCool();
const { menu, app } = useBase();
// 页面跳转
function onSelect(url: string) {
if (url != route.path) {
router.push(url);
}
// 小屏下点击收起左侧菜单
if (browser.isMini) {
app.fold(true);
}
}
// 渲染子菜单
function renderMenu() {
function deep(list: Menu.Item[], show?: boolean) {
const keyWord = props.keyWord?.toLowerCase() || '';
function filterMenu(item: Menu.Item): boolean {
if (!item.isShow) return false;
if (show) {
return true;
}
if (item.meta?.label?.toLowerCase().includes(keyWord)) return true;
if (item.children) {
return item.children.some(filterMenu);
}
return false;
}
return list.filter(filterMenu).map(e => {
if (e.meta?.label?.toLowerCase().includes(keyWord)) {
show = true;
}
const item = (e: Menu.Item) => {
const arr = [
<cl-svg name={e.icon} size={18} />,
<span class="ml-4 tracking-wider text-[14px] mr-auto text-ellipsis overflow-hidden whitespace-nowrap">
{e.meta?.label}
</span>
];
if (e.type == 1 && e.badge) {
arr.push(
<div class={['b-menu__badge', `is-${e.badgeColor}`]}>
<span>{e.badge}</span>
</div>
);
}
return arr;
};
if (e.type == 0) {
return h(
<el-sub-menu />,
{
index: String(e.id),
key: e.id,
popperClass: 'app-slider__menu'
},
{
title() {
return item(e);
},
default() {
return deep(e.children || [], show);
}
}
);
} else {
return h(
<el-menu-item />,
{
index: e.meta?.isHome ? '/' : e.path,
key: e.id
},
{
default() {
return item(e);
}
}
);
}
});
}
return deep(menu.list);
}
// 展开所有
const expand = debounce(() => {
if (!props.keyWord) {
return;
}
const deep = (list: Menu.Item[]) => {
list.forEach(e => {
if (e.type == 0) {
try {
refs.menu?.open(String(e.id));
} catch (err) { }
if (e.children) {
deep(e.children);
}
}
});
};
deep(menu.list);
}, 300);
watch(() => props.keyWord, expand);
return () => {
return (
<div class="app-slider__menu">
<el-menu
ref={setRefs('menu')}
default-active={route.path}
background-color="transparent"
collapse-transition={false}
collapse={browser.isMini ? false : app.isFold}
onSelect={onSelect}
popper-offset={10}
>
{renderMenu()}
</el-menu>
</div>
);
};
}
});

View File

@@ -0,0 +1,37 @@
<template>
<component v-for="item in list" :key="item.name" :is="item.component" />
</template>
<script setup lang="ts">
import { isFunction, orderBy } from "lodash-es";
import { markRaw, onMounted, shallowRef } from "vue";
import { module } from "/@/cool";
const list = shallowRef<any[]>([]);
async function refresh() {
const arr = orderBy(
module.list.filter((e) => e.enable !== false && !!e.global).map((e) => e.global),
"order"
);
list.value = await Promise.all(
arr
.filter((e) => e?.component)
.map(async (e) => {
if (e) {
const c = await (isFunction(e.component) ? e.component() : e.component);
return {
...e,
component: markRaw(c.default || c)
};
}
})
);
}
onMounted(() => {
refresh();
});
</script>

View File

@@ -0,0 +1,279 @@
<template>
<div class="app-process">
<ul class="app-process__op">
<li class="cl-comm__icon" @click="toBack">
<cl-svg name="back" />
</li>
<li class="cl-comm__icon" @click="toRefresh">
<cl-svg name="refresh" />
</li>
<li class="cl-comm__icon" @click="toHome">
<cl-svg name="home" />
</li>
</ul>
<div class="app-process__container">
<el-scrollbar :ref="setRefs('scroller')" class="app-process__scroller">
<div class="app-process__list">
<div
v-for="(item, index) in process.list"
:key="index"
:ref="setRefs(`item-${index}`)"
class="app-process__item"
:class="{ active: item.active }"
:data-index="index"
@click="onTap(item, Number(index))"
@contextmenu.stop.prevent="openCM($event, item)"
>
<span class="label tracking-wider">
{{ item.meta.label || item.name || item.path }}
</span>
<cl-svg class="close" name="close" @mousedown.stop="onDel(Number(index))" />
</div>
</div>
</el-scrollbar>
</div>
<ul class="app-process__op">
<li class="cl-comm__icon" @click="toFull">
<cl-svg name="screen-normal" v-if="app.isFull" />
<cl-svg name="screen-full" v-else />
</li>
</ul>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'app-process'
});
import { onMounted, watch } from 'vue';
import { last } from 'lodash-es';
import { useCool } from '/@/cool';
import { ContextMenu } from '@cool-vue/crud';
import { useBase } from '/$/base';
import { useI18n } from 'vue-i18n';
const { refs, setRefs, route, router, mitt } = useCool();
const { process, app } = useBase();
const { t } = useI18n();
// 刷新当前路由
function toRefresh() {
mitt.emit('view.refresh');
}
// 回首页
function toHome() {
router.push('/');
}
// 返回上一页
function toBack() {
router.back();
}
// 设置全屏
function toFull() {
app.setFull(!app.isFull);
}
// 跳转
function toPath() {
const d = process.list.find(e => e.active);
if (!d) {
const next = last(process.list);
router.push(next ? next.fullPath : '/');
}
}
// 移动到
function scrollTo(left: number) {
refs.scroller.wrapRef.scrollTo({
left,
behavior: 'smooth'
});
}
// 调整滚动位置
function adScroll(index: number) {
const el = refs[`item-${index}`];
if (el) {
scrollTo(el.offsetLeft - (refs.scroller.wrapRef.clientWidth + el.clientWidth) / 2);
}
}
// 选择
function onTap(item: Process.Item, index: number) {
adScroll(index);
router.push(item.fullPath);
}
// 删除
function onDel(index: number) {
process.remove(index);
toPath();
}
// 右键菜单
function openCM(e: any, item: Process.Item) {
ContextMenu.open(e, {
list: [
{
label: t('关闭当前'),
hidden: item.path !== route.path,
callback(done) {
done();
process.close();
toPath();
}
},
{
label: t('关闭其他'),
callback(done) {
done();
process.set(process.list.filter(e => e.fullPath == item.fullPath));
toPath();
}
},
{
label: t('关闭所有'),
callback(done) {
done();
process.clear();
toPath();
}
}
]
});
}
watch(
() => route.path,
function (val) {
adScroll(process.list.findIndex(e => e.fullPath === val) || 0);
}
);
onMounted(() => {
// 添加滚轮事件监听器
refs.scroller.wrapRef?.addEventListener(
'wheel',
function (event: WheelEvent) {
// 滚动的速度因子,可以根据需要调整
const scrollSpeed = 2;
// 计算滚动的距离
const distance = event.deltaY * scrollSpeed;
scrollTo(refs.scroller.wrapRef.scrollLeft + distance);
},
{ passive: false }
);
});
</script>
<style lang="scss" scoped>
.app-process {
display: flex;
align-items: center;
position: relative;
padding: 5px 10px;
user-select: none;
background-color: var(--el-bg-color);
margin-bottom: 10px;
overflow: hidden;
&__op {
display: flex;
align-items: center;
list-style: none;
.cl-comm__icon {
margin-right: 5px;
&:last-child {
margin-right: 0;
}
}
}
&__container {
height: 100%;
flex: 1;
position: relative;
margin: 0 5px;
}
&__scroller {
height: 40px;
width: 100%;
white-space: nowrap;
position: absolute;
left: 0;
top: 0;
}
&__item {
display: inline-flex;
align-items: center;
justify-content: space-between;
height: 26px;
padding: 0 8px;
cursor: pointer;
color: var(--el-text-color-regular);
border-radius: var(--el-border-radius-base);
margin-right: 5px;
border: 1px solid var(--el-fill-color-dark);
.close {
width: 0;
overflow: hidden;
transition: width 0.2s ease-in-out;
font-size: 14px;
border-radius: 4px;
opacity: 0;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
}
.label {
font-size: 12px;
line-height: 1;
}
&:last-child {
margin-right: 0;
}
&:hover:not(.active) {
background-color: var(--el-fill-color-light);
}
&.active {
background-color: var(--el-color-primary);
border-color: var(--el-color-primary);
color: #fff;
}
&:hover,
&.active {
.close {
margin-left: 10px;
margin-right: -2px;
width: 14px;
opacity: 1;
}
}
}
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div class="route-nav">
<el-text class="font-bold" v-if="browser.isMini">
{{ lastName }}
</el-text>
<template v-else>
<el-breadcrumb :separator-icon="ArrowRightBold">
<el-breadcrumb-item v-for="(item, index) in list" :key="index">
<span class="text-[14px]">{{ item.meta?.label || item.name }}</span>
</el-breadcrumb-item>
</el-breadcrumb>
</template>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'route-nav'
});
import { computed } from 'vue';
import { flattenDeep, last } from 'lodash-es';
import { ArrowRightBold } from '@element-plus/icons-vue';
import { useCool } from '/@/cool';
import { useBase } from '/$/base';
const { route, browser } = useCool();
const { menu } = useBase();
// 数据列表
const list = computed(() => {
function deep(item: any) {
if (route.path === '/') {
return false;
}
if (item.path == route.path) {
return item;
} else {
if (item.children) {
const ret = item.children.map(deep).find(Boolean);
if (ret) {
return [item, ret];
} else {
return false;
}
} else {
return false;
}
}
}
return flattenDeep(menu.group.map(deep).filter(Boolean));
});
// 最后一个节点名称
const lastName = computed(() => last(list.value)?.meta?.label);
</script>
<style lang="scss" scoped>
.route-nav {
white-space: nowrap;
user-select: none;
margin-right: 10px;
:deep(.el-breadcrumb) {
.el-breadcrumb__separator {
font-size: 10px;
margin: 0 10px;
}
.el-breadcrumb__inner {
color: var(--el-text-color-regular);
}
.el-breadcrumb__item {
&:last-child {
.el-breadcrumb__inner {
color: var(--el-text-color-primary);
}
}
}
}
}
</style>

View File

@@ -0,0 +1,188 @@
<template>
<div
class="app-slider"
:class="{
'is-collapse': app.isFold
}"
>
<div class="app-slider__logo">
<img src="/logo.png" />
<span v-if="!app.isFold || browser.isMini">{{ app.info.name }}</span>
</div>
<div class="app-slider__search">
<el-input
v-model="keyWord"
:placeholder="$t('搜索关键字')"
clearable
@focus="app.fold(false)"
>
<template #prefix>
<cl-svg name="search" :size="16" />
</template>
</el-input>
</div>
<div class="app-slider__container">
<el-scrollbar>
<b-menu :keyWord="keyWord" />
</el-scrollbar>
</div>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'app-slider'
});
import { useBase } from '/$/base';
import { useBrowser } from '/@/cool';
import BMenu from './bmenu';
import { ref } from 'vue';
const { browser } = useBrowser();
const { app } = useBase();
const keyWord = ref('');
</script>
<style lang="scss">
.app-slider {
$slider-menu-height: 50px;
--slider-bg-color: #2c3147;
--slider-text-color: #e5eaf3;
height: 100%;
background-color: var(--slider-bg-color);
border-right: 1px solid var(--el-border-color-extra-light);
&__logo {
display: flex;
align-items: center;
height: 66px;
padding: 0 21px;
user-select: none;
img {
height: 24px;
width: 24px;
}
span {
color: #fff;
font-weight: bold;
font-size: 20px;
margin-left: 10px;
white-space: nowrap;
letter-spacing: 1px;
}
}
&__search {
margin: 0 10px 10px 10px;
overflow: hidden;
border-radius: 6px;
.el-input__wrapper {
background-color: rgba(200, 200, 200, 0.1);
box-shadow: none;
height: 36px;
padding: 0 14px;
.el-input__inner {
color: var(--slider-text-color);
}
}
}
&__container {
height: calc(100% - 112px);
}
&__menu {
user-select: none;
.b-menu__badge {
display: flex;
align-items: center;
justify-content: center;
height: $slider-menu-height;
font-size: 10px;
height: 14px;
min-width: 14px;
padding: 0 3px;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.2);
font-weight: bold;
color: #fff;
transition: background-color 0.3s;
}
.el-menu {
width: 100%;
border-right: 0;
background-color: transparent;
&--popup {
border-radius: 6px;
padding: 5px;
&-container {
padding: 0;
}
.el-menu-item,
.el-sub-menu__title {
height: $slider-menu-height;
border-radius: 6px;
&:hover {
background-color: var(--el-fill-color-light);
}
}
}
&:not(&--popup) {
--el-menu-base-level-padding: 23px;
.el-menu-item,
.el-sub-menu__title {
height: $slider-menu-height;
color: var(--slider-text-color);
.cl-svg {
flex-shrink: 0;
}
&.is-active,
&:hover {
background-color: rgba(0, 0, 0, 0.25);
color: #fff;
}
&.is-active {
background-color: var(--el-color-primary);
}
}
}
}
}
&.is-collapse {
.app-slider__search {
.el-input__inner {
opacity: 0;
}
}
.app-slider__menu {
.el-sub-menu {
&.is-active {
background-color: rgba(0, 0, 0, 0.25);
}
}
}
}
}
</style>

View File

@@ -0,0 +1,219 @@
<template>
<div class="app-topbar">
<div class="cl-comm__icon mr-[10px]" @click="app.fold()">
<cl-svg name="fold" v-if="app.isFold" />
<cl-svg name="expand" v-else />
</div>
<!-- 路由导航 -->
<a-menu v-if="app.info.menu.isGroup" />
<route-nav v-else />
<div class="flex1"></div>
<!-- 工具栏 -->
<ul class="app-topbar__tools">
<li v-for="(item, index) in toolbarComponents" :key="index">
<component :is="item.component" />
</li>
</ul>
<!-- 用户信息 -->
<template v-if="user.info">
<el-dropdown
hide-on-click
popper-class="app-topbar__user-popper"
:popper-options="{}"
@command="onCommand"
>
<div class="app-topbar__user">
<el-text class="mr-[10px]">{{ user.info.nickName }}</el-text>
<cl-avatar :size="26" :src="user.info.headImg" />
</div>
<template #dropdown>
<div class="user">
<cl-avatar :size="34" :src="user.info.headImg" />
<div class="det">
<el-text size="small" tag="p">{{ user.info.nickName }}</el-text>
<el-text size="small" type="info">{{ user.info.email }}</el-text>
</div>
</div>
<el-dropdown-menu>
<el-dropdown-item command="my">
<cl-svg name="my" />
<span>{{ t('个人中心') }}</span>
</el-dropdown-item>
<el-dropdown-item command="exit">
<cl-svg name="exit" />
<span>{{ t('退出登录') }}</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'app-topbar'
});
import { computed, markRaw, onMounted, reactive } from 'vue';
import { isFunction, orderBy } from 'lodash-es';
import { useBase } from '/$/base';
import { module, useCool } from '/@/cool';
import { ElMessageBox } from 'element-plus';
import { useI18n } from 'vue-i18n';
import RouteNav from './route-nav.vue';
import AMenu from './amenu.vue';
const { router, service, browser } = useCool();
const { user, app } = useBase();
const { t } = useI18n();
// 命令事件
async function onCommand(name: string) {
switch (name) {
case 'my':
router.push('/my/info');
break;
case 'exit':
ElMessageBox.confirm(t('确定退出登录吗?'), t('提示'), {
type: 'warning'
})
.then(async () => {
await service.base.comm.logout();
user.logout();
})
.catch(() => null);
break;
}
}
// 工具栏
const toolbar = reactive({
list: [] as any[],
async init() {
const arr = orderBy(
module.list.filter(e => e.enable !== false && !!e.toolbar).map(e => e.toolbar),
'order'
);
this.list = await Promise.all(
arr
.filter(e => e?.component)
.map(async e => {
if (e) {
const c = await (isFunction(e.component) ? e.component() : e.component);
return {
...e,
component: markRaw(c.default || c)
};
}
})
);
}
});
// 工具栏组件
const toolbarComponents = computed(() => {
return toolbar.list.filter(e => {
if (browser.isMini) {
return e?.h5 ?? true;
}
return e?.pc ?? true;
});
});
onMounted(() => {
toolbar.init();
});
</script>
<style lang="scss" scoped>
.app-topbar {
display: flex;
align-items: center;
height: 46px;
padding: 0 10px;
background-color: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color-extra-light);
box-sizing: border-box;
transition: height 0.2s ease-in-out;
.flex1 {
flex: 1;
}
&__tools {
display: flex;
margin-right: 10px;
& > li {
display: flex;
justify-content: center;
align-items: center;
list-style: none;
height: 45px;
cursor: pointer;
margin-left: 10px;
}
}
&__user {
display: flex;
align-items: center;
outline: none;
cursor: pointer;
white-space: nowrap;
padding: 5px 5px 5px 10px;
border-radius: 6px;
&:hover {
background-color: var(--el-fill-color-light);
}
}
:deep(.cl-comm__icon) {
&:hover {
border-color: var(--el-color-primary);
background-color: transparent;
}
}
}
</style>
<style lang="scss">
.app-topbar__user-popper {
.el-dropdown-menu__item {
padding: 6px 12px;
font-size: 12px;
}
.user {
display: flex;
align-items: center;
padding: 10px 10px;
width: 200px;
border-bottom: 1px solid var(--el-color-info-light-9);
.det {
margin-left: 10px;
flex: 1;
font-size: 12px;
}
}
.cl-svg {
margin-right: 8px;
font-size: 16px;
}
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<div class="app-views">
<router-view v-slot="{ Component }">
<transition :name="app.info.router.transition || 'none'">
<keep-alive :key="key" :include="caches">
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'app-views'
});
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useBase } from '/$/base';
import { useCool } from '/@/cool';
const { mitt } = useCool();
const { process, app } = useBase();
// 缓存数
const key = ref(1);
// 缓存列表
const caches = computed(() => {
return process.list
.filter(e => e.meta?.keepAlive)
.map(e => {
return e.path.substring(1, e.path.length).replace(/\//g, '-');
});
});
// 刷新页面
function refresh() {
key.value += 1;
}
onMounted(() => {
mitt.on('view.refresh', refresh);
});
onUnmounted(() => {
mitt.off('view.refresh');
});
</script>
<style lang="scss" scoped>
.app-views {
flex: 1;
overflow: hidden;
margin: 0 10px 10px 10px;
width: calc(100% - 20px);
box-sizing: border-box;
border-radius: 6px;
position: relative;
.none-enter-active {
position: absolute;
}
.slide-enter-active {
position: absolute;
top: 0;
width: 100%;
transition: all 0.4s ease-in-out 0.2s;
}
.slide-leave-active {
position: absolute;
top: 0;
width: 100%;
transition: all 0.4s ease-in-out;
}
.slide-enter-to {
transform: translate3d(0, 0, 0);
opacity: 1;
}
.slide-enter-from {
transform: translate3d(-5%, 0, 0);
opacity: 0;
}
.slide-leave-to {
transform: translate3d(5%, 0, 0);
opacity: 0;
}
.slide-leave-from {
transform: translate3d(0, 0, 0);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,132 @@
<template>
<div class="app-layout" :class="{ 'is-collapse': app.isFold, 'is-full': app.isFull }">
<div class="app-layout__mask" @click="app.fold(true)"></div>
<div class="app-layout__left">
<slider />
</div>
<div class="app-layout__right">
<topbar />
<process />
<views />
</div>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'app-layout'
});
import { useBase } from '/$/base';
import Topbar from './components/topbar.vue';
import Slider from './components/slider.vue';
import process from './components/process.vue';
import Views from './components/views.vue';
const { app } = useBase();
</script>
<style lang="scss" scoped>
.app-global {
position: absolute;
left: 0;
top: 0;
}
.app-layout {
display: flex;
background-color: var(--bg-color);
height: 100%;
width: 100%;
overflow: hidden;
&__left {
overflow: hidden;
height: 100%;
width: 255px;
transition: left 0.2s;
}
&__right {
display: flex;
flex-direction: column;
height: 100%;
width: calc(100% - 255px);
}
&__mask {
position: fixed;
left: 0;
top: 0;
background-color: rgba(0, 0, 0, 0.5);
height: 100%;
width: 100%;
z-index: 999;
}
@media only screen and (max-width: 768px) {
.app-layout__left {
position: absolute;
left: 0;
z-index: 9999;
transition:
transform 0.3s cubic-bezier(0.7, 0.3, 0.1, 1),
box-shadow 0.3s cubic-bezier(0.7, 0.3, 0.1, 1);
}
.app-layout__right {
width: 100%;
}
&.is-collapse {
.app-layout__left {
transform: translateX(-100%);
}
.app-layout__mask {
display: none;
}
}
}
@media only screen and (min-width: 768px) {
.app-layout__left,
.app-layout__right {
transition: width 0.2s ease-in-out;
}
.app-layout__mask {
display: none;
}
&.is-collapse {
.app-layout__left {
width: 67px;
}
.app-layout__right {
width: calc(100% - 67px);
}
}
}
&.is-full {
.app-layout__left {
width: 0;
}
.app-layout__right {
width: 100%;
:deep(.a-menu),
:deep(.app-topbar) {
padding: 0;
height: 0;
overflow: hidden;
}
}
}
}
</style>

View File

@@ -0,0 +1,81 @@
#app {
height: 100vh;
width: 100vw;
overflow: hidden;
}
:root {
--bg-color: var(--el-fill-color-lighter);
}
a {
text-decoration: none;
}
input,
button {
outline: none;
}
input {
&:-webkit-autofill {
box-shadow: 0 0 0px 1000px white inset;
}
}
// scrollbar
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar:horizontal {
height: 6px;
}
::-webkit-scrollbar-track {
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background-color: #0003;
border-radius: 10px;
transition: all 0.2s ease-in-out;
}
::-webkit-scrollbar-thumb:hover {
cursor: pointer;
background-color: #0000004d;
}
.dark ::-webkit-scrollbar-thumb {
background-color: #fff3;
}
.dark ::-webkit-scrollbar-thumb:hover {
background-color: #fff6;
}
// custom
.cl-comm__icon {
display: flex;
align-items: center;
justify-content: center;
height: 26px;
width: 26px;
background-color: var(--el-bg-color);
border: 1px solid var(--el-fill-color-dark);
border-radius: 6px;
transition: all 0.2s ease-in-out;
outline: none;
cursor: pointer;
flex-shrink: 0;
.cl-svg {
font-size: 16px;
color: var(--el-text-color-primary);
}
&:hover {
background-color: var(--el-fill-color-light);
}
}

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1737111417178" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10050" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256"><path d="M625.8 526.7H394.4c-24.7 0-44.8-20-44.8-44.8 0-24.7 20-44.8 44.8-44.8h231.4c24.7 0 44.8 20 44.8 44.8 0 24.7-20.1 44.8-44.8 44.8zM625.8 659.3H394.4c-24.7 0-44.8-20-44.8-44.8s20-44.8 44.8-44.8h231.4c24.7 0 44.8 20 44.8 44.8s-20.1 44.8-44.8 44.8z" p-id="10051"></path><path d="M510.1 745.2c-24.7 0-44.8-20-44.8-44.8V502c0-24.7 20-44.8 44.8-44.8 24.7 0 44.8 20 44.8 44.8v198.4c0 24.7-20.1 44.8-44.8 44.8z" p-id="10052"></path><path d="M492.8 500.2c-11.5 0-22.9-4.4-31.7-13.1l-88.6-88.6c-17.5-17.5-17.5-45.8 0-63.3s45.8-17.5 63.3 0l88.6 88.6c17.5 17.5 17.5 45.8 0 63.3-8.6 8.7-20.1 13.1-31.6 13.1z" p-id="10053"></path><path d="M525.7 500.2c-11.5 0-22.9-4.4-31.7-13.1-17.5-17.5-17.5-45.8 0-63.3l90.3-90.3c17.5-17.5 45.8-17.5 63.3 0s17.5 45.8 0 63.3l-90.3 90.3c-8.7 8.7-20.1 13.1-31.6 13.1z" p-id="10054"></path><path d="M510.1 959.7C263.2 959.7 62.4 758.9 62.4 512S263.2 64.2 510.1 64.2 957.9 265.1 957.9 512 757 959.7 510.1 959.7z m0-805.9c-197.5 0-358.2 160.7-358.2 358.2s160.7 358.2 358.2 358.2S868.3 709.5 868.3 512 707.6 153.8 510.1 153.8z" p-id="10055"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
t="1735123259958"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="8100"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="256"
height="256"
>
<path
d="M653.8 840.4c12.1 0 24.2-4.6 33.4-13.8 18.5-18.5 18.5-48.3 0-66.8L437.5 510.1l249.7-249.7c18.5-18.5 18.5-48.3 0-66.8s-48.3-18.5-66.8 0L337.3 476.7c-18.5 18.5-18.5 48.3 0 66.8l283.1 283.1c9.2 9.2 21.3 13.8 33.4 13.8z"
p-id="8101"
></path>
</svg>

After

Width:  |  Height:  |  Size: 596 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1736686029767" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5153" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><path d="M639.6 685.9c-11.5 0-22.9-4.4-31.7-13.1L351.2 416c-17.5-17.5-17.5-45.9 0-63.4s45.9-17.5 63.4 0l256.7 256.7c17.5 17.5 17.5 45.9 0 63.4-8.8 8.8-20.3 13.2-31.7 13.2z" p-id="5154"></path><path d="M382.8 685.9c-11.5 0-22.9-4.4-31.7-13.1-17.5-17.5-17.5-45.9 0-63.4l256.7-256.7c17.5-17.5 45.9-17.5 63.4 0s17.5 45.9 0 63.4L414.5 672.7c-8.7 8.8-20.2 13.2-31.7 13.2z" p-id="5155"></path><path d="M511.2 960.7c-114.8 0-229.5-43.7-316.9-131.1-174.8-174.8-174.8-459.1 0-633.9C369 21 653.4 21 828.1 195.8c174.8 174.8 174.8 459.1 0 633.9-87.3 87.3-202.1 131-316.9 131z m0-806.4c-91.8 0-183.6 35-253.5 104.8-139.8 139.8-139.8 367.3 0 507.1s367.3 139.8 507.1 0 139.8-367.3 0-507.1C694.9 189.3 603 154.3 511.2 154.3z m285.3 643.6h0.2-0.2z" p-id="5156"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
t="1736594824258"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="4443"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="256"
height="256"
>
<path
d="M806.2 281.8L281.9 806.2c-17.5 17.5-46 17.5-63.6 0-17.5-17.5-17.5-46 0-63.6l524.3-524.3c17.5-17.5 46-17.5 63.6 0s17.6 46 0 63.5z"
p-id="4444"
></path>
<path
d="M806.2 806.2c-17.5 17.5-46 17.5-63.6 0L218.3 281.8c-17.5-17.5-17.5-46 0-63.6s46-17.5 63.6 0l524.3 524.3c17.6 17.7 17.6 46.1 0 63.7z"
p-id="4445"
></path>
</svg>

After

Width:  |  Height:  |  Size: 678 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1736691229720" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4788" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><path d="M883.1 308.6H141.5c-24.8 0-44.9-20.1-44.9-44.9s20.1-44.9 44.9-44.9H883c24.8 0 44.9 20.1 44.9 44.9s-20 44.9-44.8 44.9z" p-id="4789"></path><path d="M629.6 308.6H394.9c-24.8 0-44.9-20.1-44.9-44.9v-37.6c0-89.5 72.8-162.3 162.3-162.3s162.3 72.8 162.3 162.3v37.6c0 24.8-20.1 44.9-45 44.9z m-189.4-89.9h144.1c-3.7-36.5-34.6-65-72-65-37.5 0-68.4 28.5-72.1 65z" p-id="4790"></path><path d="M698.5 958.6H326c-89.5 0-162.3-72.8-162.3-162.3V263.6c0-24.8 20.1-44.9 44.9-44.9h607.2c24.8 0 44.9 20.1 44.9 44.9v532.6c0.1 89.6-72.7 162.4-162.2 162.4z m-444.9-650v487.7c0 39.9 32.5 72.4 72.4 72.4h372.5c39.9 0 72.4-32.5 72.4-72.4V308.6H253.6z" p-id="4791"></path><path d="M415.5 726.1c-24.8 0-44.9-20.1-44.9-44.9V498.1c0-24.8 20.1-44.9 44.9-44.9s44.9 20.1 44.9 44.9v183.1c0.1 24.8-20 44.9-44.9 44.9zM609 726.1c-24.8 0-44.9-20.1-44.9-44.9V498.1c0-24.8 20.1-44.9 44.9-44.9s44.9 20.1 44.9 44.9v183.1c0.1 24.8-20 44.9-44.9 44.9z" p-id="4792"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
t="1736507350407"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="4808"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="256"
height="256"
>
<path
d="M761.5 928.3H228.3C156.5 928.3 98 869.8 98 798V277.6c0-71.8 58.5-130.3 130.3-130.3h248.8c23.2 0 42.1 18.8 42.1 42.1s-18.8 42.1-42.1 42.1H228.3c-25.5 0-46.2 20.7-46.2 46.2V798c0 25.5 20.7 46.2 46.2 46.2h533.2c25.5 0 46.2-20.7 46.2-46.2V501.2c0-23.2 18.8-42.1 42.1-42.1s42.1 18.8 42.1 42.1V798c-0.1 71.8-58.5 130.3-130.4 130.3z"
p-id="4809"
></path>
<path
d="M374.5 675.7c-11.1 0-21.8-4.4-29.7-12.3-9.1-9.1-13.6-22-12-34.8 12.3-101.9 59-198 131.6-270.5l245.2-245.2C726.2 96.2 748.4 87 772 87s45.8 9.2 62.5 25.9l60.7 60.6c34.5 34.5 34.5 90.6 0 125L650 543.8c-72.6 72.6-168.6 119.3-270.5 131.6-1.7 0.2-3.4 0.3-5 0.3zM772 171.3c-1.2 0-2.3 0.4-3 1.1L523.8 417.6c-45.1 45.1-78 101.3-95.6 162.3 61-17.6 117.2-50.5 162.3-95.6l245.2-245.2c1.7-1.7 1.7-4.4 0-6.1L775 172.4c-0.6-0.7-1.8-1.1-3-1.1z m93.5 97.6h0.2-0.2z"
p-id="4810"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
t="1735123199043"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="7763"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="256"
height="256"
>
<path
d="M574.8 923.6H222c-67.2 0-121.8-54.7-121.8-121.8V223c0-67.2 54.7-121.8 121.8-121.8h352.8c67.2 0 121.8 54.7 121.8 121.8v50.6c0 22.9-18.5 41.4-41.4 41.4s-41.4-18.5-41.4-41.4V223c0-21.5-17.5-39-39-39H222c-21.5 0-39 17.5-39 39v578.8c0 21.5 17.5 39 39 39h352.8c21.5 0 39-17.5 39-39v-44.5c0-22.9 18.5-41.4 41.4-41.4s41.4 18.5 41.4 41.4v44.5c0 67.1-54.7 121.8-121.8 121.8z"
p-id="7764"
></path>
<path
d="M860.6 553.8H470.3c-22.9 0-41.4-18.5-41.4-41.4s18.5-41.4 41.4-41.4h390.4c22.9 0 41.4 18.5 41.4 41.4s-18.6 41.4-41.5 41.4z"
p-id="7765"
></path>
<path
d="M747.6 693.1c-10.6 0-21.2-4-29.3-12.1-16.2-16.2-16.2-42.4 0-58.6l110-110-110-110c-16.2-16.2-16.2-42.4 0-58.6 16.2-16.2 42.4-16.2 58.6 0l139.2 139.3c16.2 16.2 16.2 42.4 0 58.6L776.9 680.9c-8.1 8.1-18.7 12.2-29.3 12.2z"
p-id="7766"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1736678565688" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4477" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><path d="M125.28125027 160.43749973m35.15624946 0l703.12500054 0q35.15625027 0 35.15624946 35.15625027l0 0q0 35.15625027-35.15624946 35.15625027l-703.12500054 0q-35.15625027 0-35.15624946-35.15625027l0 0q0-35.15625027 35.15624946-35.15625027Z" p-id="4478"></path><path d="M441.68750027 371.37499973m35.15624946 0l386.71875054 0q35.15625027 0 35.15624946 35.15625027l0 0q0 35.15625027-35.15624946 35.15625027l-386.71875054 0q-35.15625027 0-35.15624946-35.15625027l0 0q0-35.15625027 35.15624946-35.15625027Z" p-id="4479"></path><path d="M441.68750027 582.31249973m35.15624946 0l386.71875054 0q35.15625027 0 35.15624946 35.15625027l0 0q0 35.15625027-35.15624946 35.15625027l-386.71875054 0q-35.15625027 0-35.15624946-35.15625027l0 0q0-35.15625027 35.15624946-35.15625027Z" p-id="4480"></path><path d="M125.28125027 793.24999973m35.15624946 0l703.12500054 0q35.15625027 0 35.15624946 35.15625027l0 0q0 35.15625027-35.15624946 35.15625027l-703.12500054 0q-35.15625027 0-35.15624946-35.15625027l0 0q0-35.15625027 35.15624946-35.15625027Z" p-id="4481"></path><path d="M282.95703152 378.16015625a35.15625027 35.15625027 0 0 1 44.89453098 53.92968777l-3.375 2.81249973L219.04296902 512l105.43359348 77.09765625a35.15625027 35.15625027 0 0 1 9.98437473 45.38671902l-2.35546821 3.72656196a35.15625027 35.15625027 0 0 1-45.42187554 10.01953125l-3.72656196-2.39062473-144.21093777-105.46875a35.15625027 35.15625027 0 0 1-3.375-53.96484348l3.375-2.81250054 144.21093777-105.46875z" p-id="4482"></path></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1736864587458" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4348" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256"><path d="M828.74 92A105 105 0 0 1 932 197v631.74A105 105 0 0 1 827 932H515.78a15 15 0 0 1-15-15v-60a15 15 0 0 1 15-15h311.22a15.06 15.06 0 0 0 15-15V197a15.06 15.06 0 0 0-15-15H196.7a15 15 0 0 0-14.7 15v300a15 15 0 0 1-15 15h-60A15 15 0 0 1 92 497V195.26A105 105 0 0 1 197 92zM302 486.8a15 15 0 0 1 15 15v310.98a15 15 0 0 1-24.6 11.52l-186.6-155.46a15 15 0 0 1 0-23.04l186.6-155.46a15 15 0 0 1 9.6-3.48z m300 125.52a15 15 0 0 1 15 15v60a15 15 0 0 1-15 15H317v-90z" p-id="4349"></path></svg>

After

Width:  |  Height:  |  Size: 814 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1736680594235" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4503" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><path d="M639.6 685.9c-11.5 0-22.9-4.4-31.7-13.1L351.2 416c-17.5-17.5-17.5-45.9 0-63.4s45.9-17.5 63.4 0l256.7 256.7c17.5 17.5 17.5 45.9 0 63.4-8.8 8.8-20.3 13.2-31.7 13.2z" p-id="4504"></path><path d="M382.8 685.9c-11.5 0-22.9-4.4-31.7-13.1-17.5-17.5-17.5-45.9 0-63.4l256.7-256.7c17.5-17.5 45.9-17.5 63.4 0s17.5 45.9 0 63.4L414.5 672.7c-8.7 8.8-20.2 13.2-31.7 13.2z" p-id="4505"></path><path d="M511.2 960.7c-114.8 0-229.5-43.7-316.9-131.1-174.8-174.8-174.8-459.1 0-633.9C369 21 653.4 21 828.1 195.8c174.8 174.8 174.8 459.1 0 633.9-87.3 87.3-202.1 131-316.9 131z m0-806.4c-91.8 0-183.6 35-253.5 104.8-139.8 139.8-139.8 367.3 0 507.1s367.3 139.8 507.1 0 139.8-367.3 0-507.1C694.9 189.3 603 154.3 511.2 154.3z m285.3 643.6h0.2-0.2z" p-id="4506"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1736678563154" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4316" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><path d="M860.92578152 160.43749973H160.43749973a35.15625027 35.15625027 0 1 0 0 70.31250054h700.48828179a35.15625027 35.15625027 0 0 0 0-70.31250054z m-315.3515625 210.9375H160.43749973a35.15625027 35.15625027 0 1 0 0 70.31250054h385.13671929a35.15625027 35.15625027 0 1 0 0-70.31250054z m0 210.9375H160.43749973a35.15625027 35.15625027 0 0 0 0 70.31250054h385.13671929a35.15625027 35.15625027 0 1 0 0-70.31250054z m315.3515625 210.9375H160.43749973a35.15625027 35.15625027 0 0 0 0 70.31250054h700.48828179a35.15625027 35.15625027 0 0 0 0-70.31250054zM740.62109348 378.16015625a34.94531277 34.94531277 0 0 0-48.9375 7.62890652c-10.546875 14.44921875-8.4375 34.31250027 4.21875 46.30078125l3.375 2.81249973L804.32421848 512l-105.046875 77.09765625c-14.41406223 10.546875-18.42187473 30.12890598-9.98437473 45.38671902l2.39062473 3.72656196c10.546875 14.48437527 30.02343723 18.49218777 45.24609375 10.01953125l3.69140625-2.39062473 143.75390625-105.46875c17.9296875-13.14843723 19.05468723-39.16406277 3.33984429-53.96484348l-3.33984429-2.81250054-143.75390625-105.46875z" p-id="4317"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
t="1736591017958"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="4282"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="256"
height="256"
>
<path
d="M512 88.0000003A435.6 435.6 0 0 0 72.00000031 518.8000003 431.59999969 431.59999969 0 0 0 372.8 928.0000003c22.00000031 4.00000031 30-9.19999969 30-20.80000031v-73.2c-122.4 25.99999969-148.39999969-57.6-148.39999969-57.6a114.40000031 114.40000031 0 0 0-48.80000062-63.19999969c-40.00000031-26.4 3.19999969-25.99999969 3.20000062-26.00000062a92.4 92.4 0 0 1 67.2 44.4 94.8 94.8 0 0 0 127.99999969 35.60000062 93.19999969 93.19999969 0 0 1 28.00000031-57.6c-97.60000031-10.8-199.99999969-47.59999969-200.00000062-212.80000031a166.00000031 166.00000031 0 0 1 44.4-116.4 151.2 151.2 0 0 1 4.40000062-113.59999969s37.2-11.59999969 120 43.99999969a427.2 427.2 0 0 1 219.99999938 0c84-55.60000031 120-43.99999969 120-43.99999969a151.2 151.2 0 0 1 4.40000062 113.59999969A166.00000031 166.00000031 0 0 1 792.00000031 496.0000003c0 165.6-103.2 202.00000031-200.00000062 212.79999938a100.00000031 100.00000031 0 0 1 30 80.00000062v117.99999938c0 13.99999969 7.99999969 25.2 30 20.80000031A432 432 0 0 0 951.99999969 518.8000003 435.6 435.6 0 0 0 512 88.0000003"
p-id="4283"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
t="1736671760764"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="7110"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="128"
height="128"
>
<path
d="M725 920.1H614c-24.6 0-44.6-20-44.6-44.6v-214H456.6v214c0 24.6-20 44.6-44.6 44.6H298.5c-68.8 0-124.9-56-124.9-124.8v-259l-61.7 1c-19.4 0.9-35.7-11.2-42.4-28.8-6.7-17.6-1.6-37.4 12.6-49.7L483.9 113c16.7-14.4 41.5-14.4 58.2 0l401.7 345.8c14.2 12.2 19.3 32.1 12.6 49.7-6.7 17.6-24.3 28.9-42.4 28.8l-64.1-1v259c0 68.8-56 124.8-124.9 124.8z m-66.3-89.2H725c19.6 0 35.6-16 35.6-35.6V490.9c0-12 4.8-23.4 13.3-31.8 5.6-5.6 12.6-9.5 20.2-11.4L513 205.7 231.3 448.2c6.8 2.1 13.1 5.8 18.3 10.9 8.5 8.4 13.3 19.9 13.3 31.8v304.3c0 19.6 16 35.6 35.6 35.6h68.8V658.4c0-47.5 38.6-86.1 86.1-86.1h119.2c47.5 0 86.1 38.6 86.1 86.1v172.5z"
p-id="7111"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1000 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1614441341007" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11418" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><defs><style type="text/css"></style></defs><path d="M848.896 132.192c-10.048-5.664-22.4-5.408-32.352 0.544-0.448 0.288-45.664 27.328-98.688 27.328-53.184 0-99.776-27.232-100.16-27.424a29.488 29.488 0 0 0-3.456-1.792c-3.2-1.408-79.008-34.752-149.696-34.752-70.08 0-151.936 32.96-155.36 34.368-12.032 4.896-19.936 16.64-19.936 29.632v416a32.041 32.041 0 0 0 14.24 26.624c8.928 5.984 20.192 7.04 30.08 2.912 19.68-8.224 80.992-29.536 127.68-29.536 51.52 0 115.776 24.768 126.208 28.928 11.712 6.304 68.544 35.072 133.088 35.072 72.032 0 127.776-35.616 130.08-37.152 9.088-5.92 14.592-16 14.592-26.848v-416c0.032-11.584-6.24-22.208-16.32-27.904z m-271.68 763.744H224.768V128.064c0-17.664-14.336-32-32-32s-32 14.336-32 32v768c0 0.064 0.032 0.096 0.032 0.16-16.96 0.8-30.56 14.528-30.56 31.712 0 17.696 14.336 32 32 32h414.976c17.696 0 32-14.304 32-32s-14.304-32-32-32z" p-id="11419"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg t="1676622023768" class="icon" viewBox="0 0 1024 1024" version="1.1"
xmlns="http://www.w3.org/2000/svg" p-id="1470" xmlns:xlink="http://www.w3.org/1999/xlink"
width="64" height="64">
<path
d="M373.9996544 320.53819093l298.27051947 0c20.11542827 0 39.48256427-8.5139072 52.3939168-23.95154773 30.781264-36.58213013 59.972352-81.49120213 66.52151146-121.72205867 13.19190933-80.5555168-21.33188373-121.44150293-121.44150293-121.44150293s-66.2407424 65.67941653-105.16197973 71.1059232c-29.003696 4.02308587-27.9746336-34.24302293-73.53840747-71.01233387-32.74622507-26.2904416-69.42173227 47.99647147-112.6468256 48.464208-50.42895573 0.65491627-121.34791253-13.28549867-121.34791253 72.8834912 0 34.80456213 30.5007072 81.86534827 63.80825813 120.87996374C334.2365344 311.36915413 353.50986667 320.53819093 373.9996544 320.53819093zM746.08935147 397.25758827c-15.90516267-18.05709013-39.0146144-28.16181333-63.05975254-28.16181334L364.7372416 369.09577493c-23.0158624 0-45.18962773 9.26241387-61.09500373 25.91629547C203.62599467 499.79990507 111.3755808 649.40300693 111.3755808 758.4944832c0 96.18012373 96.6478592 217.6216256 215.84384427 217.6216256l369.5636224 0c119.19598507 0 215.84384427-120.4122272 215.84384426-217.6216256C912.62710507 647.4382592 838.43356907 502.04542187 746.08935147 397.25758827zM648.8801664 529.645696l-63.99522453 116.10858453c-5.61368747 10.1980992 1.777568 22.73509227 13.37908906 22.73509227l18.15068054 0c8.4205312 0 15.3438368 6.8299296 15.3438368 15.3438368l0 0c0 8.4205312-6.8299296 15.3438368-15.3438368 15.3438368l-45.6575776 0c-8.88826667 0-15.99875307 7.11069867-16.0923424 15.99875307l0 7.5784352c0 8.4205312 6.8299296 15.3438368 15.3438368 15.3438368l46.40586986 0c8.4205312 0 15.3438368 6.8299296 15.3438368 15.3438368s-6.8299296 15.3438368-15.3438368 15.3438368l-46.40586986 0c-8.4205312 0-15.3438368 6.8299296-15.3438368 15.3438368l0 39.0146144c0 8.4205312-6.8299296 15.3438368-15.3438368 15.3438368l-27.78745387 0c-8.4205312 0-15.3438368-6.8299296-15.3438368-15.3438368l0-39.0146144c0-8.4205312-6.8299296-15.3438368-15.3438368-15.3438368l-45.9381344 0c-8.4205312 0-15.3438368-6.8299296-15.3438368-15.3438368s6.8299296-15.3438368 15.3438368-15.3438368l45.844544 0c8.51412053 0 15.34405013-6.8299296 15.4374272-15.3438368l0.0935904-7.4848448c0.0935904-8.88826667-7.11069867-16.1859328-16.0923424-16.1859328l-45.28321813 0c-8.4205312 0-15.3438368-6.8299296-15.3438368-15.3438368l0 0c0-8.4205312 6.8299296-15.3438368 15.3438368-15.3438368l17.682944 0c11.6951104 0 18.99277653-12.53699307 13.37908906-22.73509227l-63.99543786-116.10837227c-5.61368747-10.1980992 1.777568-22.7353056 13.37908906-22.7353056l30.5007072 0c5.80065387 0 11.13378453 3.27458027 13.65985814 8.4205312l52.11314773 103.94552534c5.61368747 11.2271616 21.70602987 11.2271616 27.41330667 0l52.11314773-103.94552534c2.619664-5.1457376 7.858992-8.4205312 13.65985813-8.4205312l30.5007072 0C647.10238507 507.0039808 654.4938528 519.4475968 648.8801664 529.645696z"
p-id="1471"></path>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1614441249036" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4552" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><defs><style type="text/css"></style></defs><path d="M364.8 590.62857143H182.85714286c-60.34285714 0-109.71428571-49.37142857-109.71428572-109.71428572V182.85714286c0-60.34285714 49.37142857-109.71428571 109.71428572-109.71428572h181.94285714c60.34285714 0 109.71428571 49.37142857 109.71428571 109.71428572v298.05714285c0 60.34285714-49.37142857 109.71428571-109.71428571 109.71428572zM841.14285714 358.4H659.2c-60.34285714 0-109.71428571-49.37142857-109.71428571-109.71428571V182.85714286c0-60.34285714 49.37142857-109.71428571 109.71428571-109.71428572H841.14285714c60.34285714 0 109.71428571 49.37142857 109.71428572 109.71428572v65.82857143c0 60.34285714-49.37142857 109.71428571-109.71428572 109.71428571zM364.8 950.85714286H182.85714286c-60.34285714 0-109.71428571-49.37142857-109.71428572-109.71428572v-65.82857143c0-60.34285714 49.37142857-109.71428571 109.71428572-109.71428571h181.94285714c60.34285714 0 109.71428571 49.37142857 109.71428571 109.71428571v65.82857143c0 60.34285714-49.37142857 109.71428571-109.71428571 109.71428572z m476.34285714 0H659.2c-60.34285714 0-109.71428571-49.37142857-109.71428571-109.71428572V543.08571429c0-60.34285714 49.37142857-109.71428571 109.71428571-109.71428572H841.14285714c60.34285714 0 109.71428571 49.37142857 109.71428572 109.71428572V841.14285714c0 60.34285714-49.37142857 109.71428571-109.71428572 109.71428572z" p-id="4553"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

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