Commit 0280e06e by liyijie

init project

parents
VUE_APP_API_DOMAIN=http://api.domain.com
VUE_APP_PUBLIC_PATH=/beta/
VUE_APP_API_ROOT_PATH=/
VUE_APP_VUEX_STORAGE_KEY=SCAFFOLD_PROJECT_PRODUCTION
VUE_APP_PUBLIC_PATH=/
VUE_APP_API_ROOT_PATH=/v1
VUE_APP_VUEX_STORAGE_KEY=PROJECT_STORAGE_TEST
VUE_APP_PUBLIC_PATH=/
VUE_APP_API_ROOT_PATH=/v2
VUE_APP_VUEX_STORAGE_KEY=PROJECT_STORAGE_PRODUCTION
public/*
dist/*
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
image: chenkang0503/front-end-env:latest
stages:
- test
- deploy
install:
stage: test
tags:
- web
cache:
key: ${CI_PROJECT_ID}
paths:
- node_modules
script:
- yarn config set registry https://registry.npm.taobao.org
- yarn install
- yarn run lint
.deploy_config: &deploy_config
stage: deploy
tags:
- web
cache:
key: ${CI_PROJECT_ID}
policy: pull
paths:
- node_modules
before_script:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
- chmod -R 600 ~/.ssh
- echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
- if [[ $PROJECT_PATH != *$CI_PROJECT_NAME* ]]; then false; fi;
- ssh "$WEB_HOST" "cd $PROJECT_PATH || exit"
deploy_beta:
<<: *deploy_config
when: manual
environment:
name: Beta
url: http://www.domain.cn/beta
only:
- develop
script:
- npm run build:beta
- rsync -avI --rsh=ssh dist/ "$WEB_HOST:$PROJECT_PATH/$BETA_FOLDER"
deploy_production:
<<: *deploy_config
when: manual
environment:
name: Production
url: http://www.domain.cn
only:
- master
script:
- npm run build:production
- rsync -avI --rsh=ssh dist/ "$WEB_HOST:$PROJECT_PATH/$PRODUCTION_FOLDER"
{
"recommendations": [
"octref.vetur",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"thisismanta.stylus-supremacy",
"formulahendry.auto-close-tag",
"christian-kohler.path-intellisense",
"sysoev.language-stylus",
"thisismanta.stylus-supremacy",
"streetsidesoftware.code-spell-checker",
"eamodio.gitlens"
]
}
{
"emmet.syntaxProfiles": {
"vue-html": "html",
"vue": "html"
},
// lint
"eslint.enable": true,
"eslint.packageManager": "yarn",
"eslint.validate": ["javascript", "javascriptreact", "vue", "typescript", "typescriptreact"],
"eslint.options": {
"extensions": [".js", ".vue", ".ts", ".tsx", ".jsx"]
},
// format
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"[typescript]": {
"editor.formatOnSave": false
},
"[javascript]": {
"editor.formatOnSave": false
},
"javascript.format.enable": false,
"typescript.format.enable": false,
"vetur.format.defaultFormatter.js": "none",
"vetur.format.defaultFormatter.ts": "none",
// editor
"editor.tabSize": 2,
"editor.rulers": [120],
// files
"files.eol": "\n",
"files.insertFinalNewline": true,
// css
"stylusSupremacy.insertColons": false,
"stylusSupremacy.insertBraces": false,
"stylusSupremacy.insertSemicolons": false,
"stylusSupremacy.insertNewLineAroundBlocks": "root",
"stylusSupremacy.insertNewLineAroundOthers": "root",
"stylusSupremacy.insertNewLineAroundImports": "nested",
"stylusSupremacy.sortProperties": "grouped"
}
{
"Print to console": {
"prefix": "tsvue",
"description": "Basic vue typescript template",
"body": [
"<script lang=\"ts\">",
"import { Component, Vue, Prop } from 'vue-property-decorator';",
"",
"@Component({",
" components: {},",
"})",
"export default class componentName extends Vue {",
" @Prop({ type: String, default: '' }) prop!: string;",
"",
" state: string = '';",
"",
" mounted() {",
" this.fetchData();",
" }",
"",
" fetchData() {}",
"}",
"</script>",
"",
"<template lang=\"pug\">",
".container",
" | component",
"</template>",
"",
"<style lang=\"stylus\" scoped></style>",
""
]
}
}
# 项目使用说明
## 基础命令
### 1. 安装依赖
```
yarn
```
### 2. 编译并启动开发服务
```
npm run dev
```
### 3. 构建是生产环境应用
```
npm run build
```
### 4. 运行测试
```
npm run test
```
### 5. 运行测试,并统计测试覆盖率
```
npm run test:coverage
```
### 6. 代码检查,错误不会自动修复,请手动修复
```
npm run lint
```
### 7. 格式化项目代码
```
npm run format
```
## 四、资源管理
- `assets/images` 存在项目图片
- `assets/styles` 全局和复用样式
根据开发需要,可配置 `assets/fonts`, `assets/icons` 等;
## 五、路由配置
- 项目页面按照 Restful resources 风格进行管理;
- 每个资源设置自身的路由:`router/session.route.ts`
- router/index.ts 会自动导入所有路由,无需手动 import
## 六、Model 使用
### 1. Model 定义
```javascript
import ActiveModel, { IModelConfig } from '@/lib/ActiveModel';
export interface IExample {
id: number;
name: string;
}
interface IResponse {
examples: IExample[];
}
export class Example extends ActiveModel<IExample, IResponse> {
constructor(config?: IModelConfig) {
super({
namespace: '/namespace/role',
actions: [{ name: 'action', method: 'post', on: 'collection' }],
...config,
});
}
}
```
### 2. Model 的使用
模型一般搭配 store 一起使用,由 store 进行初始化,并发起接口调用,直接使用 model 调用接口方法如下:
```javascript
const instance = new Example({
parents: [{ type: 'projects', id: 1 }],
});
...
async fetchExamples() {
const { data } = await instance.index({ page: 1, per_page: 15 });
// 相当于发起了接口:get /namespace/role/projects/1/examples?page=1&per_page=15
}
```
### 3. Model 配置参数
```typescript
interface IModelConfig {
baseUrl?: string; // 接口的 baseUrl, 一般由项目统一设置,模型可以通过此属性进行覆盖
rootPath?: string; // 路由的命名空间
name?: string; // 模型名称
namespace?: string; // 路由的命名空间
dataIndexKey?: string; // 资源名称,一般是模型名的复数形式
pathIndexKey?: string; //路由上的模型名复数形式
parents?: IParent[]; // 关联父资源
actions?: IAction[]; // 自定义接口方法
mode?: ModeType; // default: Restful 默认模式, shallow: 对于后台 shallow: true, single: 单例模式
params?: IObject; // 默认的请求参数
}
```
## 七、Store 的使用
### 1. Store 的定义
`src/store/modules` 下定义 Store module
```javascript
import { ActiveModule, ActiveStore, getModule } from '@/lib/ActiveStore';
import { Example, IExample } from '@/models/example';
@ActiveModule(Example, { name: 'ExampleStore' })
export class ExampleStore extends ActiveStore<IExample> {}
export const exampleStore = getModule(ExampleStore);
```
### 2. Store 的使用
store 实例在调用内部的请求方法之前,必须先初始化,store.init(),init 方法参数为:空、模型配置参数、模型实例;
```javascript
created() {
exampleStore.init();
// or
exampleStore.init(new Example());
// or
exampleStore.init({ parents: [{ type: 'projects', id: 1 }] });
}
```
store 内置了如下常用状态:
```
loading: 接口加载状态
records: 资源列表
record: 资源实例, find 之后会设置
currentPage: 当前页
totalPages: 总页数
totalCount: 总数量
```
```javascript
import { exampleStore } from '@/store/modules/example.store';
created() {
exampleStore.init({
parents: [{ type: 'projects', id: 1 }],
});
}
...
async fetchData() {
await exampleStore.index({ page: 1, per_page: 10 });
// 相当于发起了接口:get /namespace/role/projects/1/examples?page=1&per_page=10
this.examples = exampleStore.records;
}
```
### 3. Store 内置方法
| 方法 | 参数 | 说明 | http 请求 |
| ------ | -------------------- | ------------ | -------------------- |
| index | params(query 参数) | 获取资源列表 | get /examples |
| find | id (资源 id) | 获取资源实例 | get /examples/:id |
| create | formData (表单数据) | 创建资源 | post /examples |
| update | instance (资源实例) | 更新资源 | patch /examples/:id |
| delete | id (资源 id) | 删除资源 | delete /examples/:id |
### 4. Store module 之间的数据同步功能
> 此功能容易引起数据混乱,使用时,请确定你已经明白要实现的逻辑
1. 通过 `syncModuleNames` 配置需要同步数据的其他 module
```javascript
@ActiveModule(Example, { name: 'ExampleStore', syncModuleNames: ['OtherModuleName'] })
export class ExampleStore extends ActiveStore<IExample> {}
```
2. 业务中,通过 `store.sync` 方法进行手动同步
```javascript
exampleStore.sync(); // 同步默认配置的 modules
exampleStore.sync('OtherModuleB'); // 自定义同步的 module, syncModuleNames 将会被忽略
exampleStore.sync(['OtherModuleA', 'OtherModuleB']); // 自定义同步多个 module, syncModuleNames 将会被忽略
```
3. 开启自动同步
使用 `autoSync` 开启,开启后,`create``update``delete` 方法会触发自动同步
```javascript
@ActiveModule(Example, { name: 'ExampleStore', syncModuleNames: ['OtherModuleName'], autoSync: true })
export class ExampleStore extends ActiveStore<IExample> {}
```
4. 自动同步的数据
- currentPage
- perPage
- totalPages
- totalCount
- record
- records
## 九、代码规范
### 1. 目录接口
| 目录 | 路径 | 示例 |
| --------- | ----------------- | ----------------------------------------------- |
| Model | src/models | src/models/example.ts |
| Store | src/store/modules | src/store/modules/example.store.ts |
| route | src/route | src/router/example.route.ts |
| View | src/views | src/views/examples/Index.vue |
| Component | src/components | src/components/examples/ComExampleInstances.vue |
### 2. 文件、文件夹命名规范:
| 文件/文件夹 | 格式 | 示例 |
| ------------ | -------------------- | --------------- |
| 页面文件名 | 首字母大写 | Index.vue |
| 组件文件名 | 首字母大写,Com 开头 | ComTable.vue |
| 模型文件名 | 驼峰命名 | fooBar.ts |
| store 文件名 | 驼峰命名, store 后缀 | fooBar.store.ts |
| route 文件名 | 驼峰命名, route 后缀 | fooBar.route.ts |
| 文件夹命名 | 下划线链接 | node_modules |
### 2. 编码命名规范:
| 目标 | 格式 | 示例 |
| ------------ | ---------------------------------------------------------- | --------------------------------- |
| css | 短横线链接 | .module-container |
| Model 类名 | 首字母大写, 需要匹配 namespace | SvrAdminMember |
| Store 类名 | 首字母大写, 需要匹配 namespace | SvrAdminMemberStore |
| 页面组件类名 | 首字母大写, 需要匹配路径 | SvrAdminCardMembersIndex |
| 组件类名 | 首字母大写, 以 Com 开头 | ComAdminTable、ComMeetingActivity |
| 方法名称 | 驼峰命名, 多利用 get、set、check、fetch 等动词,命名有含义 | getUserInfo、updateUser |
## 九、代码提交格式
格式:`type(scope?): message`
- type 提交类型
- scope 提交代码的作用范围(可选)
- message 提交说明
```shell
git commit -m "feat(apis): add new api config."
```
支持的类型:[ 'build', 'ci', 'chore', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test' ]
### 更多 vue-cli 配置
See [Configuration Reference](https://cli.vuejs.org/config/).
module.exports = {
presets: ['@vue/cli-plugin-babel/preset'],
};
module.exports = {
extends: ['@commitlint/config-conventional'],
};
{
"name": "vue3-ts-scaffold",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vue-cli-service serve",
"format": "pretty-quick",
"test:unit": "vue-cli-service test:unit",
"lint": "vue-cli-service lint --fix",
"build:beta": "vue-cli-service build --mode beta",
"build:production": "vue-cli-service build",
"deploy:beta": "npm run lint && npm run build --mode development && node scripts/deploy.js",
"deploy:production": "npm run lint && npm run build && node scripts/deploy.js"
},
"dependencies": {
"axios": "^0.19.2",
"change-case": "^4.1.1",
"core-js": "^3.6.5",
"lodash-es": "^4.17.15",
"moment": "^2.27.0",
"normalize.css": "^8.0.1",
"pluralize": "^8.0.0",
"qs": "^6.9.4",
"vue": "^2.6.11",
"vue-class-component": "^7.2.3",
"vue-property-decorator": "^8.4.2",
"vue-router": "^3.2.0",
"vuex": "^3.4.0",
"vuex-module-decorators": "^0.17.0",
"vuex-persistedstate": "^3.0.1"
},
"devDependencies": {
"@commitlint/cli": "^9.1.1",
"@commitlint/config-conventional": "^9.1.1",
"@iboying/easy-deploy": "^0.3.1",
"@types/jest": "^24.0.19",
"@types/lodash-es": "^4.17.3",
"@types/pluralize": "^0.0.29",
"@types/qs": "^6.9.3",
"@typescript-eslint/eslint-plugin": "^2.33.0",
"@typescript-eslint/parser": "^2.33.0",
"@vue/cli-plugin-babel": "~4.4.0",
"@vue/cli-plugin-eslint": "~4.4.0",
"@vue/cli-plugin-router": "~4.4.0",
"@vue/cli-plugin-typescript": "~4.4.0",
"@vue/cli-plugin-unit-jest": "~4.4.0",
"@vue/cli-plugin-vuex": "~4.4.0",
"@vue/cli-service": "~4.4.0",
"@vue/eslint-config-prettier": "^6.0.0",
"@vue/eslint-config-typescript": "^5.0.2",
"@vue/test-utils": "^1.0.3",
"chalk": "^4.1.0",
"eslint": "^6.7.2",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-vue": "^6.2.2",
"husky": "^4.2.5",
"prettier": "^1.19.1",
"pretty-quick": "^2.0.1",
"stylus": "^0.54.7",
"stylus-loader": "^3.0.2",
"typescript": "~3.9.3",
"vue-cli-plugin-pug": "^1.0.7",
"vue-template-compiler": "^2.6.11"
},
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
"pre-commit": "npm run lint"
}
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended",
"@vue/typescript/recommended",
"@vue/prettier",
"@vue/prettier/@typescript-eslint"
],
"parserOptions": {
"ecmaVersion": 2020
},
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/interface-name-prefix": 0,
"@typescript-eslint/camelcase": 0
},
"overrides": [
{
"files": [
"**/__tests__/*.{j,t}s?(x)",
"**/tests/unit/**/*.spec.{j,t}s?(x)"
],
"env": {
"jest": true
}
}
]
},
"prettier": {
"printWidth": 120,
"singleQuote": true,
"proseWrap": "always",
"trailingComma": "all"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
],
"jest": {
"preset": "@vue/cli-plugin-unit-jest/presets/typescript-and-babel"
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
/* eslint-disable */
const fs = require('fs');
const EasyDeploy = require('@iboying/easy-deploy');
const chalk = require('chalk');
const localPath = 'dist/';
const TARGET = process.env.npm_lifecycle_event;
const targetOptionsMap = {
'deploy:production': [
{
tag: '正式版',
username: 'web',
host: '127.0.0.1',
port: 80,
localPath,
remotePath: '/project/path',
},
],
'deploy:beta': [
{
tag: '测试版',
username: 'web',
host: '127.0.0.1',
port: 80,
localPath,
remotePath: '/project/path',
},
],
};
const targets = targetOptionsMap[TARGET] || [];
async function saveLatestCommitId() {
const commitId = await EasyDeploy.shell('git rev-parse head');
fs.writeFileSync(`${localPath}version.json`, commitId, {
encoding: 'utf-8',
});
}
async function deployToTargets(option) {
try {
const instance = new EasyDeploy(option);
console.log(chalk.cyan(`开始部署 ${option.tag}`));
await instance.sync('-avI');
console.log(chalk.green(`部署 ${option.tag} 成功`));
} catch (err) {
console.log(chalk.red(`部署 ${option.tag} 失败`));
throw err;
}
}
(async () => {
await saveLatestCommitId();
for await (const option of targets) {
deployToTargets(option);
}
})();
<script lang="ts">
import { Component, Vue, Watch } from 'vue-property-decorator';
import MenuLayout from '@/layouts/MenuLayout.vue';
import DefaultLayout from '@/layouts/DefaultLayout.vue';
import HeaderLayout from '@/layouts/HeaderLayout.vue';
@Component({
components: {
MenuLayout,
DefaultLayout,
HeaderLayout,
},
})
export default class App extends Vue {
loading = true;
@Watch('$route')
onRouteChanged() {
document.title = this.$route.meta.title;
}
get layout() {
return `${this.$route.meta.layout || 'default'}-layout`;
}
get isKeepAlive() {
return this.$route.meta.keepAlive;
}
created() {
this.checkAuth();
}
async checkAuth() {
const publicPath: string = process.env.VUE_APP_PUBLIC_PATH as string;
const realPath = window.location.pathname.substring(
window.location.pathname.indexOf(publicPath) + publicPath.length,
);
const route: IObject = (this.$router as any).match(realPath);
if (route.path.includes('/login')) {
this.loading = false;
return;
}
if (route.meta && route.meta.requireAuth === false) {
this.loading = false;
return;
}
try {
this.loading = true;
setTimeout(() => {
// TODO: check auth token
this.loading = false;
}, 1000);
} catch (error) {
setTimeout(() => {
this.loading = false;
}, 100);
}
}
}
</script>
<template lang="pug">
#app
.loading-box(v-if="loading")
.spin 加载中...
component(:is="layout" v-else)
keep-alive(:max="10")
router-view(v-if="isKeepAlive")
router-view(v-if="!isKeepAlive")
</template>
<style lang="stylus">
#app
min-width 1080px
width 100%
height 100%
color #383838
font-weight 400
font-family -apple-system, BlinkMacSystemFont, Helvetica Neue, PingFang SC, Microsoft YaHei, sans-serif
-webkit-font-smoothing antialiased
-moz-osx-font-smoothing grayscale
.loading-box
position relative
width 100%
height 100%
.spin
position absolute
top 50%
left 50%
margin-top -15px
margin-left -27px
</style>
*, *:after, *:before
box-sizing border-box
html, body
margin 0
padding 0
height 100%
background #fff
font-family -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'
h1, h2, h3, h4, h5, h6, p
margin 0px
padding 0px
// layout
.block
display block
width 100%
.flex
display flex
align-items center
width 100%
.flex-center
display flex
justify-content center
align-items center
width 100%
.flex-around
display flex
justify-content space-around
align-items center
width 100%
.flex-between
display flex
justify-content space-between
align-items center
width 100%
.flex-end
display flex
justify-content flex-end
align-items center
width 100%
// text colors
.white
color white !important
.gray
color #808080 !important
.danger
color #e50114 !important
.warning
color #eb9e05 !important
.primary
color #3DA8F5 !important
.success
color #75C940 !important
.black
color #212121 !important
.link
color #3DA8F5
text-decoration underline
cursor pointer
.text-center
text-align center
.text-right
text-align right
.text-pre
width 100%
white-space pre-wrap
word-break break-all
.text-ellipsis
overflow hidden
width 100%
text-overflow ellipsis
white-space nowrap
.two-line-text
display -webkit-box
overflow hidden
-webkit-box-orient vertical
-webkit-line-clamp 2
import Vue from 'vue';
const capitalizeFirstLetter = (name: string) => name.charAt(0).toUpperCase() + name.slice(1);
const requireComponent = require.context('.', false, /\.vue$/);
requireComponent.keys().forEach(fileName => {
const componentConfig = requireComponent(fileName);
const componentName = capitalizeFirstLetter(fileName.replace(/^\.\//, '').replace(/\.\w+$/, ''));
Vue.component(componentName, componentConfig.default || componentConfig);
});
<template lang="pug">
h3.hello
| {{ msg }}
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
@Component
export default class ComHelloWorld extends Vue {
@Prop({ type: String }) private msg!: string;
}
</script>
<style scoped lang="stylus">
h3
margin 40px 0 0
</style>
<template lang="pug">
.layout-default
slot
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
@Component
export default class DefaultLayout extends Vue {}
</script>
<style lang="stylus" scoped>
.layout-default
position relative
overflow auto
width 100%
height 100%
</style>
<template lang="pug">
.layout-header
.layout-header__navbar
slot(name="nav")
.layout-header__main
slot
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
@Component({
components: {},
})
export default class HeaderLayout extends Vue {}
</script>
<style lang="stylus" scoped>
.layout-header
position relative
padding-top 50px
width 100%
height 100%
background #F5F5F5
.layout-header__navbar
position absolute
top 0px
left 0px
z-index 1000
width 100%
.layout-header__main
position relative
overflow auto
height 100%
</style>
<template lang="pug">
.layout-menu
.layout-menu__header
slot(name="nav")
#nav
router-link(to="/")
| Home
span |
router-link(to="/about")
| About
.layout-menu__main
.main-menu
slot(name="menu")
.main-body
slot
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
@Component({
components: {},
})
export default class MenuLayout extends Vue {}
</script>
<style lang="stylus" scoped>
.layout-menu
padding-top 50px
width 100%
height 100%
background #FFFFFF
.layout-menu__header
position absolute
top 0px
left 0px
z-index 1000
width 100%
height 50px
.layout-menu__main
position relative
display flex
overflow auto
padding 0px 60px
min-width 1080px
width 100%
height 100%
.main-menu
position fixed
top 80px
left 0px
bottom 0
padding 0px 0px 0px 60px
width 260px
background #fff
z-index 999
overflow-y auto
.main-body
padding-top 30px
padding-left 240px
width 100%
</style>
import createRequestClient from '@/utils/request';
import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { snakeCase } from 'change-case';
import { isInteger, merge } from 'lodash';
import { plural } from 'pluralize';
type IdType = number | string;
type IObject = Record<string, any>;
type ModeType = 'default' | 'shallow' | 'single';
export interface IParent {
type: string;
id: number;
}
interface IAction {
name: string;
method: 'get' | 'post' | 'patch' | 'put' | 'delete';
on: 'member' | 'collection';
}
type IResponse<T, K extends string> = { [key in K]: T[] };
type IndexResponse<T, K extends string> = IResponse<T, K> & IIndex;
export interface IActiveModel {
baseUrl: string;
rootPath: string;
namespace: string;
name: string;
pathIndexKey: string;
dataIndexKey: string;
parents: IParent[];
mode?: ModeType;
params?: IObject;
resourcePath: string;
parentMap: { [key: string]: IParent };
index(params: IObject, config?: AxiosRequestConfig): any;
find(id?: IdType, config?: AxiosRequestConfig): any;
create(payload: any, config?: AxiosRequestConfig): any;
update(instance: any, config?: AxiosRequestConfig): any;
delete(id?: IdType, config?: AxiosRequestConfig): any;
sendCollectionAction(actionName: string, config?: AxiosRequestConfig): any;
sendMemberAction(id: IdType, actionName: string, config?: AxiosRequestConfig): any;
}
export interface IModelConfig {
baseUrl?: string;
rootPath?: string; // 路由的命名空间
name?: string; // 模型名称
namespace?: string; // 路由的命名空间
dataIndexKey?: string; // 资源名称,一般是模型名的复数形式
pathIndexKey?: string; //路由上的模型名复数形式
parents?: IParent[]; // 关联父资源
actions?: IAction[]; // 自定义接口方法
mode?: ModeType; // default: Restful 默认模式, shallow: 对于后台 shallow: true, single: 单例模式
params?: IObject;
}
// index 接口返回接口
interface IIndex {
id?: number;
current_page: number;
total_pages: number;
total_count: number;
}
// 模型实例基础结构
interface IModel {
id?: IdType;
}
/**
* 模型抽象基础类
* api_path: baseUrl + rootPath + namespace + resource
* examples:
* http://www.api.com/v2/finance/user/activities
* http://www.api.com + /v2 + /finance/user + /activities
*/
export default class ActiveModel<T extends IModel = IModel, K extends string = 'records'> implements IActiveModel {
public request!: AxiosInstance;
public baseUrl!: string;
public rootPath = '/';
public namespace = '';
public name = '';
public dataIndexKey = '';
public pathIndexKey = '';
public parents: IParent[] = [];
public actions: IAction[] = [];
public mode: ModeType = 'default';
public params: object = {};
constructor(config: IModelConfig = {}) {
this.request = createRequestClient();
const { baseUrl, rootPath, name, namespace, dataIndexKey, pathIndexKey, parents, actions, mode, params } = config;
if (baseUrl) {
this.request.defaults.baseURL = baseUrl;
}
if (rootPath) {
this.request.defaults.baseURL += rootPath;
}
this.namespace = namespace || this.namespace;
const modelName = name || this.constructor.name;
this.name = snakeCase(modelName);
// this.dataIndexKey = dataIndexKey || plural(this.name);
this.dataIndexKey = dataIndexKey || 'records';
this.pathIndexKey = pathIndexKey || plural(this.name);
this.parents = parents || [];
this.actions = actions || [];
this.mode = mode || 'default';
this.params = params || {};
}
get parentMap() {
return this.parents.reduce((map, parent) => {
map[parent.type] = parent;
return map;
}, Object.create(null));
}
get resourcePath() {
return `${this.namespace}/${this.pathIndexKey}`;
}
get indexPath() {
const parentPath = this.parents.reduce((path, parent) => `${path}/${parent.type}/${parent.id}`, this.namespace);
if (this.mode === 'single') {
return `${parentPath}/${this.name}`;
}
return `${parentPath}/${this.pathIndexKey}`;
}
get memberActionMap() {
return this.actions
.filter(a => a.on === 'member')
.reduce((map, action: IAction) => {
map[action.name] = action;
return map;
}, Object.create(null));
}
get collectionActionMap() {
return this.actions
.filter(a => a.on === 'collection')
.reduce((map, action: IAction) => {
map[action.name] = action;
return map;
}, Object.create(null));
}
/**
* index
* 模型列表接口
*/
public index(params?: object, config?: AxiosRequestConfig): Promise<AxiosResponse<IndexResponse<T, K>>> {
return this.request.get<IndexResponse<T, K>>(this.indexPath, {
...config,
params: merge({ ...this.params }, params),
});
}
/**
* find
* 模型详情接口
*/
public find(id?: IdType, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.request.get<T>(this.getActionPath(id), config);
}
/**
* create
* 创建记录
*/
public create(payload: T, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.request.post<T>(
this.indexPath,
{
[this.name]: payload,
},
config,
);
}
/**
* update
* 更新记录
*/
public update(instance: T, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.request.patch(
this.getActionPath(instance.id),
{
[this.name]: instance,
},
config,
);
}
/**
* delete
* 删除记录
*/
public delete(id?: IdType, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.request.delete(this.getActionPath(id), config);
}
/**
* 触发自定义方法
*/
public sendCollectionAction(actionName: string, config?: AxiosRequestConfig) {
const action: IAction = this.collectionActionMap[actionName];
if (!action) {
throw new Error(`\n${actionName} on collection 接口不存在,请检查模型配置。\n`);
}
return this.request({
method: action.method,
url: `${this.indexPath}/${actionName}`,
...config,
});
}
/**
* 触发自定义方法
*/
public sendMemberAction(id: IdType, actionName: string, config?: AxiosRequestConfig) {
const action: IAction = this.memberActionMap[actionName];
if (!action) {
throw new Error(`\n ${actionName} on member 接口不存在,请检查模型配置。\n`);
}
return this.request({
method: action.method,
url: `${this.getActionPath(id)}/${actionName}`,
...config,
});
}
private getActionPath(action?: IdType | string) {
if (action) {
if (this.mode === 'shallow' && isInteger(Number(action))) {
return `${this.resourcePath}/${action}`;
}
return `${this.indexPath}/${action}`;
}
return this.indexPath;
}
}
import 'normalize.css';
import '@/assets/styles/global.styl';
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import store from './store';
import utils from './utils';
import moment from 'moment';
import '@/components/global';
moment.locale('zh-cn');
Vue.config.productionTip = false;
Vue.prototype.$moment = moment;
Vue.prototype.$utils = utils;
new Vue({
router,
store,
render: h => h(App),
}).$mount('#app');
import ActiveModel, { IModelConfig } from '@/lib/ActiveModel';
export interface IExample {
id: number;
name: string;
}
export class Example extends ActiveModel<IExample> {
// super 默认可以什么都不填,模型名称,indexKey, 表名,已在内部根据类名做了自动转换
// class Member => { name: 'member', resource: 'members', indexKey: 'member' }
// 外部实力化,可以传一个新的配置,注意合并之后传给 super
constructor(config?: IModelConfig) {
super({
namespace: '/namespace/role',
parents: [{ type: 'projects', id: 1 }],
actions: [{ name: 'action', method: 'post', on: 'collection' }],
...config,
});
}
}
import ActiveModel, { IModelConfig } from '@/lib/ActiveModel';
export interface ISession {
id?: number;
name?: string;
}
export class Session extends ActiveModel<ISession> {
constructor(config?: IModelConfig) {
super({
namespace: '/auth',
name: 'session',
...config,
});
}
}
export default [
{
path: '/components',
name: 'components',
component: () => import(/* webpackChunkName: "componentsIndex" */ '@/views/components/Index.vue'),
meta: {
title: '组件广场',
},
},
] as IRoute[];
export default [
{
path: '*',
name: 'notFound',
component: () => import(/* webpackChunkName: "errorNotFound" */ '@/views/error/NotFound.vue'),
meta: {
title: '404',
layout: 'header',
},
},
] as IRoute[];
export default [
{
path: '/',
name: 'homeIndex',
component: () => import(/* webpackChunkName: "homeIndex" */ '@/views/home/Index.vue'),
meta: {
title: '首页',
layout: 'menu',
},
},
{
path: '/about',
name: 'homeAbout',
component: () => import(/* webpackChunkName: "homeAbout" */ '@/views/home/About.vue'),
meta: {
title: '关于',
layout: 'menu',
},
},
] as IRoute[];
import Vue from 'vue';
import VueRouter from 'vue-router';
Vue.use(VueRouter);
const requireRoute = require.context('.', true, /\.route\.ts$/);
const routes: IRoute[] = [];
let errorRoutes: IRoute[] = [];
requireRoute.keys().forEach(fileName => {
const moduleRoutes = requireRoute(fileName).default;
if (Array.isArray(moduleRoutes)) {
if (fileName.startsWith('./error')) {
errorRoutes = moduleRoutes;
} else {
routes.push(...moduleRoutes);
}
}
});
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes: routes.concat(errorRoutes),
});
export default router;
import Vue from 'vue';
import Vuex from 'vuex';
import createPersistedState from 'vuex-persistedstate';
Vue.use(Vuex);
// 支持的模块
type TypeModuleName = 'session';
// 定义持久化插件
const NAMESPACE = process.env.VUE_APP_VUEX_STORAGE_KEY || '';
const persistModules: TypeModuleName[] = ['session'];
const persistPlugin = createPersistedState({
key: NAMESPACE,
paths: persistModules,
});
/**
* 获取 localStorage 存储的数据
* @param moduleName 持久化数据模块
*/
export function getModulePersistState(moduleName: TypeModuleName): IObject {
const state = JSON.parse(window.localStorage.getItem(NAMESPACE) || '{}');
return state[moduleName] || {};
}
/**
* 基础 store
*/
export default new Vuex.Store<any>({
state: {},
mutations: {},
actions: {},
getters: {
authFileHeader(state) {
return {
Accept: 'application/json',
authorization: `Token ${state.fileToken}`,
};
},
},
plugins: [persistPlugin],
});
import { ActiveModule, ActiveStore, getModule } from '@/lib/ActiveStore';
import { Example, IExample } from '@/models/example';
@ActiveModule(Example, { name: 'ExampleStore' })
export class ExampleStore extends ActiveStore<IExample> {}
export const exampleStore = getModule(ExampleStore);
import { ActiveModule, ActiveStore, getModule } from '@/lib/ActiveStore';
import { Session, ISession } from '@/models/session';
import { getModulePersistState } from '@/store';
const initialState = getModulePersistState('session');
@ActiveModule(Session, { name: 'SessionStore' })
export class SessionStore extends ActiveStore<ISession> {
session: ISession = initialState || {};
}
export const sessionStore = getModule(SessionStore);
type IObject = Record<string, any>;
interface RouteMeta {
title: string;
requireAuth?: boolean;
keepAlive?: true | false;
layout?: 'default' | 'menu' | 'header';
role?: 'admin' | 'user';
}
interface IRoute {
path: string;
name?: string;
component: any;
meta?: RouteMeta;
children?: IRoute[];
}
import Vue, { VNode } from 'vue';
declare global {
namespace JSX {
// tslint:disable no-empty-interface
interface Element extends VNode {}
// tslint:disable no-empty-interface
interface ElementClass extends Vue {}
interface IntrinsicElements {
[elem: string]: any;
}
}
}
declare module '*.vue' {
import Vue from 'vue';
export default Vue;
}
// 1. Make sure to import 'vue' before declaring augmented types
import moment from 'moment';
type func = (item: any) => string;
// 2. Specify a file with the types you want to augment
// Vue has the constructor type in types/vue.d.ts
declare module 'vue/types/vue' {
// 3. Declare augmentation for Vue
interface Vue {
$moment: typeof moment;
$utils: {
only(obj: IObject, keys: string | string[]): IObject;
except(obj: IObject, keys: string | string[]): IObject;
groupBy: (array: any[], func: any) => IObject;
objectify: (ary: any[], key: string | func, valueKey?: string | number) => IObject;
stringToArray: (string: string, char: string, shouldRemoveEmptyItem?: boolean) => string[];
openUrl: (path: string, target?: string) => void;
toCurrency: (price: number | string, decimalCount?: number, suffix?: string) => string;
};
}
}
import { isEqual } from 'lodash-es';
interface IObject {
[key: string]: any;
}
function diff(oldObject: IObject, newObject: IObject) {
if (oldObject === newObject) {
return newObject;
}
return Object.keys(newObject).reduce((res: IObject, key: string) => {
const oldValue = oldObject[key];
const newValue = newObject[key];
if (!isEqual(oldValue, newValue)) {
Object.assign(res, { [key]: newValue });
}
return res;
}, {});
}
export default diff;
type func = (item: any) => string;
export default {
only(obj: IObject, keys: string | string[]) {
obj = obj || {};
if ('string' == typeof keys) {
keys = keys.split(/ +/);
}
return keys.reduce((ret: IObject, key: string) => {
if (null == obj[key]) {
return ret;
}
ret[key] = obj[key];
return ret;
}, {});
},
except(obj: IObject, exceptKeys: string | string[]) {
obj = obj || {};
if ('string' == typeof exceptKeys) {
exceptKeys = exceptKeys.split(/ +/);
}
const keys = Object.keys(obj);
return keys.reduce((ret: IObject, key: string) => {
if (null == obj[key]) {
return ret;
}
if (exceptKeys.includes(key)) {
return ret;
}
ret[key] = obj[key];
return ret;
}, {});
},
groupBy(array: any[], func: any) {
return array.map(typeof func === 'function' ? func : val => val[func]).reduce(
(group: any, val: any, index: number) => ({
...group,
[val]: (group[val] || []).concat(array[index]),
}),
{},
);
},
objectify(ary: any[], key: string | func, valueKey?: string | number) {
return ary.reduce((obj, item) => {
const v = valueKey ? item[valueKey] : item;
const k = typeof key === 'function' ? key(item) : item[key];
Object.assign(obj, { [k]: v });
return obj;
}, {});
},
stringToArray(str: string) {
if (!str) {
return [];
}
const pattern = /[,,;;\s、!@#$%^&*_\-+=《》<>?\\/[\]()()'"‘’“”]/g;
const formatString = str
.replace(pattern, ' ')
.trim()
.replace(/\s{2,}/g, ' ');
return formatString.split(' ');
},
openUrl(path: string, target = '_blank') {
const publicPath = process.env.VUE_APP_PUBLIC_PATH || '';
if (path.includes('http') || path.includes(publicPath)) {
window.open(path, target);
return;
}
const newPath = path.charAt(0) === '/' ? path.slice(1) : path;
window.open(`${publicPath}${newPath}`, target);
},
toCurrency(price: number | string, decimalCount = 2, suffix = '') {
const priceNumber = Number(price);
if (Number.isNaN(priceNumber)) return null;
const priceArray = priceNumber.toFixed(decimalCount).split('.');
return `${Number(priceArray[0]).toLocaleString('en-US')}.${
priceArray[1] ? priceArray[1].padEnd(decimalCount, '0') : suffix
}`;
},
};
import Axios from 'axios';
import qs from 'qs';
export default () => {
const apiUrl = process.env.VUE_APP_API_DOMAIN || '';
const rootPath = process.env.VUE_APP_API_ROOT_PATH || '/';
const request = Axios.create({
baseURL: apiUrl + rootPath,
headers: {
Accept: 'application/json',
},
paramsSerializer(params) {
return qs.stringify(params, {
encode: true,
arrayFormat: 'brackets',
skipNulls: true,
});
},
});
request.interceptors.request.use(
(config: any) => {
if (!config.headers.authorization) {
Object.assign(config.headers, {
authorization: `Token ${''}`,
});
}
return config;
},
error => {
return Promise.reject(error);
},
);
request.interceptors.response.use(
response => response,
error => {
if (!error || !error.response) {
return Promise.reject(error);
}
switch (error.response.status) {
case 401:
console.error('未授权');
break;
case 500:
console.error('服务器异常');
break;
case 404:
console.error('资源不存在');
break;
default:
break;
}
return Promise.reject(error);
},
);
return request;
};
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import ComHelloWorld from '../../components/home/ComHelloWorld.vue';
@Component({
components: {
ComHelloWorld,
},
})
export default class ComponentsIndex extends Vue {}
</script>
<template lang="pug">
.components
.component
ComHelloWorld(msg="Hello")
</template>
<style lang="stylus" scoped>
.components
display grid
grid-template-columns repeat(3, 1fr)
gap 10px
padding 10px
.component
box-shadow 0 0 10px 0px rgba(0,0,0,0.2)
padding 10px
</style>
<template lang="pug">
.container
h1.text-center
| 页面不存在
</template>
<style lang="stylus" scoped></style>
<template lang="pug">
.about
h1 This is an about page
</template>
<template lang="pug">
.home
img(alt="Vue logo" src="@/assets/images/logo.png")
HelloWorld(msg="Welcome to Your Vue.js App")
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { exampleStore } from '@/store/modules/example.store';
import ComHelloWorld from '@/components/home/ComHelloWorld.vue';
@Component({
components: {
ComHelloWorld,
},
})
export default class Home extends Vue {
mounted() {
exampleStore.init();
this.fetchData();
}
fetchData() {
exampleStore.index();
}
}
</script>
<style lang="stylus" scoped>
.container
background #ffffff
color #333333
</style>
import { shallowMount } from '@vue/test-utils';
import HelloWorld from '@/components/HelloWorld.vue';
describe('HelloWorld.vue', () => {
it('renders props.msg when passed', () => {
const msg = 'new message';
const wrapper = shallowMount(HelloWorld, {
propsData: { msg },
});
expect(wrapper.text()).toMatch(msg);
});
});
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"jsx": "preserve",
"importHelpers": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"baseUrl": ".",
"types": ["webpack-env", "jest"],
"paths": {
"@/*": ["src/*"]
},
"lib": ["esnext", "dom", "dom.iterable", "scripthost"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "tests/**/*.ts", "tests/**/*.tsx"],
"exclude": ["node_modules"]
}
module.exports = {
publicPath: process.env.VUE_APP_PUBLIC_PATH,
};
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment