feat(all): 前端初始化

This commit is contained in:
RubyLiu 2023-05-24 11:08:08 +08:00 committed by 刘瑞斌
parent e324a79827
commit 6865624fed
240 changed files with 24989 additions and 0 deletions

View File

@ -0,0 +1 @@
VITE_API_BASE_URL= 'http://localhost:8080'

0
frontend/.env.production Normal file
View File

5
frontend/.eslintignore Normal file
View File

@ -0,0 +1,5 @@
/*.json
/src/**/*.json
dist
postcss.config.js
*.md

87
frontend/.eslintrc.js Normal file
View File

@ -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会报错提示重复声明暂未找到问题原因先关闭
// 可以在这里添加更多的规则禁用
},
},
],
};

6
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
dist-ssr
*.local
.history
coverage

4
frontend/.husky/commit-msg Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
pnpm commitlint --edit $1

4
frontend/.husky/pre-commit Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint-staged

7
frontend/.prettierignore Normal file
View File

@ -0,0 +1,7 @@
/dist/*
.local
.output.js
/node_modules/**
**/*.svg
**/*.sh

14
frontend/.prettierrc.js Normal file
View File

@ -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',
};

123
frontend/.stylelintrc.js Normal file
View File

@ -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',
],
},
};

32
frontend/.vscode/settings.json vendored Normal file
View File

@ -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,
}

805
frontend/README.md Normal file
View File

@ -0,0 +1,805 @@
## 提交规范
### 1. 提交消息格式
```bin
<Type>[optional Scope]: <Description>
[optional body]
[optional footer]
```
#### Type 提交的类型
> 表示提交的目的或影响的范围
- feat: 新功能A new feature
- fix: 修复 bugA 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, // 允许编译器编译JSJSX文件
"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',
},
})
);
```

3
frontend/babel.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
plugins: ['@vue/babel-plugin-jsx'],
};

View File

@ -0,0 +1,3 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
};

15
frontend/components.d.ts vendored Normal file
View File

@ -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']
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 [];
}

View File

@ -0,0 +1,9 @@
/**
* Whether to generate package preview
*
*/
export default {};
export function isReportMode(): boolean {
return process.env.REPORT === 'true';
}

View File

@ -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,
},
},
},
});

View File

@ -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
);

View File

@ -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
);

17
frontend/index.html Normal file
View File

@ -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>

136
frontend/package.json Normal file
View File

@ -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"
}
}

10928
frontend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
module.exports = {
plugins: [require('tailwindcss'), require('autoprefixer')],
};

28
frontend/src/App.vue Normal file
View File

@ -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>

View File

@ -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);
});
});
}
}

View File

@ -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>();
}
}

View File

@ -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;
}

View File

@ -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);
}
}
}

View File

@ -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 };
}

View File

@ -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;
}
// 不进行任何处理,直接返回
// 用于页面代码可能需要直接获取codedatamessage这些信息时开启
if (!isTransformResponse) {
return res.data;
}
// 错误的时候返回
const { data } = res;
if (!data) {
throw new Error(t('api.apiRequestFailed'));
}
// 这里 coderesultmessage为 后台统一的字段
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 schemese.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;

View File

@ -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 });
}

View File

@ -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' });
}

View File

@ -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 });
}

View File

@ -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';

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@ -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;

View File

@ -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));
}
}
}

View File

@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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('');
});
});

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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);
},
};

View File

@ -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>

View File

@ -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>

View File

@ -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,
};
}

View File

@ -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>

View File

@ -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>

View File

@ -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',
};

View File

@ -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': '登出登录',
};

View File

@ -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',
},
},
};

View File

@ -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: '标签',
},
},
};

View File

@ -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>

View File

@ -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>

View File

@ -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) {
// windowminderundefined
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>

View File

@ -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) {
// windowminderundefined
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>

View File

@ -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>

View File

@ -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>

View File

@ -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) {
// windowminderundefined
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>

View File

@ -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) {
// windowminderundefined
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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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) {
// windowminderundefined
return false;
}
return window.minder.queryCommandState && window.minder.queryCommandState('resetlayout') === -1;
});
function resetlayout() {
if (window.minder.queryCommandState('resetlayout') !== -1) {
window.minder.execCommand('resetlayout');
}
}
</script>

View File

@ -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) {
// windowminderundefined
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) {
// windowminderundefined
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) {
// windowminderundefined
return false;
}
return window.minder.queryCommandState('bold') === -1;
});
const disabledItalic = computed(() => {
try {
if (!window.minder) return false;
} catch (e) {
// windowminderundefined
return false;
}
return window.minder.queryCommandState('italic') === -1;
});
const boldSelected = computed(() => {
try {
if (!window.minder) return false;
} catch (e) {
// windowminderundefined
return false;
}
return window.minder.queryCommandState('bold') === -1;
});
const italicSelected = computed(() => {
try {
if (!window.minder) return false;
} catch (e) {
// windowminderundefined
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>

View File

@ -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) {
// windowminderundefined
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>

View File

@ -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) {
// windowminderundefined
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>

View File

@ -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>

View File

@ -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>

View File

@ -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,
},
};

View File

@ -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;

View File

@ -0,0 +1,3 @@
import Editor from './editor';
export default window.kityminder.Editor = Editor;

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 含有 &nbsp; 符号导出报错 Entity 'nbsp' not defined
svgXml = svgXml.replace(/&nbsp;/g, '&#xa0;');
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 };

View File

@ -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(/&nbsp;/g, '&#xa0;');
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 };

View File

@ -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;

View File

@ -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);
}

View File

@ -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;

View File

@ -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;

View File

@ -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',
});
}

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