feat(all): 前端初始化
|
@ -0,0 +1 @@
|
|||
VITE_API_BASE_URL= 'http://localhost:8080'
|
|
@ -0,0 +1,5 @@
|
|||
/*.json
|
||||
/src/**/*.json
|
||||
dist
|
||||
postcss.config.js
|
||||
*.md
|
|
@ -0,0 +1,87 @@
|
|||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
// Parser that checks the content of the <script> tag
|
||||
parser: '@typescript-eslint/parser',
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
env: {
|
||||
'browser': true,
|
||||
'node': true,
|
||||
'vue/setup-compiler-macros': true,
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: [
|
||||
// Airbnb JavaScript Style Guide https://github.com/airbnb/javascript
|
||||
'airbnb-base',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:import/recommended',
|
||||
'plugin:import/typescript',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: path.resolve(__dirname, './tsconfig.json'),
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': 1,
|
||||
// Vue: Recommended rules to be closed or modify
|
||||
'vue/require-default-prop': 0,
|
||||
'vue/singleline-html-element-content-newline': 0,
|
||||
'vue/max-attributes-per-line': 0,
|
||||
// Vue: Add extra rules
|
||||
'vue/custom-event-name-casing': [2, 'camelCase'],
|
||||
'vue/no-v-text': 1,
|
||||
'vue/padding-line-between-blocks': 1,
|
||||
'vue/require-direct-export': 1,
|
||||
'vue/multi-word-component-names': 0,
|
||||
// Allow @ts-ignore comment
|
||||
'@typescript-eslint/ban-ts-comment': 0,
|
||||
'@typescript-eslint/no-unused-vars': 1,
|
||||
'@typescript-eslint/no-empty-function': 1,
|
||||
'@typescript-eslint/no-explicit-any': 0,
|
||||
'consistent-return': 'off',
|
||||
'import/extensions': [
|
||||
2,
|
||||
'ignorePackages',
|
||||
{
|
||||
js: 'never',
|
||||
jsx: 'never',
|
||||
ts: 'never',
|
||||
tsx: 'never',
|
||||
},
|
||||
],
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
|
||||
'no-param-reassign': 0,
|
||||
'prefer-regex-literals': 0,
|
||||
'import/no-extraneous-dependencies': 0,
|
||||
'import/no-cycle': 'off',
|
||||
'import/order': 'off',
|
||||
'class-methods-use-this': 'off',
|
||||
'global-require': 0,
|
||||
'no-plusplus': 'off',
|
||||
'no-underscore-dangle': 'off',
|
||||
},
|
||||
// 对特定文件进行配置
|
||||
overrides: [
|
||||
{
|
||||
files: ['src/enums/**/*.ts'],
|
||||
rules: {
|
||||
'no-shadow': 'off', // eslint会报错提示重复声明,暂未找到问题原因,先关闭
|
||||
// 可以在这里添加更多的规则禁用
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.history
|
||||
coverage
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
pnpm commitlint --edit $1
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npm run lint-staged
|
|
@ -0,0 +1,7 @@
|
|||
/dist/*
|
||||
.local
|
||||
.output.js
|
||||
/node_modules/**
|
||||
|
||||
**/*.svg
|
||||
**/*.sh
|
|
@ -0,0 +1,14 @@
|
|||
module.exports = {
|
||||
plugins: [require('prettier-plugin-tailwindcss')],
|
||||
tabWidth: 2,
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
quoteProps: 'consistent',
|
||||
htmlWhitespaceSensitivity: 'strict',
|
||||
vueIndentScriptAndStyle: true,
|
||||
useTabs: false,
|
||||
trailingComma: 'es5',
|
||||
printWidth: 120,
|
||||
arrowParens: 'always',
|
||||
endOfLine: 'auto',
|
||||
};
|
|
@ -0,0 +1,123 @@
|
|||
module.exports = {
|
||||
extends: [
|
||||
'stylelint-config-standard',
|
||||
'stylelint-config-prettier',
|
||||
'stylelint-config-html/vue',
|
||||
'stylelint-config-recommended-less',
|
||||
],
|
||||
plugins: ['stylelint-less', 'stylelint-order'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
customSyntax: 'postcss-html',
|
||||
},
|
||||
],
|
||||
customSyntax: 'postcss-less',
|
||||
ignoreFiles: ['**/*.js', '**/*.jsx', '**/*.tsx', '**/*.ts', '**/*.json', 'node_modules/**/*'],
|
||||
rules: {
|
||||
'indentation': 2,
|
||||
'selector-pseudo-element-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignorePseudoElements: ['v-deep', ':deep'],
|
||||
},
|
||||
],
|
||||
'number-leading-zero': 'always',
|
||||
'no-descending-specificity': null,
|
||||
'function-url-quotes': 'always',
|
||||
'string-quotes': 'single',
|
||||
'unit-case': null,
|
||||
'color-hex-case': 'lower',
|
||||
'color-hex-length': 'long',
|
||||
'rule-empty-line-before': 'never',
|
||||
'font-family-no-missing-generic-family-keyword': null,
|
||||
'selector-type-no-unknown': null,
|
||||
'block-opening-brace-space-before': 'always',
|
||||
'at-rule-no-unknown': null,
|
||||
'no-duplicate-selectors': null,
|
||||
'property-no-unknown': null,
|
||||
'no-empty-source': null,
|
||||
'selector-class-pattern': null,
|
||||
'keyframes-name-pattern': null,
|
||||
'selector-pseudo-class-no-unknown': [true, { ignorePseudoClasses: ['global', 'deep'] }],
|
||||
'function-no-unknown': null,
|
||||
'order/properties-order': [
|
||||
'position',
|
||||
'top',
|
||||
'right',
|
||||
'bottom',
|
||||
'left',
|
||||
'z-index',
|
||||
'display',
|
||||
'justify-content',
|
||||
'align-items',
|
||||
'float',
|
||||
'clear',
|
||||
'overflow',
|
||||
'overflow-x',
|
||||
'overflow-y',
|
||||
'margin',
|
||||
'margin-top',
|
||||
'margin-right',
|
||||
'margin-bottom',
|
||||
'margin-left',
|
||||
'padding',
|
||||
'padding-top',
|
||||
'padding-right',
|
||||
'padding-bottom',
|
||||
'padding-left',
|
||||
'width',
|
||||
'min-width',
|
||||
'max-width',
|
||||
'height',
|
||||
'min-height',
|
||||
'max-height',
|
||||
'font-size',
|
||||
'font-family',
|
||||
'font-weight',
|
||||
'border',
|
||||
'border-style',
|
||||
'border-width',
|
||||
'border-color',
|
||||
'border-top',
|
||||
'border-top-style',
|
||||
'border-top-width',
|
||||
'border-top-color',
|
||||
'border-right',
|
||||
'border-right-style',
|
||||
'border-right-width',
|
||||
'border-right-color',
|
||||
'border-bottom',
|
||||
'border-bottom-style',
|
||||
'border-bottom-width',
|
||||
'border-bottom-color',
|
||||
'border-left',
|
||||
'border-left-style',
|
||||
'border-left-width',
|
||||
'border-left-color',
|
||||
'border-radius',
|
||||
'text-align',
|
||||
'text-justify',
|
||||
'text-indent',
|
||||
'text-overflow',
|
||||
'text-decoration',
|
||||
'white-space',
|
||||
'color',
|
||||
'background',
|
||||
'background-position',
|
||||
'background-repeat',
|
||||
'background-size',
|
||||
'background-color',
|
||||
'background-clip',
|
||||
'opacity',
|
||||
'filter',
|
||||
'list-style',
|
||||
'outline',
|
||||
'visibility',
|
||||
'box-shadow',
|
||||
'text-shadow',
|
||||
'resize',
|
||||
'transition',
|
||||
],
|
||||
},
|
||||
};
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true,
|
||||
"source.fixAll.stylelint": true, // 开启stylelint自动修复
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"html",
|
||||
"vue",
|
||||
"markdown",
|
||||
"json",
|
||||
"jsonc"
|
||||
],
|
||||
// 配置stylelint检查的文件类型范围
|
||||
"stylelint.validate": [
|
||||
"css",
|
||||
"less",
|
||||
"postcss",
|
||||
"scss",
|
||||
"sass",
|
||||
"vue"
|
||||
],
|
||||
"stylelint.enable": true,
|
||||
"css.validate": false,
|
||||
"less.validate": false,
|
||||
"scss.validate": false,
|
||||
}
|
|
@ -0,0 +1,805 @@
|
|||
## 提交规范
|
||||
|
||||
### 1. 提交消息格式
|
||||
|
||||
```bin
|
||||
<Type>[optional Scope]: <Description>
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
#### Type 提交的类型
|
||||
|
||||
> 表示提交的目的或影响的范围
|
||||
|
||||
- feat: 新功能(A new feature)
|
||||
- fix: 修复 bug(A bug fix)
|
||||
- docs: 文档变更(Documentation changes)
|
||||
- style: 代码样式调整(Code style changes)
|
||||
- refactor: 代码重构(Code refactoring)
|
||||
- test: 测试相关的变更(Test-related changes)
|
||||
- chore: 构建过程或工具变动(Build process or tooling changes)
|
||||
- perf: 性能优化(Performance optimization)
|
||||
- ci: CI/CD 相关的变动(Changes to the CI/CD configuration or scripts)
|
||||
- revert: 撤销之前的提交(Revert a previous commit)
|
||||
|
||||
#### Scope 作用域
|
||||
|
||||
提交的作用域,表示本次提交影响的部分代码或模块,可以根据项目的需要选择性地添加。
|
||||
|
||||
#### Description 描述
|
||||
|
||||
简明扼要地描述本次提交的内容
|
||||
|
||||
#### body 正文
|
||||
|
||||
可选的详细描述,可以包含更多的信息和上下文
|
||||
|
||||
#### footer 脚注
|
||||
|
||||
可选的脚注,通常用于引用相关的问题编号或关闭问题。
|
||||
|
||||
## 示例提交消息:
|
||||
|
||||
```
|
||||
feat(user): add login functionality
|
||||
|
||||
- Add login form and authentication logic
|
||||
- Implement user authentication API endpoints
|
||||
|
||||
Closes #123
|
||||
```
|
||||
|
||||
在这个示例中,提交类型为 feat(新功能),作用域为 user,描述了添加登录功能的内容。正文部分提供了更详细的说明,并引用了问题编号。
|
||||
|
||||
## 架构总览
|
||||
|
||||
```
|
||||
├── babel.config.js // babel配置,支持JSX
|
||||
├── commitlint.config.js // commitlint配置,校验commit信息
|
||||
├── components.d.ts // 组件注册TS声明
|
||||
├── config // 项目构建配置
|
||||
│ ├── plugin // 构建插件
|
||||
│ ├── utils // 构建工具方法
|
||||
│ ├── vite.config.base.ts // vite基础配置
|
||||
│ ├── vite.config.dev.ts // vite开发环境配置
|
||||
│ └── vite.config.prod.ts // vite 生产配置
|
||||
├── index.html // 单页面html模板
|
||||
├── src
|
||||
│ ├── App.vue // 应用入口vue
|
||||
│ ├── api // 项目请求api封装
|
||||
│ │ ├── http // axios封装
|
||||
│ │ ├── modules // 各业务模块的请求方法
|
||||
│ │ ├── requrls // 按业务模块划分的接口地址
|
||||
│ ├── assets // 全局静态资源
|
||||
│ │ ├── images
|
||||
- svg
|
||||
│ │ ├── logo.svg
|
||||
│ │ ├── style
|
||||
│ ├── components // 组件
|
||||
│ ├── config // 全局配置,常量类、JSON
|
||||
│ ├── directive // 自定义指令集
|
||||
│ ├── enums // 全局枚举定义
|
||||
│ ├── hooks // 全局hooks集
|
||||
│ ├── layout // 应用布局组件
|
||||
│ ├── locale // 国际化配置
|
||||
│ ├── main.ts // 项目主入口
|
||||
│ ├── mock // mock数据配置
|
||||
│ ├── models // 全局数据模型定义
|
||||
│ ├── router // 路由
|
||||
│ ├── store // pinia状态库
|
||||
│ ├── types // 全局TS声明
|
||||
│ ├── utils // 公共工具方法
|
||||
│ └── views
|
||||
│ ├── modules // 页面模块
|
||||
│ └── base // 公共页面,403、404等
|
||||
│ ├── env.d.ts // 环境信息TS类型声明
|
||||
└── .env.development // 开发环境变量声明
|
||||
└── .env.production // 生产环境变量声明
|
||||
└── .eslintrc.js // eslint配置
|
||||
└── .prettierrc.js // prettier配置
|
||||
└── tsconfig.json // 全局TS配置
|
||||
```
|
||||
|
||||
<a name="dd14f832"></a>
|
||||
|
||||
## -状态管理模块设计
|
||||
|
||||
Vue3 状态管理方案采用的是`pinia`,API 风格与`Redux`类的状态管理库类似,也是通过模块化的方式注册`模块store`,每个`store`中包含数据仓库`state`、数据包装过滤`getter`、同步/异步操作`action`。与`Vuex`相比,`pinia`提供了`compasition-API`、完整支持 TS 以及可扩展的`Plugin`功能。<br />整体模块划分为业务模块`modules/*`、注册入口`index`以及插件`plugins/*`。<br />首先,在`store/index.ts`中声明注册`pinia`并引入自定义的插件:
|
||||
|
||||
```typescript
|
||||
import { createPinia } from 'pinia';
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
|
||||
import { debouncePlugin } from './plugins';
|
||||
import useXXStore from './modules/xx';
|
||||
|
||||
const pinia = createPinia();
|
||||
// 插件会在实例创建后应用,因此插件不能在pinia实例创建前使用
|
||||
pinia.use(debouncePlugin).use(piniaPluginPersistedstate);
|
||||
export { useXXStore }; // 导出模块store
|
||||
export default pinia;
|
||||
```
|
||||
|
||||
然后,在项目入口`mian.ts`中使用:
|
||||
|
||||
```typescript
|
||||
import { createApp } from 'vue';
|
||||
import store from '@/store';
|
||||
import app from '@/App.vue';
|
||||
|
||||
createApp(app).use(store).mount('#app');
|
||||
```
|
||||
|
||||
<a name="piniaPlugins.ts"></a>
|
||||
|
||||
### plugins
|
||||
|
||||
在此文件内编写`pinia`插件,并在注册的时候引入使用即可:
|
||||
|
||||
```typescript
|
||||
import { debounce, isObject } from 'lodash-es';
|
||||
// 首先得声明插件使用到的额外属性,因为pinia的TS类型声明中,每个store只有三个原生属性state、getters、actions,若没有使用到额外属性则无需声明
|
||||
declare module 'pinia' {
|
||||
export interface DefineStoreOptionsBase<S, Store> {
|
||||
cache?: Partial<CacheType<S, Store>>; // 缓存配置
|
||||
debounce?: Partial<Record<keyof StoreActions<Store>, number>>; // 节流配置
|
||||
}
|
||||
}
|
||||
// 基于lodash的防抖函数封装,读取store中cache配置的属性,针对已配置的属性更改操作进行防抖处理
|
||||
export const debouncePlugin = ({ options, store }: PiniaPluginContext) => {
|
||||
if (options.debounce) {
|
||||
return Object.keys(options.debounce).reduce((debounceActions: debounceAction, action) => {
|
||||
debounceActions[action] = debounce(store[action], options.debounce![action]);
|
||||
return debounceActions;
|
||||
}, {});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
上述自定义插件,在 store 中如下配置:
|
||||
|
||||
```typescript
|
||||
export const useStore = defineStore('demo', {
|
||||
state: () => ({
|
||||
testDebounce: '',
|
||||
}),
|
||||
getters: {},
|
||||
actions: {},
|
||||
debounce: {
|
||||
// 防抖设置
|
||||
testDebounce: 500, // 值为防抖缓冲时间
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
<a name="520bb47a"></a>
|
||||
|
||||
## -网络模块设计
|
||||
|
||||
网络模块包含:请求 url 封装`api/requrls/*`、请求方法封装`api/modules/*`、请求工具封装`api/http/*`。
|
||||
<a name="slaQE"></a>
|
||||
|
||||
### requrls
|
||||
|
||||
将项目接口地址收敛至此文件夹下管理,避免出现一个项目多个重复接口 url、方便接口地址复用且方便统一处理
|
||||
|
||||
```typescript
|
||||
export const LoginUrl = '/api/user/login';
|
||||
export const LogoutUrl = '/api/user/logout';
|
||||
export const GetUserInfoUrl = '/api/user/info';
|
||||
export const GetMenuListUrl = '/api/user/menu';
|
||||
```
|
||||
|
||||
<a name="y561q"></a>
|
||||
|
||||
### modules
|
||||
|
||||
将项目实际请求方法按业务模块划分,统一管理实际业务请求,将请求方法与接口地址解耦
|
||||
|
||||
```typescript
|
||||
import axios from 'axios';
|
||||
import { LoginUrl, LogoutUrl, GetUserInfoUrl, GetMenuListUrl } from '@/api/requrls/user';
|
||||
import type { RouteRecordNormalized } from 'vue-router';
|
||||
import type { UserState } from '@/store/modules/user/types';
|
||||
import type { LoginData, LoginRes } from '@/models/user';
|
||||
|
||||
export function login(data: LoginData) {
|
||||
return axios.post<LoginRes>(LoginUrl, data);
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return axios.post<LoginRes>(LogoutUrl);
|
||||
}
|
||||
|
||||
export function getUserInfo() {
|
||||
return axios.post<UserState>(GetUserInfoUrl);
|
||||
}
|
||||
|
||||
export function getMenuList() {
|
||||
return axios.post<RouteRecordNormalized[]>(GetMenuListUrl);
|
||||
}
|
||||
```
|
||||
|
||||
最终通过`index.ts`将请求方法暴露出去
|
||||
|
||||
```typescript
|
||||
export * from './modules/user';
|
||||
export * from './modules/dashboard';
|
||||
export * from './modules/message';
|
||||
```
|
||||
|
||||
<a name="0f0a9627"></a>
|
||||
|
||||
#### 请求工具封装
|
||||
|
||||
基于`axios`封装请求方法,提供`form-data/json/urlencoded`格式的数据处理、自定义头部处理、响应拦截错误处理、分级提示(modal、message、none)、get 请求防缓存。
|
||||
|
||||
```typescript
|
||||
// 分级错误信息提示,none为静默模式即不提示、modal为对话框提示、message为tips消息提示
|
||||
// ErrorMessageMode默认为message模式,通过传入请求方法的options.errorMessageMode入参判断
|
||||
export type ErrorMessageMode = 'none' | 'modal' | 'message' | undefined;
|
||||
|
||||
// 三种入参数据格式
|
||||
export enum ContentTypeEnum {
|
||||
// json
|
||||
JSON = 'application/json;charset=UTF-8',
|
||||
// form-data qs序列化处理
|
||||
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
// form-data upload文件流处理
|
||||
FORM_DATA = 'multipart/form-data;charset=UTF-8',
|
||||
}
|
||||
|
||||
// 对上传文件请求入参格式额外定义
|
||||
export interface UploadFileParams {
|
||||
// 除文件流外的参数
|
||||
data?: Recordable;
|
||||
// 文件流字段名
|
||||
name?: string;
|
||||
// 文件内容
|
||||
file: File | Blob;
|
||||
// 文件名
|
||||
filename?: string;
|
||||
// 其他parmas
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 基准返回数据格式
|
||||
export interface Result<T = any> {
|
||||
code: number;
|
||||
type: 'success' | 'error' | 'warning';
|
||||
message: string;
|
||||
result: T;
|
||||
}
|
||||
```
|
||||
|
||||
<a name="c53518d5"></a>
|
||||
|
||||
## -directive 指令集
|
||||
|
||||
指令入口`index.ts`导入并注册定义的全部指令
|
||||
|
||||
```typescript
|
||||
import { App } from 'vue';
|
||||
import permission from './permission';
|
||||
|
||||
export default {
|
||||
install(Vue: App) {
|
||||
Vue.directive('permission', permission);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
权限指令(或其他自定义指令)在同级目录下创建,以指令名称命名文件夹,下面是示例:
|
||||
|
||||
```typescript
|
||||
import { DirectiveBinding } from 'vue';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
function checkPermission(el: HTMLElement, binding: DirectiveBinding) {
|
||||
const { value } = binding;
|
||||
const userStore = useUserStore();
|
||||
const { role } = userStore;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
const permissionValues = value;
|
||||
const hasPermission = permissionValues.includes(role);
|
||||
if (!hasPermission && el.parentNode) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`need roles! Like v-permission="['admin','user']"`);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
mounted(el: HTMLElement, binding: DirectiveBinding) {
|
||||
checkPermission(el, binding);
|
||||
},
|
||||
updated(el: HTMLElement, binding: DirectiveBinding) {
|
||||
checkPermission(el, binding);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
<a name="Kw0kR"></a>
|
||||
|
||||
## -hooks
|
||||
|
||||
全局抽象钩子集(与 Vue2 的 mixins 类似),这里只写业务逻辑的钩子!!!通过`@vueuse/core`我们已经可以得到非常多实用的钩子函数,没必要重复造轮子,在编写钩子功能前先去[Function List](https://vueuse.org/functions.html)查看是否已经有相同功能的钩子了,没有再自己写。导出钩子`export default function useXxx`,以权限钩子示例:
|
||||
|
||||
```typescript
|
||||
import { RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
export default function usePermission() {
|
||||
const userStore = useUserStore();
|
||||
return {
|
||||
accessRouter(route: RouteLocationNormalized | RouteRecordRaw) {
|
||||
// do something
|
||||
},
|
||||
findFirstPermissionRoute(_routers: any, role = 'admin') {
|
||||
// do something
|
||||
},
|
||||
// You can add any rules you want
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
<a name="In07K"></a>
|
||||
|
||||
## -layout
|
||||
|
||||
项目布局设计模块,将最上层布局组件放置此文件夹内,统一管理项目各类上层布局(若存在,例如左侧菜单-右侧内容布局、顶层菜单-中间内容-底部页脚布局等等),示例:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- router-view内为实际路由切换变化的内容,视为路由页面容器 -->
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<!-- transition为页面录音切换时提供进出动画,平滑过渡页面渲染 -->
|
||||
<transition name="fade" mode="out-in" appear>
|
||||
<component :is="Component" v-if="route.meta.ignoreCache" :key="route.fullPath" />
|
||||
<!-- keep-alive提供组件状态缓存,以便快速渲染组件内容 -->
|
||||
<keep-alive v-else :include="cacheList">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useTabBarStore } from '@/store';
|
||||
|
||||
const tabBarStore = useTabBarStore();
|
||||
const cacheList = computed(() => tabBarStore.getCacheList);
|
||||
</script>
|
||||
```
|
||||
|
||||
<a name="KZkaZ"></a>
|
||||
|
||||
## -models
|
||||
|
||||
全局数据模型,将涉及请求、组件 props、状态库的公共属性抽象为数据模型,在此文件夹内声明,保证全局数据的类型统一、方便维护。下面是请求模型示例:
|
||||
|
||||
```typescript
|
||||
export interface HttpResponse<T = unknown> {
|
||||
status: number;
|
||||
msg: string;
|
||||
code: number;
|
||||
data: T;
|
||||
}
|
||||
```
|
||||
|
||||
也可以是业务相关模型:
|
||||
|
||||
```typescript
|
||||
export interface LoginData {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginRes {
|
||||
token: string;
|
||||
}
|
||||
```
|
||||
|
||||
<a name="d3Gdu"></a>
|
||||
|
||||
## -mock
|
||||
|
||||
提供全局接口数据 mock 功能,避免因为调试而修改请求代码导致出现问题,按功能模块划分文件,通过`index.ts`暴露,示例如下:
|
||||
|
||||
```typescript
|
||||
import Mock from 'mockjs';
|
||||
import './user';
|
||||
import '@/views/dashboard/workplace/mock';
|
||||
|
||||
Mock.setup({
|
||||
timeout: '600-1000',
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
import Mock from 'mockjs';
|
||||
import setupMock, { successResponseWrap, failResponseWrap } from '@/utils/setup-mock';
|
||||
import { isLogin } from '@/utils/auth';
|
||||
|
||||
import { MockParams } from '@/types/mock';
|
||||
|
||||
setupMock({
|
||||
setup() {
|
||||
// 用户信息
|
||||
Mock.mock(new RegExp('/api/user/info'), () => {
|
||||
if (isLogin()) {
|
||||
const role = window.localStorage.getItem('userRole') || 'admin';
|
||||
return successResponseWrap({
|
||||
name: '王立群',
|
||||
avatar: '//lf1-xgcdn-tos.pstatp.com/obj/vcloud/vadmin/start.8e0e4855ee346a46ccff8ff3e24db27b.png',
|
||||
email: 'wangliqun@email.com',
|
||||
job: 'frontend',
|
||||
jobName: '前端艺术家',
|
||||
organization: 'Frontend',
|
||||
organizationName: '前端',
|
||||
location: 'beijing',
|
||||
locationName: '北京',
|
||||
introduction: '人潇洒,性温存',
|
||||
personalWebsite: 'https://www.arco.design',
|
||||
phone: '150****0000',
|
||||
registrationDate: '2013-05-10 12:10:00',
|
||||
accountId: '15012312300',
|
||||
certification: 1,
|
||||
role,
|
||||
});
|
||||
}
|
||||
return failResponseWrap(null, '未登录', 50008);
|
||||
});
|
||||
// 登出
|
||||
Mock.mock(new RegExp('/api/user/logout'), () => {
|
||||
return successResponseWrap(null);
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
<a name="QgxDQ"></a>
|
||||
|
||||
## -router
|
||||
|
||||
项目路由管理模块,入口文件`index.ts`注册并暴露全部路由,以模块命名文件夹划分模块路由放置在`routes/*`下;`guard/*`下放置路由导航控制,包含权限、登录重定向等;`app-menus/index.ts`为菜单相关的路由信息;`constants.ts`定义路由常量,包括路由白名单、重定向路由名、默认主页路由信息等
|
||||
<a name="YZWcg"></a>
|
||||
|
||||
## -enums
|
||||
|
||||
全局的枚举类型声明模块,将可枚举的类型统一管理,以`类型+Enum`命名,方便维护。示例如下:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @description: Request result set
|
||||
*/
|
||||
export enum ResultEnum {
|
||||
SUCCESS = 0,
|
||||
ERROR = 1,
|
||||
TIMEOUT = 401,
|
||||
TYPE = 'success',
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: request method
|
||||
*/
|
||||
export enum RequestEnum {
|
||||
GET = 'GET',
|
||||
POST = 'POST',
|
||||
PUT = 'PUT',
|
||||
DELETE = 'DELETE',
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: contentTyp
|
||||
*/
|
||||
export enum ContentTypeEnum {
|
||||
// json
|
||||
JSON = 'application/json;charset=UTF-8',
|
||||
// form-data qs
|
||||
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
// form-data upload
|
||||
FORM_DATA = 'multipart/form-data;charset=UTF-8',
|
||||
}
|
||||
```
|
||||
|
||||
<a name="I0KEf"></a>
|
||||
|
||||
## -locale
|
||||
|
||||
国际化模块,存放项目声明的国际化配置,按语种划分模块。模块入口文件为`index.ts`,负责定义菜单、导航栏等公共的国际化配置,其他的按系统功能声明并导入`index.ts`,还有项目内页面组件的国际化配置也需要导入至`index.ts`,页面组件的国际化配置在页面的目录下声明,如:`views/dashboard/workplace/locale`。
|
||||
|
||||
```typescript
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import en from './en-US';
|
||||
import cn from './zh-CN';
|
||||
|
||||
export const LOCALE_OPTIONS = [
|
||||
{ label: '中文', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en-US' },
|
||||
];
|
||||
const defaultLocale = localStorage.getItem('MS-locale') || 'zh-CN';
|
||||
const i18n = createI18n({
|
||||
locale: defaultLocale,
|
||||
fallbackLocale: 'en-US',
|
||||
legacy: false,
|
||||
allowComposition: true,
|
||||
messages: {
|
||||
'en-US': en,
|
||||
'zh-CN': cn,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
```
|
||||
|
||||
<a name="fZfbw"></a>
|
||||
|
||||
## -types
|
||||
|
||||
项目级别的类型声明,与业务无关的类型声明,与`models`、`enums`不同的是,这里声明的是项目模块级别的类型,或者工具模块的类型声明,例如:`axios`的配置声明、第三方插件不提供 TS 支持但需要我们自定义的声明等
|
||||
<a name="hvu5O"></a>
|
||||
|
||||
## -utils
|
||||
|
||||
公共方法、工具,按功能类型命名文件,单个文件内工具方法的功能要对应命名
|
||||
<a name="FFTqz"></a>
|
||||
|
||||
## -views
|
||||
|
||||
页面模块,按功能模块划分,公共模块有`login`、`base`,其中`base`模块内包含 404、403 页面等
|
||||
<a name="RwVcu"></a>
|
||||
|
||||
## -theme 主题配置
|
||||
1. 去Desing Lab 创建主题 https://arco.design/themes/home
|
||||
2. 以`ms-theme-` 命名为开头
|
||||
3. 点击页面的配置主题
|
||||
**“CSS 变量” + “Tailwind 配置变量” + “基于 css 变量自行计算混合色覆盖 arco-theme 变量”**
|
||||
|
||||
## -.env.\*环境变量配置
|
||||
|
||||
在`vite`中内置了环境变量配置功能,只需要在项目根目录下创建以`.env.*`开头的文件,在内部写入配置的变量即可,默认`.env`为生产环境、`.env.development`为开发环境、`.env.XXX`为自定义`XXX`环境(注意:自定义环境需要在`package.json`的项目运行命令后加入`--mode XXX`),各类环境变量配置示例如下( ⚠️ 注意!!环境变量文件内使用注释要用`#`,不能使用`//`):
|
||||
|
||||
```bash
|
||||
# .env.production 为生产环境配置
|
||||
NODE_ENV=production # 代码中通过import.meta.env.NODE_ENV访问
|
||||
VITE_STG=1 # 自定义变量必须用VITE_开头,代码中通过import.meta.env.VITE_XXX访问
|
||||
|
||||
# .env.development 为开发环境配置
|
||||
NODE_ENV=development
|
||||
VITE_APP_ENV = dev
|
||||
VITE_APP_TITLE = 我是标题
|
||||
|
||||
# .env.XXX 为自定义环境配置
|
||||
VITE_MYENV = 1 # 代码中通过import.meta.env.VITE_MYENV访问
|
||||
```
|
||||
|
||||
⚠️ 上述环境变量在代码中正常都可以用`import.meta.env.VITE_XXX`访问,但是在`vite.config.ts`配置文件中无法使用此方法访问,原因是此访问链是 vite 在初始化后通过读取本地`.env.XXX`文件并注入到`import.meta`中的,但是在`vite.config.ts`中 vite 此时还未初始化,所以无法通过上述方法访问,应通过下面方法访问:
|
||||
|
||||
```typescript
|
||||
import { defineConfig, loadEnv } from 'vite'; // 导入loadEnv方法
|
||||
loadEnv(mode, process.cwd()).VITE_XXX; // 在需要访问env里变量的地方使用此方法访问即可
|
||||
```
|
||||
|
||||
<a name="c61428f2"></a>
|
||||
|
||||
## -TailwindCSS 配置
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
content: ['./index.html', './src/**/*.{html,js,vue}', './src/*.{html,js,vue}'], // 需要解析的文件路径
|
||||
theme: {
|
||||
// 自定义主题配置
|
||||
backgroundColor: {
|
||||
// 自定义背景色
|
||||
menuHover: '#272D39',
|
||||
headerBg: '#191E29',
|
||||
},
|
||||
textColor: (theme) => ({
|
||||
// 自定义字体颜色
|
||||
...theme('colors'), // 这里必须解构原本有的颜色,不然会导致在页面style中使用@apply应用内部字体颜色类的时候报错找不到内部字体颜色类
|
||||
'40Gray': 'rgba(255,255,255,0.40)',
|
||||
'65Gray': 'rgba(255,255,255,0.65)',
|
||||
}),
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
```
|
||||
|
||||
<a name="c7baab5c"></a>
|
||||
|
||||
## -TS 配置-tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"src/components.d.ts",
|
||||
"auto-imports.d.ts"
|
||||
], // TS解析路径配置
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"allowJs": true, // 允许编译器编译JS,JSX文件
|
||||
"noEmit": true,
|
||||
"target": "esnext", // 使用es最新语法
|
||||
"useDefineForClassFields": true,
|
||||
"allowSyntheticDefaultImports": true, // 允许异步导入模块,配合自动导入插件使用
|
||||
"module": "esnext", // 使用ES模块语法
|
||||
"moduleResolution": "node",
|
||||
"strict": true, // 严格模式
|
||||
"jsx": "preserve",
|
||||
"sourceMap": true, // 代码映射
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["esnext", "dom"],
|
||||
"skipLibCheck": true, // 跳过node依赖包语法检查
|
||||
"types": [
|
||||
// "vitest/globals",
|
||||
// "vite-plugin-svg-icons/client"
|
||||
], // 手动导入TS类型声明文件
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
// 路径映射
|
||||
"@/*": ["./src/*"],
|
||||
"#/*": ["types/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<a name="50a6c7f6"></a>
|
||||
|
||||
## -vite 配置-插件详解
|
||||
|
||||
`vite`构建生产资源使用`rollup`打包,所以可以在`vite`中使用`rollup`支持的所有插件。
|
||||
|
||||
- 组件自动导入,使用`unplugin-auto-import/vite`插件实现自动导入组件、`unplugin-vue-components/vite`插件按需导入自定义组件,具体用法如下:
|
||||
|
||||
```typescript
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ArcoResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { vitePluginForArco } from '@arco-plugins/vite-vue'
|
||||
|
||||
export default () => defineConfig({
|
||||
plugins: [
|
||||
// 自定义组件自动引入
|
||||
AutoImport({å
|
||||
dts: 'src/auto-import.d.ts', // 输出声明文件地址(在使用typescript时必须配置,不然会导致页面使用未导入的组件时报错)
|
||||
resolvers: [ElementPlusResolver()], // ElementPlus自动导入
|
||||
}),
|
||||
// 自定义组件按需引入
|
||||
Components({
|
||||
dts: 'src/components.d.ts', // 输出声明文件地址(在使用typescript时必须配置,不然会导致页面使用未导入的组件时报错)
|
||||
dirs: ['src/components'], // 按需加载的文件夹
|
||||
resolvers: [
|
||||
ArcoResolver(), // ArcoDesignVue按需加载
|
||||
],
|
||||
}),
|
||||
// 样式自动导入
|
||||
vitePluginForArco({})
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
- 使用`vite-plugin-svg-icons`插件实现自动加载 svg 图片,并通过封装 svg 组件的方式,一行代码使用 svg,配置如下:
|
||||
|
||||
```typescript
|
||||
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';
|
||||
|
||||
export default () =>
|
||||
defineConfig({
|
||||
plugins: [
|
||||
createSvgIconsPlugin({
|
||||
// 指定svg读取的文件夹
|
||||
iconDirs: [resolve(process.cwd(), 'src/assets/icons/svg')],
|
||||
// 指定icon的读取名字,使用svg文件名为icon名字
|
||||
symbolId: 'icon-[dir]-[name]',
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
- 使用`vite`自带插件`loadEnv`读取环境信息,可作环境判断,使用`rollup-plugin-visualizer`插件可分析打包后的文件体积,配置如下:
|
||||
|
||||
```typescript
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
|
||||
// 这里的mode参数为package.json文件配置的环境参数,使用`--mode XXX`,如"report: rimraf dist && vite build --mode analyze"
|
||||
export default ({ mode }) =>
|
||||
defineConfig({
|
||||
plugins: [
|
||||
// 这里通过--mode analyze配置为分析模式,使用visualizer插件分析方法,输出report.html分析报告
|
||||
loadEnv(mode, process.cwd()).VITE_ANALYZE === 'Y'
|
||||
? visualizer({ open: true, brotliSize: true, filename: 'report.html' })
|
||||
: null,
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<a name="0c0978d0"></a>
|
||||
|
||||
## -vite 配置-build 详解
|
||||
|
||||
上面讲述了插件应用,下面讲解除了插件外,在`vite`中的 build 构建生产资源时,`rollupOptions`的配置:
|
||||
|
||||
- `output`为输出产物配置,我们可以通过`manualChunks`去配置分包策略(与`webpack`分包机制类似),配置如下:
|
||||
|
||||
```typescript
|
||||
import { mergeConfig } from 'vite';
|
||||
import baseConfig from './vite.config.base';
|
||||
import configCompressPlugin from './plugin/compress';
|
||||
import configVisualizerPlugin from './plugin/visualizer';
|
||||
import configArcoResolverPlugin from './plugin/arcoResolver';
|
||||
import configImageminPlugin from './plugin/imagemin';
|
||||
|
||||
export default mergeConfig(
|
||||
{
|
||||
mode: 'production',
|
||||
plugins: [
|
||||
configCompressPlugin('gzip'),
|
||||
configVisualizerPlugin(),
|
||||
configArcoResolverPlugin(),
|
||||
configImageminPlugin(),
|
||||
],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
arco: ['@arco-design/web-vue'],
|
||||
chart: ['echarts', 'vue-echarts'],
|
||||
vue: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n'],
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 2000,
|
||||
},
|
||||
},
|
||||
baseConfig
|
||||
);
|
||||
```
|
||||
|
||||
## 单元测试
|
||||
|
||||
测试框架:vitest
|
||||
|
||||
单元测试实用工具库: @vue/test-utils
|
||||
|
||||
测试环境: js-dom
|
||||
|
||||
报告: @vitest/coverage-c8
|
||||
|
||||
```bash
|
||||
pnpm add -D vitest @vue/test-utils js-dom @vitest/coverage-c8
|
||||
```
|
||||
|
||||
配置
|
||||
|
||||
根目录新建 vitest.config.ts
|
||||
|
||||
```typescript
|
||||
import { mergeConfig } from 'vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import viteConfig from './config/vite.config.dev';
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
},
|
||||
})
|
||||
);
|
||||
```
|
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
plugins: ['@vue/babel-plugin-jsx'],
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
import '@vue/runtime-core'
|
||||
|
||||
export {}
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
export interface GlobalComponents {
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* If you use the template method for development, you can use the unplugin-vue-components plugin to enable on-demand loading support.
|
||||
* 按需引入
|
||||
* https://github.com/antfu/unplugin-vue-components
|
||||
* https://arco.design/vue/docs/start
|
||||
* Although the Pro project is full of imported components, this plugin will be used by default.
|
||||
* 虽然Pro项目中是全量引入组件,但此插件会默认使用。
|
||||
*/
|
||||
import Components from 'unplugin-vue-components/vite';
|
||||
import { ArcoResolver } from 'unplugin-vue-components/resolvers';
|
||||
|
||||
export default function configArcoResolverPlugin() {
|
||||
const arcoResolverPlugin = Components({
|
||||
dirs: [], // Avoid parsing src/components. 避免解析到src/components
|
||||
deep: false,
|
||||
resolvers: [ArcoResolver()],
|
||||
});
|
||||
return arcoResolverPlugin;
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Theme import
|
||||
* 样式按需引入
|
||||
* https://github.com/arco-design/arco-plugins/blob/main/packages/plugin-vite-vue/README.md
|
||||
* https://arco.design/vue/docs/start
|
||||
*/
|
||||
import { vitePluginForArco } from '@arco-plugins/vite-vue';
|
||||
|
||||
export default function configArcoStyleImportPlugin() {
|
||||
// 按需加载主题样式
|
||||
const arcoResolverPlugin = vitePluginForArco({
|
||||
theme: '@arco-themes/vue-ms-theme',
|
||||
});
|
||||
// const arcoResolverPlugin = vitePluginForArco({});
|
||||
return arcoResolverPlugin;
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Used to package and output gzip. Note that this does not work properly in Vite, the specific reason is still being investigated
|
||||
* gzip压缩
|
||||
* https://github.com/anncwb/vite-plugin-compression
|
||||
*/
|
||||
import type { Plugin } from 'vite';
|
||||
import compressPlugin from 'vite-plugin-compression';
|
||||
|
||||
export default function configCompressPlugin(compress: 'gzip' | 'brotli', deleteOriginFile = false): Plugin | Plugin[] {
|
||||
const plugins: Plugin[] = [];
|
||||
|
||||
if (compress === 'gzip') {
|
||||
plugins.push(
|
||||
compressPlugin({
|
||||
ext: '.gz',
|
||||
deleteOriginFile,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (compress === 'brotli') {
|
||||
plugins.push(
|
||||
compressPlugin({
|
||||
ext: '.br',
|
||||
algorithm: 'brotliCompress',
|
||||
deleteOriginFile,
|
||||
})
|
||||
);
|
||||
}
|
||||
return plugins;
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Image resource files used to compress the output of the production environment
|
||||
* 图片压缩
|
||||
* https://github.com/anncwb/vite-plugin-imagemin
|
||||
*/
|
||||
import viteImagemin from 'vite-plugin-imagemin';
|
||||
|
||||
export default function configImageminPlugin() {
|
||||
const imageminPlugin = viteImagemin({
|
||||
gifsicle: {
|
||||
optimizationLevel: 7,
|
||||
interlaced: false,
|
||||
},
|
||||
optipng: {
|
||||
optimizationLevel: 7,
|
||||
},
|
||||
mozjpeg: {
|
||||
quality: 20,
|
||||
},
|
||||
pngquant: {
|
||||
quality: [0.8, 0.9],
|
||||
speed: 4,
|
||||
},
|
||||
svgo: {
|
||||
plugins: [
|
||||
{
|
||||
name: 'removeViewBox',
|
||||
},
|
||||
{
|
||||
name: 'removeEmptyAttrs',
|
||||
active: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return imageminPlugin;
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Generation packaging analysis
|
||||
* 生成打包分析
|
||||
*/
|
||||
import visualizer from 'rollup-plugin-visualizer';
|
||||
import { isReportMode } from '../utils';
|
||||
|
||||
export default function configVisualizerPlugin() {
|
||||
if (isReportMode()) {
|
||||
return visualizer({
|
||||
filename: './node_modules/.cache/visualizer/stats.html',
|
||||
open: true,
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Whether to generate package preview
|
||||
* 是否生成打包报告
|
||||
*/
|
||||
export default {};
|
||||
|
||||
export function isReportMode(): boolean {
|
||||
return process.env.REPORT === 'true';
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { resolve } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx';
|
||||
import svgLoader from 'vite-svg-loader';
|
||||
import configArcoStyleImportPlugin from './plugin/arcoStyleImport';
|
||||
import configArcoResolverPlugin from './plugin/arcoResolver';
|
||||
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
svgLoader({ svgoConfig: {} }),
|
||||
configArcoResolverPlugin(),
|
||||
configArcoStyleImportPlugin(),
|
||||
createSvgIconsPlugin({
|
||||
// 指定需要缓存的图标文件夹
|
||||
iconDirs: [resolve(process.cwd(), 'src/assets/svg')], // 与本地储存地址一致
|
||||
// 指定symbolId格式
|
||||
symbolId: 'icon-[name]',
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: '@',
|
||||
replacement: resolve(__dirname, '../src'),
|
||||
},
|
||||
{
|
||||
find: 'assets',
|
||||
replacement: resolve(__dirname, '../src/assets'),
|
||||
},
|
||||
{
|
||||
find: 'vue-i18n',
|
||||
replacement: 'vue-i18n/dist/vue-i18n.cjs.js', // Resolve the i18n warning issue
|
||||
},
|
||||
{
|
||||
find: 'vue',
|
||||
replacement: 'vue/dist/vue.esm-bundler.js', // compile template
|
||||
},
|
||||
],
|
||||
extensions: ['.ts', '.js'],
|
||||
},
|
||||
define: {
|
||||
'process.env': {},
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
less: {
|
||||
modifyVars: {
|
||||
hack: `true; @import (reference) "${resolve('src/assets/style/breakpoint.less')}";`,
|
||||
},
|
||||
javascriptEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
|
@ -0,0 +1,24 @@
|
|||
/// <reference types="vitest" />
|
||||
import { mergeConfig } from 'vite';
|
||||
import eslint from 'vite-plugin-eslint';
|
||||
import baseConfig from './vite.config.base';
|
||||
|
||||
export default mergeConfig(
|
||||
{
|
||||
mode: 'development',
|
||||
server: {
|
||||
open: true,
|
||||
fs: {
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
eslint({
|
||||
cache: false,
|
||||
include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.vue'],
|
||||
exclude: ['node_modules'],
|
||||
}),
|
||||
],
|
||||
},
|
||||
baseConfig
|
||||
);
|
|
@ -0,0 +1,25 @@
|
|||
import { mergeConfig } from 'vite';
|
||||
import baseConfig from './vite.config.base';
|
||||
import configCompressPlugin from './plugin/compress';
|
||||
import configVisualizerPlugin from './plugin/visualizer';
|
||||
import configImageminPlugin from './plugin/imagemin';
|
||||
|
||||
export default mergeConfig(
|
||||
{
|
||||
mode: 'production',
|
||||
plugins: [configCompressPlugin('gzip'), configVisualizerPlugin(), configImageminPlugin()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
arco: ['@arco-design/web-vue'],
|
||||
chart: ['echarts', 'vue-echarts'],
|
||||
vue: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n'],
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 2000,
|
||||
},
|
||||
},
|
||||
baseConfig
|
||||
);
|
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
type="image/x-icon"
|
||||
href="https://unpkg.byted-static.com/latest/byted/arco-config/assets/favicon.ico"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MeterSphere</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,136 @@
|
|||
{
|
||||
"name": "meter-sphere",
|
||||
"description": "MeterSphere FrontEnd",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"author": "MeterSphere Team",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite --config ./config/vite.config.dev.ts",
|
||||
"build": "vue-tsc --noEmit && vite build --config ./config/vite.config.prod.ts",
|
||||
"report": "cross-env REPORT=true npm run build",
|
||||
"preview": "npm run build && vite preview --host",
|
||||
"type:check": "vue-tsc --noEmit --skipLibCheck",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"lint:styles": "stylelint 'src/**/*.{vue,html,css,scss,less}' --fix",
|
||||
"lint-staged": "npx lint-staged",
|
||||
"prepare": "husky install",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,jsx,tsx}": [
|
||||
"prettier --write",
|
||||
"eslint --fix"
|
||||
],
|
||||
"*.vue": [
|
||||
"stylelint --fix",
|
||||
"prettier --write",
|
||||
"eslint --fix"
|
||||
],
|
||||
"*.{less,css,scss,sass}": [
|
||||
"stylelint --fix --custom-syntax postcss-less",
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@7polo/kity": "2.0.8",
|
||||
"@7polo/kityminder-core": "1.4.53",
|
||||
"@arco-design/web-vue": "^2.46.0",
|
||||
"@arco-themes/vue-ms-theme": "^0.0.1",
|
||||
"@form-create/arco-design": "^3.1.21",
|
||||
"@vueuse/core": "^9.13.0",
|
||||
"ace-builds": "^1.21.1",
|
||||
"axios": "^0.24.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"echarts": "^5.4.2",
|
||||
"hotbox-minder": "1.0.15",
|
||||
"jsonpath-picker-vanilla": "^1.2.4",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mitt": "^3.0.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.0.36",
|
||||
"pinia-plugin-persistedstate": "^3.1.0",
|
||||
"query-string": "^8.1.0",
|
||||
"sortablejs": "^1.15.0",
|
||||
"vue": "^3.3.2",
|
||||
"vue-echarts": "^6.5.5",
|
||||
"vue-i18n": "^9.2.2",
|
||||
"vue-router": "^4.2.0",
|
||||
"vue3-ace-editor": "^2.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arco-plugins/vite-vue": "^1.4.5",
|
||||
"@commitlint/cli": "^17.6.3",
|
||||
"@commitlint/config-conventional": "^17.6.3",
|
||||
"@types/lodash": "^4.14.194",
|
||||
"@types/lodash-es": "^4.17.7",
|
||||
"@types/mockjs": "^1.0.7",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/sortablejs": "^1.15.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.5",
|
||||
"@typescript-eslint/parser": "^5.59.5",
|
||||
"@vitejs/plugin-vue": "^3.2.0",
|
||||
"@vitejs/plugin-vue-jsx": "^2.1.1",
|
||||
"@vitest/coverage-c8": "^0.31.0",
|
||||
"@vue/babel-plugin-jsx": "^1.1.1",
|
||||
"@vue/test-utils": "^2.3.2",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"consola": "^2.15.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.5",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-vue": "^9.12.0",
|
||||
"fast-glob": "^3.2.12",
|
||||
"husky": "^8.0.3",
|
||||
"jsdom": "^22.0.0",
|
||||
"less": "^4.1.3",
|
||||
"less-loader": "^11.1.0",
|
||||
"lint-staged": "^13.2.2",
|
||||
"mockjs": "^1.1.0",
|
||||
"postcss": "^8.4.23",
|
||||
"postcss-html": "^1.5.0",
|
||||
"postcss-less": "^6.0.0",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"rollup": "^2.79.1",
|
||||
"rollup-plugin-visualizer": "^5.9.0",
|
||||
"sass": "^1.62.1",
|
||||
"stylelint": "^14.6.0",
|
||||
"stylelint-config-html": "^1.0.0",
|
||||
"stylelint-config-prettier": "^9.0.3",
|
||||
"stylelint-config-rational-order": "^0.1.2",
|
||||
"stylelint-config-recommended": "^7.0.0",
|
||||
"stylelint-config-recommended-less": "^1.0.4",
|
||||
"stylelint-config-recommended-scss": "^7.0.0",
|
||||
"stylelint-config-recommended-vue": "^1.4.0",
|
||||
"stylelint-config-standard": "^25.0.0",
|
||||
"stylelint-config-standard-scss": "^4.0.0",
|
||||
"stylelint-less": "^1.0.5",
|
||||
"stylelint-order": "^5.0.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "^4.9.5",
|
||||
"unplugin-vue-components": "^0.24.1",
|
||||
"vite": "^3.2.6",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-imagemin": "^0.6.1",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vite-svg-loader": "^3.6.0",
|
||||
"vitest": "^0.31.0",
|
||||
"vue-tsc": "^1.6.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"bin-wrapper": "npm:bin-wrapper-china",
|
||||
"rollup": "^2.79.1",
|
||||
"gifsicle": "5.2.0"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
plugins: [require('tailwindcss'), require('autoprefixer')],
|
||||
};
|
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<a-config-provider :locale="locale">
|
||||
<router-view />
|
||||
<global-setting />
|
||||
<ThemeBox />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import enUS from '@arco-design/web-vue/es/locale/lang/en-us';
|
||||
import zhCN from '@arco-design/web-vue/es/locale/lang/zh-cn';
|
||||
import GlobalSetting from '@/components/global-setting/index.vue';
|
||||
import useLocale from '@/locale/useLocale';
|
||||
import ThemeBox from '@/components/theme-box/index.vue';
|
||||
|
||||
const { currentLocale } = useLocale();
|
||||
const locale = computed(() => {
|
||||
switch (currentLocale.value) {
|
||||
case 'zh-CN':
|
||||
return zhCN;
|
||||
case 'en-US':
|
||||
return enUS;
|
||||
default:
|
||||
return zhCN;
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,177 @@
|
|||
import axios from 'axios';
|
||||
import { AxiosCanceler } from './axiosCancel';
|
||||
import { isFunction } from '@/utils/is';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { ContentTypeEnum } from '@/enums/httpEnum';
|
||||
|
||||
import type { AxiosRequestConfig, AxiosInstance, AxiosResponse, AxiosError } from 'axios';
|
||||
import type { RequestOptions, Result, UploadFileParams } from '#/axios';
|
||||
import type { CreateAxiosOptions } from './axiosTransform';
|
||||
|
||||
export * from './axiosTransform';
|
||||
|
||||
/**
|
||||
* @description: 封装axios请求,返回重新封装的数据格式
|
||||
*/
|
||||
export class MSAxios {
|
||||
private axiosInstance: AxiosInstance;
|
||||
|
||||
private readonly options: CreateAxiosOptions;
|
||||
|
||||
constructor(options: CreateAxiosOptions) {
|
||||
this.options = options;
|
||||
this.axiosInstance = axios.create(options);
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private getTransform() {
|
||||
const { transform } = this.options;
|
||||
return transform;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 拦截器配置
|
||||
*/
|
||||
private setupInterceptors() {
|
||||
const transform = this.getTransform();
|
||||
if (!transform) {
|
||||
return;
|
||||
}
|
||||
const { requestInterceptors, responseInterceptors, responseInterceptorsCatch } = transform;
|
||||
|
||||
const axiosCanceler = new AxiosCanceler();
|
||||
|
||||
// 请求拦截器
|
||||
this.axiosInstance.interceptors.request.use((config: AxiosRequestConfig) => {
|
||||
// 如果ignoreCancelToken为true,则不添加到pending中
|
||||
const {
|
||||
// @ts-ignore
|
||||
headers: { ignoreCancelToken },
|
||||
} = config;
|
||||
|
||||
const ignoreCancel =
|
||||
ignoreCancelToken !== undefined ? ignoreCancelToken : this.options.requestOptions?.ignoreCancelToken;
|
||||
|
||||
if (!ignoreCancel) {
|
||||
axiosCanceler.addPending(config);
|
||||
}
|
||||
if (requestInterceptors && isFunction(requestInterceptors)) {
|
||||
config = requestInterceptors(config, this.options);
|
||||
}
|
||||
return config;
|
||||
}, undefined);
|
||||
|
||||
// 响应拦截器
|
||||
this.axiosInstance.interceptors.response.use((res: AxiosResponse<any>) => {
|
||||
if (res) {
|
||||
axiosCanceler.removePending(res.config);
|
||||
}
|
||||
if (responseInterceptors && isFunction(responseInterceptors)) {
|
||||
res = responseInterceptors(res);
|
||||
}
|
||||
return res;
|
||||
}, undefined);
|
||||
|
||||
// 响应错误处理
|
||||
if (responseInterceptorsCatch && isFunction(responseInterceptorsCatch)) {
|
||||
this.axiosInstance.interceptors.response.use(undefined, responseInterceptorsCatch);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 文件上传
|
||||
*/
|
||||
uploadFile<T = any>(config: AxiosRequestConfig, params: UploadFileParams) {
|
||||
const formData = new window.FormData();
|
||||
const customFilename = params.name || 'file';
|
||||
|
||||
if (params.filename) {
|
||||
formData.append(customFilename, params.file, params.filename);
|
||||
} else {
|
||||
formData.append(customFilename, params.file);
|
||||
}
|
||||
|
||||
if (params.data) {
|
||||
Object.keys(params.data).forEach((key) => {
|
||||
const value = params.data[key];
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => {
|
||||
formData.append(`${key}[]`, item);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
formData.append(key, params.data[key]);
|
||||
});
|
||||
}
|
||||
|
||||
return this.axiosInstance.request<T>({
|
||||
...config,
|
||||
method: 'POST',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-type': ContentTypeEnum.FORM_DATA,
|
||||
// @ts-ignore
|
||||
'ignoreCancelToken': true, // 文件上传请求不需要添加到pending中
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
|
||||
return this.request({ ...config, method: 'GET' }, options);
|
||||
}
|
||||
|
||||
post<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
|
||||
return this.request({ ...config, method: 'POST' }, options);
|
||||
}
|
||||
|
||||
put<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
|
||||
return this.request({ ...config, method: 'PUT' }, options);
|
||||
}
|
||||
|
||||
delete<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
|
||||
return this.request({ ...config, method: 'DELETE' }, options);
|
||||
}
|
||||
|
||||
request<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
|
||||
let conf: CreateAxiosOptions = cloneDeep(config);
|
||||
const transform = this.getTransform();
|
||||
|
||||
const { requestOptions } = this.options;
|
||||
|
||||
const opt = { ...requestOptions, ...options };
|
||||
|
||||
const { beforeRequestHook, transformRequestHook } = transform || {};
|
||||
// 请求之前处理config
|
||||
if (beforeRequestHook && isFunction(beforeRequestHook)) {
|
||||
conf = beforeRequestHook(conf, opt);
|
||||
}
|
||||
conf.requestOptions = opt;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.axiosInstance
|
||||
.request<any, AxiosResponse<Result>>(conf)
|
||||
.then((res: AxiosResponse<Result>) => {
|
||||
// 请求成功后的处理
|
||||
if (transformRequestHook && isFunction(transformRequestHook)) {
|
||||
try {
|
||||
const ret = transformRequestHook(res, opt);
|
||||
resolve(ret);
|
||||
} catch (err) {
|
||||
reject(err || new Error('request error!'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
resolve(res as unknown as Promise<T>);
|
||||
})
|
||||
.catch((e: Error | AxiosError) => {
|
||||
if (axios.isAxiosError(e)) {
|
||||
// 在这可重写axios错误消息
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(e);
|
||||
}
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import type { AxiosRequestConfig, Canceler } from 'axios';
|
||||
import axios from 'axios';
|
||||
import { isFunction } from '@/utils/is';
|
||||
|
||||
let pendingMap = new Map<string, Canceler>();
|
||||
|
||||
export const getPendingUrl = (config: AxiosRequestConfig) => [config.method, config.url].join('&');
|
||||
|
||||
export class AxiosCanceler {
|
||||
/**
|
||||
* 添加请求
|
||||
* @param {Object} config
|
||||
*/
|
||||
addPending(config: AxiosRequestConfig) {
|
||||
this.removePending(config);
|
||||
const url = getPendingUrl(config);
|
||||
config.cancelToken =
|
||||
config.cancelToken ||
|
||||
new axios.CancelToken((cancel) => {
|
||||
if (!pendingMap.has(url)) {
|
||||
// 非重复请求,存入pending中
|
||||
pendingMap.set(url, cancel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 清理全部pending中的请求
|
||||
*/
|
||||
removeAllPending() {
|
||||
pendingMap.forEach((cancel) => {
|
||||
if (cancel && isFunction(cancel)) {
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
pendingMap.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消并移除指定请求
|
||||
* @param {Object} config
|
||||
*/
|
||||
removePending(config: AxiosRequestConfig) {
|
||||
const url = getPendingUrl(config);
|
||||
|
||||
if (pendingMap.has(url)) {
|
||||
// 根据标识找到pending中对应的请求并取消
|
||||
const cancel = pendingMap.get(url);
|
||||
if (cancel && isFunction(cancel)) {
|
||||
cancel(url);
|
||||
}
|
||||
pendingMap.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 重置pending列表
|
||||
*/
|
||||
static reset(): void {
|
||||
pendingMap = new Map<string, Canceler>();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Data processing class, can be configured according to the project
|
||||
*/
|
||||
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import type { RequestOptions, Result } from '#/axios';
|
||||
|
||||
export abstract class AxiosTransform {
|
||||
/**
|
||||
* @description: 请求之前处理配置
|
||||
*/
|
||||
beforeRequestHook?: (config: AxiosRequestConfig, options: RequestOptions) => AxiosRequestConfig;
|
||||
|
||||
/**
|
||||
* @description: 处理请求数据。如果数据不是预期格式,可直接抛出错
|
||||
*/
|
||||
transformRequestHook?: (res: AxiosResponse<Result>, options: RequestOptions) => any;
|
||||
|
||||
/**
|
||||
* @description: 请求之前的拦截器
|
||||
*/
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
requestInterceptors?: (config: AxiosRequestConfig, options: CreateAxiosOptions) => AxiosRequestConfig;
|
||||
|
||||
/**
|
||||
* @description: 请求之后的拦截器
|
||||
*/
|
||||
responseInterceptors?: (res: AxiosResponse<any>) => AxiosResponse<any>;
|
||||
|
||||
/**
|
||||
* @description: 请求之后的拦截器错误处理
|
||||
*/
|
||||
responseInterceptorsCatch?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export interface CreateAxiosOptions extends AxiosRequestConfig {
|
||||
authenticationScheme?: string;
|
||||
transform?: AxiosTransform;
|
||||
requestOptions?: RequestOptions;
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import useUser from '@/store/modules/user';
|
||||
|
||||
import type { ErrorMessageMode } from '#/axios';
|
||||
|
||||
export default function checkStatus(status: number, msg: string, errorMessageMode: ErrorMessageMode = 'message'): void {
|
||||
const { t } = useI18n();
|
||||
const userStore = useUser();
|
||||
let errMessage = '';
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
errMessage = `${msg}`;
|
||||
break;
|
||||
// 401: Not logged in
|
||||
case 401:
|
||||
errMessage = msg || t('api.errMsg401');
|
||||
userStore.logout();
|
||||
break;
|
||||
case 403:
|
||||
errMessage = t('api.errMsg403');
|
||||
break;
|
||||
// 404请求不存在
|
||||
case 404:
|
||||
errMessage = t('api.errMsg404');
|
||||
break;
|
||||
case 405:
|
||||
errMessage = t('api.errMsg405');
|
||||
break;
|
||||
case 408:
|
||||
errMessage = t('api.errMsg408');
|
||||
break;
|
||||
case 500:
|
||||
errMessage = t('api.errMsg500');
|
||||
break;
|
||||
case 501:
|
||||
errMessage = t('api.errMsg501');
|
||||
break;
|
||||
case 502:
|
||||
errMessage = t('api.errMsg502');
|
||||
break;
|
||||
case 503:
|
||||
errMessage = t('api.errMsg503');
|
||||
break;
|
||||
case 504:
|
||||
errMessage = t('api.errMsg504');
|
||||
break;
|
||||
case 505:
|
||||
errMessage = t('api.errMsg505');
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
if (errMessage) {
|
||||
if (errorMessageMode === 'modal') {
|
||||
Modal.error({ title: t('api.errorTip'), content: errMessage });
|
||||
} else if (errorMessageMode === 'message') {
|
||||
Message.error(errMessage);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
export function joinTimestamp<T extends boolean>(join: boolean, restful: T): T extends true ? string : object;
|
||||
|
||||
export function joinTimestamp(join: boolean, restful = false): string | object {
|
||||
if (!join) {
|
||||
return restful ? '' : {};
|
||||
}
|
||||
const now = new Date().getTime();
|
||||
if (restful) {
|
||||
return `?_t=${now}`;
|
||||
}
|
||||
return { _t: now };
|
||||
}
|
|
@ -0,0 +1,207 @@
|
|||
import { MSAxios } from './Axios';
|
||||
import checkStatus from './checkStatus';
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { RequestEnum, ContentTypeEnum, ResultEnum } from '@/enums/httpEnum';
|
||||
import { isString } from '@/utils/is';
|
||||
import { getToken } from '@/utils/auth';
|
||||
import { setObjToUrlParams, deepMerge } from '@/utils';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { joinTimestamp } from './helper';
|
||||
|
||||
import type { AxiosResponse } from 'axios';
|
||||
import type { AxiosTransform, CreateAxiosOptions } from './axiosTransform';
|
||||
import type { Recordable } from '#/global';
|
||||
import type { RequestOptions, Result } from '#/axios';
|
||||
|
||||
/**
|
||||
* @description: 数据处理,方便区分多种处理方式
|
||||
*/
|
||||
const transform: AxiosTransform = {
|
||||
/**
|
||||
* @description 请求之前处理config
|
||||
*/
|
||||
beforeRequestHook: (config, options) => {
|
||||
const { joinParamsToUrl, joinTime = true } = options;
|
||||
|
||||
const params = config.params || {};
|
||||
const data = config.data || false;
|
||||
if (config.method?.toUpperCase() === RequestEnum.GET) {
|
||||
if (!isString(params)) {
|
||||
// 给 get 请求加上时间戳参数,避免从缓存中拿数据。
|
||||
config.params = Object.assign(params || {}, joinTimestamp(joinTime, false));
|
||||
} else {
|
||||
// 兼容restful风格
|
||||
config.url = `${config.url}${params}${joinTimestamp(joinTime, true)}`;
|
||||
config.params = undefined;
|
||||
}
|
||||
} else if (isString(params)) {
|
||||
// 兼容restful风格
|
||||
config.url += params;
|
||||
config.params = undefined;
|
||||
} else {
|
||||
if (Reflect.has(config, 'data') && config.data && Object.keys(config.data).length > 0) {
|
||||
config.data = data;
|
||||
config.params = params;
|
||||
} else {
|
||||
// 非GET请求如果没有提供data,则将params视为data
|
||||
config.data = { ...params };
|
||||
config.params = undefined;
|
||||
}
|
||||
if (joinParamsToUrl) {
|
||||
config.url = setObjToUrlParams(config.url as string, { ...config.params, ...config.data });
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
|
||||
/**
|
||||
* @description: 处理请求数据。如果数据不是预期格式,可直接抛出错误
|
||||
*/
|
||||
transformRequestHook: (res: AxiosResponse<Result>, options: RequestOptions) => {
|
||||
const { t } = useI18n();
|
||||
const { isTransformResponse, isReturnNativeResponse } = options;
|
||||
// 是否返回原生响应头 比如:需要获取响应头时使用该属性
|
||||
if (isReturnNativeResponse) {
|
||||
return res;
|
||||
}
|
||||
// 不进行任何处理,直接返回
|
||||
// 用于页面代码可能需要直接获取code,data,message这些信息时开启
|
||||
if (!isTransformResponse) {
|
||||
return res.data;
|
||||
}
|
||||
// 错误的时候返回
|
||||
|
||||
const { data } = res;
|
||||
if (!data) {
|
||||
throw new Error(t('api.apiRequestFailed'));
|
||||
}
|
||||
// 这里 code,result,message为 后台统一的字段
|
||||
const { code, result, message } = data;
|
||||
|
||||
// TODO:定义完成功code后需要重写
|
||||
const hasSuccess = data && Reflect.has(data, 'code') && Number(code) === ResultEnum.SUCCESS;
|
||||
if (hasSuccess) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 在此处根据自己项目的实际情况对不同的code执行不同的操作
|
||||
// 如果不希望中断当前请求,请return数据,否则直接抛出异常即可
|
||||
let timeoutMsg = '';
|
||||
if (Number(code) === ResultEnum.TIMEOUT) {
|
||||
timeoutMsg = t('api.timeoutMessage');
|
||||
} else if (message) {
|
||||
timeoutMsg = message;
|
||||
}
|
||||
|
||||
// errorMessageMode=‘modal’的时候会显示modal错误弹窗,而不是消息提示,用于一些比较重要的错误
|
||||
// errorMessageMode='none' 一般是调用时明确表示不希望自动弹出错误提示
|
||||
if (options.errorMessageMode === 'modal') {
|
||||
Modal.error({ title: t('api.errorTip'), content: timeoutMsg });
|
||||
} else if (options.errorMessageMode === 'message') {
|
||||
Message.error(timeoutMsg);
|
||||
}
|
||||
|
||||
throw new Error(timeoutMsg || t('api.apiRequestFailed'));
|
||||
},
|
||||
|
||||
/**
|
||||
* @description: 请求拦截器处理
|
||||
*/
|
||||
requestInterceptors: (config, options) => {
|
||||
// 请求之前处理config
|
||||
const token = getToken();
|
||||
if (token && (config as Recordable)?.requestOptions?.withToken !== false) {
|
||||
// jwt token
|
||||
(config as Recordable).headers.Authorization = options.authenticationScheme
|
||||
? `${options.authenticationScheme} ${token}`
|
||||
: token;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
|
||||
/**
|
||||
* @description: 响应拦截器处理
|
||||
*/
|
||||
responseInterceptors: (res: AxiosResponse<any>) => {
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* @description: 响应错误处理
|
||||
*/
|
||||
responseInterceptorsCatch: (error: any) => {
|
||||
const { t } = useI18n();
|
||||
const { response, code, message, config } = error || {};
|
||||
const errorMessageMode = config?.requestOptions?.errorMessageMode || 'none';
|
||||
const msg: string = response?.data?.error?.message ?? '';
|
||||
const err: string = error?.toString?.() ?? '';
|
||||
let errMessage = '';
|
||||
|
||||
try {
|
||||
if (code === 'ECONNABORTED' && message.indexOf('timeout') !== -1) {
|
||||
errMessage = t('api.apiTimeoutMessage');
|
||||
}
|
||||
if (err?.includes('Network Error')) {
|
||||
errMessage = t('api.networkExceptionMsg');
|
||||
}
|
||||
|
||||
if (errMessage) {
|
||||
if (errorMessageMode === 'modal') {
|
||||
Modal.error({ title: t('api.errorTip'), content: errMessage });
|
||||
} else if (errorMessageMode === 'message') {
|
||||
Message.error(errMessage);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(e as unknown as string);
|
||||
}
|
||||
|
||||
checkStatus(error?.response?.status, msg, errorMessageMode);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
};
|
||||
|
||||
function createAxios(opt?: Partial<CreateAxiosOptions>) {
|
||||
return new MSAxios(
|
||||
deepMerge(
|
||||
{
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#authentication_schemes
|
||||
// authentication schemes,e.g: Bearer
|
||||
// authenticationScheme: 'Bearer',
|
||||
authenticationScheme: '',
|
||||
timeout: 30 * 1000,
|
||||
|
||||
headers: { 'Content-Type': ContentTypeEnum.JSON },
|
||||
// 如果是form-data格式
|
||||
// headers: { 'Content-Type': ContentTypeEnum.FORM_URLENCODED },
|
||||
// 数据处理方式
|
||||
transform,
|
||||
// 配置项,下面的选项都可以在独立的接口请求中覆盖
|
||||
requestOptions: {
|
||||
// 默认将prefix 添加到url
|
||||
joinPrefix: true,
|
||||
// 是否返回原生响应头 比如:需要获取响应头时使用该属性
|
||||
isReturnNativeResponse: false,
|
||||
// 需要对返回数据进行处理
|
||||
isTransformResponse: true,
|
||||
// post请求的时候添加参数到url
|
||||
joinParamsToUrl: false,
|
||||
// 格式化提交参数时间
|
||||
formatDate: true,
|
||||
// 消息提示类型
|
||||
errorMessageMode: 'message',
|
||||
// 是否加入时间戳
|
||||
joinTime: true,
|
||||
// 忽略重复请求
|
||||
ignoreCancelToken: true,
|
||||
// 是否携带token
|
||||
withToken: true,
|
||||
},
|
||||
},
|
||||
opt || {}
|
||||
)
|
||||
);
|
||||
}
|
||||
const MSR = createAxios();
|
||||
export default MSR;
|
|
@ -0,0 +1,22 @@
|
|||
import MSR from '@/api/http/index';
|
||||
import type { TableData } from '@arco-design/web-vue/es/table/interface';
|
||||
|
||||
export interface ContentDataRecord {
|
||||
x: string;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function queryContentData() {
|
||||
return MSR.get<ContentDataRecord[]>({ url: '/api/content-data' });
|
||||
}
|
||||
|
||||
export interface PopularRecord {
|
||||
key: number;
|
||||
clickNumber: string;
|
||||
title: string;
|
||||
increases: number;
|
||||
}
|
||||
|
||||
export function queryPopularList(params: { type: string }) {
|
||||
return MSR.get<TableData[]>({ url: '/api/popular/list', params });
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import MSR from '@/api/http/index';
|
||||
|
||||
export interface MessageRecord {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
subTitle: string;
|
||||
avatar?: string;
|
||||
content: string;
|
||||
time: string;
|
||||
status: 0 | 1;
|
||||
messageType?: number;
|
||||
}
|
||||
export type MessageListType = MessageRecord[];
|
||||
|
||||
export function queryMessageList() {
|
||||
return MSR.post<MessageListType>({ url: '/api/message/list' });
|
||||
}
|
||||
|
||||
interface MessageStatus {
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
export function setMessageStatus(data: MessageStatus) {
|
||||
return MSR.post<MessageListType>({ url: '/api/message/read', data });
|
||||
}
|
||||
|
||||
export interface ChatRecord {
|
||||
id: number;
|
||||
username: string;
|
||||
content: string;
|
||||
time: string;
|
||||
isCollect: boolean;
|
||||
}
|
||||
|
||||
export function queryChatList() {
|
||||
return MSR.post<ChatRecord[]>({ url: '/api/chat/list' });
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import MSR from '@/api/http/index';
|
||||
import { LoginUrl, LogoutUrl, GetUserInfoUrl, GetMenuListUrl } from '@/api/requrls/user';
|
||||
import type { RouteRecordNormalized } from 'vue-router';
|
||||
import type { LoginData, LoginRes } from '@/models/user';
|
||||
import type { UserState } from '@/store/modules/user/types';
|
||||
|
||||
export function login(data: LoginData) {
|
||||
return MSR.post<LoginRes>({ url: LoginUrl, data });
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return MSR.post<LoginRes>({ url: LogoutUrl });
|
||||
}
|
||||
|
||||
export function getUserInfo() {
|
||||
return MSR.post<UserState>({ url: GetUserInfoUrl });
|
||||
}
|
||||
|
||||
export function getMenuList() {
|
||||
return MSR.post<RouteRecordNormalized[]>({ url: GetMenuListUrl });
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export const LoginUrl = '/api/user/login';
|
||||
export const LogoutUrl = '/api/user/logout';
|
||||
export const GetUserInfoUrl = '/api/user/info';
|
||||
export const GetMenuListUrl = '/api/user/menu';
|
After Width: | Height: | Size: 169 KiB |
After Width: | Height: | Size: 5.4 KiB |
After Width: | Height: | Size: 4.3 KiB |
After Width: | Height: | Size: 37 KiB |
After Width: | Height: | Size: 6.9 KiB |
|
@ -0,0 +1,17 @@
|
|||
// Extra small screen / phone
|
||||
@screen-xs: 480px;
|
||||
|
||||
/* Small screen / tablet */
|
||||
@screen-sm: 576px;
|
||||
|
||||
/* Medium screen / desktop */
|
||||
@screen-md: 768px;
|
||||
|
||||
/* Large screen / wide desktop */
|
||||
@screen-lg: 992px;
|
||||
|
||||
/* Extra large screen / full hd */
|
||||
@screen-xl: 1200px;
|
||||
|
||||
/* Extra extra large screen / large desktop */
|
||||
@screen-xxl: 1600px;
|
|
@ -0,0 +1,89 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
background-color: var(--color-bg-1);
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.echarts-tooltip-diy {
|
||||
border: none !important;
|
||||
|
||||
/* Note: backdrop-filter has minimal browser support */
|
||||
|
||||
border-radius: 6px !important;
|
||||
background: linear-gradient(304.17deg, rgb(253 254 255 / 60%) -6.04%, rgb(244 247 252 / 60%) 85.2%) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
.content-panel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
padding: 0 9px;
|
||||
width: 164px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
background: rgb(255 255 255 / 80%);
|
||||
box-shadow: 6px 0 20px rgb(34 87 188 / 10%);
|
||||
line-height: 32px;
|
||||
}
|
||||
.tooltip-title {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
.tooltip-title,
|
||||
.tooltip-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
color: #1d2129;
|
||||
line-height: 15px;
|
||||
}
|
||||
.tooltip-item-icon {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
.general-card {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
& > .arco-card-header {
|
||||
padding: 20px;
|
||||
height: auto;
|
||||
border: none;
|
||||
}
|
||||
& > .arco-card-body {
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
}
|
||||
.split-line {
|
||||
border-color: rgb(var(--gray-2));
|
||||
}
|
||||
.arco-table-cell {
|
||||
.circle {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(var(--blue-6));
|
||||
&.pass {
|
||||
background-color: rgb(var(--green-6));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="947.2 183.1 174 35" style="enable-background:new 947.2 183.1 174 35;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#783887;}
|
||||
.st1{fill:#622870;}
|
||||
.st2{fill:#8B489B;}
|
||||
.st3{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g>
|
||||
<g id="XMLID_803_">
|
||||
<path id="XMLID_829_" class="st0" d="M998.3,209.6l-1.1-11.2l-3.1,11.2h-3.6l-3-11.2l-1.1,11.2l-5,0l2.4-16.5h6.3l2.2,7.8l2.2-7.8
|
||||
h6.2l2.5,16.5H998.3z"/>
|
||||
<path id="XMLID_826_" class="st0" d="M1015.2,202.7v2.1h-6.5l0,0.4c0,0.8,0.3,1.3,0.8,1.4c0.4,0.1,0.8,0.2,1.3,0.2h0.3
|
||||
c1.3,0,3.1-0.3,4.5-0.6h0l-0.5,2.9c0,0-0.6,0.3-1.8,0.5c-0.8,0.1-1.6,0.1-2.6,0.1c-2.6,0-4.2-0.9-4.9-2.6
|
||||
c-0.3-0.7-0.5-1.4-0.5-2.2v-2.8c0-2.1,1-3.4,2.7-4.1c0.8-0.3,1.6-0.4,2.4-0.4c2.4,0,4,0.8,4.7,2.4
|
||||
C1015.1,200.7,1015.2,201.6,1015.2,202.7z M1011.8,202.1c0-0.8-0.2-1.4-0.6-1.5c-0.3-0.2-0.7-0.2-1-0.2c-1,0-1.4,0.5-1.4,1.4
|
||||
l-0.1,0.6h3.1V202.1z"/>
|
||||
<path id="XMLID_823_" class="st0" d="M1037.3,202.7v2.1h-6.5l0,0.4c0,0.8,0.3,1.3,0.8,1.4c0.4,0.1,0.8,0.2,1.3,0.2h0.3
|
||||
c1.3,0,3.1-0.3,4.5-0.6h0l-0.5,2.9c0,0-0.6,0.3-1.8,0.5c-0.8,0.1-1.6,0.1-2.6,0.1c-2.6,0-4.2-0.9-4.9-2.6
|
||||
c-0.3-0.7-0.5-1.4-0.5-2.2v-2.8c0-2.1,1-3.4,2.7-4.1c0.8-0.3,1.6-0.4,2.4-0.4c2.4,0,4,0.8,4.7,2.4
|
||||
C1037.2,200.7,1037.3,201.6,1037.3,202.7z M1033.9,202.1c0-0.8-0.2-1.4-0.6-1.5c-0.3-0.2-0.7-0.2-1-0.2c-1,0-1.4,0.5-1.4,1.4
|
||||
l-0.1,0.6h3.1V202.1L1033.9,202.1z"/>
|
||||
<path id="XMLID_820_" class="st0" d="M1098.2,202.7v2.1h-6.5l0,0.4c0,0.8,0.3,1.3,0.8,1.4c0.4,0.1,0.8,0.2,1.3,0.2h0.3
|
||||
c1.3,0,3.1-0.3,4.5-0.6h0l-0.5,2.9c0,0-0.6,0.3-1.8,0.5c-0.8,0.1-1.6,0.1-2.6,0.1c-2.6,0-4.2-0.9-4.9-2.6
|
||||
c-0.3-0.7-0.5-1.4-0.5-2.2v-2.8c0-2.1,1-3.4,2.7-4.1c0.8-0.3,1.6-0.4,2.4-0.4c2.4,0,4,0.8,4.7,2.4
|
||||
C1098.1,200.7,1098.2,201.6,1098.2,202.7z M1094.8,202.1c0-0.8-0.2-1.4-0.6-1.5c-0.3-0.2-0.7-0.2-1-0.2c-1,0-1.4,0.5-1.4,1.4
|
||||
l-0.1,0.6h3.1V202.1z"/>
|
||||
<path id="XMLID_817_" class="st0" d="M1120.3,202.7v2.1h-6.5l0,0.4c0,0.8,0.3,1.3,0.8,1.4c0.4,0.1,0.8,0.2,1.3,0.2h0.3
|
||||
c1.3,0,3.1-0.3,4.5-0.6h0l-0.5,2.9c0,0-0.6,0.3-1.8,0.5c-0.8,0.1-1.6,0.1-2.6,0.1c-2.6,0-4.2-0.9-4.9-2.6
|
||||
c-0.3-0.7-0.5-1.4-0.5-2.2v-2.8c0-2.1,1-3.4,2.7-4.1c0.8-0.3,1.6-0.4,2.4-0.4c2.4,0,4,0.8,4.7,2.4
|
||||
C1120.2,200.7,1120.3,201.6,1120.3,202.7z M1116.9,202.1c0-0.8-0.2-1.4-0.6-1.5c-0.3-0.2-0.7-0.2-1-0.2c-1,0-1.4,0.5-1.4,1.4
|
||||
l-0.1,0.6h3.1V202.1z"/>
|
||||
<path id="XMLID_815_" class="st0" d="M1025.7,209.6h-2.1c-0.4,0-0.7,0-0.9,0c-0.7-0.1-1.3-0.2-1.7-0.4c-0.5-0.3-1-0.7-1.2-1.2
|
||||
c-0.3-0.6-0.5-1.4-0.5-2.5l0-4.6h-1.7v-3h1.7v-2.3l1.7,0l1.8-0.2v2.6h2.7l-0.4,3h-2.3v4.4c0,0.1,0,0.2,0,0.3c0,0.2,0,0.3,0.1,0.5
|
||||
c0.1,0.3,0.3,0.5,0.7,0.6c0.3,0.1,1.9,0.1,1.9,0.1L1025.7,209.6z"/>
|
||||
<path id="XMLID_813_" class="st0" d="M1047.7,197.9l-0.1,2.8c-0.3,0-1.2,0-2.1,0.2c-0.8,0.2-1.7,0.6-2.1,0.8v8h-3.5v-11.8h3.3
|
||||
c0,0-0.1,0.6,0,0.9l0.8-0.3c0.5-0.2,1-0.3,1.7-0.4C1046.7,197.8,1047.3,197.9,1047.7,197.9z"/>
|
||||
<path id="XMLID_811_" class="st0" d="M1108.9,197.9l-0.1,2.8c-0.3,0-1.2,0-2.1,0.2c-0.8,0.2-1.7,0.6-2.1,0.8v8h-3.5v-11.8h3.3
|
||||
c0,0-0.1,0.6,0,0.9l0.8-0.3c0.5-0.2,1-0.3,1.7-0.4C1107.9,197.8,1108.5,197.9,1108.9,197.9z"/>
|
||||
<path id="XMLID_809_" class="st0" d="M1054.6,210.1c-0.8,0-1.8-0.1-2.7-0.2c-0.4,0-0.8-0.1-1.2-0.2l-1.6-0.2l0-4.1l2.4,0.4
|
||||
c0,0,1,0.1,1.4,0.2c0.5,0.1,1,0.1,1.4,0.1c1.5,0,2.4-0.4,2.7-1.1c0.1-0.3,0.1-0.6,0-0.9c-0.2-0.3-0.5-0.6-0.9-0.8
|
||||
c-0.2-0.1-0.5-0.2-0.8-0.3l-0.9-0.3c-0.4-0.1-0.8-0.2-1.1-0.3c-0.4-0.1-0.8-0.3-1.1-0.4c-0.8-0.4-1.5-0.8-2-1.3
|
||||
c-0.4-0.5-0.7-1.1-0.9-1.8c-0.1-0.5-0.1-1.1,0-1.7c0.2-1.8,1-3.1,2.3-3.8c0.5-0.3,1.1-0.5,1.8-0.6c0.3,0,0.6-0.1,0.9-0.1l0.9-0.1
|
||||
h0.6c0.7,0,1.5,0,2.3,0.1l1,0.1l1.2,0.2l0,3.6c-0.5-0.1-1-0.2-1.7-0.3c-0.9-0.1-1.7-0.2-2.4-0.2c-1.6,0-2.2,0.3-2.5,1.1
|
||||
c-0.1,0.3-0.1,0.6-0.1,0.9c0,0.4,0,0.5,0.7,0.8c0.2,0.1,0.5,0.2,0.8,0.3l2.1,0.6c0.4,0.1,0.8,0.3,1.1,0.4c0.8,0.3,1.4,0.7,1.8,1.1
|
||||
c0.5,0.5,0.8,1.1,0.9,1.8c0.1,0.5,0.1,1.1,0,1.7c-0.2,1.8-1.1,3.4-2.4,4.1c-0.5,0.3-1.1,0.5-1.8,0.6c-0.3,0-0.6,0.1-0.9,0.1
|
||||
l-0.8,0.1L1054.6,210.1L1054.6,210.1z"/>
|
||||
<path id="XMLID_806_" class="st0" d="M1063.6,197.8h3.2l-0.1,0.8c0.3-0.2,0.6-0.3,1-0.4c0.4-0.2,0.8-0.3,1.1-0.3
|
||||
c0.9-0.2,1.8-0.2,2.7,0.1c0.5,0.2,1,0.5,1.4,1c0.2,0.2,0.3,0.4,0.4,0.6l0.1,0.2c0,0.1,0.1,0.1,0.1,0.2c0.2,0.4,0.3,0.9,0.4,1.4
|
||||
c0.1,0.6,0.2,1.3,0.2,2.2v0.7c0,1.8-0.1,2.7-0.4,3.4c-0.5,1.1-1.6,1.6-3.7,1.7c-0.3,0-0.7,0-1.2,0l-0.8,0c-0.3,0-0.5,0-0.7,0v5.1
|
||||
l-3.6,0.2V197.8L1063.6,197.8z M1067.2,206.1c0.7,0.1,1.1,0.1,1.7,0.1l0.6-0.1c0.7-0.2,1-0.6,1-1.3v-2.3c0-0.3,0-0.6-0.1-0.7
|
||||
c-0.2-0.7-0.8-1-1.8-0.9l-0.4,0.1l-0.7,0.2l-0.3,0.1L1067.2,206.1L1067.2,206.1z"/>
|
||||
<path id="XMLID_804_" class="st0" d="M1086.5,203.2v6.4h-3.6v-7c0-0.4-0.3-1.1-0.8-1.4c-0.4-0.2-1-0.2-1.2-0.1
|
||||
c-0.5,0.1-1,0.4-1.3,0.6v7.9h-3.6v-17l3.6,0v6c0.4-0.2,0.9-0.3,1.4-0.4c0.4-0.1,0.8-0.1,1.2-0.1c2.1,0,3.2,0.6,3.7,1.7
|
||||
C1086.2,200.2,1086.5,201.4,1086.5,203.2z"/>
|
||||
</g>
|
||||
<g id="XMLID_799_">
|
||||
<path id="XMLID_800_" class="st0" d="M961.7,184.4l13.7,8v16.1l-13.7,8l-13.7-8v-16.1L961.7,184.4 M961.7,183.9l-14.1,8.3v16.5
|
||||
l14.1,8.3l14.1-8.3v-16.5L961.7,183.9L961.7,183.9z"/>
|
||||
</g>
|
||||
<g id="XMLID_796_">
|
||||
<polygon id="XMLID_64_" class="st1" points="961.9,200.6 973.8,193.6 973.8,207.5 961.9,214.5 "/>
|
||||
</g>
|
||||
<polygon id="XMLID_795_" class="st2" points="949.8,193.2 961.7,186.3 973.6,193.2 961.7,200.2 "/>
|
||||
<g id="XMLID_792_">
|
||||
<polygon id="XMLID_65_" class="st0" points="949.6,207.5 949.6,193.6 961.5,200.6 961.5,214.5 "/>
|
||||
</g>
|
||||
<g id="XMLID_789_">
|
||||
<path id="XMLID_790_" class="st3" d="M957.8,209.4v-4.9l-1.8,3.9l-1.5-0.9l-0.8-5.4l-0.9,4.3l-2.1-1.3l1.7-6.2l2.6,1.6l0.6,3.8
|
||||
l1.2-2.7l2.6,1.5l0.4,7.5L957.8,209.4z"/>
|
||||
</g>
|
||||
<g id="XMLID_786_">
|
||||
<path id="XMLID_787_" class="st3" d="M968,209.1c-0.4,0.2-0.9,0.3-1.4,0.5c-0.2,0.1-0.4,0.1-0.6,0.2l-0.8,0.2l0-2l1.2-0.3
|
||||
c0,0,0.5-0.1,0.7-0.2c0.3-0.1,0.5-0.2,0.7-0.3c0.8-0.3,1.2-0.7,1.3-1.1c0.1-0.2,0.1-0.3,0-0.4c-0.1-0.1-0.2-0.2-0.5-0.2
|
||||
c-0.1,0-0.2,0-0.4,0l-0.5,0c-0.2,0-0.4,0.1-0.5,0.1c-0.2,0-0.4,0-0.6,0c-0.4,0-0.8-0.1-1-0.2c-0.2-0.1-0.4-0.4-0.5-0.7
|
||||
c-0.1-0.2-0.1-0.5,0-0.8c0.1-0.9,0.4-1.7,1.1-2.3c0.3-0.2,0.5-0.5,0.9-0.7c0.1-0.1,0.3-0.2,0.5-0.2l0.5-0.2l0.3-0.1
|
||||
c0.4-0.1,0.7-0.3,1.2-0.4l0.5-0.2l0.6-0.2l0.1,1.8c-0.2,0-0.5,0.1-0.9,0.2c-0.4,0.1-0.9,0.3-1.2,0.4c-0.8,0.3-1.1,0.6-1.2,1
|
||||
c0,0.2,0,0.3,0,0.5c0,0.2,0,0.2,0.4,0.3c0.1,0,0.3,0,0.4,0l1.1-0.1c0.2,0,0.4,0,0.5,0c0.4,0,0.7,0.1,0.9,0.2
|
||||
c0.2,0.1,0.4,0.4,0.5,0.7c0.1,0.2,0.1,0.5,0,0.8c-0.1,0.9-0.5,1.9-1.1,2.5c-0.2,0.2-0.5,0.5-0.9,0.7c-0.1,0.1-0.3,0.2-0.5,0.2
|
||||
l-0.4,0.2L968,209.1z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 6.6 KiB |
After Width: | Height: | Size: 8.3 KiB |
|
@ -0,0 +1,111 @@
|
|||
<template>
|
||||
<v-ace-editor
|
||||
:value="currentValue"
|
||||
:lang="props.lang"
|
||||
:theme="props.theme"
|
||||
:style="{ height: props.height, width: props.width }"
|
||||
@init="editorInit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, PropType, ref, onUnmounted, watch } from 'vue';
|
||||
import { VAceEditor } from 'vue3-ace-editor';
|
||||
import ace from 'ace-builds';
|
||||
import workerJavascriptUrl from 'ace-builds/src-min-noconflict/worker-javascript?url';
|
||||
import workerJsonUrl from 'ace-builds/src-min-noconflict/worker-json?url';
|
||||
import workerHtmlUrl from 'ace-builds/src-min-noconflict/worker-html?url';
|
||||
import workerXmlUrl from 'ace-builds/src-min-noconflict/worker-xml?url';
|
||||
import 'ace-builds/src-min-noconflict/ext-language_tools';
|
||||
import 'ace-builds/src-min-noconflict/ext-beautify';
|
||||
import 'ace-builds/src-min-noconflict/theme-github_dark';
|
||||
import 'ace-builds/src-min-noconflict/theme-github';
|
||||
import 'ace-builds/src-min-noconflict/theme-chrome';
|
||||
import 'ace-builds/src-min-noconflict/mode-javascript';
|
||||
import 'ace-builds/src-min-noconflict/mode-html';
|
||||
import 'ace-builds/src-min-noconflict/mode-xml';
|
||||
import 'ace-builds/src-min-noconflict/mode-json';
|
||||
import 'ace-builds/src-min-noconflict/mode-java';
|
||||
|
||||
export type LangType = 'javascript' | 'html' | 'xml' | 'json' | 'java' | 'text';
|
||||
export type ThemeType = 'github_dark' | 'github' | 'chrome';
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
init: {
|
||||
type: Function,
|
||||
},
|
||||
lang: {
|
||||
type: String as PropType<LangType>,
|
||||
default: 'javascript',
|
||||
},
|
||||
theme: {
|
||||
type: String as PropType<ThemeType>,
|
||||
default: 'github_dark',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '500px',
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:content']);
|
||||
const editorInstance = ref<any>(null);
|
||||
|
||||
const currentValue = computed({
|
||||
get() {
|
||||
return props.content;
|
||||
},
|
||||
set(val) {
|
||||
emit('update:content', val);
|
||||
},
|
||||
});
|
||||
|
||||
const onLangChange = () => {
|
||||
let useWorker = true;
|
||||
if (props.lang === 'javascript') {
|
||||
ace.config.setModuleUrl('ace/mode/javascript_worker', workerJavascriptUrl);
|
||||
} else if (props.lang === 'json') {
|
||||
ace.config.setModuleUrl('ace/mode/json_worker', workerJsonUrl);
|
||||
} else if (props.lang === 'xml') {
|
||||
ace.config.setModuleUrl('ace/mode/xml_worker', workerXmlUrl);
|
||||
} else if (props.lang === 'html') {
|
||||
ace.config.setModuleUrl('ace/mode/html_worker', workerHtmlUrl);
|
||||
} else {
|
||||
useWorker = false;
|
||||
}
|
||||
return useWorker;
|
||||
};
|
||||
|
||||
const editorInit = (editor: any) => {
|
||||
if (props.init) props.init(editor);
|
||||
editorInstance.value = editor;
|
||||
const useWorker = onLangChange();
|
||||
editor.setOptions({
|
||||
enableBasicAutocompletion: true,
|
||||
enableSnippets: false,
|
||||
enableLiveAutocompletion: true,
|
||||
wrap: true,
|
||||
useWorker,
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.lang,
|
||||
() => {
|
||||
onLangChange();
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
editorInstance.value.destroy();
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<a-breadcrumb class="container-breadcrumb">
|
||||
<a-breadcrumb-item>
|
||||
<icon-apps />
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item v-for="item in items" :key="item">
|
||||
{{ $t(item) }}
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType } from 'vue';
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array as PropType<string[]>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container-breadcrumb {
|
||||
margin: 16px 0;
|
||||
:deep(.arco-breadcrumb-item) {
|
||||
color: rgb(var(--gray-6));
|
||||
&:last-child {
|
||||
color: rgb(var(--gray-8));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,40 @@
|
|||
<template>
|
||||
<VCharts v-if="renderChart" :option="options" :autoresize="autoResize" :style="{ width, height }" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, nextTick } from 'vue';
|
||||
import VCharts from 'vue-echarts';
|
||||
// import { useAppStore } from '@/store';
|
||||
|
||||
defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
autoResize: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '100%',
|
||||
},
|
||||
});
|
||||
// const appStore = useAppStore();
|
||||
// const theme = computed(() => {
|
||||
// if (appStore.theme === 'dark') return 'dark';
|
||||
// return '';
|
||||
// });
|
||||
const renderChart = ref(false);
|
||||
// wait container expand
|
||||
nextTick(() => {
|
||||
renderChart.value = true;
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<form-create v-model="value" v-model:api="fApi" :rule="rule" :option="option"></form-create>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive } from 'vue';
|
||||
|
||||
const value = ref({});
|
||||
const fApi = ref({});
|
||||
const rule = reactive([
|
||||
{
|
||||
type: 'input',
|
||||
field: 'goods_name',
|
||||
title: '商品名称',
|
||||
},
|
||||
{
|
||||
type: 'datePicker',
|
||||
field: 'created_at',
|
||||
title: '创建时间',
|
||||
},
|
||||
]);
|
||||
const option = {
|
||||
onSubmit(formData: any) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(JSON.stringify(formData));
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,14 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import Footer from './index.vue';
|
||||
|
||||
describe('Footer', () => {
|
||||
test('mount @vue/test-utils', () => {
|
||||
const wrapper = shallowMount(Footer, {
|
||||
slots: {
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
expect(wrapper.text()).toBe('');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,16 @@
|
|||
<template>
|
||||
<a-layout-footer class="footer">MeterSphere</a-layout-footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<div class="block">
|
||||
<h5 class="title">{{ title }}</h5>
|
||||
<div v-for="option in options" :key="option.name" class="switch-wrapper">
|
||||
<span>{{ $t(option.name) }}</span>
|
||||
<form-wrapper
|
||||
:type="option.type || 'switch'"
|
||||
:name="option.key"
|
||||
:default-value="option.defaultVal"
|
||||
@input-change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType } from 'vue';
|
||||
import { useAppStore } from '@/store';
|
||||
import FormWrapper from './form-wrapper.vue';
|
||||
|
||||
interface OptionsProps {
|
||||
name: string;
|
||||
key: string;
|
||||
type?: string;
|
||||
defaultVal?: boolean | string | number;
|
||||
}
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array as PropType<OptionsProps[]>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
});
|
||||
const appStore = useAppStore();
|
||||
const handleChange = async ({ key, value }: { key: string; value: unknown }) => {
|
||||
if (key === 'colorWeak') {
|
||||
document.body.style.filter = value ? 'invert(80%)' : 'none';
|
||||
}
|
||||
if (key === 'topMenu') {
|
||||
appStore.updateSettings({
|
||||
menuCollapse: false,
|
||||
});
|
||||
}
|
||||
appStore.updateSettings({ [key]: value });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.block {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.title {
|
||||
margin: 10px 0;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.switch-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<a-input-number
|
||||
v-if="type === 'number'"
|
||||
:style="{ width: '80px' }"
|
||||
size="small"
|
||||
:default-value="(defaultValue as number)"
|
||||
@change="handleChange"
|
||||
/>
|
||||
<a-switch v-else :default-checked="(defaultValue as boolean)" size="small" @change="handleChange" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
defaultValue: {
|
||||
type: [String, Boolean, Number],
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['inputChange']);
|
||||
const handleChange = (value: unknown) => {
|
||||
emit('inputChange', {
|
||||
value,
|
||||
key: props.name,
|
||||
});
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,93 @@
|
|||
<template>
|
||||
<div v-if="!appStore.navbar" class="fixed-settings" @click="setVisible">
|
||||
<a-button type="primary">
|
||||
<template #icon>
|
||||
<icon-settings />
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
<a-drawer
|
||||
:width="300"
|
||||
unmount-on-close
|
||||
:visible="visible"
|
||||
:cancel-text="$t('settings.close')"
|
||||
:ok-text="$t('settings.copySettings')"
|
||||
@ok="copySettings"
|
||||
@cancel="cancel"
|
||||
>
|
||||
<template #title> {{ $t('settings.title') }} </template>
|
||||
<Block :options="contentOpts" :title="$t('settings.content')" />
|
||||
<Block :options="othersOpts" :title="$t('settings.otherSettings')" />
|
||||
<a-alert>{{ $t('settings.alertContent') }}</a-alert>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { useAppStore } from '@/store';
|
||||
import Block from './block.vue';
|
||||
|
||||
const emit = defineEmits(['cancel']);
|
||||
|
||||
const appStore = useAppStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { copy } = useClipboard();
|
||||
const visible = computed(() => appStore.globalSettings);
|
||||
const contentOpts = computed(() => [
|
||||
{ name: 'settings.navbar', key: 'navbar', defaultVal: appStore.navbar },
|
||||
{
|
||||
name: 'settings.menu',
|
||||
key: 'menu',
|
||||
defaultVal: appStore.menu,
|
||||
},
|
||||
{
|
||||
name: 'settings.topMenu',
|
||||
key: 'topMenu',
|
||||
defaultVal: appStore.topMenu,
|
||||
},
|
||||
{ name: 'settings.footer', key: 'footer', defaultVal: appStore.footer },
|
||||
{ name: 'settings.tabBar', key: 'tabBar', defaultVal: appStore.tabBar },
|
||||
{
|
||||
name: 'settings.menuWidth',
|
||||
key: 'menuWidth',
|
||||
defaultVal: appStore.menuWidth,
|
||||
type: 'number',
|
||||
},
|
||||
]);
|
||||
const othersOpts = computed(() => [
|
||||
{
|
||||
name: 'settings.colorWeak',
|
||||
key: 'colorWeak',
|
||||
defaultVal: appStore.colorWeak,
|
||||
},
|
||||
]);
|
||||
|
||||
const cancel = () => {
|
||||
appStore.updateSettings({ globalSettings: false });
|
||||
emit('cancel');
|
||||
};
|
||||
const copySettings = async () => {
|
||||
const text = JSON.stringify(appStore.$state, null, 2);
|
||||
await copy(text);
|
||||
Message.success(t('settings.copySettings.message'));
|
||||
};
|
||||
const setVisible = () => {
|
||||
appStore.updateSettings({ globalSettings: true });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.fixed-settings {
|
||||
position: fixed;
|
||||
top: 280px;
|
||||
right: 0;
|
||||
svg {
|
||||
font-size: 18px;
|
||||
vertical-align: -4px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,35 @@
|
|||
import { App } from 'vue';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
GraphicComponent,
|
||||
} from 'echarts/components';
|
||||
import Chart from './chart/index.vue';
|
||||
import Breadcrumb from './breadcrumb/index.vue';
|
||||
|
||||
// Manually introduce ECharts modules to reduce packing size
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
GraphicComponent,
|
||||
]);
|
||||
|
||||
export default {
|
||||
install(Vue: App) {
|
||||
Vue.component('Chart', Chart);
|
||||
Vue.component('Breadcrumb', Breadcrumb);
|
||||
},
|
||||
};
|
|
@ -0,0 +1,58 @@
|
|||
<template>
|
||||
<pre ref="jr" :class="props.class" @click="pickPath"></pre>
|
||||
<input ref="ip" :value="jsonPath" class="path" type="hidden" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, onUnmounted, Ref } from 'vue';
|
||||
import JPPicker from 'jsonpath-picker-vanilla';
|
||||
import { Recordable } from '#/global';
|
||||
|
||||
const jr: Ref<HTMLElement | null> = ref(null);
|
||||
const ip: Ref<HTMLInputElement | null> = ref(null);
|
||||
const jsonPath = ref('');
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data: object;
|
||||
opt?: Recordable<any>;
|
||||
class?: string;
|
||||
}>(),
|
||||
{
|
||||
data: () => ({
|
||||
name1: 'val1',
|
||||
name2: {
|
||||
name3: 'val2',
|
||||
},
|
||||
}),
|
||||
opt: () => ({}),
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'pick', path: string): void;
|
||||
}>();
|
||||
|
||||
onMounted(() => {
|
||||
JPPicker.jsonPathPicker(jr.value, props.data, [ip.value], props.opt);
|
||||
});
|
||||
|
||||
function pickPath(ev: any) {
|
||||
if (ev.target && ev.target.classList.contains('pick-path')) {
|
||||
setTimeout(() => {
|
||||
if (ip.value) {
|
||||
jsonPath.value = ip.value.value;
|
||||
emit('pick', jsonPath.value);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
JPPicker.clearJsonPathPicker(jr.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="css">
|
||||
@import url('jsonpath-picker-vanilla/lib/jsonpath-picker.css');
|
||||
</style>
|
|
@ -0,0 +1,149 @@
|
|||
<script lang="tsx">
|
||||
import { defineComponent, ref, h, compile, computed } from 'vue';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useRoute, useRouter, RouteRecordRaw } from 'vue-router';
|
||||
import type { RouteMeta } from 'vue-router';
|
||||
import { useAppStore } from '@/store';
|
||||
import { listenerRouteChange } from '@/utils/route-listener';
|
||||
import { openWindow, regexUrl } from '@/utils';
|
||||
import useMenuTree from './use-menu-tree';
|
||||
|
||||
export default defineComponent({
|
||||
emit: ['collapse'],
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { menuTree } = useMenuTree();
|
||||
const collapsed = computed({
|
||||
get() {
|
||||
if (appStore.device === 'desktop') return appStore.menuCollapse;
|
||||
return false;
|
||||
},
|
||||
set(value: boolean) {
|
||||
appStore.updateSettings({ menuCollapse: value });
|
||||
},
|
||||
});
|
||||
|
||||
const topMenu = computed(() => appStore.topMenu);
|
||||
const openKeys = ref<string[]>([]);
|
||||
const selectedKey = ref<string[]>([]);
|
||||
|
||||
const goto = (item: RouteRecordRaw) => {
|
||||
// Open external link
|
||||
if (regexUrl.test(item.path)) {
|
||||
openWindow(item.path);
|
||||
selectedKey.value = [item.name as string];
|
||||
return;
|
||||
}
|
||||
// Eliminate external link side effects
|
||||
const { hideInMenu, activeMenu } = item.meta as RouteMeta;
|
||||
if (route.name === item.name && !hideInMenu && !activeMenu) {
|
||||
selectedKey.value = [item.name as string];
|
||||
return;
|
||||
}
|
||||
// Trigger router change
|
||||
router.push({
|
||||
name: item.name,
|
||||
});
|
||||
};
|
||||
const findMenuOpenKeys = (target: string) => {
|
||||
const result: string[] = [];
|
||||
let isFind = false;
|
||||
const backtrack = (item: RouteRecordRaw, keys: string[]) => {
|
||||
if (item.name === target) {
|
||||
isFind = true;
|
||||
result.push(...keys);
|
||||
return;
|
||||
}
|
||||
if (item.children?.length) {
|
||||
item.children.forEach((el) => {
|
||||
backtrack(el, [...keys, el.name as string]);
|
||||
});
|
||||
}
|
||||
};
|
||||
menuTree.value.forEach((el: RouteRecordRaw) => {
|
||||
if (isFind) return; // Performance optimization
|
||||
backtrack(el, [el.name as string]);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
listenerRouteChange((newRoute) => {
|
||||
const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta;
|
||||
if (requiresAuth !== false && (!hideInMenu || activeMenu)) {
|
||||
const menuOpenKeys = findMenuOpenKeys((activeMenu || newRoute.name) as string);
|
||||
|
||||
const keySet = new Set([...menuOpenKeys, ...openKeys.value]);
|
||||
openKeys.value = [...keySet];
|
||||
|
||||
selectedKey.value = [activeMenu || menuOpenKeys[menuOpenKeys.length - 1]];
|
||||
}
|
||||
}, true);
|
||||
const setCollapse = (val: boolean) => {
|
||||
if (appStore.device === 'desktop') appStore.updateSettings({ menuCollapse: val });
|
||||
};
|
||||
|
||||
const renderSubMenu = () => {
|
||||
function travel(_route: RouteRecordRaw[], nodes = []) {
|
||||
if (_route) {
|
||||
_route.forEach((element) => {
|
||||
// This is demo, modify nodes as needed
|
||||
const icon = element?.meta?.icon ? () => h(compile(`<${element?.meta?.icon}/>`)) : null;
|
||||
const node =
|
||||
element?.children && element?.children.length !== 0 ? (
|
||||
<a-sub-menu
|
||||
key={element?.name}
|
||||
v-slots={{
|
||||
icon,
|
||||
title: () => h(compile(t(element?.meta?.locale || ''))),
|
||||
}}
|
||||
>
|
||||
{travel(element?.children)}
|
||||
</a-sub-menu>
|
||||
) : (
|
||||
<a-menu-item key={element?.name} v-slots={{ icon }} onClick={() => goto(element)}>
|
||||
{t(element?.meta?.locale || '')}
|
||||
</a-menu-item>
|
||||
);
|
||||
nodes.push(node as never);
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
return travel(menuTree.value);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<a-menu
|
||||
mode={topMenu.value ? 'horizontal' : 'vertical'}
|
||||
v-model:collapsed={collapsed.value}
|
||||
v-model:open-keys={openKeys.value}
|
||||
show-collapse-button={appStore.device !== 'mobile'}
|
||||
auto-open={false}
|
||||
selected-keys={selectedKey.value}
|
||||
auto-open-selected={true}
|
||||
level-indent={34}
|
||||
style="height: 100%;width:100%;"
|
||||
onCollapse={setCollapse}
|
||||
>
|
||||
{renderSubMenu()}
|
||||
</a-menu>
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
:deep(.arco-menu-inner) {
|
||||
.arco-menu-inline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.arco-icon {
|
||||
&:not(.arco-icon-down) {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,59 @@
|
|||
import { computed } from 'vue';
|
||||
import { RouteRecordRaw, RouteRecordNormalized } from 'vue-router';
|
||||
import usePermission from '@/hooks/usePermission';
|
||||
import appClientMenus from '@/router/app-menus';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
export default function useMenuTree() {
|
||||
const permission = usePermission();
|
||||
const menuTree = computed(() => {
|
||||
const copyRouter = cloneDeep(appClientMenus) as RouteRecordNormalized[];
|
||||
copyRouter.sort((a: RouteRecordNormalized, b: RouteRecordNormalized) => {
|
||||
return (a.meta.order || 0) - (b.meta.order || 0);
|
||||
});
|
||||
function travel(_routes: RouteRecordRaw[], layer: number) {
|
||||
if (!_routes) return null;
|
||||
|
||||
const collector: any = _routes.map((element) => {
|
||||
// 权限校验不通过
|
||||
if (!permission.accessRouter(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 叶子菜单
|
||||
if (element.meta?.hideChildrenInMenu || !element.children) {
|
||||
element.children = [];
|
||||
return element;
|
||||
}
|
||||
|
||||
// 过滤隐藏的菜单
|
||||
element.children = element.children.filter((x) => x.meta?.hideInMenu !== true);
|
||||
|
||||
// 解析子菜单
|
||||
const subItem = travel(element.children, layer + 1);
|
||||
|
||||
if (subItem.length) {
|
||||
element.children = subItem;
|
||||
return element;
|
||||
}
|
||||
// the else logic
|
||||
if (layer > 1) {
|
||||
element.children = subItem;
|
||||
return element;
|
||||
}
|
||||
|
||||
if (element.meta?.hideInMenu === false) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
return collector.filter(Boolean);
|
||||
}
|
||||
return travel(copyRouter, 0);
|
||||
});
|
||||
|
||||
return {
|
||||
menuTree,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
<template>
|
||||
<a-spin style="display: block" :loading="loading">
|
||||
<a-tabs v-model:activeKey="messageType" type="rounded" destroy-on-hide>
|
||||
<a-tab-pane v-for="item in tabList" :key="item.key">
|
||||
<template #title>
|
||||
<span> {{ item.title }}{{ formatUnreadLength(item.key) }} </span>
|
||||
</template>
|
||||
<a-result v-if="!renderList.length" status="404">
|
||||
<template #subtitle> {{ $t('messageBox.noContent') }} </template>
|
||||
</a-result>
|
||||
<List :render-list="renderList" :unread-count="unreadCount" @item-click="handleItemClick" />
|
||||
</a-tab-pane>
|
||||
<template #extra>
|
||||
<a-button type="text" @click="emptyList">
|
||||
{{ $t('messageBox.tab.button') }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-tabs>
|
||||
</a-spin>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, toRefs, computed } from 'vue';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { queryMessageList, setMessageStatus, MessageRecord, MessageListType } from '@/api/modules/message';
|
||||
import useLoading from '@/hooks/useLoading';
|
||||
import List from './list.vue';
|
||||
|
||||
interface TabItem {
|
||||
key: string;
|
||||
title: string;
|
||||
avatar?: string;
|
||||
}
|
||||
const { loading, setLoading } = useLoading(true);
|
||||
const messageType = ref('message');
|
||||
const { t } = useI18n();
|
||||
const messageData = reactive<{
|
||||
renderList: MessageRecord[];
|
||||
messageList: MessageRecord[];
|
||||
}>({
|
||||
renderList: [],
|
||||
messageList: [],
|
||||
});
|
||||
toRefs(messageData);
|
||||
const tabList: TabItem[] = [
|
||||
{
|
||||
key: 'message',
|
||||
title: t('messageBox.tab.title.message'),
|
||||
},
|
||||
{
|
||||
key: 'notice',
|
||||
title: t('messageBox.tab.title.notice'),
|
||||
},
|
||||
{
|
||||
key: 'todo',
|
||||
title: t('messageBox.tab.title.todo'),
|
||||
},
|
||||
];
|
||||
async function fetchSourceData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await queryMessageList();
|
||||
messageData.messageList = data;
|
||||
} catch (err) {
|
||||
// you can report use errorHandler or other
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
async function readMessage(data: MessageListType) {
|
||||
const ids = data.map((item) => item.id);
|
||||
await setMessageStatus({ ids });
|
||||
fetchSourceData();
|
||||
}
|
||||
const renderList = computed(() => {
|
||||
return messageData.messageList.filter((item) => messageType.value === item.type);
|
||||
});
|
||||
const unreadCount = computed(() => {
|
||||
return renderList.value.filter((item) => !item.status).length;
|
||||
});
|
||||
const getUnreadList = (type: string) => {
|
||||
const list = messageData.messageList.filter((item) => item.type === type && !item.status);
|
||||
return list;
|
||||
};
|
||||
const formatUnreadLength = (type: string) => {
|
||||
const list = getUnreadList(type);
|
||||
return list.length ? `(${list.length})` : ``;
|
||||
};
|
||||
const handleItemClick = (items: MessageListType) => {
|
||||
if (renderList.value.length) readMessage([...items]);
|
||||
};
|
||||
const emptyList = () => {
|
||||
messageData.messageList = [];
|
||||
};
|
||||
fetchSourceData();
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
:deep(.arco-popover-popup-content) {
|
||||
padding: 0;
|
||||
}
|
||||
:deep(.arco-list-item-meta) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
:deep(.arco-tabs-nav) {
|
||||
padding: 14px 0 12px 16px;
|
||||
border-bottom: 1px solid var(--color-neutral-3);
|
||||
}
|
||||
:deep(.arco-tabs-content) {
|
||||
padding-top: 0;
|
||||
.arco-result-subtitle {
|
||||
color: rgb(var(--gray-6));
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,142 @@
|
|||
<template>
|
||||
<a-list :bordered="false">
|
||||
<a-list-item
|
||||
v-for="item in renderList"
|
||||
:key="item.id"
|
||||
action-layout="vertical"
|
||||
:style="{
|
||||
opacity: item.status ? 0.5 : 1,
|
||||
}"
|
||||
>
|
||||
<template #extra>
|
||||
<a-tag v-if="item.messageType === 0" color="gray">未开始</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 1" color="green">已开通</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 2" color="blue">进行中</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 3" color="red">即将到期</a-tag>
|
||||
</template>
|
||||
<div class="item-wrap" @click="onItemClick(item)">
|
||||
<a-list-item-meta>
|
||||
<template v-if="item.avatar" #avatar>
|
||||
<a-avatar shape="circle">
|
||||
<img v-if="item.avatar" :src="item.avatar" alt="avatar" />
|
||||
<icon-desktop v-else />
|
||||
</a-avatar>
|
||||
</template>
|
||||
<template #title>
|
||||
<a-space :size="4">
|
||||
<span>{{ item.title }}</span>
|
||||
<a-typography-text type="secondary">
|
||||
{{ item.subTitle }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #description>
|
||||
<div>
|
||||
<a-typography-paragraph
|
||||
:ellipsis="{
|
||||
rows: 1,
|
||||
}"
|
||||
>{{ item.content }}</a-typography-paragraph
|
||||
>
|
||||
<a-typography-text v-if="item.type === 'message'" class="time-text">
|
||||
{{ item.time }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</div>
|
||||
</a-list-item>
|
||||
<template #footer>
|
||||
<a-space fill :size="0" :class="{ 'add-border-top': renderList.length < showMax }">
|
||||
<div class="footer-wrap">
|
||||
<a-link @click="allRead">{{ $t('messageBox.allRead') }}</a-link>
|
||||
</div>
|
||||
<div class="footer-wrap">
|
||||
<a-link>{{ $t('messageBox.viewMore') }}</a-link>
|
||||
</div>
|
||||
</a-space>
|
||||
</template>
|
||||
<div
|
||||
v-if="renderList.length && renderList.length < 3"
|
||||
:style="{ height: (showMax - renderList.length) * 86 + 'px' }"
|
||||
></div>
|
||||
</a-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType } from 'vue';
|
||||
import { MessageRecord, MessageListType } from '@/api/modules/message';
|
||||
|
||||
const props = defineProps({
|
||||
renderList: {
|
||||
type: Array as PropType<MessageListType>,
|
||||
required: true,
|
||||
},
|
||||
unreadCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['itemClick']);
|
||||
const allRead = () => {
|
||||
emit('itemClick', [...props.renderList]);
|
||||
};
|
||||
|
||||
const onItemClick = (item: MessageRecord) => {
|
||||
if (!item.status) {
|
||||
emit('itemClick', [item]);
|
||||
}
|
||||
};
|
||||
const showMax = 3;
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
:deep(.arco-list) {
|
||||
.arco-list-item {
|
||||
min-height: 86px;
|
||||
border-bottom: 1px solid rgb(var(--gray-3));
|
||||
}
|
||||
.arco-list-item-extra {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
}
|
||||
.arco-list-item-meta-content {
|
||||
flex: 1;
|
||||
}
|
||||
.item-wrap {
|
||||
cursor: pointer;
|
||||
}
|
||||
.time-text {
|
||||
font-size: 12px;
|
||||
color: rgb(var(--gray-6));
|
||||
}
|
||||
.arco-empty {
|
||||
display: none;
|
||||
}
|
||||
.arco-list-footer {
|
||||
padding: 0;
|
||||
height: 50px;
|
||||
border-top: none;
|
||||
line-height: 50px;
|
||||
.arco-space-item {
|
||||
width: 100%;
|
||||
border-right: 1px solid rgb(var(--gray-3));
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
.add-border-top {
|
||||
border-top: 1px solid rgb(var(--gray-3));
|
||||
}
|
||||
}
|
||||
.footer-wrap {
|
||||
text-align: center;
|
||||
}
|
||||
.arco-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.add-border {
|
||||
border-top: 1px solid rgb(var(--gray-3));
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,13 @@
|
|||
export default {
|
||||
'messageBox.tab.title.message': 'Message',
|
||||
'messageBox.tab.title.notice': 'Notice',
|
||||
'messageBox.tab.title.todo': 'Todo',
|
||||
'messageBox.tab.button': 'empty',
|
||||
'messageBox.allRead': 'All Read',
|
||||
'messageBox.viewMore': 'View More',
|
||||
'messageBox.noContent': 'No Content',
|
||||
'messageBox.switchRoles': 'Switch Roles',
|
||||
'messageBox.userCenter': 'User Center',
|
||||
'messageBox.userSettings': 'User Settings',
|
||||
'messageBox.logout': 'Logout',
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
export default {
|
||||
'messageBox.tab.title.message': '消息',
|
||||
'messageBox.tab.title.notice': '通知',
|
||||
'messageBox.tab.title.todo': '待办',
|
||||
'messageBox.tab.button': '清空',
|
||||
'messageBox.allRead': '全部已读',
|
||||
'messageBox.viewMore': '查看更多',
|
||||
'messageBox.noContent': '暂无内容',
|
||||
'messageBox.switchRoles': '切换角色',
|
||||
'messageBox.userCenter': '用户中心',
|
||||
'messageBox.userSettings': '用户设置',
|
||||
'messageBox.logout': '登出登录',
|
||||
};
|
|
@ -0,0 +1,93 @@
|
|||
export default {
|
||||
minder: {
|
||||
commons: {
|
||||
confirm: 'Confirm',
|
||||
clear: 'Clear',
|
||||
export: 'Export',
|
||||
cancel: 'Cancel',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
remove: 'Remove',
|
||||
return: 'Return',
|
||||
},
|
||||
menu: {
|
||||
expand: {
|
||||
expand: 'Expand',
|
||||
folding: 'Folding',
|
||||
expand_one: 'Expand one level',
|
||||
expand_tow: 'Expand tow level',
|
||||
expand_three: 'Expand three level',
|
||||
expand_four: 'Expand four level',
|
||||
expand_five: 'Expand five level',
|
||||
expand_six: 'Expand six level',
|
||||
},
|
||||
insert: {
|
||||
down: 'Subordinate',
|
||||
up: 'Superior',
|
||||
same: 'Same',
|
||||
_same: 'Same level',
|
||||
_down: 'Subordinate level',
|
||||
_up: 'Superior level',
|
||||
},
|
||||
move: {
|
||||
up: 'Up',
|
||||
down: 'Down',
|
||||
forward: 'Forward',
|
||||
backward: 'Backward',
|
||||
},
|
||||
progress: {
|
||||
progress: 'Progress',
|
||||
remove_progress: 'Remove progress',
|
||||
prepare: 'Prepare',
|
||||
complete_all: 'Complete all',
|
||||
complete: 'Complete',
|
||||
},
|
||||
selection: {
|
||||
all: 'Select all',
|
||||
invert: 'Select invert',
|
||||
sibling: 'Select sibling node',
|
||||
same: 'Select same node',
|
||||
path: 'Select path',
|
||||
subtree: 'Select subtree',
|
||||
},
|
||||
arrange: {
|
||||
arrange_layout: 'Arrange layout',
|
||||
},
|
||||
font: {
|
||||
font: 'Font',
|
||||
size: 'Font size',
|
||||
},
|
||||
style: {
|
||||
clear: 'Clear style',
|
||||
copy: 'Copy style',
|
||||
paste: 'Paste style',
|
||||
},
|
||||
},
|
||||
main: {
|
||||
header: {
|
||||
minder: 'Minder',
|
||||
style: 'Appearance style',
|
||||
},
|
||||
main: {
|
||||
save: 'Save',
|
||||
},
|
||||
navigator: {
|
||||
amplification: 'Amplification',
|
||||
narrow: 'Narrow',
|
||||
drag: 'Drag',
|
||||
locating_root: 'Locating root node',
|
||||
navigator: 'Navigator',
|
||||
},
|
||||
history: {
|
||||
undo: 'Undo',
|
||||
redo: 'Redo',
|
||||
},
|
||||
subject: {
|
||||
central: 'Central subject',
|
||||
branch: 'Subject',
|
||||
},
|
||||
priority: 'Priority',
|
||||
tag: 'Tag',
|
||||
},
|
||||
},
|
||||
};
|
|
@ -0,0 +1,93 @@
|
|||
export default {
|
||||
minder: {
|
||||
commons: {
|
||||
confirm: '确定',
|
||||
clear: '清空',
|
||||
export: '导出',
|
||||
cancel: '取消',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
remove: '移除',
|
||||
return: '返回',
|
||||
},
|
||||
menu: {
|
||||
expand: {
|
||||
expand: '展开',
|
||||
folding: '收起',
|
||||
expand_one: '展开到一级节点',
|
||||
expand_tow: '展开到二级节点',
|
||||
expand_three: '展开到三级节点',
|
||||
expand_four: '展开到四级节点',
|
||||
expand_five: '展开到五级节点',
|
||||
expand_six: '展开到六级节点',
|
||||
},
|
||||
insert: {
|
||||
down: '插入下级主题',
|
||||
up: '插入上级主题',
|
||||
same: '插入同级主题',
|
||||
_same: '同级',
|
||||
_down: '下级',
|
||||
_up: '上级',
|
||||
},
|
||||
move: {
|
||||
up: '上移',
|
||||
down: '下移',
|
||||
forward: '前移',
|
||||
backward: '后移',
|
||||
},
|
||||
progress: {
|
||||
progress: '进度',
|
||||
remove_progress: '移除进度',
|
||||
prepare: '未开始',
|
||||
complete_all: '全部完成',
|
||||
complete: '完成',
|
||||
},
|
||||
selection: {
|
||||
all: '全选',
|
||||
invert: '反选',
|
||||
sibling: '选择兄弟节点',
|
||||
same: '选择同级节点',
|
||||
path: '选择路径',
|
||||
subtree: '选择子树',
|
||||
},
|
||||
arrange: {
|
||||
arrange_layout: '整理布局',
|
||||
},
|
||||
font: {
|
||||
font: '字体',
|
||||
size: '字号',
|
||||
},
|
||||
style: {
|
||||
clear: '清除样式',
|
||||
copy: '复制样式',
|
||||
paste: '粘贴样式',
|
||||
},
|
||||
},
|
||||
main: {
|
||||
header: {
|
||||
minder: '思维导图',
|
||||
style: '外观样式',
|
||||
},
|
||||
main: {
|
||||
save: '保存',
|
||||
},
|
||||
navigator: {
|
||||
amplification: '放大',
|
||||
narrow: '缩小',
|
||||
drag: '拖拽',
|
||||
locating_root: '定位根节点',
|
||||
navigator: '导航器',
|
||||
},
|
||||
history: {
|
||||
undo: '撤销',
|
||||
redo: '重做',
|
||||
},
|
||||
subject: {
|
||||
central: '中心主题',
|
||||
branch: '分支主题',
|
||||
},
|
||||
priority: '优先级',
|
||||
tag: '标签',
|
||||
},
|
||||
},
|
||||
};
|
|
@ -0,0 +1,111 @@
|
|||
<template>
|
||||
<header>
|
||||
<a-tabs v-model="activeName" class="mind_tab-content">
|
||||
<a-tab-pane key="editMenu" :title="t('minder.main.header.minder')">
|
||||
<div class="mind-tab-panel">
|
||||
<edit-menu
|
||||
:minder="minder"
|
||||
:move-enable="props.moveEnable"
|
||||
:sequence-enable="props.sequenceEnable"
|
||||
:tag-enable="props.tagEnable"
|
||||
:progress-enable="props.progressEnable"
|
||||
:priority-count="props.priorityCount"
|
||||
:priority-prefix="props.priorityPrefix"
|
||||
:tag-edit-check="props.tagEditCheck"
|
||||
:tag-disable-check="props.tagDisableCheck"
|
||||
:priority-disable-check="props.priorityDisableCheck"
|
||||
:priority-start-with-zero="props.priorityStartWithZero"
|
||||
:tags="props.tags"
|
||||
:distinct-tags="props.distinctTags"
|
||||
:del-confirm="props.delConfirm"
|
||||
/>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="viewMenu" :title="t('minder.main.header.style')">
|
||||
<div class="mind-tab-panel">
|
||||
<view-menu :minder="minder" :default-mold="props.defaultMold" @mold-change="handleMoldChange" />
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="headerVue" setup>
|
||||
import { ref } from 'vue';
|
||||
import editMenu from '../menu/edit/editMenu.vue';
|
||||
import viewMenu from '../menu/view/viewMenu.vue';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { editMenuProps, moleProps, priorityProps, tagProps, delProps } from '../props';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
...editMenuProps,
|
||||
...moleProps,
|
||||
...priorityProps,
|
||||
...tagProps,
|
||||
...delProps,
|
||||
minder: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'moldChange', data: number): void;
|
||||
}>();
|
||||
const activeName = ref('editMenu');
|
||||
|
||||
function handleMoldChange(data: number) {
|
||||
emit('moldChange', data);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@import '../style/header';
|
||||
.mind_tab-content {
|
||||
.tab-icons {
|
||||
background-image: url('@/assets/images/minder/icons.png');
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less" scoped>
|
||||
header {
|
||||
font-size: 12px;
|
||||
background-color: #ffffff;
|
||||
& > ul {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 30px;
|
||||
background-color: #e1e1e1;
|
||||
li {
|
||||
line-height: 30px;
|
||||
display: inline-flex;
|
||||
width: 80px;
|
||||
height: 100%;
|
||||
list-style: none;
|
||||
a {
|
||||
width: inherit;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: #337ab7;
|
||||
}
|
||||
a:hover,
|
||||
a:focus {
|
||||
color: #23527c;
|
||||
}
|
||||
}
|
||||
li.selected {
|
||||
background: #ffffff;
|
||||
a {
|
||||
color: #000000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.arco-tabs-content {
|
||||
padding-top: 10px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,141 @@
|
|||
<template>
|
||||
<div ref="mec" class="minder-container" :style="{ height: `${props.height}px` }">
|
||||
<a-button type="primary" :disabled="props.disabled" class="save-btn" @click="save">{{
|
||||
t('minder.main.main.save')
|
||||
}}</a-button>
|
||||
<navigator />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="minderContainer" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import Navigator from './navigator.vue';
|
||||
import { markChangeNode, markDeleteNode } from '../script/tool/utils';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { editMenuProps, mainEditorProps, priorityProps, tagProps } from '../props';
|
||||
import Editor from '../script/editor';
|
||||
|
||||
const { t } = useI18n();
|
||||
const props = defineProps({ ...editMenuProps, ...mainEditorProps, ...tagProps, ...priorityProps });
|
||||
|
||||
const emit = defineEmits({
|
||||
afterMount: () => ({}),
|
||||
save: (json) => json,
|
||||
});
|
||||
|
||||
const mec: Ref<HTMLDivElement | null> = ref(null);
|
||||
|
||||
function save() {
|
||||
emit('save', window.minder.exportJson());
|
||||
}
|
||||
function handlePriorityButton() {
|
||||
const { priorityPrefix } = props;
|
||||
const { priorityStartWithZero } = props;
|
||||
let start = priorityStartWithZero ? 0 : 1;
|
||||
let res = '';
|
||||
for (let i = 0; i < props.priorityCount; i++) {
|
||||
res += start++;
|
||||
}
|
||||
const priority = window.minder.hotbox.state('priority');
|
||||
res.replace(/./g, (p) => {
|
||||
priority.button({
|
||||
position: 'ring',
|
||||
label: priorityPrefix + p,
|
||||
key: p,
|
||||
action() {
|
||||
const pVal = parseInt(p, 10);
|
||||
window.minder.execCommand('Priority', priorityStartWithZero ? pVal + 1 : pVal);
|
||||
},
|
||||
});
|
||||
// 需要返回字符串
|
||||
return '';
|
||||
});
|
||||
}
|
||||
function handleTagButton() {
|
||||
const tag = window.minder.hotbox.state('tag');
|
||||
props.tags?.forEach((item) => {
|
||||
tag.button({
|
||||
position: 'ring',
|
||||
label: item,
|
||||
key: item,
|
||||
action() {
|
||||
window.minder.execCommand('resource', item);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
window.editor = new Editor(mec.value, {
|
||||
sequenceEnable: props.sequenceEnable,
|
||||
tagEnable: props.tagEnable,
|
||||
progressEnable: props.progressEnable,
|
||||
moveEnable: props.moveEnable,
|
||||
});
|
||||
const { editor } = window;
|
||||
if (Object.keys(props.importJson || {}).length > 0) {
|
||||
editor.minder.importJson(props.importJson);
|
||||
}
|
||||
window.km = editor.minder;
|
||||
window.minder = window.km;
|
||||
window.minderEditor = editor;
|
||||
window.minder.moveEnable = props.moveEnable;
|
||||
|
||||
window.minder.forceRemoveNode = () => {
|
||||
markDeleteNode(window.minder);
|
||||
window.minder.execCommand('RemoveNode');
|
||||
};
|
||||
|
||||
window.minder.on('preExecCommand', (env: any) => {
|
||||
const selectNodes = env.minder.getSelectedNodes();
|
||||
const notChangeCommands = new Set([
|
||||
'camera',
|
||||
'copy',
|
||||
'expand',
|
||||
'expandToLevel',
|
||||
'hand',
|
||||
'layout',
|
||||
'template',
|
||||
'theme',
|
||||
'zoom',
|
||||
'zoomIn',
|
||||
'zoomOut',
|
||||
'append',
|
||||
'appendchildnode',
|
||||
'appendsiblingnode',
|
||||
]);
|
||||
if (selectNodes && !notChangeCommands.has(env.commandName.toLocaleLowerCase())) {
|
||||
selectNodes.forEach((node: any) => {
|
||||
markChangeNode(node);
|
||||
});
|
||||
}
|
||||
if (env.commandName === 'movetoparent') {
|
||||
setTimeout(() => {
|
||||
const targetNode = window.minder.getSelectedNode();
|
||||
targetNode.parent.renderTree();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
handlePriorityButton();
|
||||
handleTagButton();
|
||||
emit('afterMount');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@import '../style/editor.less';
|
||||
.save-btn {
|
||||
position: absolute;
|
||||
right: 30px;
|
||||
bottom: 30px;
|
||||
}
|
||||
.minder-container {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,316 @@
|
|||
<template>
|
||||
<div class="navigator">
|
||||
<div class="nav-bar">
|
||||
<div
|
||||
class="nav-btn zoom-in"
|
||||
:title="t('minder.main.navigator.amplification')"
|
||||
:class="{ active: zoomRadioIn }"
|
||||
@click="zoomIn"
|
||||
>
|
||||
<div class="icon" />
|
||||
</div>
|
||||
<div ref="zoomPan" class="zoom-pan">
|
||||
<div class="origin" :style="{ transform: 'translate(0, ' + getHeight(100) + 'px)' }" @click="RestoreSize" />
|
||||
<div
|
||||
class="indicator"
|
||||
:style="{
|
||||
transform: 'translate(0, ' + getHeight(zoom) + 'px)',
|
||||
transition: 'transform 200ms',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="nav-btn zoom-out"
|
||||
:title="t('minder.main.navigator.narrow')"
|
||||
:class="{ active: zoomRadioOut }"
|
||||
@click="zoomOut"
|
||||
>
|
||||
<div class="icon" />
|
||||
</div>
|
||||
<div class="nav-btn hand" :title="t('minder.main.navigator.drag')" :class="{ active: enableHand }" @click="hand">
|
||||
<div class="icon" />
|
||||
</div>
|
||||
<div class="nav-btn camera" :title="t('minder.main.navigator.locating_root')" @click="locateToOrigin">
|
||||
<div class="icon" />
|
||||
</div>
|
||||
<div
|
||||
class="nav-btn nav-trigger"
|
||||
:class="{ active: isNavOpen }"
|
||||
:title="t('minder.main.navigator.navigator')"
|
||||
@click="toggleNavOpen"
|
||||
>
|
||||
<div class="icon" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="isNavOpen" ref="navPreviewer" class="nav-previewer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="navigator" setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { getLocalStorage, setLocalStorage } from '../script/store';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const zoomPan: Ref<HTMLDivElement | null> = ref(null);
|
||||
const navPreviewer: Ref<HTMLDivElement | null> = ref(null);
|
||||
|
||||
const zoom = ref(100);
|
||||
const isNavOpen = ref(true);
|
||||
const previewNavigator: Ref<HTMLDivElement | null> = ref(null);
|
||||
const contentView = ref('');
|
||||
|
||||
let visibleView = reactive<any>({});
|
||||
let visibleRect = reactive<any>({});
|
||||
let nodeThumb = reactive<any>({});
|
||||
let connectionThumb = reactive<any>({});
|
||||
let paper = reactive<any>({});
|
||||
let minder = reactive<any>({});
|
||||
|
||||
const config = reactive({
|
||||
// 右侧面板最小宽度
|
||||
ctrlPanelMin: 250,
|
||||
// 右侧面板宽度
|
||||
ctrlPanelWidth: parseInt(window.localStorage.getItem('__dev_minder_ctrlPanelWidth') || '', 10) || 250,
|
||||
// 分割线宽度
|
||||
dividerWidth: 3,
|
||||
// 默认语言
|
||||
defaultLang: 'zh-cn',
|
||||
// 放大缩小比例
|
||||
zoom: [10, 20, 30, 50, 80, 100, 120, 150, 200],
|
||||
});
|
||||
|
||||
const enableHand = ref(minder && minder.queryCommandState && minder.queryCommandState('hand') === 1);
|
||||
// 避免缓存
|
||||
function getNavOpenState() {
|
||||
return getLocalStorage('navigator-hidden');
|
||||
}
|
||||
function zoomIn() {
|
||||
if (minder && minder.execCommand) {
|
||||
minder.execCommand('zoomIn');
|
||||
}
|
||||
}
|
||||
function RestoreSize() {
|
||||
if (minder && minder.execCommand) {
|
||||
minder.execCommand('zoom', 100);
|
||||
}
|
||||
}
|
||||
function zoomOut() {
|
||||
if (minder && minder.execCommand) {
|
||||
minder.execCommand('zoomOut');
|
||||
}
|
||||
}
|
||||
function hand() {
|
||||
if (minder && minder.execCommand) {
|
||||
minder.execCommand('hand');
|
||||
enableHand.value = minder.queryCommandState && minder.queryCommandState('hand') === 1;
|
||||
}
|
||||
}
|
||||
function getZoomRadio(value: number) {
|
||||
try {
|
||||
if (!minder) return 2;
|
||||
} catch (e) {
|
||||
// 如果window的还没挂载minder,先捕捉undefined异常
|
||||
return 2;
|
||||
}
|
||||
const zoomStack = minder && minder.getOption && minder.getOption('zoom');
|
||||
if (!zoomStack) {
|
||||
return 2;
|
||||
}
|
||||
const minValue = zoomStack[0];
|
||||
const maxValue = zoomStack[zoomStack.length - 1];
|
||||
const valueRange = maxValue - minValue;
|
||||
|
||||
return 1 - (value - minValue) / valueRange;
|
||||
}
|
||||
const zoomRadioIn = computed(() => getZoomRadio(zoom.value) === 0);
|
||||
const zoomRadioOut = computed(() => getZoomRadio(zoom.value) === 1);
|
||||
|
||||
function getHeight(value: number) {
|
||||
const totalHeight = Number(zoomPan.value?.style.height);
|
||||
return getZoomRadio(value) * totalHeight;
|
||||
}
|
||||
function locateToOrigin() {
|
||||
if (minder && minder.execCommand && minder.getRoot) {
|
||||
minder.execCommand('camera', minder.getRoot(), 600);
|
||||
}
|
||||
}
|
||||
|
||||
function getPathHandler(theme?: string) {
|
||||
switch (theme) {
|
||||
case 'tianpan':
|
||||
case 'tianpan-compact':
|
||||
return (nodePathData: any[], x: number, y: number, width: number) => {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const r = width >> 1;
|
||||
nodePathData.push('M', x, y + r, 'a', r, r, 0, 1, 1, 0, 0.01, 'z');
|
||||
};
|
||||
default: {
|
||||
return (nodePathData: any[], x: number, y: number, width: number, height: number) => {
|
||||
nodePathData.push('M', x, y, 'h', width, 'v', height, 'h', -width, 'z');
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pathHandler = getPathHandler(minder.getTheme ? minder.getTheme() : '');
|
||||
|
||||
function updateVisibleView() {
|
||||
if (minder.getViewDragger && visibleRect.setBox) {
|
||||
visibleView = minder.getViewDragger().getView();
|
||||
visibleRect.setBox(visibleView.intersect && visibleView.intersect(contentView.value));
|
||||
}
|
||||
}
|
||||
|
||||
function updateContentView() {
|
||||
if (!minder.getRenderContainer || !paper.setViewBox || !minder.getRoot) return;
|
||||
const view = minder.getRenderContainer().getBoundaryBox();
|
||||
contentView.value = view;
|
||||
const padding = 30;
|
||||
paper.setViewBox(
|
||||
view.x - padding - 0.5,
|
||||
view.y - padding - 0.5,
|
||||
view.width + padding * 2 + 1,
|
||||
view.height + padding * 2 + 1
|
||||
);
|
||||
const nodePathData: any[] = [];
|
||||
const connectionThumbData: any[] = [];
|
||||
minder.getRoot().traverse((node: any) => {
|
||||
const box = node.getLayoutBox();
|
||||
pathHandler(nodePathData, box.x, box.y, box.width, box.height);
|
||||
if (node.getConnection() && node.parent && node.parent.isExpanded()) {
|
||||
connectionThumbData.push(node.getConnection().getPathData());
|
||||
}
|
||||
});
|
||||
if (!paper.setStyle || !minder.getStyle || !nodeThumb.fill || !nodeThumb.setPathData) return;
|
||||
paper.setStyle('background', minder.getStyle('background'));
|
||||
if (nodePathData.length) {
|
||||
nodeThumb.fill(minder.getStyle('root-background')).setPathData(nodePathData);
|
||||
} else {
|
||||
nodeThumb.setPathData(null);
|
||||
}
|
||||
if (connectionThumbData.length && connectionThumb.stroke) {
|
||||
connectionThumb.stroke(minder.getStyle('connect-color'), '0.5%').setPathData(connectionThumbData);
|
||||
} else if (connectionThumb.setPathData) {
|
||||
connectionThumb.setPathData(null);
|
||||
}
|
||||
updateVisibleView();
|
||||
}
|
||||
|
||||
function bind() {
|
||||
if (minder && minder.on) {
|
||||
minder.on('layout layoutallfinish', updateContentView);
|
||||
minder.on('viewchange', updateVisibleView);
|
||||
}
|
||||
}
|
||||
|
||||
function unbind() {
|
||||
if (minder && minder.off) {
|
||||
minder.off('layout layoutallfinish', updateContentView);
|
||||
minder.off('viewchange', updateVisibleView);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNavOpen() {
|
||||
let isNavOpenState = false;
|
||||
isNavOpenState = !JSON.parse(getNavOpenState());
|
||||
isNavOpen.value = isNavOpenState;
|
||||
setLocalStorage('navigator-hidden', isNavOpen.value);
|
||||
|
||||
nextTick(() => {
|
||||
if (isNavOpenState) {
|
||||
bind();
|
||||
updateContentView();
|
||||
updateVisibleView();
|
||||
} else {
|
||||
unbind();
|
||||
}
|
||||
});
|
||||
}
|
||||
function navigate() {
|
||||
function moveView(center: Record<string, any>, duration?: number) {
|
||||
if (!minder.getPaper || !visibleView.width || !visibleView.height) return;
|
||||
let box = visibleView;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
center.x = -center.x;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
center.y = -center.y;
|
||||
const viewMatrix = minder.getPaper().getViewPortMatrix();
|
||||
box = viewMatrix.transformBox(box);
|
||||
if (!box.width || !box.height) return;
|
||||
const targetPosition = center.offset(box.width / 2, box.height / 2);
|
||||
if (minder.getViewDragger) minder.getViewDragger().moveTo(targetPosition, duration);
|
||||
}
|
||||
if (!paper.on) return;
|
||||
let dragging = false;
|
||||
paper.on('mousedown', (e: any) => {
|
||||
dragging = true;
|
||||
moveView(e.getPosition('top'), 200);
|
||||
previewNavigator.value?.classList.add('grab');
|
||||
});
|
||||
paper.on('mousemove', (e: any) => {
|
||||
if (dragging) {
|
||||
moveView(e.getPosition('top'));
|
||||
}
|
||||
});
|
||||
|
||||
paper.on('mouseup', () => {
|
||||
dragging = false;
|
||||
previewNavigator.value?.classList.remove('grab');
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
minder = window.minder;
|
||||
const { kity } = window;
|
||||
// 以下部分是缩略图导航器
|
||||
previewNavigator.value = navPreviewer.value;
|
||||
|
||||
// 画布,渲染缩略图
|
||||
paper = new kity.Paper(previewNavigator.value);
|
||||
|
||||
// 用两个路径来绘制节点和连线的缩略图
|
||||
if (!paper.put || !minder || !minder.on) return;
|
||||
nodeThumb = paper.put(new kity.Path());
|
||||
connectionThumb = paper.put(new kity.Path());
|
||||
// 表示可视区域的矩形
|
||||
visibleRect = paper.put(new kity.Rect(100, 100).stroke('red', '1%'));
|
||||
|
||||
contentView.value = new kity.Box();
|
||||
visibleView = new kity.Box();
|
||||
|
||||
pathHandler = getPathHandler(minder.getTheme ? minder.getTheme() : '');
|
||||
if (minder.setDefaultOptions) {
|
||||
minder.setDefaultOptions({
|
||||
zoom: config.zoom,
|
||||
});
|
||||
}
|
||||
|
||||
minder.on('zoom', (e: any) => {
|
||||
zoom.value = e.zoom;
|
||||
});
|
||||
if (isNavOpen.value) {
|
||||
bind();
|
||||
updateContentView();
|
||||
updateVisibleView();
|
||||
} else {
|
||||
unbind();
|
||||
}
|
||||
// 主题切换事件
|
||||
minder.on('themechange', (e: any) => {
|
||||
pathHandler = getPathHandler(e.theme);
|
||||
});
|
||||
|
||||
navigate();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-btn .icon {
|
||||
background: url('@/assets/images/minder/icons.png');
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div class="edit-del-group">
|
||||
<div class="edit menu-btn" :disabled="textDisabled" @click="edit">
|
||||
<i class="tab-icons" />
|
||||
<span>
|
||||
{{ t('minder.commons.edit') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="del menu-btn" :disabled="removeNodeDisabled" @click="del">
|
||||
<i class="tab-icons" />
|
||||
<span>
|
||||
{{ t('minder.commons.delete') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="edit_del" setup>
|
||||
import { onMounted, reactive, nextTick, ref } from 'vue';
|
||||
import { isDeleteDisableNode, isDisableNode } from '../../script/tool/utils';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { delProps } from '../../props';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps(delProps);
|
||||
|
||||
let minder = reactive<any>({});
|
||||
const textDisabled = ref(true);
|
||||
const removeNodeDisabled = ref(true);
|
||||
|
||||
function checkDisabled() {
|
||||
try {
|
||||
if (!minder) return false;
|
||||
} catch (e) {
|
||||
// 如果window的还没挂载minder,先捕捉undefined异常
|
||||
return false;
|
||||
}
|
||||
const node = minder.getSelectedNode();
|
||||
removeNodeDisabled.value = !node || !!isDeleteDisableNode(minder) || node.parent === null;
|
||||
textDisabled.value = !node || !!isDisableNode(minder);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
minder = window.minder;
|
||||
minder.on('selectionchange', () => {
|
||||
checkDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function editNode() {
|
||||
if (!minder.queryCommandValue) return;
|
||||
const editor = window.minderEditor;
|
||||
const receiverElement = editor.receiver.element;
|
||||
const { fsm } = editor;
|
||||
const { receiver } = editor;
|
||||
|
||||
receiverElement.innerText = minder.queryCommandValue('text');
|
||||
fsm.jump('input', 'input-request');
|
||||
receiver.selectAll();
|
||||
}
|
||||
|
||||
function edit() {
|
||||
if (textDisabled.value || !minder.queryCommandState) {
|
||||
return;
|
||||
}
|
||||
if (minder.queryCommandState('text') !== -1) {
|
||||
editNode();
|
||||
}
|
||||
}
|
||||
function del() {
|
||||
if (removeNodeDisabled.value || !minder.queryCommandState || !minder.execCommand) {
|
||||
return;
|
||||
}
|
||||
if (props.delConfirm) {
|
||||
props.delConfirm();
|
||||
return;
|
||||
}
|
||||
minder.forceRemoveNode();
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,38 @@
|
|||
<template>
|
||||
<div class="menu-container">
|
||||
<expand />
|
||||
<selection />
|
||||
<insert-box />
|
||||
<move-box :move-enable="props.moveEnable" />
|
||||
<edit-del :del-confirm="props.delConfirm" />
|
||||
<sequence-box
|
||||
v-if="props.sequenceEnable"
|
||||
:priority-prefix="props.priorityPrefix"
|
||||
:priority-count="props.priorityCount"
|
||||
:priority-disable-check="props.priorityDisableCheck"
|
||||
:priority-start-with-zero="props.priorityStartWithZero"
|
||||
/>
|
||||
<progress-box v-if="props.progressEnable" />
|
||||
<tag-box
|
||||
v-if="props.tagEnable"
|
||||
:tags="props.tags"
|
||||
:tag-disable-check="props.tagDisableCheck"
|
||||
:tag-edit-check="props.tagEditCheck"
|
||||
:distinct-tags="props.distinctTags"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="editMenu" setup>
|
||||
import insertBox from './insertBox.vue';
|
||||
import moveBox from './moveBox.vue';
|
||||
import editDel from './editDel.vue';
|
||||
import sequenceBox from './sequenceBox.vue';
|
||||
import progressBox from './progressBox.vue';
|
||||
import expand from './expand.vue';
|
||||
import selection from './selection.vue';
|
||||
import TagBox from './tagBox.vue';
|
||||
import { editMenuProps, priorityProps, tagProps, delProps } from '../../props';
|
||||
|
||||
const props = defineProps({ ...editMenuProps, ...priorityProps, ...tagProps, ...delProps });
|
||||
</script>
|
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<div class="expand-group">
|
||||
<a-button class="tab-icons expand" type="text" @click="expandAll" />
|
||||
<a-dropdown :popup-max-height="false" @select="handleCommand">
|
||||
<span class="dropdown-link">
|
||||
{{ t('minder.menu.expand.expand') }}
|
||||
<icon-caret-down />
|
||||
</span>
|
||||
<template #content>
|
||||
<a-doption value="1">{{ t('minder.menu.expand.expand_one') }}</a-doption>
|
||||
<a-doption value="2">{{ t('minder.menu.expand.expand_tow') }}</a-doption>
|
||||
<a-doption value="3">{{ t('minder.menu.expand.expand_three') }}</a-doption>
|
||||
<a-doption value="4">{{ t('minder.menu.expand.expand_four') }}</a-doption>
|
||||
<a-doption value="5">{{ t('minder.menu.expand.expand_five') }}</a-doption>
|
||||
<a-doption value="6">{{ t('minder.menu.expand.expand_six') }}</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="expand" setup>
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
function handleCommand(command: string | number | Record<string, any> | undefined) {
|
||||
window.minder?.execCommand('ExpandToLevel', command);
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
window.minder?.execCommand('ExpandToLevel', 9999);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.dropdown-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<div class="insert-group">
|
||||
<div class="insert-child-box menu-btn" :disabled="appendChildNodeDisabled" @click="execCommand('AppendChildNode')">
|
||||
<i class="tab-icons" />
|
||||
<span>{{ t('minder.menu.insert.down') }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="insert-parent-box menu-btn"
|
||||
:disabled="appendParentNodeDisabled"
|
||||
@click="execCommand('AppendParentNode')"
|
||||
>
|
||||
<i class="tab-icons" />
|
||||
<span>{{ t('minder.menu.insert.up') }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="insert-sibling-box menu-btn"
|
||||
:disabled="appendSiblingNodeDisabled"
|
||||
@click="execCommand('AppendSiblingNode')"
|
||||
>
|
||||
<i class="tab-icons" />
|
||||
<span>{{ t('minder.menu.insert.same') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="insertBox" setup>
|
||||
import { nextTick, onMounted, ref } from 'vue';
|
||||
import { isDisableNode } from '../../script/tool/utils';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const minder = ref<any>({});
|
||||
const appendChildNodeDisabled = ref(false);
|
||||
const appendParentNodeDisabled = ref(false);
|
||||
const appendSiblingNodeDisabled = ref(false);
|
||||
|
||||
function checkDisabled() {
|
||||
try {
|
||||
if (Object.keys(minder.value).length === 0) return false;
|
||||
} catch (e) {
|
||||
// 如果window的还没挂载minder,先捕捉undefined异常
|
||||
return false;
|
||||
}
|
||||
appendChildNodeDisabled.value = minder.value.queryCommandState('AppendChildNode') === -1;
|
||||
const node = minder.value.getSelectedNode();
|
||||
appendSiblingNodeDisabled.value = !node || !!isDisableNode(minder.value) || node.parent === null;
|
||||
appendParentNodeDisabled.value = !node || node.parent === null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
minder.value = window.minder;
|
||||
minder.value.on('selectionchange', () => {
|
||||
checkDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function execCommand(command: string) {
|
||||
if (minder.value.queryCommandState(command) !== -1) {
|
||||
minder.value.execCommand(command);
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<div class="move-group">
|
||||
<div class="move-up menu-btn" :disabled="arrangeUpDisabled" @click="execCommand('ArrangeUp')">
|
||||
<i class="tab-icons" />
|
||||
<span>{{ t('minder.menu.move.up') }}</span>
|
||||
</div>
|
||||
<div class="move-down menu-btn" :disabled="arrangeDownDisabled" @click="execCommand('ArrangeDown')">
|
||||
<i class="tab-icons" />
|
||||
<span>{{ t('minder.menu.move.down') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="moveBox" setup>
|
||||
import { nextTick, onMounted, reactive, ref } from 'vue';
|
||||
import { isDisableNode } from '../../script/tool/utils';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
moveEnable: boolean;
|
||||
}>();
|
||||
|
||||
let minder = reactive<any>({});
|
||||
const arrangeUpDisabled = ref(true);
|
||||
const arrangeDownDisabled = ref(true);
|
||||
|
||||
function checkDisabled() {
|
||||
try {
|
||||
if (Object.keys(minder).length === 0) return false;
|
||||
} catch (e) {
|
||||
// 如果window的还没挂载minder,先捕捉undefined异常
|
||||
return false;
|
||||
}
|
||||
const node = minder.getSelectedNode();
|
||||
if (!props.moveEnable || !node || node.parent === null || isDisableNode(minder)) {
|
||||
arrangeUpDisabled.value = true;
|
||||
arrangeDownDisabled.value = true;
|
||||
return;
|
||||
}
|
||||
if (window.minder.queryCommandState) {
|
||||
arrangeUpDisabled.value = window.minder.queryCommandState('ArrangeUp') === -1;
|
||||
arrangeDownDisabled.value = window.minder.queryCommandState('ArrangeDown') === -1;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
minder = window.minder;
|
||||
minder.on('selectionchange', () => {
|
||||
checkDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function execCommand(command: string) {
|
||||
if (window.minder.queryCommandState(command) !== -1) window.minder.execCommand(command);
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,95 @@
|
|||
<template>
|
||||
<div class="progress-group">
|
||||
<ul>
|
||||
<ul :disabled="commandDisabled">
|
||||
<li
|
||||
v-for="(item, index) in items"
|
||||
:key="item.text"
|
||||
class="menu-btn"
|
||||
:class="classArray(index)"
|
||||
:title="title(index)"
|
||||
@click="execCommand(index)"
|
||||
/>
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="progressBox" setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
|
||||
import { isDisableNode } from '../../script/tool/utils';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
let minder = reactive<any>({});
|
||||
const commandValue = ref('');
|
||||
|
||||
const items = [
|
||||
{ text: '0' },
|
||||
{ text: '1' },
|
||||
{ text: '2' },
|
||||
{ text: '3' },
|
||||
{ text: '4' },
|
||||
{ text: '5' },
|
||||
{ text: '6' },
|
||||
{ text: '7' },
|
||||
{ text: '8' },
|
||||
{ text: '9' },
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
minder = window.minder;
|
||||
});
|
||||
});
|
||||
|
||||
const commandDisabled = computed(() => {
|
||||
if (Object.keys(minder).length === 0 || !minder.on) return true;
|
||||
minder.on('interactchange', () => {
|
||||
commandValue.value = minder.queryCommandValue && minder.queryCommandValue('progress');
|
||||
});
|
||||
if (isDisableNode(minder)) {
|
||||
return true;
|
||||
}
|
||||
return minder.queryCommandState && minder.queryCommandState('progress') === -1;
|
||||
});
|
||||
|
||||
function execCommand(index: number) {
|
||||
if (!commandDisabled.value && minder.execCommand) {
|
||||
minder.execCommand('progress', index);
|
||||
}
|
||||
}
|
||||
|
||||
function classArray(index: number) {
|
||||
const isActive = minder.queryCommandValue && minder.queryCommandValue('progress') === index;
|
||||
const sequence = `progress-${index}`;
|
||||
|
||||
// 用数组返回多个class
|
||||
const arr = [
|
||||
{
|
||||
active: isActive,
|
||||
},
|
||||
sequence,
|
||||
];
|
||||
return arr;
|
||||
}
|
||||
function title(index: number) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return t('minder.menu.progress.remove_progress');
|
||||
case 1:
|
||||
return t('minder.menu.progress.prepare');
|
||||
case 9:
|
||||
return t('minder.menu.progress.complete_all');
|
||||
default:
|
||||
return `${t('minder.menu.progress.complete') + (index - 1)}/8`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.progress-group li {
|
||||
background-image: url('@/assets/images/minder/iconprogress.png');
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,123 @@
|
|||
<template>
|
||||
<div class="selection-group">
|
||||
<a-button type="text" class="tab-icons selection" @click="selectAll" />
|
||||
<a-dropdown :popup-max-height="false" @select="handleCommand">
|
||||
<span class="dropdown-link">
|
||||
{{ t('minder.menu.selection.all') }}
|
||||
<icon-caret-down />
|
||||
</span>
|
||||
<template #content>
|
||||
<a-doption value="1">{{ t('minder.menu.selection.invert') }}</a-doption>
|
||||
<a-doption value="2">{{ t('minder.menu.selection.sibling') }}</a-doption>
|
||||
<a-doption value="3">{{ t('minder.menu.selection.same') }}</a-doption>
|
||||
<a-doption value="4">{{ t('minder.menu.selection.path') }}</a-doption>
|
||||
<a-doption value="5">{{ t('minder.menu.selection.subtree') }}</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="selection" setup>
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
function selectAll() {
|
||||
const selection: any[] = [];
|
||||
window.minder.getRoot().traverse((node: any) => {
|
||||
selection.push(node);
|
||||
});
|
||||
window.minder.select(selection, true);
|
||||
window.minder.fire('receiverfocus');
|
||||
}
|
||||
|
||||
function selectRevert() {
|
||||
const selected = window.minder.getSelectedNodes();
|
||||
const selection: any[] = [];
|
||||
window.minder.getRoot().traverse((node: any) => {
|
||||
if (selected.indexOf(node) === -1) {
|
||||
selection.push(node);
|
||||
}
|
||||
});
|
||||
window.minder.select(selection, true);
|
||||
window.minder.fire('receiverfocus');
|
||||
}
|
||||
|
||||
function selectSiblings() {
|
||||
const selected = window.minder.getSelectedNodes();
|
||||
const selection: any[] = [];
|
||||
selected.forEach((node: any) => {
|
||||
if (!node.parent) return;
|
||||
node.parent.children.forEach((sibling: string) => {
|
||||
if (selection.indexOf(sibling) === -1) selection.push(sibling);
|
||||
});
|
||||
});
|
||||
window.minder.select(selection, true);
|
||||
window.minder.fire('receiverfocus');
|
||||
}
|
||||
|
||||
function selectLevel() {
|
||||
const selectedLevel = window.minder.getSelectedNodes().map((node: any) => node.getLevel());
|
||||
const selection: any[] = [];
|
||||
window.minder.getRoot().traverse((node: any) => {
|
||||
if (selectedLevel.indexOf(node.getLevel()) !== -1) {
|
||||
selection.push(node);
|
||||
}
|
||||
});
|
||||
window.minder.select(selection, true);
|
||||
window.minder.fire('receiverfocus');
|
||||
}
|
||||
|
||||
function selectPath() {
|
||||
const selected = window.minder.getSelectedNodes();
|
||||
const selection: any[] = [];
|
||||
selected.forEach((node: any) => {
|
||||
let tempNode = node;
|
||||
while (tempNode && selection.indexOf(tempNode) === -1) {
|
||||
selection.push(tempNode);
|
||||
tempNode = node.parent;
|
||||
}
|
||||
});
|
||||
window.minder.select(selection, true);
|
||||
window.minder.fire('receiverfocus');
|
||||
}
|
||||
|
||||
function selectTree() {
|
||||
const selected = window.minder.getSelectedNodes();
|
||||
const selection: any[] = [];
|
||||
selected.forEach((parent: any) => {
|
||||
parent.traverse((node: any) => {
|
||||
if (selection.indexOf(node) === -1) selection.push(node);
|
||||
});
|
||||
});
|
||||
window.minder.select(selection, true);
|
||||
window.minder.fire('receiverfocus');
|
||||
}
|
||||
|
||||
function handleCommand(value: string | number | Record<string, any> | undefined) {
|
||||
switch (value) {
|
||||
case 1:
|
||||
selectRevert();
|
||||
break;
|
||||
case 2:
|
||||
selectSiblings();
|
||||
break;
|
||||
case 3:
|
||||
selectLevel();
|
||||
break;
|
||||
case 4:
|
||||
selectPath();
|
||||
break;
|
||||
case 5:
|
||||
selectTree();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.dropdown-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,161 @@
|
|||
<template>
|
||||
<div :disabled="commandDisabled">
|
||||
<a-button class="delete-btn" shape="circle" @click="execCommand()">
|
||||
<template #icon>
|
||||
<icon-delete />
|
||||
</template>
|
||||
</a-button>
|
||||
<template v-for="(item, pIndex) in priorityCount + 1">
|
||||
<a-button
|
||||
v-if="pIndex != 0"
|
||||
:key="item"
|
||||
class="priority-btn"
|
||||
:class="'priority-btn_' + pIndex"
|
||||
size="small"
|
||||
@click="execCommand(pIndex)"
|
||||
>
|
||||
{{ priorityPrefix }}{{ priorityStartWithZero ? pIndex - 1 : pIndex }}
|
||||
</a-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="sequenceBox" setup>
|
||||
import { onMounted, reactive, nextTick, ref } from 'vue';
|
||||
import { isDisableNode, setPriorityView } from '../../script/tool/utils';
|
||||
import { priorityProps } from '../../props';
|
||||
|
||||
const props = defineProps(priorityProps);
|
||||
|
||||
const commandValue = ref('');
|
||||
const commandDisabled = ref(true);
|
||||
let minder = reactive<any>({});
|
||||
|
||||
const isDisable = (): boolean => {
|
||||
if (Object.keys(minder).length === 0) return true;
|
||||
nextTick(() => {
|
||||
setPriorityView(props.priorityStartWithZero, props.priorityPrefix);
|
||||
});
|
||||
if (minder.on) {
|
||||
minder.on('interactchange', () => {
|
||||
commandValue.value = minder.queryCommandValue && minder.queryCommandValue('priority');
|
||||
});
|
||||
}
|
||||
const node = minder.getSelectedNode();
|
||||
if (isDisableNode(minder) || !node || node.parent === null) {
|
||||
return true;
|
||||
}
|
||||
if (props.priorityDisableCheck) {
|
||||
return props.priorityDisableCheck();
|
||||
}
|
||||
return !!minder.queryCommandState && minder.queryCommandState('priority') === -1;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
minder = window.minder;
|
||||
const freshFuc = setPriorityView;
|
||||
if (minder.on) {
|
||||
minder.on('contentchange', () => {
|
||||
// 异步执行,否则执行完,还会被重置
|
||||
setTimeout(() => {
|
||||
freshFuc(props.priorityStartWithZero, props.priorityPrefix);
|
||||
}, 0);
|
||||
});
|
||||
minder.on('selectionchange', () => {
|
||||
commandDisabled.value = isDisable();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function execCommand(index?: number) {
|
||||
if (index && minder.execCommand) {
|
||||
if (!commandDisabled.value) {
|
||||
minder.execCommand('priority', index);
|
||||
}
|
||||
setPriorityView(props.priorityStartWithZero, props.priorityPrefix);
|
||||
} else if (minder.execCommand && !commandDisabled.value) {
|
||||
minder.execCommand('priority');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.delete-btn {
|
||||
margin: 0 4px;
|
||||
padding: 2px !important;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
i {
|
||||
width: 1em !important;
|
||||
height: 1em !important;
|
||||
}
|
||||
}
|
||||
.priority-btn {
|
||||
margin-right: 4px;
|
||||
padding: 0;
|
||||
padding-right: 5px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: 12px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-style: italic;
|
||||
}
|
||||
.priority-btn_1 {
|
||||
border-bottom: 3px solid #840023;
|
||||
background-color: #ff1200;
|
||||
}
|
||||
.priority-btn_1:hover {
|
||||
border-bottom: 3px solid #840023;
|
||||
color: white;
|
||||
background-color: #ff1200;
|
||||
}
|
||||
.priority-btn_2 {
|
||||
border-bottom: 3px solid #01467f;
|
||||
background-color: #0074ff;
|
||||
}
|
||||
.priority-btn_2:hover {
|
||||
border-bottom: 3px solid #01467f;
|
||||
color: white;
|
||||
background-color: #0074ff;
|
||||
}
|
||||
.priority-btn_3 {
|
||||
border-bottom: 3px solid #006300;
|
||||
background-color: #00af00;
|
||||
}
|
||||
.priority-btn_3:hover {
|
||||
border-bottom: 3px solid #006300;
|
||||
color: white;
|
||||
background-color: #00af00;
|
||||
}
|
||||
.priority-btn_4 {
|
||||
border-bottom: 3px solid #b25000;
|
||||
background-color: #ff962e;
|
||||
}
|
||||
.priority-btn_4:hover {
|
||||
border-bottom: 3px solid #b25000;
|
||||
color: white;
|
||||
background-color: #ff962e;
|
||||
}
|
||||
.priority-btn_5 {
|
||||
border-bottom: 3px solid #4720c4;
|
||||
background-color: #a464ff;
|
||||
}
|
||||
.priority-btn_5:hover {
|
||||
border-bottom: 3px solid #4720c4;
|
||||
color: white;
|
||||
background-color: #a464ff;
|
||||
}
|
||||
.priority-btn_6 {
|
||||
border-bottom: 3px solid #515151;
|
||||
background-color: #a3a3a3;
|
||||
}
|
||||
.priority-btn_6:hover {
|
||||
border-bottom: 3px solid #515151;
|
||||
color: white;
|
||||
background-color: #a3a3a3;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,100 @@
|
|||
<template>
|
||||
<div :disabled="commandDisabled">
|
||||
<a-tag
|
||||
v-for="item in props.tags"
|
||||
:key="item"
|
||||
size="small"
|
||||
:color="getResourceColor(item)"
|
||||
@click="editResource(item)"
|
||||
>{{ item }}</a-tag
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="TagBox" setup>
|
||||
import { nextTick, onMounted, reactive, ref } from 'vue';
|
||||
import { isDisableNode, isTagEnable } from '../../script/tool/utils';
|
||||
import { tagProps } from '../../props';
|
||||
|
||||
const props = defineProps(tagProps);
|
||||
|
||||
let minder = reactive<any>({});
|
||||
const commandDisabled = ref(true);
|
||||
|
||||
const isDisable = (): boolean => {
|
||||
if (Object.keys(minder).length === 0 || !minder.on) return true;
|
||||
if (isDisableNode(minder) && !isTagEnable(minder)) {
|
||||
return true;
|
||||
}
|
||||
if (props.tagDisableCheck) {
|
||||
return props.tagDisableCheck();
|
||||
}
|
||||
return !!minder.queryCommandState && minder.queryCommandState('resource') === -1;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
minder = window.minder;
|
||||
minder.on('selectionchange', () => {
|
||||
commandDisabled.value = isDisable();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getResourceColor(resource: string) {
|
||||
if (minder.getResourceColor) {
|
||||
return minder.getResourceColor(resource).toHEX();
|
||||
}
|
||||
}
|
||||
|
||||
function editResource(resourceName: string) {
|
||||
if (commandDisabled.value) {
|
||||
return;
|
||||
}
|
||||
if (props.tagEditCheck) {
|
||||
if (!props.tagEditCheck(resourceName)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!resourceName || !/\S/.test(resourceName)) {
|
||||
return;
|
||||
}
|
||||
const origin = window.minder.queryCommandValue('resource');
|
||||
const index = origin.indexOf(resourceName);
|
||||
// 先删除排他的标签
|
||||
if (props.distinctTags.indexOf(resourceName) > -1) {
|
||||
for (let i = 0; i < origin.length; i++) {
|
||||
if (props.distinctTags.indexOf(origin[i]) > -1) {
|
||||
origin.splice(i, 1);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (index !== -1) {
|
||||
origin.splice(index, 1);
|
||||
} else {
|
||||
origin.push(resourceName);
|
||||
}
|
||||
window.minder.execCommand('resource', origin);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.arco-tag {
|
||||
margin-right: 4px;
|
||||
border: 0;
|
||||
color: black;
|
||||
}
|
||||
.arco-tag:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.arco-tag:first-child {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.add-btn {
|
||||
padding: 0 !important;
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
border-style: dashed !important;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<div class="arrange-group">
|
||||
<div class="arrange menu-btn" :disabled="disabled" @click="resetlayout">
|
||||
<span class="tab-icons" />
|
||||
<span class="label">
|
||||
{{ t('minder.menu.arrange.arrange_layout') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="Arrange" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const disabled = computed(() => {
|
||||
try {
|
||||
if (!window.minder) return false;
|
||||
} catch (e) {
|
||||
// 如果window的还没挂载minder,先捕捉undefined异常
|
||||
return false;
|
||||
}
|
||||
return window.minder.queryCommandState && window.minder.queryCommandState('resetlayout') === -1;
|
||||
});
|
||||
|
||||
function resetlayout() {
|
||||
if (window.minder.queryCommandState('resetlayout') !== -1) {
|
||||
window.minder.execCommand('resetlayout');
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,269 @@
|
|||
<template>
|
||||
<div class="font-group">
|
||||
<a-select
|
||||
v-model="fontFamilyDefaultValue"
|
||||
:placeholder="t('minder.menu.font.font')"
|
||||
class="font-family-select"
|
||||
:disabled="disabledFont"
|
||||
size="mini"
|
||||
@change="execCommandFontFamily"
|
||||
>
|
||||
<a-option
|
||||
v-for="item in fontFamilys"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
:style="{ 'font-family': item.value }"
|
||||
/>
|
||||
</a-select>
|
||||
<a-select
|
||||
v-model="fontSizeDefaultValue"
|
||||
:placeholder="t('minder.menu.font.size')"
|
||||
class="font-size-select"
|
||||
:disabled="disabledFontSize"
|
||||
size="mini"
|
||||
@change="execCommandFontSize"
|
||||
>
|
||||
<a-option
|
||||
v-for="item in fontSizes"
|
||||
:key="item.id"
|
||||
:label="item.label.toString()"
|
||||
:value="item.value"
|
||||
:style="{
|
||||
'font-size': item.value + 'px',
|
||||
'height': 2 * item.value + 'px',
|
||||
'line-height': 2 * item.value + 'px',
|
||||
'padding': 0,
|
||||
}"
|
||||
/>
|
||||
</a-select>
|
||||
<span class="font-btn">
|
||||
<span
|
||||
class="menu-btn tab-icons font-bold"
|
||||
:class="{ selected: boldSelected }"
|
||||
:disabled="disabledBold"
|
||||
@click="execCommandFontStyle('bold')"
|
||||
/>
|
||||
<span
|
||||
class="font-italic menu-btn tab-icons"
|
||||
:class="{ selected: italicSelected }"
|
||||
:disabled="disabledItalic"
|
||||
@click="execCommandFontStyle('italic')"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="StyleOpreation" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const fontFamilys = [
|
||||
{
|
||||
id: 1,
|
||||
value: '宋体,SimSun',
|
||||
name: '宋体',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
value: '微软雅黑,Microsoft YaHei',
|
||||
name: '微软雅黑',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
value: '楷体,楷体_GB2312,SimKai',
|
||||
name: '楷体',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
value: '黑体, SimHei',
|
||||
name: '黑体',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
value: '隶书, SimLi',
|
||||
name: '隶书',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
value: 'andale mono',
|
||||
name: 'Andale Mono',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
value: 'arial,helvetica,sans-serif',
|
||||
name: 'Arial',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
value: 'arial black,avant garde',
|
||||
name: 'arialBlack',
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
value: 'comic sans ms',
|
||||
name: 'comic Sans Ms',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
value: 'impact,chicago',
|
||||
name: 'Impact',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
value: 'times new roman',
|
||||
name: 'times New Roman',
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
value: 'sans-serif',
|
||||
name: 'Sans-Serif',
|
||||
},
|
||||
];
|
||||
const fontSizes = [
|
||||
{
|
||||
id: 1,
|
||||
value: 10,
|
||||
label: 10,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
value: 12,
|
||||
label: 12,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
value: 16,
|
||||
label: 16,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
value: 18,
|
||||
label: 18,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
value: 24,
|
||||
label: 24,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
value: 32,
|
||||
label: 32,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
value: 48,
|
||||
label: 48,
|
||||
},
|
||||
];
|
||||
|
||||
const fontFamilyDefaultValue = ref('');
|
||||
const fontSizeDefaultValue = ref('');
|
||||
|
||||
const disabledFont = computed(() => {
|
||||
try {
|
||||
if (!window.minder) return false;
|
||||
} catch (e) {
|
||||
// 如果window的还没挂载minder,先捕捉undefined异常
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentFontFamily = window.minder.queryCommandValue('fontfamily');
|
||||
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
|
||||
fontFamilyDefaultValue.value = currentFontFamily || t('minder.menu.font.font');
|
||||
return window.minder.queryCommandState('fontfamily') === -1;
|
||||
});
|
||||
const disabledFontSize = computed(() => {
|
||||
try {
|
||||
if (!window.minder) return false;
|
||||
} catch (e) {
|
||||
// 如果window的还没挂载minder,先捕捉undefined异常
|
||||
return false;
|
||||
}
|
||||
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
|
||||
fontSizeDefaultValue.value = window.minder.queryCommandValue('fontsize') || t('minder.menu.font.size');
|
||||
return window.minder.queryCommandState('fontsize') === -1;
|
||||
});
|
||||
const disabledBold = computed(() => {
|
||||
try {
|
||||
if (!window.minder) return false;
|
||||
} catch (e) {
|
||||
// 如果window的还没挂载minder,先捕捉undefined异常
|
||||
return false;
|
||||
}
|
||||
return window.minder.queryCommandState('bold') === -1;
|
||||
});
|
||||
const disabledItalic = computed(() => {
|
||||
try {
|
||||
if (!window.minder) return false;
|
||||
} catch (e) {
|
||||
// 如果window的还没挂载minder,先捕捉undefined异常
|
||||
return false;
|
||||
}
|
||||
return window.minder.queryCommandState('italic') === -1;
|
||||
});
|
||||
const boldSelected = computed(() => {
|
||||
try {
|
||||
if (!window.minder) return false;
|
||||
} catch (e) {
|
||||
// 如果window的还没挂载minder,先捕捉undefined异常
|
||||
return false;
|
||||
}
|
||||
return window.minder.queryCommandState('bold') === -1;
|
||||
});
|
||||
const italicSelected = computed(() => {
|
||||
try {
|
||||
if (!window.minder) return false;
|
||||
} catch (e) {
|
||||
// 如果window的还没挂载minder,先捕捉undefined异常
|
||||
return false;
|
||||
}
|
||||
return window.minder.queryCommandState('italic') === -1;
|
||||
});
|
||||
|
||||
function execCommandFontFamily(
|
||||
value: string | number | Record<string, any> | (string | number | Record<string, any>)[]
|
||||
) {
|
||||
if (value === t('minder.menu.font.font')) {
|
||||
return;
|
||||
}
|
||||
window.minder.execCommand('fontfamily', value);
|
||||
}
|
||||
|
||||
function execCommandFontSize(
|
||||
value: string | number | Record<string, any> | (string | number | Record<string, any>)[]
|
||||
) {
|
||||
if (typeof value !== 'number') {
|
||||
return;
|
||||
}
|
||||
window.minder.execCommand('fontsize', value);
|
||||
}
|
||||
|
||||
function execCommandFontStyle(style: string) {
|
||||
switch (style) {
|
||||
case 'bold':
|
||||
if (window.minder.queryCommandState('bold') !== -1) {
|
||||
window.minder.execCommand('bold');
|
||||
}
|
||||
break;
|
||||
case 'italic':
|
||||
if (window.minder.queryCommandState('italic') !== -1) {
|
||||
window.minder.execCommand('italic');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.font-group {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.font-btn {
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,105 @@
|
|||
<template>
|
||||
<div class="mold-group" :disabled="disabled">
|
||||
<a-dropdown class="toggle" @select="handleCommand">
|
||||
<div>
|
||||
<span class="dropdown-toggle mold-icons menu-btn" :class="'mold-' + (moldIndex + 1)" />
|
||||
<span class="dropdown-link">
|
||||
<icon-caret-down />
|
||||
</span>
|
||||
</div>
|
||||
<template #content>
|
||||
<a-doption class="dropdown-item mold-icons mold-1" :value="1" />
|
||||
<a-doption class="dropdown-item mold-icons mold-2" :value="2" />
|
||||
<a-doption class="dropdown-item mold-icons mold-3" :value="3" />
|
||||
<a-doption class="dropdown-item mold-icons mold-4" :value="4" />
|
||||
<a-doption class="dropdown-item mold-icons mold-5" :value="5" />
|
||||
<a-doption class="dropdown-item mold-icons mold-6" :value="6" />
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="Mold" setup>
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
import { moleProps } from '../../props';
|
||||
|
||||
const props = defineProps(moleProps);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'moldChange', data: number): void;
|
||||
}>();
|
||||
|
||||
const moldIndex = ref(0);
|
||||
|
||||
const disabled = computed(() => {
|
||||
try {
|
||||
if (!window.minder) return false;
|
||||
} catch (e) {
|
||||
// 如果window的还没挂载minder,先捕捉undefined异常
|
||||
return false;
|
||||
}
|
||||
return window.minder.queryCommandState('template') === -1;
|
||||
});
|
||||
|
||||
const templateList = computed(() => window.kityminder.Minder.getTemplateList());
|
||||
|
||||
function handleCommand(value: string | number | Record<string, any> | undefined) {
|
||||
moldIndex.value = value as number;
|
||||
window.minder.execCommand('template', Object.keys(templateList.value)[value as number]);
|
||||
emit('moldChange', value as number);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => handleCommand(props.defaultMold));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.toggle {
|
||||
.arco-dropdown-list {
|
||||
display: grid;
|
||||
padding: 5px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.dropdown-toggle .mold-icons,
|
||||
.mold-icons {
|
||||
background-image: url('@/assets/images/minder/mold.png');
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.mold-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 80px;
|
||||
.dropdown-toggle {
|
||||
display: flex;
|
||||
margin-top: 5px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
.dropdown-link {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mold-loop(@i) when (@i > 0) {
|
||||
.mold-@{i} {
|
||||
display: flex;
|
||||
margin-top: 5px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-position: (1 - @i) * 50px 0;
|
||||
}
|
||||
.mold-loop(@i - 1);
|
||||
}
|
||||
|
||||
.mold-loop(6);
|
||||
</style>
|
|
@ -0,0 +1,78 @@
|
|||
<template>
|
||||
<div class="style-group">
|
||||
<div class="clear-style-btn menu-btn" :disabled="disabled" @click="clearstyle">
|
||||
<span class="tab-icons" />
|
||||
<span class="label">
|
||||
{{ t('minder.menu.style.clear') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="copy-paste-panel" @click="copystyle">
|
||||
<div class="copy-style menu-btn" :disabled="disabled">
|
||||
<span class="tab-icons" />
|
||||
<span class="label">
|
||||
{{ t('minder.menu.style.copy') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="paste-style menu-btn" :disabled="disabled" @click="pastestyle">
|
||||
<span class="tab-icons" />
|
||||
<span class="label">
|
||||
{{ t('minder.menu.style.paste') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="StyleOpreation" setup>
|
||||
import { nextTick, onMounted, reactive, ref } from 'vue';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
let minder = reactive<any>({});
|
||||
const disabled = ref(true);
|
||||
|
||||
function checkDisabled() {
|
||||
try {
|
||||
if (Object.keys(minder).length === 0) return false;
|
||||
} catch (e) {
|
||||
// 如果window的还没挂载minder,先捕捉undefined异常
|
||||
return false;
|
||||
}
|
||||
const nodes = minder.getSelectedNodes && minder.getSelectedNodes();
|
||||
disabled.value = nodes === null || nodes.length === 0;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
minder = window.minder;
|
||||
minder.on('selectionchange', () => {
|
||||
checkDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function clearstyle() {
|
||||
if (minder.queryCommandState && minder.execCommand && minder.queryCommandState('clearstyle') !== -1) {
|
||||
minder.execCommand('clearstyle');
|
||||
}
|
||||
}
|
||||
function copystyle() {
|
||||
if (minder.queryCommandState && minder.execCommand && minder.queryCommandState('copystyle') !== -1) {
|
||||
minder.execCommand('copystyle');
|
||||
}
|
||||
}
|
||||
function pastestyle() {
|
||||
if (minder.queryCommandState && minder.execCommand && minder.queryCommandState('pastestyle') !== -1) {
|
||||
minder.execCommand('pastestyle');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.mold-dropdown-list .mold-icons,
|
||||
.mold-icons {
|
||||
background-image: url('@/assets/images/minder/mold.png');
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<div class="menu-container">
|
||||
<mold :default-mold="props.defaultMold" @mold-change="handleMoldChange" />
|
||||
<arrange />
|
||||
<style-operation />
|
||||
<font-operation />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="viewMenu" setup>
|
||||
import mold from './mold.vue';
|
||||
import arrange from './arrange.vue';
|
||||
import styleOperation from './styleOperation.vue';
|
||||
import fontOperation from './fontOperation.vue';
|
||||
import { moleProps } from '../../props';
|
||||
|
||||
const props = defineProps(moleProps);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'moldChange', data: number): void;
|
||||
}>();
|
||||
|
||||
function handleMoldChange(data: number) {
|
||||
emit('moldChange', data);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.menu-container {
|
||||
height: 60px;
|
||||
i {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,72 @@
|
|||
<template>
|
||||
<div class="main-container">
|
||||
<header-menu
|
||||
:sequence-enable="props.sequenceEnable"
|
||||
:tag-enable="props.tagEnable"
|
||||
:progress-enable="props.progressEnable"
|
||||
:priority-count="props.priorityCount"
|
||||
:priority-prefix="props.priorityPrefix"
|
||||
:priority-start-with-zero="props.priorityStartWithZero"
|
||||
:tags="props.tags"
|
||||
:move-enable="props.moveEnable"
|
||||
:tag-edit-check="props.tagEditCheck"
|
||||
:tag-disable-check="props.tagDisableCheck"
|
||||
:priority-disable-check="props.priorityDisableCheck"
|
||||
:distinct-tags="props.distinctTags"
|
||||
:default-mold="props.defaultMold"
|
||||
:del-confirm="props.delConfirm"
|
||||
@mold-change="handleMoldChange"
|
||||
/>
|
||||
<main-editor
|
||||
:disabled="props.disabled"
|
||||
:sequence-enable="props.sequenceEnable"
|
||||
:tag-enable="props.tagEnable"
|
||||
:move-enable="props.moveEnable"
|
||||
:progress-enable="props.progressEnable"
|
||||
:import-json="props.importJson"
|
||||
:height="props.height"
|
||||
:tags="props.tags"
|
||||
:distinct-tags="props.distinctTags"
|
||||
:tag-edit-check="props.tagEditCheck"
|
||||
:tag-disable-check="props.tagDisableCheck"
|
||||
:priority-count="props.priorityCount"
|
||||
:priority-prefix="props.priorityPrefix"
|
||||
:priority-start-with-zero="props.priorityStartWithZero"
|
||||
@after-mount="emit('afterMount')"
|
||||
@save="save"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="minderEditor" setup>
|
||||
import { onMounted } from 'vue';
|
||||
import headerMenu from './main/header.vue';
|
||||
import mainEditor from './main/mainEditor.vue';
|
||||
import { editMenuProps, mainEditorProps, moleProps, priorityProps, tagProps, delProps } from './props';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'moldChange', data: number): void;
|
||||
(e: 'save', data: Record<string, any>): void;
|
||||
(e: 'afterMount'): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps({
|
||||
...editMenuProps,
|
||||
...mainEditorProps,
|
||||
...moleProps,
|
||||
...priorityProps,
|
||||
...tagProps,
|
||||
...delProps,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
window.minderProps = props;
|
||||
});
|
||||
|
||||
function handleMoldChange(data: number) {
|
||||
emit('moldChange', data);
|
||||
}
|
||||
function save(data: Record<string, any>) {
|
||||
emit('save', data);
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Api 列表
|
||||
*/
|
||||
|
||||
export const mainEditorProps = {
|
||||
importJson: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
root: {
|
||||
data: {
|
||||
text: 'test111',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
data: {
|
||||
text: '地图',
|
||||
},
|
||||
},
|
||||
{
|
||||
data: {
|
||||
text: '百科',
|
||||
expandState: 'collapse',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
template: 'default',
|
||||
};
|
||||
},
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 500,
|
||||
},
|
||||
disabled: Boolean,
|
||||
};
|
||||
|
||||
export const priorityProps = {
|
||||
priorityCount: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
validator: (value: number) => {
|
||||
// 优先级最多支持 9 个级别
|
||||
return value <= 9;
|
||||
},
|
||||
},
|
||||
priorityStartWithZero: {
|
||||
// 优先级是否从0开始
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
priorityPrefix: {
|
||||
// 优先级显示的前缀
|
||||
type: String,
|
||||
default: 'P',
|
||||
},
|
||||
priorityDisableCheck: Function,
|
||||
operators: [],
|
||||
};
|
||||
|
||||
export const tagProps = {
|
||||
tags: {
|
||||
// 自定义标签
|
||||
type: Array<string>,
|
||||
default() {
|
||||
return [] as string[];
|
||||
},
|
||||
},
|
||||
distinctTags: {
|
||||
// 个别标签二选一
|
||||
type: Array<string>,
|
||||
default() {
|
||||
return [] as string[];
|
||||
},
|
||||
},
|
||||
tagDisableCheck: Function,
|
||||
tagEditCheck: Function,
|
||||
};
|
||||
|
||||
export const editMenuProps = {
|
||||
sequenceEnable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
tagEnable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
progressEnable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
moveEnable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const moleProps = {
|
||||
// 默认样式
|
||||
defaultMold: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
};
|
||||
|
||||
export const delProps = {
|
||||
delConfirm: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,92 @@
|
|||
import '@7polo/kity/dist/kity';
|
||||
import 'hotbox-minder/hotbox';
|
||||
import '@7polo/kityminder-core';
|
||||
import container from './runtime/container';
|
||||
import fsm from './runtime/fsm';
|
||||
import minder from './runtime/minder';
|
||||
import receiver from './runtime/receiver';
|
||||
import hotbox from './runtime/hotbox';
|
||||
import input from './runtime/input';
|
||||
import clipboardMimetype from './runtime/clipboard-mimetype';
|
||||
import clipboard from './runtime/clipboard';
|
||||
import drag from './runtime/drag';
|
||||
import node from './runtime/node';
|
||||
import history from './runtime/history';
|
||||
import jumping from './runtime/jumping';
|
||||
import priority from './runtime/priority';
|
||||
import progress from './runtime/progress';
|
||||
import exportsRuntime from './runtime/exports';
|
||||
import tag from './runtime/tag';
|
||||
|
||||
type EditMenuProps = {
|
||||
sequenceEnable: boolean;
|
||||
tagEnable: boolean;
|
||||
progressEnable: boolean;
|
||||
moveEnable: boolean;
|
||||
};
|
||||
|
||||
type Runtime = {
|
||||
name: string;
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
call: (thisArg: KMEditor, editor: KMEditor) => void;
|
||||
};
|
||||
|
||||
const runtimes: Runtime[] = [];
|
||||
|
||||
function assemble(runtime: Runtime) {
|
||||
runtimes.push(runtime);
|
||||
}
|
||||
|
||||
function isEnable(editMenuProps: EditMenuProps, runtime: Runtime) {
|
||||
switch (runtime.name) {
|
||||
case 'PriorityRuntime':
|
||||
return editMenuProps.sequenceEnable === true;
|
||||
case 'TagRuntime':
|
||||
return editMenuProps.tagEnable === true;
|
||||
case 'ProgressRuntime':
|
||||
return editMenuProps.progressEnable === true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class KMEditor {
|
||||
public selector: HTMLDivElement | null | string;
|
||||
|
||||
public editMenuProps: EditMenuProps;
|
||||
|
||||
constructor(selector: HTMLDivElement | null | string, editMenuPropsC: EditMenuProps) {
|
||||
this.selector = selector;
|
||||
this.editMenuProps = editMenuPropsC;
|
||||
this.init();
|
||||
}
|
||||
|
||||
public init() {
|
||||
for (let i = 0; i < runtimes.length; i++) {
|
||||
if (typeof runtimes[i].call === 'function' && isEnable(this.editMenuProps, runtimes[i])) {
|
||||
runtimes[i].call(this, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assemble(container);
|
||||
assemble(fsm);
|
||||
assemble(minder);
|
||||
assemble(receiver);
|
||||
assemble(hotbox);
|
||||
assemble(input);
|
||||
assemble(clipboardMimetype);
|
||||
assemble(clipboard);
|
||||
assemble(drag);
|
||||
assemble(node);
|
||||
assemble(history);
|
||||
assemble(jumping);
|
||||
assemble(priority);
|
||||
assemble(progress);
|
||||
assemble(exportsRuntime);
|
||||
assemble(tag);
|
||||
|
||||
window.kityminder.Editor = KMEditor;
|
||||
|
||||
export default KMEditor;
|
|
@ -0,0 +1,3 @@
|
|||
import Editor from './editor';
|
||||
|
||||
export default window.kityminder.Editor = Editor;
|
|
@ -0,0 +1,70 @@
|
|||
const priorities = [
|
||||
{ jp: 1, mp: 'full-1' },
|
||||
{ jp: 2, mp: 'full-2' },
|
||||
{ jp: 3, mp: 'full-3' },
|
||||
{ jp: 4, mp: 'full-4' },
|
||||
{ jp: 5, mp: 'full-5' },
|
||||
{ jp: 6, mp: 'full-6' },
|
||||
{ jp: 7, mp: 'full-7' },
|
||||
{ jp: 8, mp: 'full-8' },
|
||||
];
|
||||
const mmVersion = '<map version="1.0.1">\n';
|
||||
const iconTextPrefix = '<icon BUILTIN="';
|
||||
const iconTextSuffix = '"/>\n';
|
||||
const nodeCreated = '<node CREATED="';
|
||||
const nodeId = '" ID="';
|
||||
const nodeText = '" TEXT="';
|
||||
const nodeSuffix = '">\n';
|
||||
const entityNode = '</node>\n';
|
||||
const entityMap = '</map>';
|
||||
|
||||
function concatNodes(node: any) {
|
||||
let result = '';
|
||||
const datas = node.data;
|
||||
result += nodeCreated + datas.created + nodeId + datas.id + nodeText + datas.text + nodeSuffix;
|
||||
if (datas.priority) {
|
||||
const mapped = priorities.find((d) => {
|
||||
return d.jp === datas.priority;
|
||||
});
|
||||
if (mapped) {
|
||||
result += iconTextPrefix + mapped.mp + iconTextSuffix;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function traverseJson(node: any) {
|
||||
let result = '';
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
result += concatNodes(node);
|
||||
if (node.children && node.children.length > 0) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const element of node.children) {
|
||||
result += traverseJson(element);
|
||||
result += entityNode;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function exportFreeMind(minder: any) {
|
||||
const minds = minder.exportJson();
|
||||
const mmContent = mmVersion + traverseJson(minds.root) + entityNode + entityMap;
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
const blob = new Blob([`\ufeff${mmContent}`], {
|
||||
type: 'text/xml',
|
||||
});
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = `${minds.root.data.text}.mm`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-alert, no-console
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
export default { exportFreeMind };
|
|
@ -0,0 +1,19 @@
|
|||
function exportJson(minder: any) {
|
||||
const minds = minder.exportJson();
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
const blob = new Blob([`\ufeff${JSON.stringify(minds)}`], {
|
||||
type: 'text/json',
|
||||
});
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = `${minds.root.data.text}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
export default { exportJson };
|
|
@ -0,0 +1,66 @@
|
|||
/* eslint-disable no-underscore-dangle */
|
||||
const EMPTY_LINE = '';
|
||||
const NOTE_MARK_START = '<!--Note-->';
|
||||
const NOTE_MARK_CLOSE = '<!--/Note-->';
|
||||
|
||||
function _generateHeaderSharp(level: number) {
|
||||
let sharps = '';
|
||||
while (level--) sharps += '#';
|
||||
return sharps;
|
||||
}
|
||||
|
||||
function _build(node: any, level: number) {
|
||||
let lines: string[] = [];
|
||||
|
||||
level = level || 1;
|
||||
|
||||
const sharps = _generateHeaderSharp(level);
|
||||
lines.push(`${sharps} ${node.data.text}`);
|
||||
lines.push(EMPTY_LINE);
|
||||
|
||||
let { note } = node.data;
|
||||
if (note) {
|
||||
const hasSharp = /^#/.test(note);
|
||||
if (hasSharp) {
|
||||
lines.push(NOTE_MARK_START);
|
||||
note = note.replace(/^#+/gm, ($0: string) => {
|
||||
return sharps + $0;
|
||||
});
|
||||
}
|
||||
lines.push(note);
|
||||
if (hasSharp) {
|
||||
lines.push(NOTE_MARK_CLOSE);
|
||||
}
|
||||
lines.push(EMPTY_LINE);
|
||||
}
|
||||
|
||||
if (node.children)
|
||||
node.children.forEach((child: any) => {
|
||||
lines = lines.concat(_build(child, level + 1));
|
||||
});
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function encode(json: any) {
|
||||
return _build(json, 1).join('\n');
|
||||
}
|
||||
function exportMarkdown(minder: any) {
|
||||
const minds = minder.exportJson();
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
const blob = new Blob([`\ufeff${encode(minds.root)}`], {
|
||||
type: 'markdown',
|
||||
});
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = `${minds.root.data.text}.md`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-alert
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
export default { exportMarkdown };
|
|
@ -0,0 +1,41 @@
|
|||
const LINE_ENDING = '\r';
|
||||
const TAB_CHAR = '\t';
|
||||
|
||||
function repeat(s: string, n: number) {
|
||||
let result = '';
|
||||
while (n--) result += s;
|
||||
return result;
|
||||
}
|
||||
|
||||
function encode(json: any, level: number) {
|
||||
let local = '';
|
||||
level = level || 0;
|
||||
local += repeat(TAB_CHAR, level);
|
||||
local += json.data.text + LINE_ENDING;
|
||||
if (json.children) {
|
||||
json.children.forEach((child: any) => {
|
||||
local += encode(child, level + 1);
|
||||
});
|
||||
}
|
||||
return local;
|
||||
}
|
||||
|
||||
function exportTextTree(minder: any) {
|
||||
const minds = minder.exportJson();
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
const blob = new Blob([`\ufeff${encode(minds.root, 0)}`], {
|
||||
type: 'text/plain',
|
||||
});
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = `${minds.root.data.text}.txt`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
export default { exportTextTree };
|
|
@ -0,0 +1,167 @@
|
|||
/* eslint-disable prefer-const */
|
||||
const DOMURL = window.URL || window.webkitURL || window;
|
||||
|
||||
function downloadImage(fileURI: string, fileName: string) {
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
link.href = fileURI;
|
||||
link.download = `${fileName}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(url: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = document.createElement('img');
|
||||
image.onload = function () {
|
||||
resolve(this);
|
||||
};
|
||||
image.onerror = (err) => {
|
||||
reject(err);
|
||||
};
|
||||
image.crossOrigin = '';
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function getSVGInfo(minder: any) {
|
||||
const paper = minder.getPaper();
|
||||
let svgXml;
|
||||
let $svg;
|
||||
const renderContainer = minder.getRenderContainer();
|
||||
const renderBox = renderContainer.getRenderBox();
|
||||
const width = renderBox.width + 1;
|
||||
const height = renderBox.height + 1;
|
||||
let blob;
|
||||
let svgUrl;
|
||||
// 保存原始变换,并且移动到合适的位置
|
||||
const paperTransform = paper.shapeNode.getAttribute('transform');
|
||||
paper.shapeNode.setAttribute('transform', 'translate(0.5, 0.5)');
|
||||
renderContainer.translate(-renderBox.x, -renderBox.y);
|
||||
|
||||
// 获取当前的 XML 代码
|
||||
svgXml = paper.container.innerHTML;
|
||||
|
||||
// 回复原始变换及位置
|
||||
renderContainer.translate(renderBox.x, renderBox.y);
|
||||
paper.shapeNode.setAttribute('transform', paperTransform);
|
||||
|
||||
// 过滤内容
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = svgXml;
|
||||
$svg = el.getElementsByTagName('svg');
|
||||
|
||||
const index = $svg.length - 1;
|
||||
|
||||
$svg[index].setAttribute('width', renderBox.width + 1);
|
||||
$svg[index].setAttribute('height', renderBox.height + 1);
|
||||
$svg[index].setAttribute('style', 'font-family: Arial, "Microsoft Yahei","Heiti SC";');
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.appendChild($svg[index]);
|
||||
svgXml = div.innerHTML;
|
||||
|
||||
// Dummy IE
|
||||
svgXml = svgXml.replace(
|
||||
' xmlns="http://www.w3.org/2000/svg" xmlns:NS1="" NS1:ns1:xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:NS2="" NS2:xmlns:ns1=""',
|
||||
''
|
||||
);
|
||||
|
||||
// svg 含有 符号导出报错 Entity 'nbsp' not defined
|
||||
svgXml = svgXml.replace(/ /g, ' ');
|
||||
|
||||
blob = new Blob([svgXml], {
|
||||
type: 'image/svg+xml',
|
||||
});
|
||||
|
||||
svgUrl = DOMURL.createObjectURL(blob);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
dataUrl: svgUrl,
|
||||
xml: svgXml,
|
||||
};
|
||||
}
|
||||
|
||||
function fillBackground(ctx: any, style: CanvasPattern | null | undefined, canvas: any) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = style;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawImage(ctx: any, image: any, x: number, y: number) {
|
||||
ctx.drawImage(image, x, y);
|
||||
}
|
||||
|
||||
function generateDataUrl(canvas: any) {
|
||||
try {
|
||||
const url = canvas.toDataURL('png');
|
||||
return url;
|
||||
} catch (e) {
|
||||
throw new Error('当前浏览器版本不支持导出 PNG 功能,请尝试升级到最新版本!');
|
||||
}
|
||||
}
|
||||
|
||||
function exportPNGImage(minder: any) {
|
||||
/* 绘制 PNG 的画布及上下文 */
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
/* 尝试获取背景图片 URL 或背景颜色 */
|
||||
const bgDeclare = minder.getStyle('background').toString();
|
||||
const bgUrl = /url\((.+)\)/.exec(bgDeclare);
|
||||
const bgColor = window.kity.Color.parse(bgDeclare);
|
||||
|
||||
/* 获取 SVG 文件内容 */
|
||||
const svgInfo = getSVGInfo(minder);
|
||||
const { width } = svgInfo;
|
||||
const { height } = svgInfo;
|
||||
const svgDataUrl = svgInfo.dataUrl;
|
||||
|
||||
/* 画布的填充大小 */
|
||||
const padding = 20;
|
||||
|
||||
canvas.width = width + padding * 2;
|
||||
canvas.height = height + padding * 2;
|
||||
|
||||
function drawSVG() {
|
||||
const mind = window.editor.minder.exportJson();
|
||||
if (typeof window.canvg !== 'undefined') {
|
||||
return window.canvg(canvas, svgInfo.xml, {
|
||||
ignoreMouse: true,
|
||||
ignoreAnimation: true,
|
||||
ignoreDimensions: true,
|
||||
ignoreClear: true,
|
||||
offsetX: padding,
|
||||
offsetY: padding,
|
||||
renderCallback() {
|
||||
downloadImage(generateDataUrl(canvas), mind.root.data.text);
|
||||
},
|
||||
});
|
||||
}
|
||||
return loadImage(svgDataUrl).then((svgImage) => {
|
||||
drawImage(ctx, svgImage, padding, padding);
|
||||
DOMURL.revokeObjectURL(svgDataUrl);
|
||||
downloadImage(generateDataUrl(canvas), mind.root.data.text);
|
||||
});
|
||||
}
|
||||
|
||||
if (bgUrl) {
|
||||
loadImage(bgUrl[1]).then((image) => {
|
||||
fillBackground(ctx, ctx?.createPattern(image as CanvasImageSource, 'repeat'), canvas);
|
||||
drawSVG();
|
||||
});
|
||||
} else {
|
||||
fillBackground(ctx, bgColor.toString(), canvas);
|
||||
drawSVG();
|
||||
}
|
||||
}
|
||||
|
||||
export default { exportPNGImage };
|
|
@ -0,0 +1,73 @@
|
|||
/* eslint-disable no-bitwise */
|
||||
/* eslint-disable prefer-const */
|
||||
|
||||
function downloadSVG(fileURI: string, fileName: string) {
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
link.href = fileURI;
|
||||
link.download = `${fileName}.svg`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
function exportSVG(minder: any) {
|
||||
const paper = minder.getPaper();
|
||||
const paperTransform = paper.shapeNode.getAttribute('transform');
|
||||
let svgXml;
|
||||
let $svg;
|
||||
|
||||
const renderContainer = minder.getRenderContainer();
|
||||
const renderBox = renderContainer.getRenderBox();
|
||||
const { width, height } = renderBox;
|
||||
const padding = 20;
|
||||
|
||||
paper.shapeNode.setAttribute('transform', 'translate(0.5, 0.5)');
|
||||
svgXml = paper.container.innerHTML;
|
||||
paper.shapeNode.setAttribute('transform', paperTransform);
|
||||
|
||||
const { document } = window;
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = svgXml;
|
||||
$svg = el.getElementsByTagName('svg');
|
||||
|
||||
const index = $svg.length - 1;
|
||||
|
||||
$svg[index].setAttribute('width', width + padding * 2 || 0);
|
||||
$svg[index].setAttribute('height', height + padding * 2 || 0);
|
||||
$svg[index].setAttribute(
|
||||
'style',
|
||||
`font-family: Arial, "Microsoft Yahei", "Heiti SC"; background: ${minder.getStyle('background')}`
|
||||
);
|
||||
|
||||
$svg[index].setAttribute(
|
||||
'viewBox',
|
||||
[
|
||||
(renderBox.x - padding) | 0,
|
||||
(renderBox.y - padding) | 0,
|
||||
(width + padding * 2) | 0,
|
||||
(height + padding * 2) | 0,
|
||||
].join(' ')
|
||||
);
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.appendChild($svg[index]);
|
||||
svgXml = div.innerHTML;
|
||||
svgXml = svgXml.replace(/ /g, ' ');
|
||||
|
||||
const blob = new Blob([svgXml], {
|
||||
type: 'image/svg+xml',
|
||||
});
|
||||
|
||||
const DOMURL = window.URL || window.webkitURL || window;
|
||||
const svgUrl = DOMURL.createObjectURL(blob);
|
||||
|
||||
const mind = window.editor.minder.exportJson();
|
||||
downloadSVG(svgUrl, mind.root.data.text);
|
||||
}
|
||||
|
||||
export default { exportSVG };
|
|
@ -0,0 +1,98 @@
|
|||
interface MimeTypes {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface Signatures {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const MimeType = () => {
|
||||
const SPLITOR = '\uFEFF';
|
||||
const MIMETYPE: MimeTypes = {
|
||||
'application/km': '\uFFFF',
|
||||
};
|
||||
const SIGN: Signatures = {
|
||||
'\uFEFF': 'SPLITOR',
|
||||
'\uFFFF': 'application/km',
|
||||
};
|
||||
|
||||
function getSpitor(): string {
|
||||
return SPLITOR;
|
||||
}
|
||||
|
||||
function isPureText(text: string): boolean {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
return !~text.indexOf(getSpitor());
|
||||
}
|
||||
|
||||
function getPureText(text: string): string {
|
||||
if (isPureText(text)) {
|
||||
return text;
|
||||
}
|
||||
return text.split(getSpitor())[1];
|
||||
}
|
||||
|
||||
function getMimeType(sign?: string): MimeTypes | string | null {
|
||||
if (sign !== undefined) {
|
||||
return SIGN[sign] || null;
|
||||
}
|
||||
return MIMETYPE;
|
||||
}
|
||||
|
||||
function whichMimeType(text: string): MimeTypes | string | null {
|
||||
if (isPureText(text)) {
|
||||
return null;
|
||||
}
|
||||
return getMimeType(text.split(getSpitor())[0]);
|
||||
}
|
||||
|
||||
function process(mimetype: string | false, text: string): string {
|
||||
if (!isPureText(text)) {
|
||||
const _mimetype = whichMimeType(text);
|
||||
if (!_mimetype) {
|
||||
throw new Error('unknown mimetype!');
|
||||
}
|
||||
text = getPureText(text);
|
||||
}
|
||||
if (mimetype === false) {
|
||||
return text;
|
||||
}
|
||||
return mimetype + SPLITOR + text;
|
||||
}
|
||||
|
||||
function registMimeTypeProtocol(type: string, sign: string): void {
|
||||
if (sign && SIGN[sign]) {
|
||||
throw new Error('sign has registered!');
|
||||
}
|
||||
if (type && !!MIMETYPE[type]) {
|
||||
throw new Error('mimetype has registered!');
|
||||
}
|
||||
SIGN[sign] = type;
|
||||
MIMETYPE[type] = sign;
|
||||
}
|
||||
|
||||
function getMimeTypeProtocol(type: string, text?: string): any {
|
||||
const mimetype = MIMETYPE[type] || false;
|
||||
|
||||
if (text === undefined) {
|
||||
return process.bind(null, mimetype);
|
||||
}
|
||||
|
||||
return process(mimetype, text);
|
||||
}
|
||||
|
||||
return {
|
||||
registMimeTypeProtocol,
|
||||
getMimeTypeProtocol,
|
||||
getSpitor,
|
||||
getMimeType,
|
||||
};
|
||||
};
|
||||
|
||||
const MimeTypeRuntime = function r(this: any) {
|
||||
if (this.minder.supportClipboardEvent && !window.kity.Browser.gecko) {
|
||||
this.MimeType = MimeType();
|
||||
}
|
||||
};
|
||||
|
||||
export default MimeTypeRuntime;
|
|
@ -0,0 +1,199 @@
|
|||
import { markDeleteNode, resetNodes } from '../tool/utils';
|
||||
|
||||
interface INode {
|
||||
getLevel(): number;
|
||||
isAncestorOf(node: INode): boolean;
|
||||
appendChild(node: INode): INode;
|
||||
}
|
||||
|
||||
interface IData {
|
||||
getRegisterProtocol(protocol: string): {
|
||||
encode: (nodes: Array<INode>) => Array<INode>;
|
||||
decode: (nodes: Array<INode>) => Array<INode>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ICliboardEvent extends ClipboardEvent {
|
||||
clipboardData: DataTransfer;
|
||||
}
|
||||
|
||||
export default function ClipboardRuntime(this: any) {
|
||||
const { minder } = this;
|
||||
const { receiver } = this;
|
||||
const Data: IData = window.kityminder.data;
|
||||
|
||||
if (!minder.supportClipboardEvent || window.kity.Browser.gecko) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kmencode = this.MimeType.getMimeTypeProtocol('application/km');
|
||||
const { decode } = Data.getRegisterProtocol('json');
|
||||
let _selectedNodes: Array<INode> = [];
|
||||
|
||||
function encode(nodes: Array<INode>): string {
|
||||
const _nodes = [];
|
||||
for (let i = 0, l = nodes.length; i < l; i++) {
|
||||
_nodes.push(minder.exportNode(nodes[i]));
|
||||
}
|
||||
return kmencode(Data.getRegisterProtocol('json').encode(_nodes));
|
||||
}
|
||||
|
||||
const beforeCopy = (e: ICliboardEvent) => {
|
||||
if (document.activeElement === receiver.element) {
|
||||
const clipBoardEvent = e;
|
||||
const state = this.fsm.state();
|
||||
|
||||
switch (state) {
|
||||
case 'input': {
|
||||
break;
|
||||
}
|
||||
case 'normal': {
|
||||
const nodes = [...minder.getSelectedNodes()];
|
||||
if (nodes.length) {
|
||||
if (nodes.length > 1) {
|
||||
let targetLevel;
|
||||
nodes.sort((a: any, b: any) => {
|
||||
return a.getLevel() - b.getLevel();
|
||||
});
|
||||
// eslint-disable-next-line prefer-const
|
||||
targetLevel = nodes[0].getLevel();
|
||||
if (targetLevel !== nodes[nodes.length - 1].getLevel()) {
|
||||
let pnode;
|
||||
let idx = 0;
|
||||
const l = nodes.length;
|
||||
let pidx = l - 1;
|
||||
|
||||
pnode = nodes[pidx];
|
||||
|
||||
while (pnode.getLevel() !== targetLevel) {
|
||||
idx = 0;
|
||||
while (idx < l && nodes[idx].getLevel() === targetLevel) {
|
||||
if (nodes[idx].isAncestorOf(pnode)) {
|
||||
nodes.splice(pidx, 1);
|
||||
break;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
pidx--;
|
||||
pnode = nodes[pidx];
|
||||
}
|
||||
}
|
||||
}
|
||||
const str = encode(nodes);
|
||||
clipBoardEvent.clipboardData.setData('text/plain', str);
|
||||
}
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const beforeCut = (e: ClipboardEvent) => {
|
||||
const { activeElement } = document;
|
||||
if (activeElement === receiver.element) {
|
||||
if (minder.getStatus() !== 'normal') {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const clipBoardEvent = e;
|
||||
const state = this.fsm.state();
|
||||
|
||||
switch (state) {
|
||||
case 'input': {
|
||||
break;
|
||||
}
|
||||
case 'normal': {
|
||||
markDeleteNode(minder);
|
||||
const nodes = minder.getSelectedNodes();
|
||||
if (nodes.length) {
|
||||
clipBoardEvent.clipboardData?.setData('text/plain', encode(nodes));
|
||||
minder.execCommand('removenode');
|
||||
}
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const beforePaste = (e: ClipboardEvent) => {
|
||||
if (document.activeElement === receiver.element) {
|
||||
if (minder.getStatus() !== 'normal') {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const clipBoardEvent = e;
|
||||
const state = this.fsm.state();
|
||||
const textData = clipBoardEvent.clipboardData?.getData('text/plain');
|
||||
|
||||
switch (state) {
|
||||
case 'input': {
|
||||
// input状态下如果格式为application/km则不进行paste操作
|
||||
if (!this.MimeType.isPureText(textData)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'normal': {
|
||||
/*
|
||||
* 针对normal状态下通过对选中节点粘贴导入子节点文本进行单独处理
|
||||
*/
|
||||
const sNodes = minder.getSelectedNodes();
|
||||
|
||||
if (this.MimeType.whichMimeType(textData) === 'application/km') {
|
||||
const nodes = decode(this.MimeType.getPureText(textData));
|
||||
resetNodes(nodes);
|
||||
let _node;
|
||||
sNodes.forEach((node: INode) => {
|
||||
// 由于粘贴逻辑中为了排除子节点重新排序导致逆序,因此复制的时候倒过来
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
_node = minder.createNode(null, node);
|
||||
minder.importNode(_node, nodes[i]);
|
||||
_selectedNodes.push(_node);
|
||||
node.appendChild(_node);
|
||||
}
|
||||
});
|
||||
minder.select(_selectedNodes, true);
|
||||
_selectedNodes = [];
|
||||
|
||||
minder.refresh();
|
||||
} else if (clipBoardEvent.clipboardData && clipBoardEvent.clipboardData.items[0].type.indexOf('image') > -1) {
|
||||
const imageFile = clipBoardEvent.clipboardData.items[0].getAsFile();
|
||||
const serverService = window.angular.element(document.body).injector().get('server');
|
||||
|
||||
return serverService.uploadImage(imageFile).then((json: Record<string, any>) => {
|
||||
const resp = json.data;
|
||||
if (resp.errno === 0) {
|
||||
minder.execCommand('image', resp.data.url);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
sNodes.forEach((node: INode) => {
|
||||
minder.Text2Children(node, textData);
|
||||
});
|
||||
}
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
// 触发命令监听
|
||||
minder.execCommand('paste');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 由editor的receiver统一处理全部事件,包括clipboard事件
|
||||
* @Editor: Naixor
|
||||
* @Date: 2015.9.24
|
||||
*/
|
||||
document.addEventListener('copy', () => beforeCopy);
|
||||
document.addEventListener('cut', () => beforeCut);
|
||||
document.addEventListener('paste', () => beforePaste);
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
function ContainerRuntime(this: { selector: string; container?: HTMLElement }) {
|
||||
let container: HTMLElement | null;
|
||||
|
||||
if (typeof this.selector === 'string') {
|
||||
container = document.querySelector(this.selector);
|
||||
} else {
|
||||
container = this.selector;
|
||||
}
|
||||
|
||||
if (!container) throw new Error(`Invalid selector: ${this.selector}`);
|
||||
|
||||
// 这个类名用于给编辑器添加样式
|
||||
container.classList.add('km-editor');
|
||||
|
||||
// 暴露容器给其他运行时使用
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
export default ContainerRuntime;
|
|
@ -0,0 +1,180 @@
|
|||
/* eslint-disable no-underscore-dangle */
|
||||
/**
|
||||
* @fileOverview
|
||||
*
|
||||
* 用于拖拽节点时屏蔽键盘事件
|
||||
*
|
||||
* @author: techird
|
||||
* @copyright: Baidu FEX, 2014
|
||||
*/
|
||||
interface DragRuntimeOptions {
|
||||
fsm: any;
|
||||
minder: any;
|
||||
hotbox: any;
|
||||
receiver: any;
|
||||
}
|
||||
|
||||
function createDragRuntime(this: DragRuntimeOptions) {
|
||||
const { fsm, minder, hotbox } = this;
|
||||
|
||||
// listen the fsm changes, make action.
|
||||
function setupFsm() {
|
||||
// when jumped to drag mode, enter
|
||||
fsm.when('* -> drag', () => {
|
||||
// now is drag mode
|
||||
});
|
||||
|
||||
fsm.when('drag -> *', (exit: any, enter: any, reason: string) => {
|
||||
if (reason === 'drag-finish') {
|
||||
// now exit drag mode
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// setup everything to go
|
||||
setupFsm();
|
||||
|
||||
let downX: number;
|
||||
let downY: number;
|
||||
const MOUSE_HAS_DOWN = 0;
|
||||
const MOUSE_HAS_UP = 1;
|
||||
const BOUND_CHECK = 20;
|
||||
let flag = MOUSE_HAS_UP;
|
||||
let maxX: number;
|
||||
let maxY: number;
|
||||
let containerY: number;
|
||||
let freeHorizen = false;
|
||||
let freeVirtical = false;
|
||||
let frame: number | null = null;
|
||||
|
||||
function move(direction: 'left' | 'top' | 'right' | 'bottom' | false, speed?: number) {
|
||||
if (!direction) {
|
||||
freeHorizen = false;
|
||||
freeVirtical = false;
|
||||
if (frame) {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
frame = null;
|
||||
return;
|
||||
}
|
||||
if (!frame) {
|
||||
frame = requestAnimationFrame(
|
||||
((directionF: 'left' | 'top' | 'right' | 'bottom', minderF: any, speedF = 0): any => {
|
||||
return () => {
|
||||
switch (directionF) {
|
||||
case 'left':
|
||||
minderF._viewDragger.move(
|
||||
{
|
||||
x: -speedF,
|
||||
y: 0,
|
||||
},
|
||||
0
|
||||
);
|
||||
break;
|
||||
case 'top':
|
||||
minderF._viewDragger.move(
|
||||
{
|
||||
x: 0,
|
||||
y: -speedF,
|
||||
},
|
||||
0
|
||||
);
|
||||
break;
|
||||
case 'right':
|
||||
minderF._viewDragger.move(
|
||||
{
|
||||
x: speedF,
|
||||
y: 0,
|
||||
},
|
||||
0
|
||||
);
|
||||
break;
|
||||
case 'bottom':
|
||||
minderF._viewDragger.move(
|
||||
{
|
||||
x: 0,
|
||||
y: speedF,
|
||||
},
|
||||
0
|
||||
);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
if (frame) {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
};
|
||||
})(direction, minder, speed)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
minder.on('mousedown', (e: any) => {
|
||||
flag = MOUSE_HAS_DOWN;
|
||||
const rect = minder.getPaper().container.getBoundingClientRect();
|
||||
downX = e.originEvent.clientX;
|
||||
downY = e.originEvent.clientY;
|
||||
containerY = rect.top;
|
||||
maxX = rect.width;
|
||||
maxY = rect.height;
|
||||
});
|
||||
|
||||
minder.on('mousemove', (e: any) => {
|
||||
if (
|
||||
fsm.state() === 'drag' &&
|
||||
flag === MOUSE_HAS_DOWN &&
|
||||
minder.getSelectedNode() &&
|
||||
(Math.abs(downX - e.originEvent.clientX) > BOUND_CHECK || Math.abs(downY - e.originEvent.clientY) > BOUND_CHECK)
|
||||
) {
|
||||
const osx = e.originEvent.clientX;
|
||||
const osy = e.originEvent.clientY - containerY;
|
||||
|
||||
if (osx < BOUND_CHECK) {
|
||||
move('right', BOUND_CHECK - osx);
|
||||
} else if (osx > maxX - BOUND_CHECK) {
|
||||
move('left', BOUND_CHECK + osx - maxX);
|
||||
} else {
|
||||
freeHorizen = true;
|
||||
}
|
||||
|
||||
if (osy < BOUND_CHECK) {
|
||||
move('bottom', osy);
|
||||
} else if (osy > maxY - BOUND_CHECK) {
|
||||
move('top', BOUND_CHECK + osy - maxY);
|
||||
} else {
|
||||
freeVirtical = true;
|
||||
}
|
||||
|
||||
if (freeHorizen && freeVirtical) {
|
||||
move(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
fsm.state() !== 'drag' &&
|
||||
flag === MOUSE_HAS_DOWN &&
|
||||
minder.getSelectedNode() &&
|
||||
(Math.abs(downX - e.originEvent.clientX) > BOUND_CHECK || Math.abs(downY - e.originEvent.clientY) > BOUND_CHECK)
|
||||
) {
|
||||
if (fsm.state() === 'hotbox') {
|
||||
hotbox.active(window.HotBox.STATE_IDLE);
|
||||
}
|
||||
|
||||
fsm.jump('drag', 'user-drag');
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener(
|
||||
'mouseup',
|
||||
() => {
|
||||
flag = MOUSE_HAS_UP;
|
||||
if (fsm.state() === 'drag') {
|
||||
move(false);
|
||||
fsm.jump('normal', 'drag-finish');
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
}
|
||||
export default createDragRuntime;
|
|
@ -0,0 +1,85 @@
|
|||
import png from '../protocol/png';
|
||||
import svg from '../protocol/svg';
|
||||
import json from '../protocol/json';
|
||||
import plain from '../protocol/plain';
|
||||
import md from '../protocol/markdown';
|
||||
import mm from '../protocol/freemind';
|
||||
import useLocaleNotVue from '../tool/useLocaleNotVue';
|
||||
|
||||
const tran = useLocaleNotVue;
|
||||
|
||||
export default function ExportRuntime(this: any) {
|
||||
const { minder, hotbox } = this;
|
||||
|
||||
function canExp() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function exportJson() {
|
||||
json.exportJson(minder);
|
||||
}
|
||||
|
||||
function exportImage() {
|
||||
png.exportPNGImage(minder);
|
||||
}
|
||||
|
||||
function exportSVG() {
|
||||
svg.exportSVG(minder);
|
||||
}
|
||||
|
||||
function exportTextTree() {
|
||||
plain.exportTextTree(minder);
|
||||
}
|
||||
|
||||
function exportMarkdown() {
|
||||
md.exportMarkdown(minder);
|
||||
}
|
||||
|
||||
function exportFreeMind() {
|
||||
mm.exportFreeMind(minder);
|
||||
}
|
||||
|
||||
const exps = [
|
||||
{ label: '.json', key: 'j', cmd: exportJson },
|
||||
{ label: '.png', key: 'p', cmd: exportImage },
|
||||
{ label: '.svg', key: 's', cmd: exportSVG },
|
||||
{ label: '.txt', key: 't', cmd: exportTextTree },
|
||||
{ label: '.md', key: 'm', cmd: exportMarkdown },
|
||||
{ label: '.mm', key: 'f', cmd: exportFreeMind },
|
||||
];
|
||||
|
||||
const main = hotbox.state('main');
|
||||
main.button({
|
||||
position: 'top',
|
||||
label: tran('minder.commons.export'),
|
||||
key: 'E',
|
||||
enable: canExp,
|
||||
beforeShow() {
|
||||
this.$button.children[0].innerHTML = tran('minder.commons.export');
|
||||
},
|
||||
next: 'exp',
|
||||
});
|
||||
|
||||
const exp = hotbox.state('exp');
|
||||
exps.forEach((item) => {
|
||||
exp.button({
|
||||
position: 'ring',
|
||||
label: item.label,
|
||||
key: null,
|
||||
action: item.cmd,
|
||||
beforeShow() {
|
||||
this.$button.children[0].innerHTML = tran(item.label);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
exp.button({
|
||||
position: 'center',
|
||||
label: tran('minder.commons.cancel'),
|
||||
key: 'esc',
|
||||
beforeShow() {
|
||||
this.$button.children[0].innerHTML = tran('minder.commons.cancel');
|
||||
},
|
||||
next: 'back',
|
||||
});
|
||||
}
|