├── .editorconfig ├── .env.development ├── .env.production ├── .env.staging ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .gitlab-ci.yml ├── .npmrc ├── README.md ├── babel.config.js ├── config └── env.js ├── gulpfile.js ├── gulp-px2rpx │ └── index.js ├── gulp-wx-babel │ ├── babel-plugins.js │ └── index.js └── index.js ├── package.json ├── project.config.json.example ├── script ├── upload.js └── upload.key ├── src ├── app.js ├── app.json ├── app.scss ├── constants │ └── index.js ├── models │ ├── record.d.ts │ ├── record.js │ └── user.js ├── pages │ └── root │ │ └── home │ │ ├── home.js │ │ ├── home.json │ │ ├── home.scss │ │ └── home.wxml ├── static │ ├── empty.png │ ├── logo.png │ └── refresh.png ├── stores │ ├── auth-store.js │ ├── helper │ │ ├── collection.js │ │ ├── observable.js │ │ └── simple-store.js │ ├── index.js │ ├── observer.js │ └── ui-store.js ├── styles │ ├── colors.scss │ ├── flex.scss │ └── mixins.scss └── utils │ ├── check-api-auth.js │ ├── decoder.js │ ├── index.js │ ├── merge-share-message.js │ ├── nav.js │ ├── prompt.js │ ├── random.js │ ├── request.js │ ├── save-files.js │ ├── sleep.js │ ├── to-json-deep.js │ ├── upload-files.js │ └── wxp.js ├── webpack.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | APP_ENV=development 3 | APP_API_HOST=https://fondwell.beansmile-dev.com 4 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | NODE_ENV=production 2 | APP_ENV=production 3 | APP_API_HOST=https://fondwell.beansmile-dev.com 4 | -------------------------------------------------------------------------------- /.env.staging: -------------------------------------------------------------------------------- 1 | NODE_ENV=production 2 | APP_ENV=staging 3 | APP_API_HOST=https://fondwell.beansmile-dev.com 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # eslint 白名单 2 | dist/* 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "beansmile/weapp" 4 | ], 5 | "globals": { 6 | "Behavior": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # start of template https://github.com/github/gitignore/blob/master/Global/macOS.gitignore 2 | *.DS_Store 3 | 4 | node_modules/ 5 | dist/ 6 | .idea/ 7 | jsconfig.json 8 | project.config.json 9 | .env.local 10 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - test 3 | - build 4 | 5 | cache: 6 | key: node_modules 7 | paths: 8 | - node_modules/ 9 | 10 | before_script: 11 | - nodenv shell 11.13.0 12 | - yarn install 13 | - cp project.config.json.example project.config.json 14 | 15 | test: 16 | script: 17 | - yarn run lint 18 | except: 19 | - develop 20 | - staging 21 | - master 22 | 23 | deploy_staging: 24 | stage: build # 对应stages上的job名称 25 | script: 26 | - yarn build:staging 27 | - APP_ENV=staging node script/upload.js 28 | only: 29 | - staging 30 | tags: 31 | - mac-shell-runner 32 | 33 | deploy_production: 34 | stage: build # 对应stages上的job名称 35 | script: 36 | - yarn build:production 37 | - APP_ENV=production node script/upload.js 38 | only: 39 | - master 40 | tags: 41 | - mac-shell-runner 42 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | sass_binary_site=https://npm.taobao.org/mirrors/node-sass/ 2 | registry=https://registry.npm.taobao.org 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 微信小程序开发脚手架 2 | 3 | ## Installation 4 | 5 | ``` 6 | # 克隆仓库到指定的文件夹 7 | $ git clone git@github.com:jamieYou/weapp-starter-kit.git 8 | $ cd weapp-starter-kit 9 | $ cp project.config.json.example project.config.json 10 | ``` 11 | 12 | ## Develop 13 | * 在项目根目录运行 `yarn` 14 | * 在项目根目录运行 `yarn start` 15 | * 打开微信Web开放者工具,将项目的跟目录导入进去,填写或选择相应信息 16 | 17 | ## Script 18 | ``` 19 | yarn start //开发模式 20 | NODE_ENV=xxx yarn build //部署 # NODE_ENV 默认为 production 21 | yarn lint // 用于 ci 的 esling 检查 22 | ``` 23 | 24 | ## css 25 | 1. 使用 scss 作为开发语言 26 | 2. 页面、组件以外的 scss 文件,请以 _ 开头或者放在 styles 目录 27 | 28 | ## npm 支持 29 | 可以在 js 中引入 npm 的包 30 | 31 | ## alias 32 | 通过 `babel-plugin-module-resolver` 支持 require 的路径别名配置 33 | 34 | `require('@/store')` `@`指 src 的目录 35 | 36 | ## wxp 37 | 全局变量 wxp,对 wx 的 api 进行 Promise 封装 38 | 39 | ## 环境变量 40 | 支持通过 `.env` 文件修改环境变量,[参考](https://www.npmjs.com/package/node-env-file) 41 | 42 | ## Observer 43 | 让页面和 mobx 结合使用 44 | 45 | ```javascript 46 | Observer.Page({ 47 | state() { 48 | return { 49 | show: true, 50 | authStore, 51 | list: new Collection, 52 | } 53 | }, 54 | computed: { 55 | get nickname() { 56 | return authStore.user.nickname 57 | } 58 | }, 59 | onLoad() { 60 | // state 和 computed 设置到 this 上,并同步到 data 61 | this.authStore; 62 | this.nickname; 63 | } 64 | }) 65 | ``` 66 | 支持页面、组件(Observer.Component)、Behavior(Observer.Behavior),其他参数按照官方文档即可 67 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { appENV } = require('./config/env') 3 | 4 | module.exports = { 5 | presets: [ 6 | [ 7 | '@babel/preset-env', 8 | { 9 | 'useBuiltIns': 'usage', 10 | 'corejs': 3, 11 | 'targets': { 12 | 'chrome': '49', 13 | 'safari': '10' 14 | } 15 | } 16 | ] 17 | ], 18 | plugins: [ 19 | 'lodash', 20 | [ 21 | 'transform-define', 22 | _.mapKeys(appENV, (v, key) => `process.env.${key}`) 23 | ], 24 | [ 25 | '@babel/plugin-transform-runtime', 26 | { 27 | 'corejs': false, 28 | 'helpers': true, 29 | 'regenerator': false, 30 | 'useESModules': false 31 | } 32 | ], 33 | '@babel/plugin-syntax-dynamic-import', 34 | '@babel/plugin-syntax-import-meta', 35 | ['@babel/plugin-proposal-decorators', { 'legacy': true }], 36 | ['@babel/plugin-proposal-class-properties', { 'loose': true }], 37 | '@babel/plugin-proposal-export-namespace-from', 38 | '@babel/plugin-proposal-throw-expressions', 39 | '@babel/plugin-proposal-export-default-from', 40 | ['@babel/plugin-proposal-pipeline-operator', { 'proposal': 'minimal' }], 41 | '@babel/plugin-proposal-do-expressions', 42 | '@babel/plugin-proposal-function-bind', 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /config/env.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const env = require('node-env-file') 4 | const _ = require('lodash') 5 | 6 | const APP_ENV = process.env.APP_ENV || 'development' 7 | const localEnv = path.resolve('.env.local') 8 | 9 | env(path.resolve(`.env.${APP_ENV}`)) 10 | if (fs.existsSync(localEnv)) env(localEnv) 11 | 12 | module.exports = { 13 | appENV: _.pickBy(process.env, (v, key) => { 14 | return /^APP_/.test(key) || key === 'NODE_ENV' 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /gulpfile.js/gulp-px2rpx/index.js: -------------------------------------------------------------------------------- 1 | const through = require('through2') 2 | 3 | // 默认参数 4 | const opts = { 5 | baseDpr: 1, 6 | viewportUnit: 'rpx', 7 | minPixelValue: 1, 8 | precision: 2, 9 | } 10 | const ZPXRegExp = /(\d+)px/ 11 | 12 | function createPxReplace({ minPixelValue, baseDpr, viewportUnit, precision }) { 13 | return function ($0, $1) { 14 | if (!$1) return 15 | const pixels = parseFloat($1) 16 | if (pixels <= minPixelValue) return $1 + 'px' 17 | return toFixed(pixels / baseDpr * 2, precision) + viewportUnit 18 | } 19 | } 20 | 21 | function toFixed(number, precision) { 22 | const multiplier = Math.pow(10, precision + 1), 23 | wholeNumber = Math.floor(number * multiplier) 24 | return Math.round(wholeNumber / 10) * 10 / multiplier 25 | } 26 | 27 | function px2rpx() { 28 | return through.obj( 29 | function (file, encoding, cb) { 30 | if (file.isNull()) return cb(null, file) 31 | 32 | let _source = file.contents.toString() 33 | let pxGlobalRegExp = new RegExp(ZPXRegExp.source, 'g') 34 | if (pxGlobalRegExp.test(_source)) { 35 | _source = _source.replace(pxGlobalRegExp, createPxReplace(opts)) 36 | } 37 | 38 | file.contents = Buffer.from(_source, 'utf-8') 39 | this.push(file) 40 | cb() 41 | } 42 | ) 43 | } 44 | 45 | module.exports = px2rpx 46 | -------------------------------------------------------------------------------- /gulpfile.js/gulp-wx-babel/babel-plugins.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const babel = require('@babel/core') 3 | const _ = require('lodash') 4 | const fs = require('fs-extra') 5 | const resolve = require('enhanced-resolve'); 6 | const { appENV } = require('../../config/env') 7 | 8 | const myResolve = resolve.create.sync(require(path.resolve('webpack.config')).resolve); 9 | 10 | const node_modules = {} 11 | const src_path = path.join(process.cwd(), 'src') 12 | const dist_path = path.join(process.cwd(), 'dist') 13 | const node_modules_path = path.join(process.cwd(), 'node_modules') 14 | const miniprogram_npm_path = path.join(dist_path, 'miniprogram_npm') 15 | const require_mask_reg = new RegExp(', "gulp-wx-babel-done"', 'g') 16 | 17 | function handle(nodePath, state) { 18 | if (nodePath.node.callee.name === 'require') { 19 | const file_path = state.file.opts.filename 20 | const require_path = _.get(nodePath, 'node.arguments[0].value') 21 | const status = _.get(nodePath, 'node.arguments[1].value') 22 | if (require_path && status !== 'gulp-wx-babel-done') { 23 | const absolutePath = myResolve(path.dirname(file_path), require_path) 24 | if (_.includes(file_path, src_path) && _.includes(absolutePath, node_modules_path)) { 25 | const require_module = absolutePath.replace(node_modules_path, '').replace(/\\/g, '/').replace(/^\//, '') 26 | nodePath.replaceWithSourceString( 27 | `require("${require_module}", "gulp-wx-babel-done")` 28 | ) 29 | } else { 30 | const require_module = path.relative(path.dirname(file_path), absolutePath) 31 | nodePath.replaceWithSourceString( 32 | `require("${require_module.replace(/\\/g, '/')}", "gulp-wx-babel-done")` 33 | ) 34 | } 35 | 36 | if (_.includes(absolutePath, node_modules_path)) { 37 | transformFileSync(absolutePath, opts) 38 | } 39 | } 40 | } 41 | } 42 | 43 | function transformFileSync(file_path, opts) { 44 | if (node_modules[file_path]) return 45 | const res = babel.transformFileSync(file_path, opts) 46 | const output_path = file_path.replace(node_modules_path, miniprogram_npm_path) 47 | fs.outputFileSync(output_path, res.code.replace(require_mask_reg, '')) 48 | node_modules[file_path] = output_path 49 | } 50 | 51 | const plugin = function () { 52 | return { 53 | visitor: { 54 | CallExpression: handle 55 | } 56 | } 57 | } 58 | 59 | const opts = { 60 | configFile: false, 61 | plugins: [[ 62 | 'transform-define', 63 | _.mapKeys(appENV, (v, key) => `process.env.${key}`) 64 | ], 65 | '@babel/plugin-transform-modules-commonjs', 66 | plugin 67 | ], 68 | } 69 | 70 | exports.plugin = plugin 71 | exports.node_modules = node_modules 72 | exports.require_mask_reg = require_mask_reg 73 | -------------------------------------------------------------------------------- /gulpfile.js/gulp-wx-babel/index.js: -------------------------------------------------------------------------------- 1 | const through = require('through2') 2 | const path = require('path') 3 | const PluginError = require('plugin-error') 4 | const babel = require('@babel/core') 5 | const { plugin, require_mask_reg } = require('./babel-plugins') 6 | 7 | const app_js_path = path.join(process.cwd(), 'src', 'app.js') 8 | 9 | function wxBabel() { 10 | return through.obj( 11 | function (file, encoding, cb) { 12 | if (file.isNull()) return cb(null, file) 13 | 14 | const opts = { 15 | filename: file.path, 16 | filenameRelative: file.relative, 17 | sourceMap: Boolean(file.sourceMap), 18 | sourceFileName: file.relative, 19 | configFile: false, 20 | plugins: [plugin], 21 | } 22 | 23 | babel.transformAsync(file.contents.toString(), opts) 24 | .then(res => { 25 | let code = res.code.replace(/"use strict";/g, '').replace(require_mask_reg, '') 26 | if (file.path === app_js_path) { 27 | code = `(function () {globalThis = this})(); 28 | ${code}` 29 | } 30 | file.contents = Buffer.from(code, 'utf-8') 31 | this.push(file) 32 | }) 33 | .catch(err => { 34 | this.emit('error', new PluginError('gulp-wx-babel', err, { 35 | fileName: file.path, 36 | showProperties: true 37 | })) 38 | }) 39 | .finally(cb) 40 | } 41 | ) 42 | } 43 | 44 | module.exports = wxBabel 45 | -------------------------------------------------------------------------------- /gulpfile.js/index.js: -------------------------------------------------------------------------------- 1 | require('../config/env') 2 | const del = require('del') 3 | const gulp = require('gulp') 4 | const gutil = require('gulp-util') 5 | const uglify = require('gulp-uglify-es').default 6 | const eslint = require('gulp-eslint') 7 | const gulpif = require('gulp-if') 8 | const changed = require('gulp-changed') 9 | const notifier = require('node-notifier') 10 | const babel = require('gulp-babel') 11 | const sass = require('gulp-sass') 12 | const header = require('gulp-header') 13 | const autoprefixer = require('gulp-autoprefixer') 14 | const rename = require('gulp-rename') 15 | const wxBabel = require('./gulp-wx-babel') 16 | const px2rpx = require('./gulp-px2rpx') 17 | 18 | const handleError = function (err) { 19 | const colors = gutil.colors 20 | gutil.log(colors.red('Error!')) 21 | gutil.log('fileName: ' + colors.red(err.fileName)) 22 | gutil.log('lineNumber: ' + colors.red(err.lineNumber)) 23 | gutil.log('message: ' + err.message) 24 | gutil.log('plugin: ' + colors.yellow(err.plugin)) 25 | notifier.notify({ title: `${err.plugin}错误`, message: err.fileName || err.message }) 26 | this.emit('end') 27 | } 28 | 29 | const fileTypes = { 30 | fileCopy: ['src/**/*.json', 'src/**/*.{png,svg,jpg,jpeg}', 'src/**/*.wxs'], 31 | wxmlCopy: 'src/**/*.wxml', 32 | wxssCompile: 'src/**/*.wxss', 33 | scssCompile: ['src/**/*.scss', '!_*.scss', '!src/styles/*.scss'], 34 | buildJS: ['src/**/*.js'], 35 | } 36 | 37 | function fileCopy() { 38 | return gulp.src(fileTypes.fileCopy, { base: 'src' }) 39 | .pipe(changed('dist')) 40 | .pipe(gulp.dest('dist')) 41 | } 42 | 43 | function wxmlCopy() { 44 | return gulp.src(fileTypes.wxmlCopy, { base: 'src' }) 45 | .pipe(changed('dist')) 46 | .pipe(px2rpx()) 47 | .pipe(gulp.dest('dist')) 48 | } 49 | 50 | function wxssCompile() { 51 | return gulp.src(fileTypes.wxssCompile, { base: 'src' }) 52 | .pipe(changed('dist')) 53 | .pipe(px2rpx()) 54 | .pipe(autoprefixer(['iOS >= 8', 'Android >= 4.1'])) 55 | .pipe(gulp.dest('dist')) 56 | } 57 | 58 | const sassHeader = ` 59 | @import 'src/styles/colors.scss'; 60 | @import 'src/styles/mixins.scss'; 61 | ` 62 | 63 | function scssTask(useChanged) { 64 | return gulp.src(fileTypes.scssCompile, { base: 'src' }) 65 | .pipe(gulpif(useChanged, changed('dist', { extension: '.wxss' }))) 66 | .pipe(header(sassHeader)) 67 | .pipe(sass()) 68 | .on('error', handleError) 69 | .pipe(px2rpx()) 70 | .pipe(autoprefixer(['iOS >= 8', 'Android >= 4.1'])) 71 | .pipe(rename({ extname: '.wxss' })) 72 | .pipe(gulp.dest('dist')) 73 | } 74 | 75 | function scssCompile() { 76 | return scssTask(true) 77 | } 78 | 79 | function buildJS() { 80 | return gulp.src(fileTypes.buildJS, { base: 'src' }) 81 | .pipe(changed('dist')) 82 | .pipe(eslint()) 83 | .pipe(babel()) 84 | .pipe(wxBabel()) 85 | .on('error', handleError) 86 | .pipe(gulp.dest('dist')) 87 | } 88 | 89 | function uglifyJs() { 90 | return gulp.src('dist/**/*.js', { base: 'dist' }) 91 | .pipe(uglify({ toplevel: true })) 92 | .pipe(gulp.dest('dist')) 93 | } 94 | 95 | function watch(done) { 96 | gulp.watch(fileTypes.fileCopy, gulp.series(fileCopy)) 97 | gulp.watch(fileTypes.wxmlCopy, gulp.series(wxmlCopy)) 98 | gulp.watch(fileTypes.wxssCompile, gulp.series(wxssCompile)) 99 | gulp.watch(fileTypes.scssCompile, gulp.series(scssCompile)) 100 | gulp.watch(fileTypes.buildJS, gulp.series(buildJS)) 101 | gulp.watch( 102 | ['_*.scss', 'src/styles/*.scss'], 103 | function scssCompile() { 104 | return scssTask(false) 105 | } 106 | ) 107 | done() 108 | } 109 | 110 | function clean() { 111 | return del(['./dist/**']) 112 | } 113 | 114 | function eslintCode() { 115 | return gulp 116 | .src('src/**/*.js') 117 | .pipe(eslint()) 118 | .pipe(eslint.failAfterError()) 119 | } 120 | 121 | const common = [clean, gulp.parallel(fileCopy, wxmlCopy, wxssCompile, buildJS, scssCompile)] 122 | 123 | exports.dev = gulp.series(...common, watch) 124 | exports.build = gulp.series(...common, uglifyJs) 125 | exports.eslint = eslintCode 126 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fengma-mini-program", 3 | "version": "2.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "start": "cross-env APP_ENV=development gulp dev", 7 | "lint": "gulp eslint", 8 | "build:staging": "cross-env APP_ENV=staging gulp build", 9 | "build:production": "cross-env APP_ENV=production gulp build" 10 | }, 11 | "dependencies": { 12 | "@minapp/wx": "^2.2.1", 13 | "@vuex-orm/core": "^0.36.3", 14 | "axios": "^0.19.2", 15 | "core-decorators": "^0.20.0", 16 | "core-js": "^3.6.5", 17 | "dayjs": "^1.8.30", 18 | "lodash": "^4.17.19", 19 | "mobx": "^5.15.4", 20 | "qs": "^6.9.4" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "^7.10.5", 24 | "@babel/plugin-proposal-class-properties": "^7.10.4", 25 | "@babel/plugin-proposal-decorators": "^7.10.5", 26 | "@babel/plugin-proposal-do-expressions": "^7.10.4", 27 | "@babel/plugin-proposal-export-default-from": "^7.10.4", 28 | "@babel/plugin-proposal-export-namespace-from": "^7.10.4", 29 | "@babel/plugin-proposal-function-bind": "^7.10.5", 30 | "@babel/plugin-proposal-optional-chaining": "^7.10.4", 31 | "@babel/plugin-proposal-pipeline-operator": "^7.10.5", 32 | "@babel/plugin-proposal-throw-expressions": "^7.10.4", 33 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 34 | "@babel/plugin-syntax-import-meta": "^7.10.4", 35 | "@babel/plugin-transform-runtime": "^7.10.5", 36 | "@babel/preset-env": "^7.10.4", 37 | "@babel/runtime": "^7.10.5", 38 | "babel-plugin-lodash": "^3.3.4", 39 | "babel-plugin-transform-define": "^2.0.0", 40 | "cross-env": "^7.0.2", 41 | "del": "^5.1.0", 42 | "enhanced-resolve": "^4.3.0", 43 | "eslint-config-beansmile": "https://github.com/beansmile/eslint-config-beansmile.git", 44 | "fs-extra": "^9.0.1", 45 | "gulp": "^4.0.2", 46 | "gulp-autoprefixer": "^7.0.1", 47 | "gulp-babel": "^8.0.0", 48 | "gulp-changed": "^4.0.2", 49 | "gulp-eslint": "^6.0.0", 50 | "gulp-header": "^2.0.9", 51 | "gulp-if": "^3.0.0", 52 | "gulp-rename": "^2.0.0", 53 | "gulp-sass": "^4.1.0", 54 | "gulp-uglify-es": "^2.0.0", 55 | "gulp-util": "^3.0.8", 56 | "ipv4": "^1.0.4", 57 | "node-env-file": "^0.1.8", 58 | "node-notifier": "^7.0.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /project.config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "description": "项目配置文件。", 3 | "setting": { 4 | "urlCheck": false, 5 | "es6": false, 6 | "postcss": false, 7 | "minified": false, 8 | "newFeature": true 9 | }, 10 | "miniprogramRoot": "./dist/", 11 | "compileType": "miniprogram", 12 | "libVersion": "2.0.0", 13 | "appid": "touristappid", 14 | "projectname": "weapp-starter-kit", 15 | "condition": { 16 | "search": { 17 | "current": -1, 18 | "list": [] 19 | }, 20 | "conversation": { 21 | "current": -1, 22 | "list": [] 23 | }, 24 | "plugin": { 25 | "current": -1, 26 | "list": [] 27 | }, 28 | "game": { 29 | "list": [] 30 | }, 31 | "miniprogram": { 32 | "current": -1, 33 | "list": [] 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /script/upload.js: -------------------------------------------------------------------------------- 1 | const dayjs = require('dayjs'); 2 | const path = require('path'); 3 | const ci = require( 4 | path.join( 5 | process.execPath.replace(/bin\/node$/, ''), 6 | 'lib/node_modules/miniprogram-ci', 7 | ) 8 | ); 9 | 10 | const robots = ['', 'staging', 'production']; 11 | 12 | const config = { 13 | version: dayjs().format('YYMMDDTHH'), 14 | env: process.env.APP_ENV, 15 | }; 16 | 17 | function getAppId() { 18 | return require('../project.config.json').appid; 19 | } 20 | 21 | async function upload(env = config.env, desc, version = config.version) { 22 | const desc_str = desc || env; 23 | const projectPath = path.resolve(); 24 | const privateKeyPath = path.resolve('script/upload.key'); 25 | const appid = getAppId(); 26 | const robot = robots.indexOf(env); 27 | 28 | const project = new ci.Project({ 29 | appid, type: 'miniProgram', 30 | projectPath, privateKeyPath, 31 | ignores: ['node_modules/**/*'], 32 | }); 33 | 34 | await ci.upload({ 35 | project, version, 36 | desc: desc_str, 37 | robot, onProgressUpdate: console.log, 38 | }); 39 | } 40 | 41 | upload().catch(err => { 42 | console.error(err); 43 | process.exit(1); 44 | }); 45 | -------------------------------------------------------------------------------- /script/upload.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | demo 3 | -----END RSA PRIVATE KEY----- 4 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import './utils/wxp' 2 | 3 | App({}) 4 | -------------------------------------------------------------------------------- /src/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "pages": [ 3 | "pages/root/home/home" 4 | ], 5 | "window": { 6 | "backgroundTextStyle": "light", 7 | "navigationBarBackgroundColor": "#fff", 8 | "navigationBarTitleText": "app", 9 | "navigationBarTextStyle": "black" 10 | }, 11 | "usingComponents": {} 12 | } 13 | -------------------------------------------------------------------------------- /src/app.scss: -------------------------------------------------------------------------------- 1 | /*每个页面公共css */ 2 | @import './styles/flex.scss'; 3 | 4 | page { 5 | font-size: 14px; 6 | color: #333333; 7 | padding-bottom: env(safe-area-inset-bottom); 8 | } 9 | 10 | ::-webkit-scrollbar { 11 | display: none; 12 | } 13 | 14 | .text-color-primary { 15 | color: $primary; 16 | } 17 | 18 | .text-center { 19 | text-align: center; 20 | } 21 | 22 | .text-overflow { 23 | overflow: hidden; 24 | text-overflow: ellipsis; 25 | white-space: nowrap; 26 | } 27 | 28 | .text-overflow-2 { 29 | @include text-overflow(2); 30 | } 31 | 32 | // 重置 button 样式,当做 div 使用 33 | .reset-btn { 34 | padding: 0; 35 | line-height: inherit; 36 | border-radius: 0; 37 | margin: 0; 38 | background-color: inherit; 39 | color: inherit; 40 | font-size: inherit; 41 | 42 | &::after { 43 | display: none; 44 | } 45 | } 46 | 47 | image { 48 | height: 0; 49 | vertical-align: top; 50 | } 51 | 52 | .disabled { 53 | pointer-events: none; 54 | } 55 | -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | export const ACCESS_TOKEN_KEY = `${process.env.APP_ENV}.access_token`; 2 | 3 | export const SHARE_COVER = ''; 4 | -------------------------------------------------------------------------------- /src/models/record.d.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '@vuex-orm/core/lib' 2 | import { AxiosInstance } from 'axios' 3 | 4 | export default class Record extends Model { 5 | static api(): AxiosInstance 6 | api(): AxiosInstance 7 | updateAttrs(attrs: Object) 8 | } 9 | -------------------------------------------------------------------------------- /src/models/record.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { decorate, observable, extendObservable } from 'mobx'; 3 | import Model from '@vuex-orm/core/lib/model/Model'; 4 | import { request } from '@/utils/request'; 5 | 6 | export default class Record extends Model { 7 | static fields() { 8 | return { 9 | id: this.attr(null), 10 | createdAt: this.attr(''), 11 | updatedAt: this.attr(''), 12 | remarks: this.attr(''), 13 | }; 14 | } 15 | 16 | static api(cb) { 17 | const methods = ['get', 'delete', 'post', 'put', 'patch', 'request']; 18 | const obj = Object.create(request); 19 | methods.forEach(method => { 20 | obj[method] = async (...arg) => { 21 | const res = await request[method](...arg); 22 | if (cb) { 23 | cb(res); 24 | } else if (_.isArray(res.data)) { 25 | res.data = res.data.map(v => new this(v)); 26 | } else { 27 | res.data = new this(res.data); 28 | } 29 | return res; 30 | }; 31 | }); 32 | return obj; 33 | } 34 | 35 | api() { 36 | return this.constructor.api(res => res.data = this.updateAttrs(res.data)); 37 | } 38 | 39 | constructor(attrs) { 40 | super(attrs); 41 | const newAttrs = {} 42 | _.keys(attrs).forEach(key => { 43 | if (!_.hasIn(this, key)) { 44 | newAttrs[key] = attrs[key]; 45 | } 46 | }); 47 | extendObservable(this, newAttrs) 48 | } 49 | 50 | updateAttrs(attrs) { 51 | if (!_.isPlainObject(attrs)) { 52 | // eslint-disable-next-line no-console 53 | console.warn('attrs 尽量传递[普通对象](https://www.html.cn/doc/lodash/#_isplainobjectvalue)'); 54 | } 55 | const model = new this.constructor(attrs); 56 | const newAttr = _.pick(model, _.keys(attrs)); 57 | return Object.assign(this, newAttr); 58 | } 59 | } 60 | 61 | export function observables(target) { 62 | const fields = target.fields() 63 | decorate(target, _.mapValues(fields, () => observable)) 64 | return target 65 | } 66 | 67 | Record.prototype.toJSON = require('@/utils/to-json-deep').default; 68 | -------------------------------------------------------------------------------- /src/models/user.js: -------------------------------------------------------------------------------- 1 | import Record, { observables } from './record'; 2 | 3 | @observables 4 | export default class User extends Record { 5 | static fields() { 6 | return { 7 | ...super.fields(), 8 | nickname: this.attr(''), 9 | avatar: this.attr(''), 10 | }; 11 | } 12 | 13 | get wechat_authorized() { 14 | return this.avatar && this.nickname; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/root/home/home.js: -------------------------------------------------------------------------------- 1 | import { Observer, authStore, Collection } from '@/stores' 2 | 3 | Observer.Page({ 4 | state() { 5 | return { 6 | authStore, list: new Collection 7 | } 8 | }, 9 | computed: { 10 | get nickname() { 11 | return authStore.user.nickname 12 | } 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /src/pages/root/home/home.json: -------------------------------------------------------------------------------- 1 | { 2 | "usingComponents": { 3 | } 4 | } 5 | -------------------------------------------------------------------------------- /src/pages/root/home/home.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamieYou/weapp-starter-kit/a1be8c77ae57ecb6c1c687bbb3f3fd4530c14371/src/pages/root/home/home.scss -------------------------------------------------------------------------------- /src/pages/root/home/home.wxml: -------------------------------------------------------------------------------- 1 | {{ authStore.user.nickname || 'hello' }} 2 | -------------------------------------------------------------------------------- /src/static/empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamieYou/weapp-starter-kit/a1be8c77ae57ecb6c1c687bbb3f3fd4530c14371/src/static/empty.png -------------------------------------------------------------------------------- /src/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamieYou/weapp-starter-kit/a1be8c77ae57ecb6c1c687bbb3f3fd4530c14371/src/static/logo.png -------------------------------------------------------------------------------- /src/static/refresh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamieYou/weapp-starter-kit/a1be8c77ae57ecb6c1c687bbb3f3fd4530c14371/src/static/refresh.png -------------------------------------------------------------------------------- /src/stores/auth-store.js: -------------------------------------------------------------------------------- 1 | import { observable } from 'mobx' 2 | import { SimpleStore } from './helper/simple-store'; 3 | import { ACCESS_TOKEN_KEY } from '@/constants'; 4 | import User from '@/models/user'; 5 | import { request } from '@/utils'; 6 | 7 | class AuthStore extends SimpleStore { 8 | @observable $access_token = wxp.getStorageSync(ACCESS_TOKEN_KEY) 9 | user = new User 10 | 11 | async checkLogin() { 12 | if (this.token) { 13 | try { 14 | await wxp.checkSession(); 15 | return this.token; 16 | } catch (e) { 17 | return this.login(); 18 | } 19 | } 20 | return this.login(); 21 | } 22 | 23 | async login() { 24 | const { code } = await wxp.login(); 25 | const { data: { access_token, user } } = await request.post('/users/token', { code }); 26 | this.user = new User(user); 27 | return this.access_token = access_token; 28 | } 29 | 30 | set access_token(v) { 31 | this.$access_token = v; 32 | wxp.setStorageSync(ACCESS_TOKEN_KEY, v); 33 | } 34 | 35 | get access_token() { 36 | return this.$access_token; 37 | } 38 | } 39 | 40 | export const authStore = AuthStore.create(); 41 | -------------------------------------------------------------------------------- /src/stores/helper/collection.js: -------------------------------------------------------------------------------- 1 | import { observable } from 'mobx' 2 | import { SimpleStore } from './simple-store'; 3 | 4 | export class Collection extends SimpleStore { 5 | static defaultParams = { 6 | page: 1, 7 | per_page: 10, 8 | } 9 | 10 | static excludeJsonNames = ['$params'] 11 | 12 | @observable meta = { total: 0, page: 1, offset: 0 } 13 | @observable data = [] 14 | $params = Object.assign({}, this.constructor.defaultParams, this.params) 15 | 16 | fetchData() { 17 | this.params.offset = 0; 18 | return this.fetching(async () => { 19 | const { data, meta } = await this.fetch(this.params); 20 | this.meta = meta; 21 | this.data = data; 22 | }); 23 | } 24 | 25 | fetchMoreData() { 26 | if (this.isFetching || this.isComplete) { 27 | return ; 28 | } 29 | this.params.offset = this.data.length; 30 | return this.fetching(async () => { 31 | const { data, meta } = await this.fetch(this.params); 32 | this.meta = meta; 33 | this.data.push(...data); 34 | }); 35 | } 36 | 37 | resetData() { 38 | this.isFulfilled = false; 39 | this.data = []; 40 | } 41 | 42 | unshift(item) { 43 | this.data.unshift(item); 44 | this.meta.total += 1; 45 | } 46 | 47 | findItemById(id) { 48 | return this.data.find(item => +item.id === +id); 49 | } 50 | 51 | removeItemById(id) { 52 | const index = this.data.findIndex(item => Number(item.id) === Number(id)); 53 | if (index !== -1) { 54 | this.data.splice(index, 1); 55 | } 56 | } 57 | 58 | replaceItem(newItem) { 59 | const index = this.data.findIndex(item => +item.id === +newItem.id); 60 | if (index > -1) { 61 | this.data.splice(index, 1, newItem); 62 | } 63 | } 64 | 65 | get params() { 66 | return this.$params; 67 | } 68 | 69 | set params(properties) { 70 | this.$params = Object.assign({}, this.constructor.defaultParams, properties); 71 | } 72 | 73 | get isComplete() { 74 | return this.isFulfilled && this.data.length >= this.meta.total; 75 | } 76 | 77 | get isEmpty() { 78 | return this.isFulfilled && this.data.length === 0; 79 | } 80 | 81 | get loadMoreStatus() { 82 | if (this.isEmpty) { return 'empty'; } 83 | if (this.isComplete) { return 'noMore'; } 84 | if (this.isFetching) { return 'loading'; } 85 | return 'more'; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/stores/helper/observable.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | export default class Observable { 4 | static create(state) { 5 | const instance = new this(); 6 | if (state) { 7 | _.forEach(Object.getOwnPropertyDescriptors(state), (descriptor, key) => { 8 | if ('value' in descriptor) { 9 | instance[key] = descriptor.value; 10 | } else { 11 | Object.defineProperty(instance, key, descriptor); 12 | } 13 | }); 14 | } 15 | return instance; 16 | } 17 | } 18 | 19 | Observable.prototype.toJSON = require('@/utils/to-json-deep').default; 20 | -------------------------------------------------------------------------------- /src/stores/helper/simple-store.js: -------------------------------------------------------------------------------- 1 | import { observable } from 'mobx' 2 | import Observable from './observable'; 3 | 4 | export class SimpleStore extends Observable { 5 | @observable isFetching = false 6 | @observable isRejected = false 7 | @observable isFulfilled = false 8 | 9 | fetchData() { 10 | return Promise.resolve(); 11 | } 12 | 13 | tryFetchData() { 14 | return !this.isFulfilled && this.fetchData(...arguments); 15 | } 16 | 17 | mergeFetched(handle) { 18 | return this.fetching(handle, true); 19 | } 20 | 21 | async fetching(handle, autoMerge = false) { 22 | this.isFetching = true; 23 | 24 | try { 25 | const res = await (typeof handle === 'function' ? handle() : handle); 26 | const newState = autoMerge ? (res.isResponse ? res.data : res) : void 0; 27 | Object.assign(this, { 28 | isFetching: false, 29 | isRejected: false, 30 | isFulfilled: true, 31 | }, newState); 32 | return res; 33 | } catch (err) { 34 | Object.assign(this, { 35 | isFetching: false, 36 | isRejected: true, 37 | }); 38 | throw err; 39 | } 40 | } 41 | } 42 | 43 | export function fetching(target, name, descriptor) { 44 | const func = descriptor.value; 45 | descriptor.value = function () { 46 | return this.fetching(func.apply(this, arguments)); 47 | }; 48 | } 49 | 50 | export function mergeFetched(target, name, descriptor) { 51 | const func = descriptor.value; 52 | descriptor.value = function () { 53 | return this.mergeFetched(func.apply(this, arguments)); 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /src/stores/index.js: -------------------------------------------------------------------------------- 1 | export { SimpleStore } from './helper/simple-store'; 2 | export { Collection } from './helper/collection'; 3 | export { authStore } from './auth-store'; 4 | export { default as uiStore } from './ui-store'; 5 | export Observer from './observer'; 6 | -------------------------------------------------------------------------------- /src/stores/observer.js: -------------------------------------------------------------------------------- 1 | import { autorun, observable, createAtom, extendObservable } from 'mobx' 2 | import _ from 'lodash' 3 | 4 | export default function Observer(options = {}) { 5 | let { 6 | $type, state, computed, 7 | onLoad, onUnload, 8 | lifetimes = {}, methods = {}, 9 | ...config 10 | } = options 11 | 12 | function install(e) { 13 | // 只设置一次新属性, 使用 behaviors 时,install 会被 behaviors 和 Component 触发 14 | if (!this.isObserver) { 15 | this.$clears = [] 16 | this.$dataAtom = createAtom('setDataAtom') 17 | this.$setData = this.setData 18 | this.setData = (data, cb) => { 19 | this.$setData(data, cb) 20 | this.$dataAtom.reportChanged() 21 | } 22 | this.$stateKeys = [] 23 | this.$computedKeys = [] 24 | this.$propertyKeys = [] 25 | this.isObserver = true 26 | } 27 | 28 | if (state) { 29 | const states = state.call(this, e) 30 | this.$stateKeys.push(...Object.keys(states)) 31 | extendObservable(this, states) 32 | } 33 | 34 | // 只有页面和组件才同步 state 到 data 35 | if (['Page', 'Component'].includes($type) && this.$stateKeys.length) { 36 | this.$clears.push( 37 | autorun(() => { 38 | this.$dataAtom.reportObserved() 39 | const state = _.pick(this, this.$stateKeys) 40 | this.$setData(_.mapValues(state, toJSON)) 41 | }, { delay: 1 }) 42 | ) 43 | } 44 | 45 | if (computed) { 46 | this.$computedKeys.push(...Object.getOwnPropertyNames(computed)) 47 | extendObservable(this, computed) 48 | } 49 | 50 | // 只有页面和组件才同步 computed 到 data 51 | if (['Page', 'Component'].includes($type) && this.$computedKeys.length) { 52 | this.$clears.push( 53 | autorun(() => { 54 | this.$dataAtom.reportObserved() 55 | const computed = _.pick(this, this.$computedKeys) 56 | this.$setData(_.mapValues(computed, toJSON)) 57 | }, { delay: 1 }) 58 | ) 59 | } 60 | 61 | if (config.properties) { 62 | this.$propertyKeys.push(...Object.keys(config.properties)) 63 | } 64 | 65 | // 只有页面和组件才让 properties 变成 observable 66 | if (['Page', 'Component'].includes($type) && this.$propertyKeys.length) { 67 | const properties = observable({}, null, { deep: false }) 68 | _.forEach(this.properties, (v, key) => { 69 | if (this.$propertyKeys.includes(key)) { 70 | properties[key] = v 71 | 72 | Object.defineProperty(this.properties, key, { 73 | get() { 74 | return properties[key] 75 | }, 76 | set(value) { 77 | properties[key] = value 78 | } 79 | }) 80 | } 81 | }) 82 | } 83 | } 84 | 85 | function uninstall() { 86 | this.$clears.forEach(cb => cb()) 87 | } 88 | 89 | function $nextTick(cb) { 90 | if (cb) { 91 | this.$setData(null, cb) 92 | } else { 93 | return new Promise(resolve => this.$setData(null, resolve)) 94 | } 95 | } 96 | 97 | if ($type === 'Page') { 98 | return Page(Object.assign(config, { 99 | $nextTick, 100 | onLoad(e) { 101 | install.call(this, e) 102 | return onLoad && onLoad.call(this, e) 103 | }, 104 | 105 | onUnload() { 106 | uninstall.call(this) 107 | return onUnload && onUnload.call(this) 108 | } 109 | })) 110 | } else { 111 | const attachedEnd = lifetimes.attached || config.attached 112 | const detachedEnd = lifetimes.detached || config.detached 113 | 114 | const attached = function () { 115 | install.call(this) 116 | return attachedEnd && attachedEnd.call(this) 117 | } 118 | 119 | const detached = function () { 120 | uninstall.call(this) 121 | return detachedEnd && detachedEnd.call(this) 122 | } 123 | 124 | if ($type === 'Component') { 125 | Object.assign(methods, { $nextTick }) 126 | Object.assign(lifetimes, { attached, detached }) 127 | 128 | return Component(Object.assign(config, { methods, lifetimes })) 129 | } else { 130 | return Behavior(Object.assign(config, { methods, attached })) 131 | } 132 | } 133 | } 134 | 135 | function toJSON(v) { 136 | if (v && v.toJSON) { 137 | v = v.toJSON() 138 | } 139 | if (_.isObjectLike(v)) { 140 | if (_.isArray(v)) { 141 | return _.map(v, toJSON) 142 | } else { 143 | return _.mapValues(v, toJSON) 144 | } 145 | } else { 146 | return v 147 | } 148 | } 149 | 150 | Observer.Page = function (options = {}) { 151 | return Observer({ ...options, $type: 'Page' }) 152 | } 153 | 154 | Observer.Component = function (options = {}) { 155 | return Observer({ ...options, $type: 'Component' }) 156 | } 157 | 158 | Observer.Behavior = function (options = {}) { 159 | return Observer({ ...options, $type: 'Behavior' }) 160 | } 161 | -------------------------------------------------------------------------------- /src/stores/ui-store.js: -------------------------------------------------------------------------------- 1 | export default new class { 2 | systemInfo = {} 3 | 4 | constructor() { 5 | this.setSystemInfo(); 6 | } 7 | 8 | setSystemInfo() { 9 | try { 10 | this.systemInfo = wxp.getSystemInfoSync(); 11 | } catch (error) { 12 | this.systemInfo = wxp.getSystemInfoSync(); 13 | } finally { 14 | this.systemInfo.platform || (this.systemInfo = wxp.getSystemInfoSync()); 15 | } 16 | } 17 | 18 | isFullScreenModel() { 19 | return this.isIphone11 || this.isIphoneX; 20 | } 21 | 22 | /** 23 | * iPhone X: iPhone10,3 / iPhone10,6 24 | * iPhone XR: iPhone11,8 25 | * iPhone XS: iPhone11,2 26 | * iPhone 11: iPhone12,1 27 | * iPhone 11 Pro: iPhone12,3 28 | * iPhone XS Max: iPhone11,6 / iPhone11,4 29 | * iPhone 11 Pro Max: iPhone12,5 30 | */ 31 | get isIphoneX() { 32 | const { model } = this.systemInfo; 33 | return /iPhone10,3|iPhone10,6|iPhone11,8|iPhone11,1|iPhone11,2|iPhone11,3|iPhone11,6|iPhone11,4|iPhone12,5/ig.test(model); 34 | } 35 | 36 | get safeBottom() { 37 | return this.isIphoneX ? '32rpx' : ''; 38 | } 39 | 40 | get isIOS() { 41 | const { platform } = this.systemInfo; 42 | if (platform) { 43 | return platform.toUpperCase() === 'IOS'; 44 | } else { 45 | return false; 46 | } 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/styles/colors.scss: -------------------------------------------------------------------------------- 1 | $primary: #007aff; 2 | $success: green; 3 | $warning: #f0ad4e; 4 | $danger: #dd524d; 5 | 6 | $text-color: #333; 7 | $bg-color: #f8f8f8; 8 | $border-color: #c8c7cc; 9 | $text-color-sm: #979797; 10 | -------------------------------------------------------------------------------- /src/styles/flex.scss: -------------------------------------------------------------------------------- 1 | .flex { 2 | display: flex; 3 | 4 | &.no-shirk, 5 | .no-shirk { 6 | flex-shrink: 0; 7 | } 8 | 9 | .flex-1 { 10 | flex: 1; 11 | } 12 | 13 | &.row { 14 | flex-direction: row; 15 | } 16 | 17 | &.column { 18 | flex-direction: column; 19 | } 20 | 21 | &.wrap { 22 | flex-wrap: wrap; 23 | } 24 | 25 | &.item-start { 26 | align-items: flex-start; 27 | } 28 | 29 | &.item-end { 30 | align-items: flex-end; 31 | } 32 | 33 | &.item-center { 34 | align-items: center; 35 | } 36 | 37 | &.item-baseline { 38 | align-items: baseline; 39 | } 40 | 41 | &.item-stretch { 42 | align-items: stretch; 43 | } 44 | 45 | &.content-start { 46 | justify-content: flex-start; 47 | } 48 | 49 | &.content-end { 50 | justify-content: flex-end; 51 | } 52 | 53 | &.content-center { 54 | justify-content: center; 55 | } 56 | 57 | &.content-between { 58 | justify-content: space-between; 59 | } 60 | 61 | &.content-around { 62 | justify-content: space-around; 63 | } 64 | 65 | .self-center { 66 | align-self: center; 67 | } 68 | 69 | .self-start { 70 | align-self: flex-start; 71 | } 72 | 73 | .self-end { 74 | align-self: flex-end; 75 | } 76 | 77 | .overflow-hidden { 78 | overflow: hidden; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/styles/mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin text-overflow($line: 1) { 2 | display: -webkit-box; 3 | -webkit-box-orient: vertical; 4 | -webkit-line-clamp: $line; 5 | overflow: hidden; 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/check-api-auth.js: -------------------------------------------------------------------------------- 1 | const SCOPE_MAP = { 2 | userInfo: '用户信息', 3 | userLocation: '地理位置', 4 | userLocationBackground: '后台定位', 5 | address: '通讯地址', 6 | invoiceTitle: '发票抬头', 7 | invoice: '发票', 8 | werun: '微信运动步数', 9 | record: '录音功能', 10 | writePhotosAlbum: '相册功能', 11 | }; 12 | 13 | const SCOPES = { 14 | getUserInfo: 'userInfo', 15 | chooseLocation: 'userLocation', 16 | getLocation: 'userLocation', 17 | startLocationUpdateBackground: 'userLocationBackground', 18 | chooseAddress: 'address', 19 | chooseInvoiceTitle: 'invoiceTitle', 20 | chooseInvoice: 'invoice', 21 | getWeRunData: 'werun', 22 | startRecord: 'record', 23 | saveVideoToPhotosAlbum: 'writePhotosAlbum', 24 | saveImageToPhotosAlbum: 'writePhotosAlbum', 25 | }; 26 | 27 | /** 28 | * 自动检查并获取手机权限 29 | * 30 | * @param {String} scope 需要调用的小程序API,取值 SCOPES 31 | * @param {Object} options 调用API传的参数,默认空对象{} 32 | * @param {Boolean} isAuto 是否自动调用相对应的API,默认 true 33 | * 34 | * @return {Object|Boolean} 返回API调用后的返回值,如果 isAuto 为 false,打开权限后return true,用户拒绝 return false 35 | */ 36 | async function checkApiAuth(scope, options = {}, isAuto = true) { 37 | const fullScope = SCOPES[scope]; 38 | const { authSetting } = await wxp.getSetting(); 39 | const isFirst = !Object.prototype.hasOwnProperty.call(authSetting, `scope.${fullScope}`); 40 | 41 | try { 42 | if (!authSetting[`scope.${fullScope}`]) { 43 | await wxp.authorize({ scope: `scope.${fullScope}` }); 44 | } 45 | const res = isAuto ? (await wxp[scope](options)) : true; 46 | return res; 47 | } catch (e) { 48 | if (e.errMsg.indexOf('authorize:fail') !== -1) { 49 | if (isFirst) { 50 | return false; 51 | } 52 | const { confirm } = await wxp.showModal({ content: `授权失败,请在设置中打开“${SCOPE_MAP[fullScope]}”开关后继续操作`, confirmText: '去设置' }); 53 | if (confirm) { 54 | const { authSetting } = await wxp.openSetting(); 55 | if (authSetting[`scope.${fullScope}`]) { 56 | return true; 57 | } 58 | } 59 | return false; 60 | } 61 | throw e; 62 | } 63 | } 64 | 65 | export default checkApiAuth; 66 | -------------------------------------------------------------------------------- /src/utils/decoder.js: -------------------------------------------------------------------------------- 1 | export default function decoder(text) { 2 | if (/^(\d+|\d*\.\d+)$/.test(text)) { 3 | const num = parseFloat(text); 4 | return num.toString() === text ? num : text; 5 | } 6 | const keywords = { true: true, false: false, null: null, undefined: undefined }; 7 | if (text in keywords) { 8 | return keywords[text]; 9 | } 10 | try { 11 | return decodeURIComponent(text.replace(/\+/g, ' ')); 12 | } catch (err) { 13 | return text; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export { request } from './request'; 2 | export { 3 | autoLoading, 4 | autoLoadingDecorator, 5 | showLoading, 6 | showToast, 7 | errToast, 8 | pageRefresh, 9 | confirm, 10 | alert 11 | } from './prompt'; 12 | export { nav } from './nav'; 13 | export { default as sleep } from './sleep'; 14 | export { randomString, randomFileName } from './random'; 15 | export { uploadFiles } from './upload-files'; 16 | export { saveFiles } from './save-files'; 17 | export checkApiAuth from './check-api-auth'; 18 | export mergeShareMessage from './merge-share-message'; 19 | -------------------------------------------------------------------------------- /src/utils/merge-share-message.js: -------------------------------------------------------------------------------- 1 | export default function mergeShareMessage(config = {}) { 2 | let { title, path, imageUrl } = config 3 | 4 | if (/^http/.test(path)) { 5 | path = `/pages/extra/web-site/index?src=${encodeURIComponent(path)}` 6 | } else if (!path) { 7 | path = '/pages/root/home/index' 8 | } 9 | 10 | if (!imageUrl) { 11 | imageUrl = '' 12 | } 13 | 14 | return { title, path, imageUrl } 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/nav.js: -------------------------------------------------------------------------------- 1 | import qs from 'qs'; 2 | 3 | class Nav { 4 | tabPages = [ 5 | '/pages/root/home' 6 | ] 7 | 8 | tabQueryList = { 9 | '/pages/root/home': {}, 10 | } 11 | 12 | get currentPage() { 13 | const pages = getCurrentPages(); 14 | return pages[pages.length - 1]; 15 | } 16 | 17 | get prevPage() { 18 | const pages = getCurrentPages(); 19 | return pages[pages.length - 2]; 20 | } 21 | 22 | goHome() { 23 | return wxp.reLaunch({ url: '/pages/root/home' }); 24 | } 25 | 26 | isTabPage(url = this.currentPage.route) { 27 | const reg = new RegExp(url); 28 | return !!this.tabPages.find(item => reg.test(item)); 29 | } 30 | 31 | navigateTo(options) { 32 | const { url } = options; 33 | 34 | // 处理超过十级页面无法跳转问题 35 | const pages = getCurrentPages(); 36 | const navType = pages.length < 10 ? 'navigateTo' : 'redirectTo'; 37 | return wxp[navType]({ ...options, url }); 38 | } 39 | 40 | redirectTo(options) { 41 | return wxp.redirectTo(options); 42 | } 43 | 44 | reLaunch(options) { 45 | return wxp.redirectTo(options); 46 | } 47 | 48 | switchTab(options) { 49 | const { url } = options; 50 | const [link, search] = url.split('?'); 51 | // 支持跳转 tab 时,传递参数,一般是 url 来源 api 52 | this.tabQueryList[url] = qs.parse(search); 53 | wxp.switchTab({ ...options, url: link }); 54 | } 55 | 56 | navigateBack(options = {}) { 57 | const { delta = 1 } = options; 58 | const pages = getCurrentPages(); 59 | const canBack = pages.length > delta; 60 | canBack ? wxp.navigateBack(options) : this.goHome(); 61 | } 62 | 63 | nav(link) { 64 | if (link) { 65 | if (/^http/.test(link)) { 66 | this.navigateTo({ url: `/pages/extra/web-site?src=${encodeURIComponent(link)}` }); 67 | } else if (this.isTabPage(link)) { 68 | this.switchTab({ url: link }); 69 | } else { 70 | this.navigateTo({ url: link }); 71 | } 72 | } 73 | } 74 | } 75 | 76 | export const nav = new Nav(); 77 | -------------------------------------------------------------------------------- /src/utils/prompt.js: -------------------------------------------------------------------------------- 1 | export function showToast(params) { 2 | const options = { 3 | title: '', 4 | icon: 'none', 5 | mask: true, 6 | duration: 1500, 7 | }; 8 | if (typeof params === 'string') { 9 | Object.assign(options, { title: params }); 10 | } else { 11 | Object.assign(options, params); 12 | } 13 | wxp.showToast(options); 14 | return new Promise(resolve => setTimeout(resolve, options.duration)); 15 | } 16 | 17 | export function showLoading(params) { 18 | const options = { 19 | title: '', 20 | mask: true, 21 | }; 22 | if (typeof params === 'string') { 23 | Object.assign(options, { title: params }); 24 | } else { 25 | Object.assign(options, params); 26 | } 27 | return wxp.showLoading(options); 28 | } 29 | 30 | export const alert = (content, opts = {}) => { 31 | return wxp.showModal({ 32 | content, 33 | confirmText: '我知道了', 34 | showCancel: false, 35 | ...opts 36 | }); 37 | }; 38 | 39 | export const confirm = async (content, opts = {}) => { 40 | const { confirm } = await wxp.showModal({ 41 | content, 42 | confirmText: '确认', 43 | showCancel: true, 44 | cancelText: '取消', 45 | ...opts 46 | }); 47 | if (!confirm) { 48 | return Promise.reject(new Error('用户取消')); 49 | } 50 | }; 51 | 52 | export function autoLoadingDecorator(target, name, descriptor) { 53 | const func = descriptor.value; 54 | descriptor.value = function () { 55 | return autoLoading(func.apply(this, arguments)); 56 | }; 57 | } 58 | 59 | export function autoLoading(target, options) { 60 | showLoading(options || '加载中'); 61 | const action = Promise.resolve(target instanceof Function ? target() : target); 62 | return action 63 | .finally(() => { 64 | wxp.hideLoading(); 65 | }) 66 | .catch(err => { 67 | errHandle(err); 68 | }); 69 | } 70 | 71 | export function pageRefresh(target, name, descriptor) { 72 | const func = descriptor.value; 73 | descriptor.value = async function () { 74 | try { 75 | await func.apply(this, arguments); 76 | } catch (err) { 77 | errHandle(err); 78 | } finally { 79 | wxp.stopPullDownRefresh(); 80 | } 81 | }; 82 | } 83 | 84 | export function errToast(target, name, descriptor) { 85 | const func = descriptor.value; 86 | descriptor.value = function () { 87 | return func.apply(this, arguments).catch(err => { 88 | wxp.hideLoading(); 89 | errHandle(err); 90 | }); 91 | }; 92 | } 93 | 94 | export function errHandle(err) { 95 | const ignoreErrors = /(cancel|ignore|请先登录)/i; 96 | const msg = err.message || err.errMsg; 97 | if (!ignoreErrors.test(msg)) { 98 | msg && alert(msg, { 99 | title: '请求失败', 100 | }); 101 | } 102 | throw err; 103 | } 104 | -------------------------------------------------------------------------------- /src/utils/random.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | export function randomString() { 4 | return Math.random().toString(36).substr(2, 9); 5 | } 6 | 7 | export function randomFileName(fileName) { 8 | return [ 9 | dayjs().format('YYMMDD'), 10 | randomString(), 11 | fileName 12 | ].filter(Boolean).join('/'); 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/request.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import qs from 'qs'; 3 | import axios from 'axios'; 4 | import settle from 'axios/lib/core/settle'; 5 | import createError from 'axios/lib/core/createError'; 6 | import buildFullPath from 'axios/lib/core/buildFullPath'; 7 | import buildURL from 'axios/lib/helpers/buildURL'; 8 | import decoder from './decoder'; 9 | 10 | const request = axios.create({ 11 | baseURL: process.env.APP_API_HOST + '/app/api/v1', 12 | timeout: 30000, 13 | headers: { 'Content-Type': 'application/json' }, 14 | paramsSerializer(params) { 15 | return qs.stringify(params, { arrayFormat: 'brackets' }); 16 | }, 17 | transformRequest: [(data, headers) => { 18 | if (!headers['Authorization']) { 19 | const { authStore } = require('@/stores/auth-store'); 20 | headers['Authorization'] = authStore.access_token; 21 | } 22 | return data; 23 | }], 24 | adapter(config) { 25 | const fullPath = buildFullPath(config.baseURL, config.url); 26 | return wxp.request({ 27 | method: config.method.toUpperCase(), 28 | url: buildURL(fullPath, config.params, config.paramsSerializer), 29 | header: config.headers, 30 | data: config.data, 31 | dataType: config.dataType || undefined, 32 | responseType: config.responseType || 'text', 33 | enableCache: true, 34 | }) 35 | .then( 36 | res => { 37 | return new Promise((resolve, reject) => { 38 | settle(resolve, reject, { 39 | data: res.data, 40 | status: res.statusCode, 41 | statusText: res.errMsg, 42 | headers: res.header, 43 | config: config, 44 | }); 45 | }); 46 | }, 47 | res => { 48 | return Promise.reject(createError( 49 | res.errMsg, 50 | config, 51 | 0, 52 | )); 53 | } 54 | ); 55 | } 56 | }); 57 | 58 | request.interceptors.response.use( 59 | res => { 60 | res.isResponse = true; 61 | res.meta = {}; 62 | _.forEach(res.headers, (v, k) => { 63 | if (/^x-/i.test(k)) { 64 | const key = _.snakeCase(k.replace(/^x-/i, '')); 65 | res.meta[key] = decoder(v); 66 | } 67 | }); 68 | return res; 69 | }, 70 | err => { 71 | const response = _.get(err, 'response', {}); 72 | const { error_message, messages, error, code } = response.data; 73 | err.message = error_message || messages || error || err.message; 74 | err.code = code; 75 | err.status = response.status; 76 | if (response.status === 401) { 77 | return login_once().then(() => request.request(response.config)); 78 | } 79 | return Promise.reject(err); 80 | } 81 | ); 82 | 83 | let login_once = _.once(login); 84 | 85 | async function login() { 86 | const { authStore } = require('@/stores/auth-store'); 87 | try { 88 | await authStore.login(); 89 | } finally { 90 | login_once = _.once(login); 91 | } 92 | } 93 | 94 | export { request }; 95 | -------------------------------------------------------------------------------- /src/utils/save-files.js: -------------------------------------------------------------------------------- 1 | import checkApiAuth from './check-api-auth'; 2 | 3 | /** 4 | * 保存图片或视频到本地相册 5 | * 6 | * @param {String|Array} urls 媒体文件本地链接或网络链接 7 | * @param {String} mediumType 标识保存的是图片还是视频,默认保存图片 8 | */ 9 | export async function saveFiles(urls, mediumType = 'image') { 10 | const method = mediumType === 'video' ? 'saveVideoToPhotosAlbum' : 'saveImageToPhotosAlbum'; 11 | await checkApiAuth(method, {}, false); 12 | await saveToPhotosAlbum(urls, method); 13 | } 14 | 15 | async function saveToPhotosAlbum(urls, method) { 16 | for (const url of [].concat(urls)) { 17 | let filePath; 18 | if (/^wxfile/.test(url) || /^http:\/\/tmp\//.test(url)) { 19 | filePath = url; 20 | } else { 21 | const { tempFilePath } = await wxp.downloadFile({ url }); 22 | filePath = tempFilePath; 23 | } 24 | await wxp[method]({ filePath }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/sleep.js: -------------------------------------------------------------------------------- 1 | export default function(time) { 2 | return new Promise((resolve) => { 3 | setTimeout(() => resolve(), time); 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/to-json-deep.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | // 通过 class new 出来的对象,在小程序 setData 时会丢失 getter 属性,通过下面的函数可解决 4 | // 此方法已用于 src/stores/helper/observable.js、src/models/record.js 5 | export default function toJSONDeep() { 6 | if (!this.$json_names) { 7 | const ownNames = Object.keys(this); 8 | const getComputedNames = function (store, __proto__ = store.__proto__) { 9 | if (__proto__ === Object.prototype) { 10 | return; 11 | } 12 | const descriptors = Object.getOwnPropertyDescriptors(__proto__); 13 | _.forEach(descriptors, (descriptor, name) => { 14 | if (!ownNames.includes(name) && descriptor.get) { 15 | ownNames.push(name); 16 | } 17 | }); 18 | getComputedNames(store, __proto__.__proto__); 19 | }; 20 | 21 | getComputedNames(this); 22 | this.$json_names = ownNames; 23 | } 24 | 25 | const excludeJsonNames = this.constructor.excludeJsonNames || []; 26 | return _.pick(this, _.without(this.$json_names, ...excludeJsonNames)); 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/upload-files.js: -------------------------------------------------------------------------------- 1 | import { request } from './request'; 2 | import { randomString } from './random'; 3 | 4 | /** 5 | * 上传文件 6 | * 7 | * @param {String|String[]} tempFilePaths 本地文件地址 8 | * @param {String} type 文件类型,默认是 image,上传视频时传 video 9 | * 10 | * @return {Promise} 11 | */ 12 | export function uploadFiles(tempFilePaths, type = 'image') { 13 | const results = [].concat(tempFilePaths).map(async path => { 14 | const fileInfo = await wxp.getFileInfo({ filePath: path }); 15 | 16 | let extname = type === 'image' ? 'jpeg' : 'mp4'; 17 | if (/\./.test(path)) { 18 | extname = path.split('.').pop(); 19 | } 20 | const filename = `${randomString()}.${extname}`; 21 | const params = { 22 | hex_digest: fileInfo.digest, 23 | filename, 24 | byte_size: fileInfo.size, 25 | content_type: `${type}/${extname}`, 26 | }; 27 | 28 | // 获取签名相关 29 | const { data: { direct_upload, signed_id } } = await request.post('/active_storage/direct_upload', params); 30 | 31 | // 获取文件Binary 32 | const fileSystemManager = wxp.getFileSystemManager(); 33 | const fileBinary = fileSystemManager.readFileSync(path); 34 | 35 | // 上传到 oss 36 | // Todo 目前只验证了阿里云,七牛和 S3 是否适用还得等实际项目测试 37 | await request.put(direct_upload.url, fileBinary, { headers: direct_upload.headers }); 38 | 39 | return { 40 | signed_id, 41 | url: `${process.env.APP_API_HOST}/rails/active_storage/blobs/${signed_id}/${filename}`, 42 | }; 43 | }); 44 | 45 | return Promise.all(results); 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/wxp.js: -------------------------------------------------------------------------------- 1 | import { wxp } from '@minapp/wx/wxp' 2 | 3 | (function () { 4 | this.wxp = wxp 5 | })() 6 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | resolve: { 5 | alias: { 6 | '@': path.resolve('src'), 7 | 'mobx$': 'mobx/lib/mobx', 8 | }, 9 | aliasFields: ['browser', 'main'], 10 | unsafeCache: true, 11 | }, 12 | } 13 | --------------------------------------------------------------------------------