This commit is contained in:
lixin 2025-01-09 16:13:14 +08:00
commit 4dbcc7b837
471 changed files with 47757 additions and 0 deletions

5
.dockerignore Normal file
View File

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

11
.editorconfig Normal file
View File

@ -0,0 +1,11 @@
# 🎨 editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = tab
indent_size = 4
trim_trailing_whitespace = true
insert_final_newline = true

5
.env Normal file
View File

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

3
.eslintignore Normal file
View File

@ -0,0 +1,3 @@
packages/
dist/
node_modules/

47
.eslintrc.js Normal file
View File

@ -0,0 +1,47 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
root: true,
extends: [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/eslint-config-typescript",
"@vue/eslint-config-prettier/skip-formatting"
],
parserOptions: {
ecmaVersion: "latest"
},
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",
"vue/no-mutating-props": "off",
"vue/component-name-in-template-casing": ["error", "kebab-case"],
"vue/component-definition-name-casing": ["error", "kebab-case"],
"no-use-before-define": "off",
"no-unused-vars": "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",
"space-before-function-paren": "off",
"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",
"no-self-assign": "off"
}
};

4
.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

18
.gitignore vendored Normal file
View File

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

16
.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"
}
}

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"tabWidth": 4,
"useTabs": true,
"semi": true,
"singleQuote": false,
"printWidth": 100,
"trailingComma": "none"
}

15
.vscode/config.code-snippets vendored Normal file
View File

@ -0,0 +1,15 @@
{
"module-config": {
"prefix": "module-config",
"scope": "typescript",
"body": [
"import type { ModuleConfig } from \"/@/cool\";",
"",
"export default (): ModuleConfig => {",
" return {};",
"};",
""
],
"description": "module config snippets"
}
}

102
.vscode/crud.code-snippets vendored Normal file
View File

@ -0,0 +1,102 @@
{
"cl-crud": {
"prefix": "cl-crud",
"scope": "vue",
"body": [
"<template>",
" <cl-crud ref=\"Crud\">",
" <cl-row>",
" <!-- 刷新按钮 -->",
" <cl-refresh-btn />",
" <!-- 新增按钮 -->",
" <cl-add-btn />",
" <!-- 删除按钮 -->",
" <cl-multi-delete-btn />",
" <cl-flex1 />",
" <!-- 关键字搜索 -->",
" <cl-search-key />",
" </cl-row>",
"",
" <cl-row>",
" <!-- 数据表格 -->",
" <cl-table ref=\"Table\" />",
" </cl-row>",
"",
" <cl-row>",
" <cl-flex1 />",
" <!-- 分页控件 -->",
" <cl-pagination />",
" </cl-row>",
"",
" <!-- 新增、编辑 -->",
" <cl-upsert ref=\"Upsert\" />",
" </cl-crud>",
"</template>",
"",
"<script lang=\"ts\" name=\"菜单名称\" setup>",
"import { useCrud, useTable, useUpsert } from \"@cool-vue/crud\";",
"import { useCool } from \"/@/cool\";",
"",
"const { service } = useCool();",
"",
"// cl-upsert",
"const Upsert = useUpsert({",
" items: []",
"});",
"",
"// cl-table",
"const Table = useTable({",
" columns: []",
"});",
"",
"// cl-crud",
"const Crud = useCrud(",
" {",
" service: service.demo.goods",
" },",
" (app) => {",
" app.refresh();",
" }",
");",
"",
"// 刷新",
"function refresh(params?: any) {",
" Crud.value?.refresh(params);",
"}",
"</script>",
""
],
"description": "cl-crud snippets"
},
"cl-filter": {
"prefix": "cl-filter",
"scope": "html",
"body": [
"<cl-filter label=\"\">",
" <cl-select :options=\"[$1]\" prop=\"\" />",
"</cl-filter>"
],
"description": "cl-filter snippets"
},
"item": {
"prefix": "item",
"scope": "typescript",
"body": [
"{",
" label: \"$1\",",
" prop: \"\",",
" component: {",
" name: \"\"",
" }",
"},",
""
],
"description": "item snippets"
},
"column": {
"prefix": "column",
"scope": "typescript",
"body": ["{", " label: \"$1\",", " prop: \"\",", "},", ""],
"description": "column snippets"
}
}

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"editor.cursorSmoothCaretAnimation": "on",
"editor.formatOnSave": true,
}

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM node:lts-alpine
WORKDIR /build
# 设置Node-Sass的镜像地址
RUN npm config set sass_binary_site=https://npm.taobao.org/mirrors/node-sass/
# 设置npm镜像
RUN npm config set registry https://registry.npm.taobao.org
COPY package.json /build/package.json
RUN yarn
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

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 cool-team-official
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.

57
README.md Normal file
View File

@ -0,0 +1,57 @@
# 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>
## 地址
- [📌 v6 vue3 + element-plus + ts + vite](https://github.com/cool-team-official/cool-admin-vue/tree/6.x)
- [⚡️ v5 vue3 + element-plus + ts + vite](https://github.com/cool-team-official/cool-admin-vue/tree/5.x)
- [🌐 码云仓库地址](https://gitee.com/cool-team-official/cool-admin-vue)
## 演示
[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)
## 微信群
<img width="260" src="https://cool-show.oss-cn-shanghai.aliyuncs.com/admin/wechat.jpeg" alt="Admin Wechat"></a>
## 安装项目依赖
推荐使用 `yarn`
```shell
yarn
```
## 运行应用程序
安装过程完成后,运行以下命令启动服务。您可以在浏览器中预览网站 [http://localhost:9000](http://localhost:9000)
```shell
yarn dev
```
### 低价服务器
[阿里云、腾讯云、华为云低价云服务器,不限新老](https://cool-js.com/ad/server.html)

3475
build/cool/eps.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

1
build/cool/eps.json Normal file

File diff suppressed because one or more lines are too long

172
index.html Normal file
View File

@ -0,0 +1,172 @@
<!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%;
}
* {
margin: 0;
padding: 0;
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;
}
.preload__title {
color: #fff;
font-size: 14px;
margin: 30px 0 20px 0;
}
.preload__sub-title {
color: #ababab;
font-size: 12px;
}
.preload__footer {
text-align: center;
padding: 10px 0 20px 0;
}
.preload__footer a {
font-size: 12px;
color: #ababab;
text-decoration: none;
}
.preload__loading {
height: 30px;
width: 30px;
border-radius: 30px;
border: 7px solid currentColor;
border-bottom-color: #2f3447 !important;
position: relative;
animation: r 1s infinite cubic-bezier(0.17, 0.67, 0.83, 0.67),
bc 2s infinite ease-in;
transform: rotate(0deg);
}
@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">
<p class="preload__name">%VITE_NAME%</p>
<div class="preload__loading"></div>
<p class="preload__title">正在加载资源...</p>
<p class="preload__sub-title">初次加载资源可能需要较多时间 请耐心等待</p>
</div>
<div class="preload__footer">
<a href="https://cool-js.com" target="_blank"> https://cool-js.com </a>
</div>
</div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

123
nginx.conf Normal file
View File

@ -0,0 +1,123 @@
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 backend {
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://backend/;
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;
}
# location /im {
# proxy_pass http://backend/im;
# 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;
# }
# location /socket {
# proxy_pass http://backend/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;
# }
location /adminer/
{
proxy_pass http://adminer:8080/;
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;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}

64
package.json Normal file
View File

@ -0,0 +1,64 @@
{
"name": "cool-admin",
"version": "7.2.0",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview",
"format": "prettier --write src/",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .eslintignore"
},
"dependencies": {
"@cool-vue/crud": "^7.2.0",
"@element-plus/icons-vue": "^2.3.1",
"@vueuse/core": "^10.4.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.7.2",
"chardet": "^2.0.0",
"core-js": "^3.32.1",
"dayjs": "^1.11.10",
"echarts": "^5.4.3",
"element-plus": "^2.7.7",
"file-saver": "^2.0.5",
"lodash-es": "^4.17.21",
"marked": "^11.1.1",
"mitt": "^3.0.1",
"mockjs": "^1.1.0",
"monaco-editor": "0.49.0",
"nprogress": "^0.2.0",
"pinia": "^2.1.7",
"socket.io-client": "^4.7.2",
"store": "^2.0.12",
"vue": "^3.4.15",
"vue-echarts": "^6.6.1",
"vue-router": "^4.4.0",
"vuedraggable": "^4.1.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@cool-vue/vite-plugin": "^7.2.1",
"@rushstack/eslint-patch": "^1.8.0",
"@types/file-saver": "^2.0.7",
"@types/lodash-es": "^4.17.8",
"@types/mockjs": "^1.0.7",
"@types/node": "^20.14.5",
"@types/nprogress": "^0.2.0",
"@types/store": "^2.0.2",
"@vitejs/plugin-vue": "^5.0.3",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"@vue/compiler-sfc": "^3.4.15",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"prettier": "^3.3.3",
"rollup-plugin-visualizer": "^5.9.2",
"sass": "^1.53.0",
"terser": "^5.27.0",
"typescript": "^5.4.0",
"vite": "^5.3.4",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "^7.3.1"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

10
src/App.vue Normal file
View File

@ -0,0 +1,10 @@
<template>
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>
<script lang="ts" setup>
import { ElConfigProvider } from "element-plus";
import zhCn from "element-plus/dist/locale/zh-cn.mjs";
</script>

20
src/config/dev.ts Normal file
View File

@ -0,0 +1,20 @@
import { getUrlParam, storage } from "/@/cool/utils";
import { proxy } from "./proxy";
export default {
// 根地址
host: proxy["/dev/"].target,
// 请求地址
get baseUrl() {
let proxy = getUrlParam("proxy");
if (proxy) {
storage.set("proxy", proxy);
} else {
proxy = storage.get("proxy") || "dev";
}
return `/${proxy}`;
}
};

60
src/config/index.ts Normal file
View File

@ -0,0 +1,60 @@
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: "history",
// 转场动画
transition: "slide"
},
// 字体图标库
iconfont: []
},
// 忽略规则
ignore: {
// 不显示请求进度条
NProgress: [
"/__cool_eps",
"/base/open/eps",
"/base/comm/person",
"/base/comm/permmenu",
"/base/comm/upload",
"/base/comm/uploadMode",
"/dict/info/data",
"/space/info/add"
],
// 页面不需要登录验证
token: ["/login", "/401", "/403", "/404", "/500", "/502"]
},
// 调试
test: {
token: "",
eps: true
},
// 当前环境
...(isDev ? dev : prod)
};
export * from "./proxy";

9
src/config/prod.ts Normal file
View File

@ -0,0 +1,9 @@
import { proxy } from "./proxy";
export default {
// 根地址
host: proxy["/prod/"].target,
// 请求地址
baseUrl: "/api"
};

13
src/config/proxy.ts Normal file
View File

@ -0,0 +1,13 @@
export 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")
}
};

129
src/cool/bootstrap/eps.ts Normal file
View File

@ -0,0 +1,129 @@
import { cloneDeep, merge } from "lodash-es";
import { BaseService, service } from "../service";
import { Module } from "../types";
import { path2Obj } from "../utils";
import { config, isDev } from "/@/config";
import { eps } from "virtual:eps";
import { hmr } from "../hooks";
import { module } from "../module";
// 更新事件
function onUpdate() {
// 设置 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);
// 合并[local]
merge(
service,
cloneDeep(
path2Obj(
module.list.reduce((a, b) => {
return a.concat(...((b.services as any[]) || []));
}, [])
)
)
);
// 热更新处理
hmr.setData("service", service);
// 提示
if (isDev) {
console.log("[cool-eps] updated");
}
}
export function createEps(modules: Module[]) {
// 更新 eps
onUpdate();
// 开发环境下,生成本地 service 的类型描述文件
if (isDev && config.test.eps) {
const list: any[] = [];
// 模拟 eps 数据
modules.forEach((m) => {
m.services?.forEach((s) => {
const api = Array.from(
new Set([
...Object.getOwnPropertyNames(s.value.constructor.prototype),
"page",
"list",
"info",
"delete",
"update",
"add"
])
)
.filter((e) => !["constructor", "namespace"].includes(e))
.map((e) => {
return {
path: `/${e}`
};
});
list.push({
api,
module: m.name,
name: s.value.constructor.name + "Entity",
prefix: `/admin/${s.path}`,
isLocal: true
});
});
});
// 生成文件
service.request({
url: "/__cool_eps",
method: "POST",
proxy: false,
data: {
list
}
});
}
}
// 监听 vite 触发事件
if (import.meta.hot) {
import.meta.hot.on("eps-update", ({ service }) => {
if (service) {
eps.service = service;
}
onUpdate();
});
}

View File

@ -0,0 +1,24 @@
import { createPinia } from "pinia";
import { 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, list } = createModule(app);
// eps
createEps(list);
// 加载
Loading.set([eventLoop()]);
}

View File

@ -0,0 +1,116 @@
import { type App, type Directive } from "vue";
import { assign, isFunction, orderBy } from "lodash-es";
import { filename } from "../utils";
import { module } from "../module";
import { hmr } from "../hooks";
// 扫描文件
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) => {
// 初始化
e.install?.(app, e.options);
// 注册组件
e.components?.forEach(async (c) => {
// @ts-ignore
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);
});
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));
}
}
}
};
}

30
src/cool/hooks/browser.ts Normal file
View File

@ -0,0 +1,30 @@
import { useEventListener } from "@vueuse/core";
import { reactive, watch } from "vue";
import { getBrowser } from "../utils";
const browser = reactive(getBrowser());
const events: (() => void)[] = [];
watch(
() => browser.screen,
() => {
events.forEach((ev) => ev());
}
);
useEventListener(window, "resize", () => {
Object.assign(browser, getBrowser());
});
export function useBrowser() {
return {
browser,
onScreenChange(ev: () => void, immediate = true) {
events.push(ev);
if (immediate) {
ev();
}
}
};
}

23
src/cool/hooks/hmr.ts Normal file
View File

@ -0,0 +1,23 @@
// 解决热更新后失效问题;
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];
}
};

54
src/cool/hooks/index.ts Normal file
View File

@ -0,0 +1,54 @@
import { getCurrentInstance, Ref, reactive } from "vue";
import { useRoute, useRouter } from "vue-router";
import { service } from "../service";
import { useBrowser } from "./browser";
import { useMitt } from "./mitt";
export function useRefs() {
const refs = reactive<{ [key: string]: any }>({});
function setRefs(name: string) {
return (el: any) => {
refs[name] = el;
return () => refs[name];
};
}
return { refs, setRefs };
}
export function useParent(name: string, r: Ref) {
const d = getCurrentInstance();
if (d) {
let parent = d.proxy?.$.parent;
if (parent) {
while (parent && parent.type?.name != name) {
parent = parent?.parent;
}
if (parent) {
if (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";

8
src/cool/hooks/mitt.ts Normal file
View File

@ -0,0 +1,8 @@
import Mitt, { Emitter } from "mitt";
import { hmr } from "./hmr";
const mitt: Emitter<any> = hmr.getData("mitt", Mitt());
export function useMitt() {
return mitt;
}

7
src/cool/index.ts Normal file
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";

27
src/cool/module/index.ts Normal file
View File

@ -0,0 +1,27 @@
import { Module } from "../types";
import { hmr } from "../hooks";
import { ctx } from "virtual:ctx";
// 模块列表
const list: Module[] = hmr.getData("modules", []);
// 模块对象
const module = {
list,
dirs: ctx.modules,
req: Promise.resolve(),
get(name: string): Module {
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 };

217
src/cool/router/index.ts Normal file
View File

@ -0,0 +1,217 @@
import { ElMessage } from "element-plus";
import { createRouter, createWebHashHistory, createWebHistory, RouteRecordRaw } from "vue-router";
import { Router, storage, module } from "/@/cool";
import { isArray } from "lodash-es";
import { useBase } from "/$/base";
import { Loading } from "../utils";
import { config } 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((err: Error) => {
if (!lock) {
lock = true;
ElMessage.error(`页面存在错误:${err.message}`);
console.error(err);
// 动态加载组件错误,刷新页面
if (err.message?.includes("Failed to fetch dynamically imported module")) {
window.location.reload();
}
setTimeout(() => {
lock = false;
}, 0);
}
});
// 添加试图,页面路由
router.append = function (data) {
const list = isArray(data) ? data : [data];
list.forEach((d) => {
if (!d.meta) {
d.meta = {};
}
// 组件路径
if (!d.component) {
const url = d.viewPath;
if (url) {
if (url.indexOf("http") == 0) {
if (d.meta) {
d.meta.iframeUrl = url;
}
d.component = () => import("/$/base/views/frame.vue");
} else {
d.component = files["/src/" + url.replace("cool/", "")];
}
} else {
d.redirect = "/404";
}
}
// 是否动态添加
d.meta.dynamic = true;
if (d.isPage) {
router.addRoute(d as any);
} else {
router.addRoute("index", d as any);
}
});
};
// 清空路由
router.clear = function () {
const rs = router.getRoutes();
rs.forEach((e) => {
if (e.name && e.meta?.dynamic) {
router.removeRoute(e.name);
}
});
};
// 找路由
router.find = function (path: string) {
return router.getRoutes().find((e) => {
if (path == "/") {
return e.path == path && e.name != "index";
} else {
return e.path == path;
}
});
};
// 注册
router.register = async function (path: string) {
// 当前路由是否注册
const isReg = Boolean(router.find(path));
if (!isReg) {
const { menu } = useBase();
// 等待应用配置加载完
await Loading.wait();
// 待注册列表
const list: any[] = [];
// 动态菜单数据
menu.routes.find((e) => {
list.push({
...e,
isPage: e.viewPath?.includes("/pages")
});
});
// 本地模块数据
module.list.forEach((e) => {
if (e.views) {
list.push(...e.views);
}
if (e.pages) {
list.push(
...e.pages.map((d) => {
return {
...d,
isPage: true
};
})
);
}
});
// 需要注册的路由
const r = list.find((e) => e.path == path);
if (r) {
router.append(r);
}
}
return { route: router.find(path), isReg };
};
// 路由守卫
router.beforeEach(async (to, from, next) => {
// 数据缓存
const { user, process } = useBase();
// 预先注册路由
const { isReg, route } = await router.register(to.path);
// 组件不存在、路由不存在
if (!route?.components) {
next(user.token ? "/404" : "/login");
} else {
if (!isReg) {
next(to.fullPath);
} else {
// 登录成功
if (user.token) {
// 在登录页
if (to.path.includes("/login")) {
// Token 未过期
if (!storage.isExpired("token")) {
// 回到首页
return next("/");
}
} else {
// 添加路由进程
process.add(to);
}
} else {
// 忽略部分 Token 验证
if (!config.ignore.token.find((e) => to.path == e)) {
return next("/login");
}
}
next();
}
}
});
export { router };

117
src/cool/service/base.ts Normal file
View File

@ -0,0 +1,117 @@
// @ts-nocheck
import { isDev, config, proxy } from "../../config";
import { isObject } from "lodash-es";
import { request } from "./request";
import { AxiosRequestConfig } from "axios";
export function Service(
value:
| string
| {
proxy?: string;
namespace?: string;
url?: string;
}
) {
return function (target: any) {
// 命名
if (typeof value == "string") {
target.prototype.namespace = value;
}
// 复杂项
if (isObject(value)) {
const { namespace, proxy: proxyName, url } = value;
target.prototype.namespace = namespace;
if (proxyName) {
target.prototype.url = proxy[proxyName]?.target || url;
} else {
target.prototype.url = url;
}
}
};
}
export class BaseService {
constructor(namespace?: string) {
if (namespace) {
this.namespace = namespace;
}
}
async request(options: AxiosRequestConfig = {}) {
if (options.url) {
// 过滤 http 开头的地址
if (options.url.indexOf("http") < 0) {
let ns = "";
if (isDev) {
ns = this.proxy || config.baseUrl;
} else {
ns = this.proxy ? this.url : config.baseUrl;
}
// 拼接前缀
if (this.namespace) {
ns += "/" + this.namespace;
}
// 处理地址
if (options.proxy === undefined || options.proxy) {
options.url = ns + options.url;
}
}
}
return request(options);
}
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,9 @@
import { hmr } from "../hooks";
import { BaseService } from "./base";
// service 数据集合
export const service: Eps.Service = hmr.getData("service", {
request: new BaseService().request
});
export * from "./base";

158
src/cool/service/request.ts Normal file
View File

@ -0,0 +1,158 @@
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";
const request = axios.create({
timeout: import.meta.env.VITE_TIMEOUT,
withCredentials: false
});
NProgress.configure({
showSpinner: true
});
// 请求队列
let queue: Array<(token: string) => void> = [];
// 是否刷新中
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();
}
// 验证 token
if (user.token) {
// 请求标识
if (req.headers && 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 {
// 是否在刷新中
if (!isRefreshing) {
isRefreshing = true;
user.refreshToken()
.then((token) => {
queue.forEach((cb) => cb(token));
queue = [];
isRefreshing = false;
})
.catch(() => {
user.logout();
});
}
return new Promise((resolve) => {
// 继续请求
queue.push((token) => {
// 重新设置 token
if (req.headers) {
req.headers["Authorization"] = 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.message });
}
);
export { request };

60
src/cool/types/index.ts Normal file
View File

@ -0,0 +1,60 @@
import { Component, Directive, App } from "vue";
import { Router as VueRouter, RouteRecordRaw } from "vue-router";
export declare type Merge<A, B> = Omit<A, keyof B> & B;
export declare interface ModuleConfig {
name?: string;
label?: string;
description?: string;
order?: number;
version?: string;
logo?: string;
author?: string;
updateTime?: string;
demo?: { name: string; component: Component }[] | string;
options?: {
[key: string]: any;
};
toolbar?: {
order?: number;
pc?: boolean;
h5?: boolean;
component: Promise<any>;
};
components?: Component[];
views?: RouteRecordRaw[];
pages?: RouteRecordRaw[];
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): RouteRecordRaw | undefined;
append(
data: {
name?: string;
path: string;
component?: any;
viewPath?: string;
isPage?: boolean;
[key: string]: any;
}[]
): void;
register(path: string): Promise<{ route: RouteRecordRaw | undefined; isReg: boolean }>;
[key: string]: any;
}

301
src/cool/utils/index.ts Normal file
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";

37
src/cool/utils/loading.ts Normal file
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.className += " is-hide";
}, 0);
}
}
};
Loading.next = new Promise<void>((resolve) => {
Loading.resolve = resolve;
});

81
src/cool/utils/storage.ts Normal file
View File

@ -0,0 +1,81 @@
import store from "store";
export default {
// 后缀标识
suffix: "_deadtime",
/**
*
* @param {string} key
*/
get(key: string) {
return store.get(key);
},
/**
*
*/
info() {
const d: any = {};
store.each(function (value: any, key: any) {
d[key] = value;
});
return d;
},
/**
*
* @param {string} key
* @param {*} value
* @param {number} expires
*/
set(key: string, value: any, expires?: any) {
store.set(key, value);
if (expires) {
store.set(`${key}${this.suffix}`, Date.parse(String(new Date())) + expires * 1000);
}
},
/**
*
* @param {string} key
*/
isExpired(key: string) {
return (this.getExpiration(key) || 0) - Date.parse(String(new Date())) <= 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();
}
};

17
src/main.ts Normal file
View File

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

View File

@ -0,0 +1,148 @@
<template>
<cl-crud ref="Crud">
<cl-row>
<!-- 刷新按钮 -->
<cl-refresh-btn />
<cl-filter label="状态">
<cl-select :options="options.status" prop="status" :width="120" />
</cl-filter>
<cl-filter label="投诉类型">
<cl-select :options="options.type" prop="type" :width="120" />
</cl-filter>
<cl-flex1 />
<!-- 关键字搜索 -->
<cl-search-key placeholder="搜索联系方式、用户、处理人" />
</cl-row>
<cl-row>
<!-- 数据表格 -->
<cl-table ref="Table" />
</cl-row>
<cl-row>
<cl-flex1 />
<!-- 分页控件 -->
<cl-pagination />
</cl-row>
<!-- 新增编辑 -->
<cl-upsert ref="Upsert" />
</cl-crud>
</template>
<script lang="ts" name="app-complain" setup>
import { useCrud, useTable, useUpsert } from "@cool-vue/crud";
import { useCool } from "/@/cool";
import { reactive, computed } from "vue";
import { useDict } from "/$/dict";
const { service } = useCool();
const { dict } = useDict();
const options = reactive({
status: [
{
label: "未处理",
value: 0,
type: "warning"
},
{
label: "已处理",
value: 1
}
],
type: computed(() => {
return dict.get("complainType");
})
});
// cl-upsert
const Upsert = useUpsert({
props: {
labelWidth: 80
},
items: [
{
prop: "status",
label: "状态",
component: { name: "el-radio-group", options: options.status },
required: true
},
{
prop: "remark",
label: "处理结果",
component: {
name: "el-input",
props: {
type: "textarea",
rows: 4
}
}
}
]
});
// cl-table
const Table = useTable({
columns: [
{ type: "index", label: "#", width: 60 },
{ prop: "avatarUrl", label: "头像", component: { name: "cl-avatar" }, minWidth: 80 },
{ prop: "nickName", label: "用户", minWidth: 120 },
{ prop: "contact", label: "联系方式", minWidth: 120 },
{
prop: "content",
label: "内容",
component: { name: "cl-editor-preview", props: { name: "wang" } },
minWidth: 120
},
{
prop: "images",
label: "图片",
component: { name: "cl-image", props: { size: 60 } },
minWidth: 100
},
{
prop: "type",
label: "类型",
dict: options.type,
dictFormatter(arr) {
return arr.map((e) => e.label).join("、");
},
minWidth: 120
},
{ prop: "status", label: "状态", dict: options.status, minWidth: 100 },
{ prop: "handlerName", label: "处理人", minWidth: 120 },
{ prop: "remark", label: "处理结果", showOverflowTooltip: true, minWidth: 200 },
{ prop: "createTime", label: "创建时间", sortable: "desc", minWidth: 160 },
{ prop: "updateTime", label: "更新时间", sortable: "custom", minWidth: 160 },
{
type: "op",
width: 120,
buttons({ scope }) {
if (scope.row.status == 0) {
return ["edit"];
}
return [];
}
}
]
});
// cl-crud
const Crud = useCrud(
{
dict: {
label: {
update: "处理"
}
},
service: service.app.complain
},
(app) => {
app.refresh();
}
);
</script>

View File

@ -0,0 +1,148 @@
<template>
<cl-crud ref="Crud">
<cl-row>
<!-- 刷新按钮 -->
<cl-refresh-btn />
<cl-filter label="状态">
<cl-select :options="options.status" prop="status" :width="120" />
</cl-filter>
<cl-filter label="反馈类型">
<cl-select :options="options.type" prop="type" :width="120" />
</cl-filter>
<cl-flex1 />
<!-- 关键字搜索 -->
<cl-search-key placeholder="搜索联系方式、用户、处理人" />
</cl-row>
<cl-row>
<!-- 数据表格 -->
<cl-table ref="Table" />
</cl-row>
<cl-row>
<cl-flex1 />
<!-- 分页控件 -->
<cl-pagination />
</cl-row>
<!-- 新增编辑 -->
<cl-upsert ref="Upsert" />
</cl-crud>
</template>
<script lang="ts" name="app-feedback" setup>
import { useCrud, useTable, useUpsert } from "@cool-vue/crud";
import { useCool } from "/@/cool";
import { computed, reactive } from "vue";
import { useDict } from "/$/dict";
const { service } = useCool();
const { dict } = useDict();
const options = reactive({
status: [
{
label: "未处理",
value: 0,
type: "warning"
},
{
label: "已处理",
value: 1
}
],
type: computed(() => {
return dict.get("feedbackType");
})
});
// cl-upsert
const Upsert = useUpsert({
props: {
labelWidth: 80
},
items: [
{
prop: "status",
label: "状态",
component: { name: "el-radio-group", options: options.status },
required: true
},
{
prop: "remark",
label: "处理结果",
component: {
name: "el-input",
props: {
type: "textarea",
rows: 4
}
}
}
]
});
// cl-table
const Table = useTable({
columns: [
{ type: "index", label: "#", width: 60 },
{ prop: "avatarUrl", label: "头像", component: { name: "cl-avatar" }, minWidth: 80 },
{ prop: "nickName", label: "用户", minWidth: 120 },
{ prop: "contact", label: "联系方式", minWidth: 120 },
{
prop: "content",
label: "内容",
component: { name: "cl-editor-preview", props: { name: "wang" } },
minWidth: 120
},
{
prop: "images",
label: "图片",
component: { name: "cl-image", props: { size: 60 } },
minWidth: 100
},
{
prop: "type",
label: "类型",
dict: options.type,
dictFormatter(arr) {
return arr.map((e) => e.label).join("、");
},
minWidth: 120
},
{ prop: "status", label: "状态", dict: options.status, minWidth: 100 },
{ prop: "handlerName", label: "处理人", minWidth: 120 },
{ prop: "remark", label: "处理结果", showOverflowTooltip: true, minWidth: 200 },
{ prop: "createTime", label: "创建时间", sortable: "desc", minWidth: 160 },
{ prop: "updateTime", label: "更新时间", sortable: "custom", minWidth: 160 },
{
type: "op",
width: 120,
buttons({ scope }) {
if (scope.row.status == 0) {
return ["edit"];
}
return [];
}
}
]
});
// cl-crud
const Crud = useCrud(
{
service: service.app.feedback,
dict: {
label: {
update: "处理"
}
}
},
(app) => {
app.refresh();
}
);
</script>

View File

@ -0,0 +1,188 @@
<template>
<cl-crud ref="Crud">
<cl-row>
<!-- 刷新按钮 -->
<cl-refresh-btn />
<!-- 新增按钮 -->
<cl-add-btn />
<!-- 删除按钮 -->
<cl-multi-delete-btn />
<cl-filter label="状态">
<cl-select :options="options.status" prop="status" :width="120" />
</cl-filter>
<cl-filter label="类型">
<cl-select :options="options.type" prop="type" :width="120" />
</cl-filter>
<cl-flex1 />
<!-- 关键字搜索 -->
<cl-search-key placeholder="搜索标题" />
</cl-row>
<cl-row>
<!-- 数据表格 -->
<cl-table ref="Table">
<template #column-tagColor="{ scope }">
<!-- 显示颜色 -->
<div
:style="{
backgroundColor: scope.row.tagColor,
height: '10px',
width: '10px',
borderRadius: '100%',
margin: 'auto'
}"
/>
</template>
</cl-table>
</cl-row>
<cl-row>
<cl-flex1 />
<!-- 分页控件 -->
<cl-pagination />
</cl-row>
<!-- 新增编辑 -->
<cl-upsert ref="Upsert" />
</cl-crud>
</template>
<script lang="ts" name="app-goods" setup>
import { useCrud, useTable, useUpsert } from "@cool-vue/crud";
import { useCool } from "/@/cool";
import { reactive } from "vue";
const { service } = useCool();
const options = reactive({
status: [
{
label: "启用",
value: 1
},
{
label: "禁用",
value: 0
}
],
type: [
{ label: "天", value: 0 },
{ label: "月", value: 1, type: "success" },
{ label: "年", value: 2, type: "warning" }
]
});
// cl-upsert
const Upsert = useUpsert({
items: [
{ prop: "title", label: "标题", required: true, component: { name: "el-input" } },
{
prop: "description",
label: "描述",
component: {
name: "el-input",
props: {
type: "textarea",
rows: 3
}
}
},
{
span: 12,
required: true,
prop: "price",
label: "价格",
hook: { bind: ["number"] },
component: { name: "el-input-number", props: { min: 0 } }
},
{
required: true,
span: 12,
prop: "originalPrice",
label: "原价",
hook: { bind: ["number"] },
component: { name: "el-input-number", props: { min: 0 } }
},
{
span: 12,
prop: "type",
label: "类型",
component: {
name: "el-radio-group",
options: options.type
},
value: 0,
required: true
},
{
span: 12,
prop: "duration",
label: "时长",
required: true,
component: { name: "el-input-number", props: { min: 0 } }
},
{ span: 12, prop: "tag", label: "标签", component: { name: "el-input" } },
{
span: 12,
prop: "tagColor",
label: "标签颜色",
value: "#26A7FD",
component: { name: "el-color-picker" }
},
{
value: 0,
prop: "sort",
label: "排序",
hook: { bind: ["number"] },
component: { name: "el-input-number", props: { min: 0 } },
required: true
},
{
prop: "status",
flex: false,
value: 1,
label: "状态",
component: { name: "cl-switch" },
required: true
}
]
});
// cl-table
const Table = useTable({
columns: [
{ type: "selection" },
{ prop: "title", label: "标题", minWidth: 150 },
{ prop: "price", label: "价格", minWidth: 120 },
{ prop: "originalPrice", label: "原价", minWidth: 120 },
{ prop: "description", label: "描述", showOverflowTooltip: true, minWidth: 200 },
{ prop: "status", label: "状态", component: { name: "cl-switch" }, minWidth: 80 },
{ prop: "sort", label: "排序", minWidth: 80 },
{
prop: "type",
label: "类型",
dict: options.type,
minWidth: 80
},
{ prop: "duration", label: "时长", minWidth: 80 },
{ prop: "tag", label: "标签", minWidth: 150 },
{ prop: "tagColor", label: "标签颜色", minWidth: 100 },
{ prop: "createTime", label: "创建时间", sortable: "desc", minWidth: 160 },
{ prop: "updateTime", label: "更新时间", sortable: "custom", minWidth: 160 },
{ type: "op", buttons: ["edit", "delete"] }
]
});
// cl-crud
const Crud = useCrud(
{
service: service.app.goods
},
(app) => {
app.refresh();
}
);
</script>

View File

@ -0,0 +1,177 @@
<template>
<cl-crud ref="Crud">
<cl-row>
<!-- 刷新按钮 -->
<cl-refresh-btn />
<!-- 新增按钮 -->
<cl-add-btn />
<!-- 删除按钮 -->
<cl-multi-delete-btn />
<cl-filter label="状态">
<cl-select :options="options.status" prop="status" :width="120" />
</cl-filter>
<cl-filter label="类型">
<cl-select :options="options.type" prop="type" :width="120" />
</cl-filter>
<cl-flex1 />
<!-- 关键字搜索 -->
<cl-search-key placeholder="搜索名称、版本" />
</cl-row>
<cl-row>
<!-- 数据表格 -->
<cl-table ref="Table" />
</cl-row>
<cl-row>
<cl-flex1 />
<!-- 分页控件 -->
<cl-pagination />
</cl-row>
<!-- 新增编辑 -->
<cl-upsert ref="Upsert" />
</cl-crud>
</template>
<script lang="ts" name="app-version" setup>
import { useCrud, useTable, useUpsert } from "@cool-vue/crud";
import { useCool } from "/@/cool";
import { useDict } from "/$/dict";
import { computed, reactive } from "vue";
const { service } = useCool();
const { dict } = useDict();
const options = reactive({
//
status: [
{
label: "启用",
value: 1,
type: "success"
},
{
label: "禁用",
value: 0,
type: "danger"
}
],
//
type: computed(() => {
return dict.get("upgradeType");
})
});
// cl-upsert
const Upsert = useUpsert({
items: [
{ span: 12, prop: "name", label: "名称", required: true, component: { name: "el-input" } },
{
span: 12,
prop: "version",
label: "版本号",
required: true,
component: { name: "el-input" }
},
{
prop: "description",
label: "描述",
required: true,
component: {
name: "el-input",
props: {
type: "textarea",
rows: 4
}
}
},
{
prop: "type",
label: "类型",
value: 0,
component: { name: "el-radio-group", options: options.type },
required: true
},
{
prop: "url",
label: "下载地址",
component: { name: "cl-upload", props: { type: "file", limit: 1 } },
required: true
},
{
prop: "hotUpdate",
label: "热更新",
flex: false,
value: 0,
component: {
name: "cl-switch"
},
required: true
},
{
prop: "forceUpdate",
label: "强制更新",
flex: false,
value: 0,
component: {
name: "cl-switch"
},
required: true
},
{
prop: "status",
label: "状态",
value: 1,
flex: false,
component: { name: "cl-switch" },
required: true
}
]
});
// cl-table
const Table = useTable({
columns: [
{ type: "selection" },
{ prop: "name", label: "名称", minWidth: 150 },
{ prop: "version", label: "版本号", minWidth: 100 },
{ prop: "type", label: "类型", dict: options.type, minWidth: 100 },
{ prop: "url", label: "下载地址", component: { name: "cl-link" }, minWidth: 120 },
{
prop: "forceUpdate",
label: "强制更新",
formatter(row) {
return row.forceUpdate ? "是" : "否";
},
minWidth: 100
},
{
prop: "hotUpdate",
label: "热更新",
formatter(row) {
return row.hotUpdate ? "是" : "否";
},
minWidth: 100
},
{ prop: "status", label: "状态", component: { name: "cl-switch" }, minWidth: 100 },
{ prop: "description", label: "描述", showOverflowTooltip: true, minWidth: 200 },
{ prop: "createTime", label: "创建时间", sortable: "desc", minWidth: 160 },
{ prop: "updateTime", label: "更新时间", sortable: "custom", minWidth: 160 },
{ type: "op", buttons: ["edit", "delete"] }
]
});
// cl-crud
const Crud = useCrud(
{
service: service.app.version
},
(app) => {
app.refresh();
}
);
</script>

View File

@ -0,0 +1,2 @@
export * from "./theme";
export * from "./permission";

View File

@ -0,0 +1,30 @@
import { useStore } from "../store";
import { isObject } from "lodash-es";
function parse(value: any) {
const { menu } = useStore();
if (typeof value == "string") {
return value ? menu.perms.some((e: any) => e.includes(value.replace(/\s/g, ""))) : false;
} else {
return Boolean(value);
}
}
export function checkPerm(value: string | { or?: string[]; and?: string[] }) {
if (!value) {
return false;
}
if (isObject(value)) {
if (value.or) {
return value.or.some(parse);
}
if (value.and) {
return value.and.some((e: any) => !parse(e)) ? false : true;
}
}
return parse(value);
}

View File

@ -0,0 +1,12 @@
import { config } from "/@/config";
import { createLink } from "../utils";
// 字体图标库加载
if (config.app.iconfont) {
config.app.iconfont.forEach((e: string) => {
createLink(e);
});
}
// 默认
createLink("//at.alicdn.com/t/c/font_3254019_h02ghb7ckt5.css");

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,134 @@
<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="text">{{ text }}</span>
</template>
<viewer />
</el-popover>
</div>
<viewer v-else>
<template #op>
<slot name="op"> </slot>
</template>
</viewer>
</template>
<script lang="tsx" name="cl-code-json" setup>
import { useClipboard } from "@vueuse/core";
import { ElMessage } from "element-plus";
import { isObject, isString } from "lodash-es";
import { computed, defineComponent } from "vue";
const props = defineProps({
modelValue: [String, Object],
popover: Boolean,
height: {
type: [Number, String],
default: "100%"
},
maxHeight: {
type: [Number, String],
default: 300
}
});
const { copy } = useClipboard();
//
const text = computed(() => {
const v = props.modelValue;
if (isString(v)) {
return v;
} else if (isObject(v)) {
return JSON.stringify(v, null, 4);
} else {
return "";
}
});
//
const viewer = defineComponent({
setup(_, { slots }) {
function toCopy() {
copy(text.value);
ElMessage.success("复制成功");
}
return () => {
return (
<div class="cl-code-json">
<div class="op">
<el-button type="success" size="small" onClick={toCopy}>
copy
</el-button>
{slots.op && slots.op()}
</div>
<el-scrollbar
class="scrollbar"
max-height={props.maxHeight}
height={props.height}
>
<pre>
<code>{text.value}</code>
</pre>
</el-scrollbar>
</div>
);
};
}
});
</script>
<style lang="scss">
.cl-code-json {
border-radius: 6px;
position: relative;
min-width: 200px;
max-width: 500px;
.op {
position: absolute;
right: 8px;
top: 8px;
z-index: 9;
}
.scrollbar {
code {
display: block;
padding: 10px;
font-size: 14px;
white-space: pre-wrap;
}
}
&__wrap {
.text {
display: block;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
&:hover {
color: var(--color-primary);
}
}
}
&__popper {
padding: 0 !important;
}
}
</style>

View File

@ -0,0 +1,104 @@
<template>
<div class="cl-dept-check">
<div class="cl-dept-check__search">
<el-input v-model="keyword" placeholder="输入关键字进行过滤" />
</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" name="cl-dept-check" setup>
import { ref, watch } from "vue";
import { deepTree } from "/@/cool/utils";
import { useCool } from "/@/cool";
import { useUpsert } from "@cool-vue/crud";
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,83 @@
<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"
></el-tree-select>
</div>
</template>
<script lang="ts" name="cl-dept-select" setup>
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,42 @@
import { defineComponent, h, resolveComponent, ref, reactive, watch } from "vue";
import { isComponent } from "/@/cool/utils";
export default defineComponent({
name: "cl-editor",
props: {
name: {
type: String,
required: true
}
},
setup(props, { slots, expose }) {
const Editor = ref();
const ex = reactive({});
watch(Editor, (v) => {
if (v) {
Object.assign(ex, v);
}
});
expose(ex);
return () => {
return isComponent(props.name) ? (
h(
// @ts-ignore
resolveComponent(props.name),
{
...props,
ref: Editor
},
slots
)
) : (
<el-input type="textarea" rows={4} placeholder="请输入" {...props} />
);
};
}
});

View File

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

View File

@ -0,0 +1,98 @@
<template>
<div
class="cl-image"
:style="{
height: style.h
}"
>
<el-image
:src="urls[0]"
:fit="fit"
:lazy="lazy"
:preview-src-list="urls"
:style="{
height: style.h,
width: style.w
}"
preview-teleported
>
<template #error>
<div class="cl-image__slot">
<el-icon :size="20"><picture-filled /></el-icon>
</div>
</template>
</el-image>
</div>
</template>
<script lang="ts">
import { type PropType, computed, defineComponent } from "vue";
import { isArray, isNumber, isString } from "lodash-es";
import { PictureFilled } from "@element-plus/icons-vue";
import { parsePx } from "/@/cool/utils";
export default defineComponent({
name: "cl-image",
components: {
PictureFilled
},
props: {
modelValue: [String, Array],
src: [String, Array],
size: {
type: [Number, Array],
default: 100
},
lazy: Boolean,
fit: {
type: String as PropType<"" | "contain" | "cover" | "none" | "fill" | "scale-down">,
default: "cover"
}
},
setup(props) {
const urls = computed(() => {
const urls: any = props.modelValue || props.src;
if (isArray(urls)) {
return urls;
}
if (isString(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)
};
});
return {
urls,
style
};
}
});
</script>
<style lang="scss" scoped>
.cl-image {
&__slot {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background-color: #f7f7f7;
border-radius: 4px;
}
}
</style>

View File

@ -0,0 +1,86 @@
<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">
import { defineComponent, computed } from "vue";
import { isArray, isString, last } from "lodash-es";
import { Link } from "@element-plus/icons-vue";
export default defineComponent({
name: "cl-link",
components: {
"icon-link": Link
},
props: {
modelValue: [String, Array],
href: [String, Array],
text: String,
target: {
type: String,
default: "_blank"
}
},
setup(props) {
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("/"));
}
return {
urls,
filename
};
}
});
</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,92 @@
<template>
<div class="cl-menu-check">
<el-input v-model="keyword" placeholder="输入关键字进行过滤" />
<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" name="cl-menu-check" setup>
import { ref, watch } from "vue";
import { deepTree } from "/@/cool/utils";
import { useCool } from "/@/cool";
import { useUpsert } from "@cool-vue/crud";
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,128 @@
<template>
<div class="cl-menu-file">
<template v-if="isEdit">
<el-input placeholder="请输入" v-model="text" @change="onTextChange"></el-input>
<el-tooltip content="选择文件">
<el-icon @click="toggle(false)">
<folder-checked />
</el-icon>
</el-tooltip>
</template>
<template v-else>
<el-cascader
v-model="path"
:options="data"
clearable
filterable
allow-create
@change="onPathChange"
/>
<el-tooltip content="输入编辑">
<el-icon @click="toggle(true)">
<edit />
</el-icon>
</el-tooltip>
</template>
</div>
</template>
<script lang="ts" name="cl-menu-file" setup>
import { ref, watch } from "vue";
import { deepPaths } from "/@/cool/utils";
import { FolderChecked, Edit } from "@element-plus/icons-vue";
const props = defineProps({
modelValue: {
type: String,
default: ""
}
});
const emit = defineEmits(["update:modelValue", "change"]);
//
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: string[]) {
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(f: boolean) {
isEdit.value = f;
}
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%;
:deep(.el-cascader) {
width: 100%;
}
.el-icon {
margin: 0 0 0 10px;
font-size: 18px;
cursor: pointer;
&:hover {
color: var(--el-color-primary);
}
}
}
</style>

View File

@ -0,0 +1,51 @@
<template>
<el-select filterable v-model="value" fit-input-width>
<div class="cl-menu-icon">
<el-option :value="item" v-for="item in list" :key="item">
<cl-svg :name="item" />
</el-option>
</div>
</el-select>
</template>
<script lang="ts" name="cl-menu-icon" setup>
import { ref, useModel } from "vue";
import { svgIcons } from "virtual:svg-icons";
const props = defineProps({
modelValue: {
type: String,
default: ""
}
});
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;
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,91 @@
<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" name="cl-menu-perms" setup>
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,83 @@
<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" name="cl-menu-select" setup>
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,90 @@
import type { ModuleConfig } from "/@/cool";
import { useStore } from "./store";
import { config } from "/@/config";
import "./static/css/index.scss";
export default (): ModuleConfig => {
return {
order: 99,
components: Object.values(import.meta.glob("./components/**/*.{vue,tsx}")),
views: [
{
path: "/my/info",
meta: {
label: "个人中心"
},
component: () => import("./views/info.vue")
}
],
pages: [
{
path: "/login",
component: () => import("./pages/login/index.vue")
},
{
path: "/401",
meta: {
process: false
},
component: () => import("./pages/error/401.vue")
},
{
path: "/403",
meta: {
process: false
},
component: () => import("./pages/error/403.vue")
},
{
path: "/404",
meta: {
process: false
},
component: () => import("./pages/error/404.vue")
},
{
path: "/500",
meta: {
process: false
},
component: () => import("./pages/error/500.vue")
},
{
path: "/502",
meta: {
process: false
},
component: () => import("./pages/error/502.vue")
}
],
install() {
// 设置标题
document.title = config.app.name;
},
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 "../common/permission";
function change(el: any, binding: any) {
el.style.display = checkPerm(binding.value) ? el.getAttribute("_display") : "none";
}
export default {
created(el: any, binding: any) {
el.setAttribute("_display", el.style.display || "");
change(el, binding);
},
updated: change
};

View File

@ -0,0 +1,43 @@
import { TreeData } from "element-plus/es/components/tree/src/tree.type";
import { service } from "/@/cool";
import Node from "element-plus/es/components/tree/src/model/node";
import ClAvatar from "../components/avatar/index";
import { type ClViewGroup, useViewGroup } from "/@/plugins/view";
export function useDeptViewGroup(options: DeepPartial<ClViewGroup.Options>) {
const { ViewGroup } = useViewGroup({
label: "员工列表",
service: service.base.sys.department,
enableAdd: false,
enableRefresh: false,
enableContextMenu: false,
tree: {
lazy: true,
onLoad(node: Node, resolve: (data: TreeData) => void) {
if (node.data.id) {
service.base.sys.user.list({ departmentId: node.data.id }).then((res) => {
res.forEach((e) => {
e.isLeaf = true;
e.icon = (
<ClAvatar
size={22}
src={e.headImg}
style={{ marginRight: "6px" }}
/>
);
});
res.unshift(...(node.data.children || []));
resolve(res);
});
}
}
},
...options
});
return {
ViewGroup
};
}

View File

@ -0,0 +1 @@
export * from "./dept";

11
src/modules/base/index.ts Normal file
View File

@ -0,0 +1,11 @@
import { useStore } from "./store";
export function useBase() {
return {
...useStore()
};
}
export * from "./common";
export * from "./hooks";
export * from "./types/index.d";

View File

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

View File

@ -0,0 +1,7 @@
<template>
<error-page :code="403" desc="您无权访问此页面" />
</template>
<script lang="ts" name="403" setup>
import ErrorPage from "./components/error-page.vue";
</script>

View File

@ -0,0 +1,7 @@
<template>
<error-page :code="404" desc="找不到您要查找的页面" />
</template>
<script lang="ts" name="404" setup>
import ErrorPage from "./components/error-page.vue";
</script>

View File

@ -0,0 +1,7 @@
<template>
<error-page :code="500" desc="糟糕,出了点问题" />
</template>
<script lang="ts" name="500" setup>
import ErrorPage from "./components/error-page.vue";
</script>

View File

@ -0,0 +1,7 @@
<template>
<error-page :code="502" desc="马上回来" />
</template>
<script lang="ts" name="502" setup>
import ErrorPage from "./components/error-page.vue";
</script>

View File

@ -0,0 +1,136 @@
<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">回到首页</el-button>
<el-button type="primary" @click="reLogin">重新登录</el-button>
</div>
</template>
<template v-else>
<div class="error-page__btns">
<el-button type="primary" @click="toLogin">返回登录页</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>
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-if="isError">
<el-text type="danger"> 后端未启动 </el-text>
<el-icon color="#f56c6c" :size="16">
<warning-filled />
</el-icon>
</template>
<template v-else>
<el-icon class="is-loading" :size="18">
<loading />
</el-icon>
</template>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { Loading, WarningFilled } from "@element-plus/icons-vue";
import { useCool } from "/@/cool";
const emit = defineEmits(["update:modelValue", "change"]);
const { service } = useCool();
//
const isError = ref(false);
// base64
const base64 = ref("");
// svg
const svg = ref("");
//
async function refresh() {
isError.value = false;
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 {
ElMessage.error("验证码获取失败");
}
})
.catch((err) => {
ElMessage.error(err.message);
isError.value = true;
});
}
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%;
}
.el-icon {
position: absolute;
right: 20px;
}
}
</style>

View File

@ -0,0 +1,297 @@
<template>
<div class="page-login">
<div class="box">
<div class="logo">
<img src="/logo.png" alt="Logo" />
<div class="name">
<span v-for="text in app.info.name" :key="text">{{ text }}</span>
</div>
</div>
<p class="desc">快速开发后台权限管理系统</p>
<div class="form">
<el-form label-position="top" class="form" :disabled="saving">
<el-form-item label="用户名">
<input
v-model="form.username"
placeholder="请输入用户名"
maxlength="20"
type="text"
:readonly="readonly"
autocomplete="off"
@focus="readonly = false"
/>
</el-form-item>
<el-form-item label="密码">
<input
v-model="form.password"
type="password"
placeholder="请输入密码"
maxlength="20"
autocomplete="off"
/>
</el-form-item>
<el-form-item label="验证码">
<div class="row">
<input
v-model="form.verifyCode"
placeholder="图片验证码"
maxlength="4"
@keyup.enter="toLogin"
/>
<pic-captcha
:ref="setRefs('picCaptcha')"
v-model="form.captchaId"
@change="
() => {
form.verifyCode = '';
}
"
/>
</div>
</el-form-item>
<div class="op">
<el-button type="primary" :loading="saving" @click="toLogin"
>登录</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" name="login" setup>
import { reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { useCool } from "/@/cool";
import { useBase } from "/$/base";
import PicCaptcha from "./components/pic-captcha.vue";
import { storage } from "/@/cool/utils";
const { refs, setRefs, router, service } = useCool();
const { user, app } = useBase();
//
const saving = ref(false);
//
const readonly = ref(true);
//
const form = reactive({
username: storage.get("username") || "",
password: "",
captchaId: "",
verifyCode: ""
});
//
async function toLogin() {
if (!form.username) {
return ElMessage.error("用户名不能为空");
}
if (!form.password) {
return ElMessage.error("密码不能为空");
}
if (!form.verifyCode) {
return ElMessage.error("图片验证码不能为空");
}
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: any) {
//
refs.picCaptcha.refresh();
//
ElMessage.error(err.message);
}
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%;
fill: $color;
}
}
.copyright {
position: absolute;
bottom: 15px;
left: 0;
text-align: center;
width: 100%;
color: #666;
font-size: 14px;
}
.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;
img {
height: 46px;
background-color: $color;
border-radius: 50px;
border: 3px solid $color;
margin-right: 10px;
}
span {
display: inline-block;
font-size: 38px;
font-weight: bold;
line-height: 1;
letter-spacing: 3px;
&:nth-child(6) {
animation: dou 1s infinite linear;
}
}
}
.desc {
font-size: 15px;
letter-spacing: 1px;
margin-bottom: 50px;
}
.form {
width: 300px;
:deep(.el-form) {
.el-form-item {
margin-bottom: 20px;
}
.el-form-item__label {
padding-left: 5px;
}
input {
height: 45px;
width: 100%;
box-sizing: border-box;
font-size: 17px;
border: 0;
border-radius: 0;
background-color: #f8f8f8;
padding: 0 15px;
border-radius: 6px;
position: relative;
&:-webkit-autofill {
box-shadow: none;
-webkit-box-shadow: 0 0 0 1000px #f8f8f8 inset;
box-shadow: 0 0 0 1000px #f8f8f8 inset;
}
&::placeholder {
font-size: 14px;
}
}
.row {
display: flex;
align-items: center;
width: 100%;
position: relative;
.pic-captcha {
position: absolute;
right: 0;
top: 0;
}
}
}
}
.op {
display: flex;
justify-content: center;
margin-top: 40px;
:deep(.el-button) {
height: 45px;
width: 100%;
font-size: 15px;
border-radius: 6px;
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,154 @@
<template>
<div class="a-menu">
<el-menu
:default-active="active"
mode="horizontal"
background-color="transparent"
@select="select"
>
<template v-for="(item, index) in list" :key="item.id">
<el-menu-item :index="`${index}`">
<cl-svg v-if="item.icon" :name="item.icon" :size="18" />
<span class="a-menu__name">{{ item.meta?.label }}</span>
</el-menu-item>
</template>
</el-menu>
</div>
</template>
<script lang="ts" name="a-menu" setup>
import { computed, ref, watch } from "vue";
import { useBase, Menu } from "/$/base";
import { useCool } from "/@/cool";
import { ElMessage } from "element-plus";
const { router, route } = useCool();
const { menu } = useBase();
//
const active = ref("0");
//
const list = computed(() => {
return menu.group.filter((e) => e.isShow);
});
//
function select(index: any) {
if (String(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(`${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 = String(index);
//
menu.setMenu(index);
}
//
watch(
() => [route.path, menu.group.length],
() => {
refresh();
},
{
immediate: true
}
);
</script>
<style lang="scss" scoped>
.a-menu {
margin: 5px 0 0 10px;
.el-menu {
height: 40px;
background: transparent;
border: 0;
:deep(.el-sub-menu__title) {
border: 0 !important;
}
:deep(.el-menu-item) {
display: flex;
align-items: center;
height: 40px;
padding: 0 15px;
background: transparent;
border: 0;
color: #999;
span {
font-size: 12px;
margin-left: 3px;
line-height: normal;
}
&:hover {
background: transparent;
}
&.is-active {
color: var(--color-primary);
border-radius: 6px 6px 0 0;
background: #fff;
color: #000;
}
.cl-svg {
margin-right: 5px;
}
}
}
&__name {
margin-left: 8px;
}
}
</style>

View File

@ -0,0 +1,97 @@
import { defineComponent, h } from "vue";
import { useBase, Menu } from "/$/base";
import { useCool } from "/@/cool";
export default defineComponent({
name: "b-menu",
setup() {
const { router, route, browser } = useCool();
const { menu, app } = useBase();
// 页面跳转
function toView(url: string) {
if (url != route.path) {
router.push(url);
}
// 小屏下点击收起左侧菜单
if (browser.isMini) {
app.fold(true);
}
}
// 渲染子菜单
function renderMenu() {
function deep(list: Menu.Item[], index: number) {
return list
.filter((e) => e.isShow)
.map((e) => {
const item = (e: Menu.Item) => {
return [
<el-icon>
<cl-svg name={e.icon} />
</el-icon>,
<span v-show={!app.isFold || index != 1}>{e.meta?.label}</span>
];
};
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 || [], index + 1);
}
}
);
} else {
return h(
<el-menu-item />,
{
index:
route.path == "/"
? e.meta?.isHome
? "/"
: e.path
: e.path,
key: e.id
},
{
default() {
return item(e);
}
}
);
}
});
}
return deep(menu.list, 1);
}
return () => {
return (
<div class="app-slider__menu">
<el-menu
default-active={route.path}
background-color="transparent"
collapse-transition={false}
collapse={browser.isMini ? false : app.isFold}
onSelect={toView}
>
{renderMenu()}
</el-menu>
</div>
);
};
}
});

View File

@ -0,0 +1,278 @@
<template>
<div class="app-process">
<ul class="app-process__op">
<li class="item" @click="toBack">
<i class="cl-iconfont cl-icon-back"></i>
</li>
<li class="item" @click="toRefresh">
<i class="cl-iconfont cl-icon-refresh"></i>
</li>
<li class="item" @click="toHome">
<i class="cl-iconfont cl-icon-home"></i>
</li>
</ul>
<div class="app-process__container">
<el-scrollbar :ref="setRefs('scroller')" class="app-process__scroller">
<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>{{ item.meta?.label || item.name || item.path }}</span>
<el-icon @mousedown.stop="onDel(Number(index))">
<close-bold />
</el-icon>
</div>
</el-scrollbar>
</div>
</div>
</template>
<script lang="ts" name="app-process" setup>
import { onMounted, watch } from "vue";
import { last } from "lodash-es";
import { useCool } from "/@/cool";
import { CloseBold } from "@element-plus/icons-vue";
import { ContextMenu } from "@cool-vue/crud";
import { useBase, Process } from "/$/base";
const { refs, setRefs, route, router, mitt } = useCool();
const { process } = useBase();
//
function toRefresh() {
mitt.emit("view.refresh");
}
//
function toHome() {
router.push("/");
}
//
function toBack() {
router.back();
}
//
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, {
hover: {
target: "app-process__item"
},
list: [
{
label: "关闭当前",
hidden: item.fullPath !== route.path,
callback(done) {
onDel(process.list.findIndex((e) => e.fullPath == item.fullPath));
done();
toPath();
}
},
{
label: "关闭其他",
callback(done) {
process.set(process.list.filter((e) => e.fullPath == item.fullPath));
done();
toPath();
}
},
{
label: "关闭所有",
callback(done) {
process.clear();
done();
toPath();
}
}
]
});
}
watch(
() => route.path,
function (val) {
adScroll(process.list.findIndex((e) => e.fullPath === val) || 0);
}
);
onMounted(() => {
//
refs.scroller.wrapRef?.addEventListener("wheel", function (event: WheelEvent) {
//
event.preventDefault();
//
const scrollSpeed = 2;
//
const distance = event.deltaY * scrollSpeed;
scrollTo(refs.scroller.wrapRef.scrollLeft + distance);
});
});
</script>
<style lang="scss" scoped>
.app-process {
display: flex;
align-items: center;
height: 30px;
position: relative;
margin: 0 0 10px 0;
padding: 0 10px;
user-select: none;
&__op {
display: flex;
background-color: #fff;
height: 30px;
border-radius: 4px;
margin-right: 10px;
list-style: none;
.item {
position: relative;
padding: 0 10px;
line-height: 30px;
color: #333;
cursor: pointer;
font-weight: bold;
&:not(:last-child)::after {
display: block;
content: "";
position: absolute;
right: 0;
top: calc(50% - 5px);
height: 10px;
width: 1px;
background-color: #eee;
}
&:hover {
color: var(--el-color-primary);
}
}
}
&__container {
height: 30px;
flex: 1;
position: relative;
overflow: hidden;
}
&__scroller {
height: 40px;
width: 100%;
white-space: nowrap;
position: absolute;
left: 0;
top: 0;
}
&__item {
display: inline-flex;
align-items: center;
border-radius: 4px;
height: 30px;
padding: 0 10px;
background-color: #fff;
font-size: 12px;
margin-right: 10px;
color: #909399;
cursor: pointer;
.el-icon {
font-size: 13px;
width: 0;
overflow: hidden;
transition: all 0.3s;
color: #909399;
opacity: 0;
&:hover {
color: #f56c6c !important;
}
}
&:last-child {
margin-right: 0;
}
&:hover {
&:not(.active) {
background-color: #eee;
}
}
&.active {
background-color: var(--color-primary);
span {
color: #fff;
}
.el-icon {
color: #fff;
}
}
&:hover,
&.active {
.el-icon {
opacity: 1;
width: 13px;
margin-left: 5px;
}
}
}
}
</style>

View File

@ -0,0 +1,72 @@
<template>
<div class="route-nav">
<p v-if="browser.isMini" class="route-nav__title">
{{ lastName }}
</p>
<template v-else>
<el-breadcrumb :separator-icon="ArrowRight">
<el-breadcrumb-item v-for="(item, index) in list" :key="index">
{{ item.meta?.label || item.name }}
</el-breadcrumb-item>
</el-breadcrumb>
</template>
</div>
</template>
<script lang="ts" name="route-nav" setup>
import { computed } from "vue";
import { flattenDeep, last } from "lodash-es";
import { ArrowRight } 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)?.name);
</script>
<style lang="scss" scoped>
.route-nav {
white-space: nowrap;
font-size: 14px;
:deep(.el-breadcrumb) {
margin: 0 10px;
}
&__title {
font-weight: 500;
margin-left: 8px;
}
}
</style>

View File

@ -0,0 +1,95 @@
<template>
<div class="app-slider">
<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__container">
<el-scrollbar>
<b-menu />
</el-scrollbar>
</div>
</div>
</template>
<script lang="ts" name="app-slider" setup>
import { useBase } from "/$/base";
import { useBrowser } from "/@/cool";
import BMenu from "./bmenu";
const { browser } = useBrowser();
const { app } = useBase();
</script>
<style lang="scss">
.app-slider {
height: 100%;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
background-color: #2f3447;
&__logo {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
cursor: pointer;
img {
height: 30px;
width: 30px;
}
span {
color: #fff;
font-weight: bold;
font-size: 26px;
margin-left: 10px;
line-height: 1;
white-space: nowrap;
}
}
&__container {
height: calc(100% - 80px);
}
&__menu {
.el-menu {
border-right: 0;
background-color: transparent;
.el-menu-item,
.el-menu__title,
.el-sub-menu__title {
height: 50px;
color: #fff;
.cl-svg {
font-size: 16px;
}
span {
font-size: 12px;
user-select: none;
letter-spacing: 1px;
}
&.is-active,
&:hover {
background-color: var(--color-primary);
color: #fff;
}
}
&--popup {
.el-menu-item,
.el-menu__title,
.el-sub-menu__title {
color: var(--el-text-color-primary);
}
}
}
}
}
</style>

View File

@ -0,0 +1,186 @@
<template>
<a-menu v-if="app.info.menu.isGroup" />
<div class="app-topbar">
<div
class="app-topbar__collapse"
:class="{
unfold: !app.isFold
}"
@click="app.fold()"
>
<i class="cl-iconfont cl-icon-fold"></i>
</div>
<!-- 路由导航 -->
<route-nav />
<div class="flex1"></div>
<!-- 工具栏 -->
<ul class="app-topbar__tools">
<li v-for="(item, index) in toolbarComponents" :key="index">
<component :is="item.component" />
</li>
</ul>
<!-- 用户信息 -->
<div class="app-topbar__user" v-if="user.info">
<el-dropdown trigger="click" hide-on-click @command="onCommand">
<span class="el-dropdown-link">
<span class="name">{{ user.info.nickName }}</span>
<cl-avatar :size="32" :src="user.info.headImg" />
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="my">
<i class="cl-iconfont cl-icon-user"></i>
<span>个人中心</span>
</el-dropdown-item>
<el-dropdown-item command="exit">
<i class="cl-iconfont cl-icon-exit"></i>
<span>退出</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script lang="ts" name="app-topbar" setup>
import { computed, markRaw, onMounted, reactive } from "vue";
import { isFunction, orderBy } from "lodash-es";
import { useBase } from "/$/base";
import { module, useCool } from "/@/cool";
import RouteNav from "./route-nav.vue";
import AMenu from "./amenu.vue";
import { ElMessageBox } from "element-plus";
const { router, service, browser } = useCool();
const { user, app } = useBase();
//
async function onCommand(name: string) {
switch (name) {
case "my":
router.push("/my/info");
break;
case "exit":
ElMessageBox.confirm("确定退出登录吗?", "提示", {
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.map((e) => e.toolbar).filter(Boolean), "order");
this.list = await Promise.all(
arr.map(async (e) => {
if (e) {
const c = await (isFunction(e.component) ? e.component() : e.component);
return {
...e,
component: markRaw(c.default)
};
}
})
);
}
});
//
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: 50px;
padding: 0 10px;
background-color: #fff;
margin-bottom: 10px;
&__collapse {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
width: 40px;
cursor: pointer;
transform: rotateY(180deg);
&.unfold {
transform: rotateY(0);
}
i {
font-size: 20px;
}
}
.flex1 {
flex: 1;
}
&__tools {
display: flex;
margin-right: 20px;
& > li {
display: flex;
justify-content: center;
align-items: center;
list-style: none;
height: 45px;
padding: 0 10px;
cursor: pointer;
}
}
&__user {
margin-right: 10px;
cursor: pointer;
.el-dropdown-link {
display: flex;
align-items: center;
}
.name {
white-space: nowrap;
margin-right: 15px;
}
.el-icon-arrow-down {
margin-left: 10px;
}
}
}
</style>

View File

@ -0,0 +1,95 @@
<template>
<div class="app-views">
<router-view v-slot="{ Component }">
<transition :name="app.info.router.transition || 'none'">
<keep-alive :include="caches" :key="key">
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
</div>
</template>
<script lang="ts" name="app-views" setup>
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: 4px;
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,105 @@
<template>
<div class="app-layout" :class="{ collapse: app.isFold }">
<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" name="app-layout" setup>
import Topbar from "./components/topbar.vue";
import Slider from "./components/slider.vue";
import process from "./components/process.vue";
import Views from "./components/views.vue";
import { useBase } from "/$/base";
const { app } = useBase();
</script>
<style lang="scss" scoped>
.app-layout {
display: flex;
background-color: #f7f7f7;
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%;
}
&.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;
}
&.collapse {
.app-layout__left {
width: 64px;
}
.app-layout__right {
width: calc(100% - 64px);
}
}
}
}
</style>

View File

@ -0,0 +1,29 @@
@keyframes dou {
0% {
transform: rotate(0);
}
11% {
transform: rotate(7.61deg);
}
23% {
transform: rotate(-5.8deg);
}
36% {
transform: rotate(3.35deg);
}
49% {
transform: rotate(-1.9deg);
}
62% {
transform: rotate(1.12deg);
}
75% {
transform: rotate(-0.64deg);
}
88% {
transform: rotate(0.37deg);
}
100% {
transform: rotate(-0.28deg);
}
}

View File

@ -0,0 +1,26 @@
#app {
height: 100vh;
width: 100vw;
overflow: hidden;
}
:root {
--view-bg-color: #f7f7f7;
}
a {
text-decoration: none;
}
input,
button {
outline: none;
}
input {
&:-webkit-autofill {
box-shadow: 0 0 0px 1000px white inset;
}
}
@import "./animation.scss";

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

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="1614441250987" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4692" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><defs><style type="text/css"></style></defs><path d="M825.42250667 584.80298667H628.39239111l-29.53557333-99.30865778c74.05568-33.86026667 125.62545778-108.54172445 125.62545777-195.44291556 0-118.69297778-95.98862222-214.90005333-214.37553777-214.90005333-118.40170667 0-214.37553778 96.22186667-214.37553778 214.90005333 0 86.91484445 51.56977778 161.59744 125.62545778 195.44291556l-29.53557333 99.30865778h-197.02897778a70.59000889 70.59000889 0 0 0-70.59000889 70.59000888v131.0572089c0 17.31697778 14.03904 31.34122667 31.34008889 31.34122666h709.05628444a31.36967111 31.36967111 0 0 0 31.36967111-31.37080889V655.36341333a70.54563555 70.54563555 0 0 0-70.54563555-70.56042666z m48.71509333 291.24266666h-728.07537778a21.84533333 21.84533333 0 0 0-21.84533333 21.84533334v29.12711111a21.84533333 21.84533333 0 0 0 21.84533333 21.84533333h728.09016889a21.84533333 21.84533333 0 0 0 21.84533334-21.84533333v-29.12711111a21.84533333 21.84533333 0 0 0-21.86012445-21.84533334z" p-id="4693"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 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="1614441301181" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8337" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><defs><style type="text/css"></style></defs><path d="M864.35351563 176.78515625c-22.93945313 3.95507813-46.40625 6.06445313-70.48828126 6.06445313-101.42578125 0-194.32617188-36.82617188-265.95703124-97.73437501-9.31640625-7.91015625-23.02734375-7.91015625-32.34375001 0-71.63085938 60.99609375-164.53125 97.734375-265.95703124 97.734375-23.73046875 0-47.02148438-2.02148438-69.60937501-5.88867187-15.1171875-2.54882813-28.91601563 9.140625-29.00390625 24.52148437-0.17578125 95.88867188-0.3515625 209.26757813-0.3515625 213.13476563 0 413.96484375 346.72851563 520.92773438 378.80859376 529.98046875 1.58203125 0.43945313 3.1640625 0.43945313 4.74609374 0C546.18945313 935.6328125 890.80859375 829.28515625 893.00585938 418.57226562l0.35156249-217.08984374c0-15.46875-13.88671875-27.33398438-29.00390625-24.69726563zM541.8828125 562.00976563v135.79101562c0 16.5234375-13.359375 29.8828125-29.8828125 29.8828125s-29.8828125-13.359375-29.8828125-29.8828125V562.00976563c-59.94140625-13.62304688-104.67773438-67.1484375-104.67773438-131.22070313 0-74.26757813 60.20507813-134.56054688 134.56054688-134.56054688S646.56054688 356.43359375 646.56054688 430.7890625c0 64.07226563-44.73632813 117.68554688-104.67773438 131.22070313z" p-id="8338"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 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="1614441322440" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10017" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><defs><style type="text/css"></style></defs><path d="M509.27 62.598c-248.668 0-450.254 199.753-450.254 446.161s201.586 446.162 450.255 446.162 450.255-199.754 450.255-446.162S757.939 62.598 509.27 62.598z m0 148.72c55.666 0 107.78 15.033 152.468 41.2L250.676 659.842c-26.404-44.281-41.575-95.922-41.575-151.084 0-164.27 134.386-297.441 300.17-297.441z m0 594.883c-55.666 0-107.78-15.033-152.469-41.198l411.062-407.326C794.27 401.96 809.44 453.6 809.44 508.76c0 164.277-134.394 297.442-300.17 297.442z" p-id="10018"></path></svg>

After

Width:  |  Height:  |  Size: 853 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="1614441302655" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8477" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><defs><style type="text/css"></style></defs><path d="M284.22599111 249.97250845c11.41782755-31.32742163 22.64147437-62.24099555 33.94279348-93.11573334 5.90309452-16.13035141 16.45317689-23.63907792 33.554432-23.65121422 106.48871822-0.05218608 212.97743645-0.05218608 319.46615466 0 17.70928355 0.0121363 28.54456889 7.85703822 34.61635793 24.69979022 10.16172089 28.2338797 20.42781392 56.42892325 30.26549571 84.76596148 1.99399348 5.76109985 4.88121837 7.46989037 10.84863526 7.43105423 45.03779555-0.25971675 90.06102755-0.1432083 135.09760948-0.1432083 46.33152475 0 84.05477452 36.93317689 84.11909689 83.25256533 0.19418075 158.4126483 0.44054755 316.81194667-0.12864474 475.22459497-0.19418075 53.15333689-45.21862637 83.49771852-83.951616 83.43339615-247.67025303-0.46603378-495.35506963-0.23301689-743.02532267-0.23301689-43.83387497 0-81.34959408-37.54241897-81.36173036-81.46610253-0.02669985-159.4612243-0.01334992-318.93458489 0-478.39580918 0-44.29990875 37.55455525-81.7767917 81.90543643-81.80227792 47.92380682-0.0121363 95.85974992 0 144.65130193 0z m395.04129897 308.73524147c0.75123675-92.05502103-75.58849422-164.60094578-163.6555283-166.58280295-93.00043852-2.09593837-171.21522725 72.74131911-171.65577481 165.14465185-0.42719763 88.27456475 69.05067141 167.64351525 167.87531851 167.43598459 100.13293985-0.21966697 167.90201837-80.54617125 167.4359846-165.99904711z" p-id="8478"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 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="1614441255776" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4972" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><defs><style type="text/css"></style></defs><path d="M891.733 170.667H132.267c-38.4 0-68.267 29.866-68.267 68.266v102.4h896v-102.4c0-38.4-29.867-68.266-68.267-68.266z m0 704c38.4 0 68.267-29.867 68.267-68.267V426.667H64V806.4c0 38.4 29.867 68.267 68.267 68.267h759.466zM192 554.667h253.867c19.2 0 32 12.8 32 32s-12.8 32-32 32H187.733c-17.066-2.134-27.733-14.934-27.733-32 0-19.2 12.8-32 32-32z m0 128h128c19.2 0 32 12.8 32 32s-12.8 32-32 32H187.733c-17.066-2.134-27.733-14.934-27.733-32 0-19.2 12.8-32 32-32z" p-id="4973"></path></svg>

After

Width:  |  Height:  |  Size: 859 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="1614441257585" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5112" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><defs><style type="text/css"></style></defs><path d="M259.2 896a64 64 0 1 0 128 0 64 64 0 1 0-128 0zM768 896a64 64 0 1 0 128 0 64 64 0 1 0-128 0zM940.8 284.8c-16-19.2-38.4-28.8-64-28.8H249.6l-22.4-112c-6.4-44.8-44.8-80-92.8-80H96c-19.2 0-32 16-32 32s12.8 32 32 32h38.4c12.8 0 25.6 9.6 28.8 25.6L192 310.4l51.2 371.2c6.4 48 44.8 86.4 89.6 86.4h486.4c44.8 0 83.2-32 89.6-73.6L960 355.2c3.2-25.6-3.2-51.2-19.2-70.4z" p-id="5113"></path></svg>

After

Width:  |  Height:  |  Size: 763 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="1614441314533" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9317" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><defs><style type="text/css"></style></defs><path d="M478.176 454.848c-17.12 0-33.184 6.656-45.248 18.72-24.928 24.96-24.928 65.568 0 90.592 24.128 24.096 66.304 24.064 90.496-0.032 12.096-12.096 18.752-28.16 18.752-45.28s-6.656-33.184-18.752-45.28c-12.064-12.096-28.096-18.72-45.248-18.72zM512 63.968c-247.04 0-448 172.256-448 384 0 116.48 63.008 226.048 170.592 298.944l87.328 71.52c-3.904 19.328-17.408 60.096-32.64 97.536-4.928 12.096-1.984 25.984 7.36 35.072a32.049 32.049 0 0 0 22.272 8.992c4.384 0 8.8-0.896 12.992-2.752 36.416-16.224 147.488-68.96 187.584-125.376C763.104 828.448 960 657.568 960 447.968c0-211.744-200.96-384-448-384z m214.624 334.656c12.512 12.512 12.512 32.736 0 45.248-6.24 6.24-14.432 9.376-22.624 9.376s-16.384-3.136-22.624-9.376l-39.84-39.84-52.288 52.288c10.688 18.944 16.896 40.128 16.928 62.496 0.032 34.24-13.312 66.4-37.504 90.592-24.16 24.16-56.288 37.472-90.496 37.472-34.176 0-66.336-13.312-90.496-37.472-49.888-49.984-49.888-131.2 0-181.056 24.16-24.16 56.288-37.472 90.496-37.472 24.032 0 46.912 6.912 66.848 19.2l136.352-136.352c12.512-12.512 32.736-12.512 45.248 0s12.512 32.736 0 45.248l-39.84 39.84 39.84 39.808z" p-id="9318"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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