init
This commit is contained in:
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*.{js,json,wxml,wxss,md}]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
miniprogram_npm/
|
||||
|
||||
# Build and coverage outputs
|
||||
dist/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
.cache/
|
||||
.eslintcache
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor and IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.suo
|
||||
*.user
|
||||
*.swp
|
||||
*.tmp
|
||||
|
||||
# Local environment and secrets
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
|
||||
# WeChat DevTools local config
|
||||
project.private.config.json
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
5
.prettierrc.json
Normal file
5
.prettierrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
135
README.md
Normal file
135
README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# xuanzhi-wx
|
||||
|
||||
微信原生小程序项目骨架,技术栈为 `原生小程序 + JS + npm + TDesign`。
|
||||
|
||||
当前骨架包含:
|
||||
|
||||
- 主包页面:`首页`、`登录页`
|
||||
- 示例分包:`packages/demo/pages/workbench`
|
||||
- 公共层:`services`、`stores`、`config`、`components`
|
||||
- 工程化:`ESLint`、`Jest`、`Prettier`、`miniprogram-ci`
|
||||
|
||||
## 环境准备
|
||||
|
||||
开始前请先准备:
|
||||
|
||||
- Node.js 和 npm
|
||||
- 微信开发者工具
|
||||
|
||||
## 初始化
|
||||
|
||||
首次拉起项目时执行:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
安装完成后,用微信开发者工具打开项目根目录:
|
||||
|
||||
```text
|
||||
D:\Code3\wdp\xuanzhi-wx
|
||||
```
|
||||
|
||||
然后执行一次:
|
||||
|
||||
```text
|
||||
工具 -> 构建 npm
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 项目使用了 `tdesign-miniprogram`
|
||||
- 只执行 `npm install` 不够,还需要在微信开发者工具里构建 npm
|
||||
- 当你更新了 npm 依赖后,需要重新执行一次“构建 npm”
|
||||
|
||||
## 本地运行
|
||||
|
||||
1. 打开微信开发者工具
|
||||
2. 导入项目根目录
|
||||
3. 确认使用项目内的 `AppID`
|
||||
4. 执行 `工具 -> 构建 npm`
|
||||
5. 点击编译或预览
|
||||
|
||||
项目当前默认入口:
|
||||
|
||||
- 主包首页:`pages/home/index`
|
||||
- 登录页:`pages/login/index`
|
||||
- 示例分包页:`packages/demo/pages/workbench/index`
|
||||
|
||||
## 常用命令
|
||||
|
||||
安装依赖:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
运行测试:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
监听测试:
|
||||
|
||||
```bash
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
检查代码规范:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
格式化文件:
|
||||
|
||||
```bash
|
||||
npm run format
|
||||
```
|
||||
|
||||
## 目录简介
|
||||
|
||||
```text
|
||||
.
|
||||
├─components
|
||||
│ ├─base # 基础组件封装,当前包含 app-button
|
||||
│ └─biz # 业务组件封装,当前包含 entry-card
|
||||
├─config # 环境配置、常量
|
||||
├─pages # 主包页面
|
||||
├─packages # 分包页面
|
||||
├─scripts/ci # miniprogram-ci 脚本
|
||||
├─services # request 和 API 模块
|
||||
├─stores # 全局轻量状态
|
||||
├─tests # Jest 测试
|
||||
└─utils # 纯工具函数
|
||||
```
|
||||
|
||||
## 运行说明
|
||||
|
||||
- 页面里不要直接调用 `wx.request`,统一走 `services/request`
|
||||
- 跨页共享状态统一放 `stores`
|
||||
- 主包尽量只放首屏、登录、Tab 和公共能力
|
||||
- 新业务页面优先考虑放进对应分包
|
||||
|
||||
## CI 上传
|
||||
|
||||
项目已预留 `miniprogram-ci` 脚本:
|
||||
|
||||
```bash
|
||||
npm run ci:preview
|
||||
npm run ci:upload
|
||||
```
|
||||
|
||||
运行前需要准备环境变量:
|
||||
|
||||
- `WEAPP_PRIVATE_KEY_PATH`
|
||||
- `WEAPP_APPID`(可选,不传则默认读取 `project.config.json`)
|
||||
- `WEAPP_VERSION`
|
||||
- `WEAPP_DESC`
|
||||
- `WEAPP_ROBOT`
|
||||
|
||||
## 备注
|
||||
|
||||
- 当前基础库已配置为 `stable`
|
||||
- 如果微信开发者工具里出现组件找不到,优先检查是否已经执行“构建 npm”
|
||||
27
app.js
Normal file
27
app.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const { getRuntimeConfig } = require('./config/env')
|
||||
const { setUnauthorizedHandler } = require('./services/request')
|
||||
const { sessionStore } = require('./stores')
|
||||
|
||||
App({
|
||||
globalData: {
|
||||
runtimeConfig: getRuntimeConfig(),
|
||||
session: sessionStore.getState()
|
||||
},
|
||||
onLaunch() {
|
||||
this.globalData.runtimeConfig = getRuntimeConfig()
|
||||
sessionStore.hydrate()
|
||||
this.globalData.session = sessionStore.getState()
|
||||
sessionStore.subscribe(nextSession => {
|
||||
this.globalData.session = nextSession
|
||||
})
|
||||
setUnauthorizedHandler(() => {
|
||||
const currentRoute = getCurrentPages().slice(-1)[0]?.route
|
||||
|
||||
if (currentRoute !== 'pages/login/index') {
|
||||
wx.reLaunch({
|
||||
url: '/pages/login/index'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
35
app.json
Normal file
35
app.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/home/index",
|
||||
"pages/login/index"
|
||||
],
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "packages/demo",
|
||||
"pages": [
|
||||
"pages/workbench/index"
|
||||
]
|
||||
}
|
||||
],
|
||||
"preloadRule": {
|
||||
"pages/home/index": {
|
||||
"network": "all",
|
||||
"packages": [
|
||||
"packages/demo"
|
||||
]
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "玄志",
|
||||
"navigationBarBackgroundColor": "#f5f7fa",
|
||||
"backgroundColor": "#f5f7fa"
|
||||
},
|
||||
"networkTimeout": {
|
||||
"request": 10000
|
||||
},
|
||||
"style": "v2",
|
||||
"componentFramework": "glass-easel",
|
||||
"sitemapLocation": "sitemap.json",
|
||||
"lazyCodeLoading": "requiredComponents"
|
||||
}
|
||||
47
app.wxss
Normal file
47
app.wxss
Normal file
@@ -0,0 +1,47 @@
|
||||
@import 'tdesign-miniprogram/common/style/index.wxss';
|
||||
|
||||
page {
|
||||
min-height: 100%;
|
||||
background: #f4f6fb;
|
||||
color: #0f172a;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
view,
|
||||
text {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
padding: 40rpx 32rpx 56rpx;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #ffffff;
|
||||
border: 1rpx solid rgba(15, 23, 42, 0.06);
|
||||
border-radius: 28rpx;
|
||||
box-shadow: 0 20rpx 48rpx rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
}
|
||||
44
components/base/app-button/index.js
Normal file
44
components/base/app-button/index.js
Normal file
@@ -0,0 +1,44 @@
|
||||
Component({
|
||||
options: {
|
||||
multipleSlots: true
|
||||
},
|
||||
properties: {
|
||||
text: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
value: 'primary'
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
value: 'base'
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
value: 'medium'
|
||||
},
|
||||
block: {
|
||||
type: Boolean,
|
||||
value: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
value: false
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
value: false
|
||||
},
|
||||
shape: {
|
||||
type: String,
|
||||
value: 'rectangle'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleTap(event) {
|
||||
this.triggerEvent('tap', event.detail)
|
||||
}
|
||||
}
|
||||
})
|
||||
6
components/base/app-button/index.json
Normal file
6
components/base/app-button/index.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {
|
||||
"t-button": "tdesign-miniprogram/button/button"
|
||||
}
|
||||
}
|
||||
14
components/base/app-button/index.wxml
Normal file
14
components/base/app-button/index.wxml
Normal file
@@ -0,0 +1,14 @@
|
||||
<t-button
|
||||
class="app-button"
|
||||
theme="{{theme}}"
|
||||
variant="{{variant}}"
|
||||
size="{{size}}"
|
||||
block="{{block}}"
|
||||
disabled="{{disabled}}"
|
||||
loading="{{loading}}"
|
||||
shape="{{shape}}"
|
||||
bind:tap="handleTap"
|
||||
>
|
||||
<block wx:if="{{text}}">{{text}}</block>
|
||||
<slot></slot>
|
||||
</t-button>
|
||||
7
components/base/app-button/index.wxss
Normal file
7
components/base/app-button/index.wxss
Normal file
@@ -0,0 +1,7 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.app-button {
|
||||
width: 100%;
|
||||
}
|
||||
31
components/biz/entry-card/index.js
Normal file
31
components/biz/entry-card/index.js
Normal file
@@ -0,0 +1,31 @@
|
||||
Component({
|
||||
properties: {
|
||||
title: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
badge: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
actionText: {
|
||||
type: String,
|
||||
value: '进入'
|
||||
},
|
||||
actionPath: {
|
||||
type: String,
|
||||
value: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleAction() {
|
||||
this.triggerEvent('action', {
|
||||
path: this.properties.actionPath
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
7
components/biz/entry-card/index.json
Normal file
7
components/biz/entry-card/index.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {
|
||||
"app-button": "../../base/app-button/index",
|
||||
"t-tag": "tdesign-miniprogram/tag/tag"
|
||||
}
|
||||
}
|
||||
14
components/biz/entry-card/index.wxml
Normal file
14
components/biz/entry-card/index.wxml
Normal file
@@ -0,0 +1,14 @@
|
||||
<view class="entry-card panel">
|
||||
<view class="entry-card__head">
|
||||
<view class="entry-card__copy">
|
||||
<text class="entry-card__title">{{title}}</text>
|
||||
<text class="entry-card__description">{{description}}</text>
|
||||
</view>
|
||||
<t-tag wx:if="{{badge}}" theme="primary" variant="light">{{badge}}</t-tag>
|
||||
</view>
|
||||
|
||||
<view class="entry-card__footer">
|
||||
<slot></slot>
|
||||
<app-button text="{{actionText}}" variant="outline" bind:tap="handleAction"></app-button>
|
||||
</view>
|
||||
</view>
|
||||
38
components/biz/entry-card/index.wxss
Normal file
38
components/biz/entry-card/index.wxss
Normal file
@@ -0,0 +1,38 @@
|
||||
.entry-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
padding: 28rpx;
|
||||
}
|
||||
|
||||
.entry-card__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.entry-card__copy {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.entry-card__title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.entry-card__description {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.entry-card__footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
18
config/constants.js
Normal file
18
config/constants.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const STORAGE_KEYS = {
|
||||
session: 'app.session'
|
||||
}
|
||||
|
||||
const RESULT_CODES = {
|
||||
success: 0
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT = 10000
|
||||
|
||||
const AUTH_EXPIRED_CODES = [401, 419, 10001]
|
||||
|
||||
module.exports = {
|
||||
AUTH_EXPIRED_CODES,
|
||||
REQUEST_TIMEOUT,
|
||||
RESULT_CODES,
|
||||
STORAGE_KEYS
|
||||
}
|
||||
57
config/env.js
Normal file
57
config/env.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const { REQUEST_TIMEOUT } = require('./constants')
|
||||
|
||||
const ENVIRONMENTS = {
|
||||
dev: {
|
||||
name: 'dev',
|
||||
baseURL: 'https://dev-api.example.com',
|
||||
timeout: REQUEST_TIMEOUT
|
||||
},
|
||||
test: {
|
||||
name: 'test',
|
||||
baseURL: 'https://test-api.example.com',
|
||||
timeout: REQUEST_TIMEOUT
|
||||
},
|
||||
prod: {
|
||||
name: 'prod',
|
||||
baseURL: 'https://api.example.com',
|
||||
timeout: REQUEST_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRuntimeEnv(envVersion) {
|
||||
const runtime = envVersion || getWxEnvVersion()
|
||||
|
||||
if (runtime === 'release' || runtime === 'prod') {
|
||||
return 'prod'
|
||||
}
|
||||
|
||||
if (runtime === 'trial' || runtime === 'test') {
|
||||
return 'test'
|
||||
}
|
||||
|
||||
return 'dev'
|
||||
}
|
||||
|
||||
function getWxEnvVersion() {
|
||||
if (typeof wx === 'undefined' || typeof wx.getAccountInfoSync !== 'function') {
|
||||
return process.env.MINIPROGRAM_ENV || 'develop'
|
||||
}
|
||||
|
||||
try {
|
||||
const accountInfo = wx.getAccountInfoSync()
|
||||
return accountInfo?.miniProgram?.envVersion || 'develop'
|
||||
} catch (error) {
|
||||
return 'develop'
|
||||
}
|
||||
}
|
||||
|
||||
function getRuntimeConfig(envVersion) {
|
||||
const envKey = resolveRuntimeEnv(envVersion)
|
||||
return ENVIRONMENTS[envKey]
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ENVIRONMENTS,
|
||||
getRuntimeConfig,
|
||||
resolveRuntimeEnv
|
||||
}
|
||||
30
eslint.config.mjs
Normal file
30
eslint.config.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import globals from 'globals'
|
||||
import prettier from 'eslint-config-prettier'
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['node_modules/**', 'miniprogram_npm/**', 'coverage/**']
|
||||
},
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
App: 'readonly',
|
||||
Behavior: 'readonly',
|
||||
Component: 'readonly',
|
||||
Page: 'readonly',
|
||||
getApp: 'readonly',
|
||||
getCurrentPages: 'readonly',
|
||||
wx: 'readonly'
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }]
|
||||
}
|
||||
},
|
||||
prettier
|
||||
]
|
||||
5
jest.config.cjs
Normal file
5
jest.config.cjs
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
rootDir: '.',
|
||||
testEnvironment: 'jsdom',
|
||||
testMatch: ['<rootDir>/tests/**/*.test.js']
|
||||
}
|
||||
11
jsconfig.json
Normal file
11
jsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"checkJs": true,
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"baseUrl": ".",
|
||||
"types": ["miniprogram-api-typings"]
|
||||
},
|
||||
"include": ["**/*.js"],
|
||||
"exclude": ["node_modules", "miniprogram_npm"]
|
||||
}
|
||||
19017
package-lock.json
generated
Normal file
19017
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "xuanzhi-wx",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Native WeChat miniprogram architecture scaffold with JS, npm, and TDesign.",
|
||||
"scripts": {
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"test": "jest --runInBand",
|
||||
"test:watch": "jest --watch",
|
||||
"format": "prettier --write \"**/*.{js,json,md,wxml,wxss}\"",
|
||||
"ci:preview": "node scripts/ci/preview.js",
|
||||
"ci:upload": "node scripts/ci/upload.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"tdesign-miniprogram": "1.13.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "10.2.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.5.0",
|
||||
"jest": "30.3.0",
|
||||
"jest-environment-jsdom": "30.3.0",
|
||||
"miniprogram-api-typings": "5.1.3",
|
||||
"miniprogram-ci": "2.1.31",
|
||||
"miniprogram-simulate": "1.6.1",
|
||||
"prettier": "3.8.3"
|
||||
}
|
||||
}
|
||||
48
packages/demo/pages/workbench/index.js
Normal file
48
packages/demo/pages/workbench/index.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const { sessionStore } = require('../../../../stores')
|
||||
|
||||
const MODULES = [
|
||||
{
|
||||
title: 'Request Layer',
|
||||
description: '统一超时、重试、鉴权失效和重复提交控制,页面不直接碰 wx.request。',
|
||||
badge: 'service',
|
||||
actionText: '查看首页',
|
||||
actionPath: '/pages/home/index'
|
||||
},
|
||||
{
|
||||
title: 'Session Store',
|
||||
description: '跨页共享的登录态只保留在 store,本页的筛选和列表状态仍然是页面本地状态。',
|
||||
badge: 'store',
|
||||
actionText: '打开登录页',
|
||||
actionPath: '/pages/login/index'
|
||||
}
|
||||
]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
modules: MODULES,
|
||||
currentUser: '访客',
|
||||
localTodos: [
|
||||
'把 /services/api 替换成真实后端接口',
|
||||
'按业务域继续拆 packages/<domain>',
|
||||
'接入 miniprogram-ci 上传非生产环境'
|
||||
]
|
||||
},
|
||||
onLoad() {
|
||||
const state = sessionStore.getState()
|
||||
|
||||
this.setData({
|
||||
currentUser: state.userInfo?.nickname || state.userInfo?.name || '访客'
|
||||
})
|
||||
},
|
||||
handleEntryAction(event) {
|
||||
const { path } = event.detail
|
||||
|
||||
if (!path) {
|
||||
return
|
||||
}
|
||||
|
||||
wx.navigateTo({
|
||||
url: path
|
||||
})
|
||||
}
|
||||
})
|
||||
7
packages/demo/pages/workbench/index.json
Normal file
7
packages/demo/pages/workbench/index.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "工作台",
|
||||
"usingComponents": {
|
||||
"entry-card": "../../../../components/biz/entry-card/index",
|
||||
"t-tag": "tdesign-miniprogram/tag/tag"
|
||||
}
|
||||
}
|
||||
29
packages/demo/pages/workbench/index.wxml
Normal file
29
packages/demo/pages/workbench/index.wxml
Normal file
@@ -0,0 +1,29 @@
|
||||
<view class="page-shell">
|
||||
<view class="panel workbench-hero">
|
||||
<t-tag theme="success" variant="light">Subpackage</t-tag>
|
||||
<text class="workbench-hero__title">业务工作台示例</text>
|
||||
<text class="section-copy">
|
||||
当前用户:{{currentUser}}。这个页面位于分包中,用来承接持续增长的业务,而不是继续把主包做胖。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="entry-list">
|
||||
<entry-card
|
||||
wx:for="{{modules}}"
|
||||
wx:key="title"
|
||||
title="{{item.title}}"
|
||||
description="{{item.description}}"
|
||||
badge="{{item.badge}}"
|
||||
actionText="{{item.actionText}}"
|
||||
actionPath="{{item.actionPath}}"
|
||||
bind:action="handleEntryAction"
|
||||
></entry-card>
|
||||
</view>
|
||||
|
||||
<view class="panel todo-panel">
|
||||
<text class="section-title">下一步扩展</text>
|
||||
<view class="todo-list">
|
||||
<text wx:for="{{localTodos}}" wx:key="*this" class="todo-item">{{index + 1}}. {{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
37
packages/demo/pages/workbench/index.wxss
Normal file
37
packages/demo/pages/workbench/index.wxss
Normal file
@@ -0,0 +1,37 @@
|
||||
.workbench-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.workbench-hero__title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.todo-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.todo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
color: #475569;
|
||||
}
|
||||
93
pages/home/index.js
Normal file
93
pages/home/index.js
Normal file
@@ -0,0 +1,93 @@
|
||||
const { getRuntimeConfig } = require('../../config/env')
|
||||
const { sessionStore } = require('../../stores')
|
||||
const { maskToken } = require('../../utils/util')
|
||||
|
||||
const QUICK_ENTRIES = [
|
||||
{
|
||||
title: '登录主包',
|
||||
description: '演示主包内的认证入口、全局会话同步和基础 UI 组件封装。',
|
||||
badge: '主包',
|
||||
actionText: '打开登录页',
|
||||
actionPath: '/pages/login/index'
|
||||
},
|
||||
{
|
||||
title: '业务工作台',
|
||||
description: '演示分包页面,只在需要时加载,保持主包轻量和首页启动稳定。',
|
||||
badge: '分包',
|
||||
actionText: '进入工作台',
|
||||
actionPath: '/packages/demo/pages/workbench/index'
|
||||
}
|
||||
]
|
||||
|
||||
function buildSessionView(state) {
|
||||
const userName = state.userInfo?.nickname || state.userInfo?.name || '访客'
|
||||
|
||||
return {
|
||||
statusLabel: state.isLoggedIn ? '已登录' : '未登录',
|
||||
userName,
|
||||
tokenLabel: maskToken(state.token),
|
||||
permissionsLabel: state.permissions.length ? state.permissions.join(' / ') : '暂无权限'
|
||||
}
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
quickEntries: QUICK_ENTRIES,
|
||||
envName: '',
|
||||
apiBaseUrl: '',
|
||||
isLoggedIn: false,
|
||||
sessionView: buildSessionView(sessionStore.getState())
|
||||
},
|
||||
onLoad() {
|
||||
const runtimeConfig = getRuntimeConfig()
|
||||
|
||||
this.unsubscribe = sessionStore.subscribe(nextState => {
|
||||
this.syncSession(nextState)
|
||||
})
|
||||
|
||||
this.setData({
|
||||
envName: runtimeConfig.name.toUpperCase(),
|
||||
apiBaseUrl: runtimeConfig.baseURL
|
||||
})
|
||||
this.syncSession(sessionStore.getState())
|
||||
},
|
||||
onUnload() {
|
||||
this.unsubscribe?.()
|
||||
},
|
||||
syncSession(state) {
|
||||
this.setData({
|
||||
isLoggedIn: state.isLoggedIn,
|
||||
sessionView: buildSessionView(state)
|
||||
})
|
||||
},
|
||||
handlePrimaryAction() {
|
||||
if (this.data.isLoggedIn) {
|
||||
wx.navigateTo({
|
||||
url: '/packages/demo/pages/workbench/index'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
wx.navigateTo({
|
||||
url: '/pages/login/index'
|
||||
})
|
||||
},
|
||||
handleLogout() {
|
||||
sessionStore.clearSession()
|
||||
wx.showToast({
|
||||
title: '已清理登录态',
|
||||
icon: 'success'
|
||||
})
|
||||
},
|
||||
handleEntryAction(event) {
|
||||
const { path } = event.detail
|
||||
|
||||
if (!path) {
|
||||
return
|
||||
}
|
||||
|
||||
wx.navigateTo({
|
||||
url: path
|
||||
})
|
||||
}
|
||||
})
|
||||
7
pages/home/index.json
Normal file
7
pages/home/index.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "首页",
|
||||
"usingComponents": {
|
||||
"app-button": "../../components/base/app-button/index",
|
||||
"entry-card": "../../components/biz/entry-card/index"
|
||||
}
|
||||
}
|
||||
65
pages/home/index.wxml
Normal file
65
pages/home/index.wxml
Normal file
@@ -0,0 +1,65 @@
|
||||
<view class="page-shell">
|
||||
<view class="panel hero">
|
||||
<text class="hero__eyebrow">Stable Native Architecture</text>
|
||||
<text class="hero__title">玄志小程序基础骨架</text>
|
||||
<text class="hero__summary">
|
||||
原生小程序 + JS + npm + TDesign + 分包 + service/store 分层,保留微信原生性能和长期维护边界。
|
||||
</text>
|
||||
<app-button
|
||||
block
|
||||
text="{{isLoggedIn ? '进入业务工作台' : '前往登录页'}}"
|
||||
bind:tap="handlePrimaryAction"
|
||||
></app-button>
|
||||
<app-button
|
||||
wx:if="{{isLoggedIn}}"
|
||||
block
|
||||
text="清理登录态"
|
||||
theme="default"
|
||||
variant="outline"
|
||||
bind:tap="handleLogout"
|
||||
></app-button>
|
||||
</view>
|
||||
|
||||
<view class="panel session-card">
|
||||
<text class="section-title">当前会话</text>
|
||||
<view class="session-card__rows">
|
||||
<view class="meta-row">
|
||||
<text class="session-card__label">运行环境</text>
|
||||
<text class="session-card__value">{{envName}}</text>
|
||||
</view>
|
||||
<view class="meta-row">
|
||||
<text class="session-card__label">API 地址</text>
|
||||
<text class="session-card__value session-card__value--mono">{{apiBaseUrl}}</text>
|
||||
</view>
|
||||
<view class="meta-row">
|
||||
<text class="session-card__label">登录状态</text>
|
||||
<text class="session-card__value">{{sessionView.statusLabel}}</text>
|
||||
</view>
|
||||
<view class="meta-row">
|
||||
<text class="session-card__label">当前用户</text>
|
||||
<text class="session-card__value">{{sessionView.userName}}</text>
|
||||
</view>
|
||||
<view class="meta-row">
|
||||
<text class="session-card__label">Token</text>
|
||||
<text class="session-card__value session-card__value--mono">{{sessionView.tokenLabel}}</text>
|
||||
</view>
|
||||
<view class="meta-row meta-row--top">
|
||||
<text class="session-card__label">权限</text>
|
||||
<text class="session-card__value session-card__value--mono">{{sessionView.permissionsLabel}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="entry-list">
|
||||
<entry-card
|
||||
wx:for="{{quickEntries}}"
|
||||
wx:key="actionPath"
|
||||
title="{{item.title}}"
|
||||
description="{{item.description}}"
|
||||
badge="{{item.badge}}"
|
||||
actionText="{{item.actionText}}"
|
||||
actionPath="{{item.actionPath}}"
|
||||
bind:action="handleEntryAction"
|
||||
></entry-card>
|
||||
</view>
|
||||
</view>
|
||||
68
pages/home/index.wxss
Normal file
68
pages/home/index.wxss
Normal file
@@ -0,0 +1,68 @@
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
padding: 36rpx 32rpx;
|
||||
background: linear-gradient(160deg, #0f172a 0%, #1e293b 100%);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.hero__eyebrow {
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 4rpx;
|
||||
text-transform: uppercase;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.hero__title {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero__summary {
|
||||
font-size: 26rpx;
|
||||
line-height: 1.8;
|
||||
color: rgba(248, 250, 252, 0.8);
|
||||
}
|
||||
|
||||
.session-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.session-card__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.session-card__label {
|
||||
flex-shrink: 0;
|
||||
font-size: 24rpx;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.session-card__value {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 24rpx;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.session-card__value--mono {
|
||||
font-family: 'Cascadia Code', 'Courier New', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.meta-row--top {
|
||||
align-items: flex-start;
|
||||
}
|
||||
75
pages/login/index.js
Normal file
75
pages/login/index.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const { sessionStore } = require('../../stores')
|
||||
const { formatDateTime } = require('../../utils/util')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
submitting: false,
|
||||
form: {
|
||||
nickname: '',
|
||||
mobile: ''
|
||||
},
|
||||
mockHint: `请求层已接好,可把接口替换为真实后端。当前时间:${formatDateTime(new Date())}`
|
||||
},
|
||||
handleNicknameChange(event) {
|
||||
this.setData({
|
||||
'form.nickname': event.detail.value
|
||||
})
|
||||
},
|
||||
handleMobileChange(event) {
|
||||
this.setData({
|
||||
'form.mobile': event.detail.value
|
||||
})
|
||||
},
|
||||
handleMockLogin() {
|
||||
const { nickname, mobile } = this.data.form
|
||||
|
||||
if (!nickname.trim()) {
|
||||
wx.showToast({
|
||||
title: '请输入昵称',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({
|
||||
submitting: true
|
||||
})
|
||||
|
||||
sessionStore.setSession({
|
||||
token: `mock_${Date.now()}`,
|
||||
userInfo: {
|
||||
nickname: nickname.trim(),
|
||||
mobile: mobile.trim()
|
||||
},
|
||||
permissions: ['workbench:view', 'profile:update']
|
||||
})
|
||||
|
||||
wx.showToast({
|
||||
title: '模拟登录成功',
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
this.setData({
|
||||
submitting: false
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
wx.reLaunch({
|
||||
url: '/pages/home/index'
|
||||
})
|
||||
}, 300)
|
||||
},
|
||||
handleClearSession() {
|
||||
sessionStore.clearSession()
|
||||
this.setData({
|
||||
form: {
|
||||
nickname: '',
|
||||
mobile: ''
|
||||
}
|
||||
})
|
||||
wx.showToast({
|
||||
title: '已清理',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
})
|
||||
8
pages/login/index.json
Normal file
8
pages/login/index.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"navigationBarTitleText": "登录",
|
||||
"usingComponents": {
|
||||
"app-button": "../../components/base/app-button/index",
|
||||
"t-input": "tdesign-miniprogram/input/input",
|
||||
"t-tag": "tdesign-miniprogram/tag/tag"
|
||||
}
|
||||
}
|
||||
39
pages/login/index.wxml
Normal file
39
pages/login/index.wxml
Normal file
@@ -0,0 +1,39 @@
|
||||
<view class="page-shell">
|
||||
<view class="panel login-hero">
|
||||
<t-tag theme="primary" variant="light">Mock Auth</t-tag>
|
||||
<text class="login-hero__title">主包认证入口</text>
|
||||
<text class="section-copy">{{mockHint}}</text>
|
||||
</view>
|
||||
|
||||
<view class="panel login-form">
|
||||
<text class="section-title">登录信息</text>
|
||||
<t-input
|
||||
label="昵称"
|
||||
clearable
|
||||
placeholder="请输入昵称"
|
||||
value="{{form.nickname}}"
|
||||
bind:change="handleNicknameChange"
|
||||
></t-input>
|
||||
<t-input
|
||||
label="手机号"
|
||||
clearable
|
||||
type="number"
|
||||
placeholder="选填"
|
||||
value="{{form.mobile}}"
|
||||
bind:change="handleMobileChange"
|
||||
></t-input>
|
||||
<app-button
|
||||
block
|
||||
text="写入会话并返回首页"
|
||||
loading="{{submitting}}"
|
||||
bind:tap="handleMockLogin"
|
||||
></app-button>
|
||||
<app-button
|
||||
block
|
||||
text="清理本地会话"
|
||||
theme="default"
|
||||
variant="outline"
|
||||
bind:tap="handleClearSession"
|
||||
></app-button>
|
||||
</view>
|
||||
</view>
|
||||
19
pages/login/index.wxss
Normal file
19
pages/login/index.wxss
Normal file
@@ -0,0 +1,19 @@
|
||||
.login-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.login-hero__title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
padding: 32rpx;
|
||||
}
|
||||
41
project.config.json
Normal file
41
project.config.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "stable",
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"setting": {
|
||||
"coverView": true,
|
||||
"es6": true,
|
||||
"postcss": true,
|
||||
"minified": true,
|
||||
"enhance": true,
|
||||
"showShadowRootInWxmlPanel": true,
|
||||
"packNpmRelationList": [],
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
},
|
||||
"compileWorklet": false,
|
||||
"uglifyFileName": false,
|
||||
"uploadWithSourceMap": true,
|
||||
"packNpmManually": false,
|
||||
"minifyWXSS": true,
|
||||
"minifyWXML": true,
|
||||
"localPlugins": false,
|
||||
"condition": false,
|
||||
"swc": false,
|
||||
"disableSWC": true,
|
||||
"disableUseStrict": false,
|
||||
"useCompilerPlugins": false
|
||||
},
|
||||
"condition": {},
|
||||
"editorSetting": {
|
||||
"tabIndent": "auto",
|
||||
"tabSize": 2
|
||||
},
|
||||
"appid": "wx3462e6109c8d301b",
|
||||
"simulatorPluginLibVersion": {}
|
||||
}
|
||||
14
scripts/ci/preview.js
Normal file
14
scripts/ci/preview.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const ci = require('miniprogram-ci')
|
||||
const { createProject, getBaseSetting, getPreviewOutput } = require('./shared')
|
||||
|
||||
ci.preview({
|
||||
project: createProject(),
|
||||
desc: process.env.WEAPP_DESC || 'codex preview',
|
||||
qrcodeFormat: 'image',
|
||||
qrcodeOutputDest: getPreviewOutput(),
|
||||
setting: getBaseSetting(),
|
||||
onProgressUpdate() {}
|
||||
}).catch(error => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
42
scripts/ci/shared.js
Normal file
42
scripts/ci/shared.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const path = require('path')
|
||||
const ci = require('miniprogram-ci')
|
||||
const projectConfig = require('../../project.config.json')
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name]
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function createProject() {
|
||||
return new ci.Project({
|
||||
appid: process.env.WEAPP_APPID || projectConfig.appid,
|
||||
type: 'miniProgram',
|
||||
projectPath: process.cwd(),
|
||||
privateKeyPath: requireEnv('WEAPP_PRIVATE_KEY_PATH'),
|
||||
ignores: ['node_modules/**/*']
|
||||
})
|
||||
}
|
||||
|
||||
function getBaseSetting() {
|
||||
return {
|
||||
es6: true,
|
||||
minifyJS: true,
|
||||
minifyWXML: true,
|
||||
minifyWXSS: true
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviewOutput() {
|
||||
return path.join(process.cwd(), 'dist', 'preview.jpg')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createProject,
|
||||
getBaseSetting,
|
||||
getPreviewOutput
|
||||
}
|
||||
14
scripts/ci/upload.js
Normal file
14
scripts/ci/upload.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const ci = require('miniprogram-ci')
|
||||
const { createProject, getBaseSetting } = require('./shared')
|
||||
|
||||
ci.upload({
|
||||
project: createProject(),
|
||||
version: process.env.WEAPP_VERSION || '0.1.0',
|
||||
desc: process.env.WEAPP_DESC || 'codex upload',
|
||||
robot: Number(process.env.WEAPP_ROBOT || 1),
|
||||
setting: getBaseSetting(),
|
||||
onProgressUpdate() {}
|
||||
}).catch(error => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
5
services/api/index.js
Normal file
5
services/api/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const { userApi } = require('./user')
|
||||
|
||||
module.exports = {
|
||||
userApi
|
||||
}
|
||||
40
services/api/user.js
Normal file
40
services/api/user.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const { request: defaultRequest } = require('../request')
|
||||
|
||||
/**
|
||||
* @typedef {Object} UserProfilePayload
|
||||
* @property {string} nickname
|
||||
* @property {string} mobile
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {{ request?: (options: Record<string, any>) => Promise<{ code: number, data: any, message: string }> }} [options]
|
||||
*/
|
||||
function createUserApi(options = {}) {
|
||||
const request = options.request || defaultRequest
|
||||
|
||||
return {
|
||||
getProfile() {
|
||||
return request({
|
||||
url: '/users/me',
|
||||
method: 'GET'
|
||||
})
|
||||
},
|
||||
/**
|
||||
* @param {UserProfilePayload} payload
|
||||
*/
|
||||
updateProfile(payload) {
|
||||
return request({
|
||||
url: '/users/me',
|
||||
method: 'PUT',
|
||||
data: payload
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userApi = createUserApi()
|
||||
|
||||
module.exports = {
|
||||
createUserApi,
|
||||
userApi
|
||||
}
|
||||
181
services/request/index.js
Normal file
181
services/request/index.js
Normal file
@@ -0,0 +1,181 @@
|
||||
const { AUTH_EXPIRED_CODES, REQUEST_TIMEOUT, RESULT_CODES } = require('../../config/constants')
|
||||
const { getRuntimeConfig } = require('../../config/env')
|
||||
const { sessionStore: defaultSessionStore } = require('../../stores')
|
||||
|
||||
class RequestError extends Error {
|
||||
constructor(options = {}) {
|
||||
super(options.message || 'Request failed')
|
||||
this.name = 'RequestError'
|
||||
this.code = typeof options.code === 'number' ? options.code : -1
|
||||
this.statusCode = options.statusCode || 0
|
||||
this.data = options.data || null
|
||||
this.originalError = options.originalError
|
||||
}
|
||||
}
|
||||
|
||||
function defaultShouldRetry(error) {
|
||||
const message = `${error.message || ''}`.toLowerCase()
|
||||
return message.includes('timeout') || message.includes('timed out')
|
||||
}
|
||||
|
||||
function createRequestKey(options) {
|
||||
return JSON.stringify({
|
||||
url: options.url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data || null
|
||||
})
|
||||
}
|
||||
|
||||
function createWxRequestAdapter() {
|
||||
return function wxRequestAdapter(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof wx === 'undefined' || typeof wx.request !== 'function') {
|
||||
reject(new Error('wx.request is not available'))
|
||||
return
|
||||
}
|
||||
|
||||
wx.request({
|
||||
...options,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeError(error, fallback = {}) {
|
||||
if (error instanceof RequestError) {
|
||||
return error
|
||||
}
|
||||
|
||||
return new RequestError({
|
||||
code: typeof fallback.code === 'number' ? fallback.code : -1,
|
||||
message: error?.message || fallback.message || 'Network request failed',
|
||||
statusCode: fallback.statusCode || 0,
|
||||
data: fallback.data || null,
|
||||
originalError: error
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeResponse(response) {
|
||||
const payload = response?.data || {}
|
||||
const code = typeof payload.code === 'number' ? payload.code : RESULT_CODES.success
|
||||
const data = Object.prototype.hasOwnProperty.call(payload, 'data') ? payload.data : null
|
||||
const message = payload.message || ''
|
||||
|
||||
return {
|
||||
code,
|
||||
data,
|
||||
message,
|
||||
statusCode: response?.statusCode || 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* requestAdapter?: (options: Record<string, any>) => Promise<any>,
|
||||
* authExpiredCodes?: number[],
|
||||
* onUnauthorized?: (error: RequestError) => void,
|
||||
* sessionStore?: { getState(): { token: string }, clearSession(): void },
|
||||
* getConfig?: () => { baseURL: string, timeout: number },
|
||||
* shouldRetry?: (error: RequestError, attempt: number) => boolean
|
||||
* }} [options]
|
||||
*/
|
||||
function createRequester(options = {}) {
|
||||
const requestAdapter = options.requestAdapter || createWxRequestAdapter()
|
||||
const authExpiredCodes = options.authExpiredCodes || AUTH_EXPIRED_CODES
|
||||
const onUnauthorized = options.onUnauthorized
|
||||
const sessionStore = options.sessionStore || defaultSessionStore
|
||||
const getConfig = options.getConfig || getRuntimeConfig
|
||||
const shouldRetry = options.shouldRetry || defaultShouldRetry
|
||||
const inflightMap = new Map()
|
||||
|
||||
async function executeRequest(requestOptions, attempt) {
|
||||
const config = getConfig()
|
||||
const sessionState = typeof sessionStore.getState === 'function' ? sessionStore.getState() : { token: '' }
|
||||
const headers = {
|
||||
...(requestOptions.header || {})
|
||||
}
|
||||
|
||||
if (sessionState.token) {
|
||||
headers.Authorization = `Bearer ${sessionState.token}`
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await requestAdapter({
|
||||
url: `${config.baseURL}${requestOptions.url}`,
|
||||
method: requestOptions.method || 'GET',
|
||||
data: requestOptions.data,
|
||||
timeout: requestOptions.timeout || config.timeout || REQUEST_TIMEOUT,
|
||||
header: headers
|
||||
})
|
||||
const normalizedResponse = normalizeResponse(response)
|
||||
|
||||
if (authExpiredCodes.includes(normalizedResponse.code)) {
|
||||
const error = new RequestError(normalizedResponse)
|
||||
sessionStore.clearSession?.()
|
||||
onUnauthorized?.(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (normalizedResponse.code !== RESULT_CODES.success) {
|
||||
throw new RequestError(normalizedResponse)
|
||||
}
|
||||
|
||||
return {
|
||||
code: normalizedResponse.code,
|
||||
data: normalizedResponse.data,
|
||||
message: normalizedResponse.message
|
||||
}
|
||||
} catch (error) {
|
||||
const normalizedError = normalizeError(error)
|
||||
|
||||
if (attempt < (requestOptions.retry || 0) && shouldRetry(normalizedError, attempt + 1)) {
|
||||
return executeRequest(requestOptions, attempt + 1)
|
||||
}
|
||||
|
||||
throw normalizedError
|
||||
}
|
||||
}
|
||||
|
||||
return function request(requestOptions) {
|
||||
const dedupeKey = requestOptions.dedupe ? createRequestKey(requestOptions) : ''
|
||||
|
||||
if (dedupeKey && inflightMap.has(dedupeKey)) {
|
||||
return inflightMap.get(dedupeKey)
|
||||
}
|
||||
|
||||
const pendingRequest = executeRequest(requestOptions, 0).finally(() => {
|
||||
if (dedupeKey) {
|
||||
inflightMap.delete(dedupeKey)
|
||||
}
|
||||
})
|
||||
|
||||
if (dedupeKey) {
|
||||
inflightMap.set(dedupeKey, pendingRequest)
|
||||
}
|
||||
|
||||
return pendingRequest
|
||||
}
|
||||
}
|
||||
|
||||
let unauthorizedHandler = null
|
||||
|
||||
function setUnauthorizedHandler(handler) {
|
||||
unauthorizedHandler = handler
|
||||
}
|
||||
|
||||
const request = createRequester({
|
||||
sessionStore: defaultSessionStore,
|
||||
onUnauthorized(error) {
|
||||
unauthorizedHandler?.(error)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
RequestError,
|
||||
createRequestKey,
|
||||
createRequester,
|
||||
request,
|
||||
setUnauthorizedHandler
|
||||
}
|
||||
7
sitemap.json
Normal file
7
sitemap.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
|
||||
"rules": [{
|
||||
"action": "allow",
|
||||
"page": "*"
|
||||
}]
|
||||
}
|
||||
5
stores/index.js
Normal file
5
stores/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const { sessionStore } = require('./modules/session')
|
||||
|
||||
module.exports = {
|
||||
sessionStore
|
||||
}
|
||||
128
stores/modules/session.js
Normal file
128
stores/modules/session.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const { STORAGE_KEYS } = require('../../config/constants')
|
||||
|
||||
const EMPTY_STATE = Object.freeze({
|
||||
isLoggedIn: false,
|
||||
token: '',
|
||||
userInfo: null,
|
||||
permissions: []
|
||||
})
|
||||
|
||||
function cloneSessionState(state) {
|
||||
return {
|
||||
isLoggedIn: Boolean(state.token),
|
||||
token: state.token || '',
|
||||
userInfo: state.userInfo ? { ...state.userInfo } : null,
|
||||
permissions: Array.isArray(state.permissions) ? [...state.permissions] : []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSession(payload = {}) {
|
||||
return cloneSessionState({
|
||||
...EMPTY_STATE,
|
||||
...payload
|
||||
})
|
||||
}
|
||||
|
||||
function createStorageAdapter() {
|
||||
return {
|
||||
get(key) {
|
||||
if (typeof wx === 'undefined' || typeof wx.getStorageSync !== 'function') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
return wx.getStorageSync(key)
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
set(key, value) {
|
||||
if (typeof wx === 'undefined' || typeof wx.setStorageSync !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
wx.setStorageSync(key, value)
|
||||
},
|
||||
remove(key) {
|
||||
if (typeof wx === 'undefined' || typeof wx.removeStorageSync !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
wx.removeStorageSync(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} SessionPayload
|
||||
* @property {string} token
|
||||
* @property {Record<string, any>|null} userInfo
|
||||
* @property {string[]} permissions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* storage?: {get(key: string): any, set(key: string, value: any): void, remove(key: string): void},
|
||||
* storageKey?: string
|
||||
* }} [options]
|
||||
*/
|
||||
function createSessionStore(options = {}) {
|
||||
const storage = options.storage || createStorageAdapter()
|
||||
const storageKey = options.storageKey || STORAGE_KEYS.session
|
||||
const listeners = new Set()
|
||||
let state = normalizeSession()
|
||||
|
||||
function emitChange() {
|
||||
const snapshot = cloneSessionState(state)
|
||||
listeners.forEach(listener => listener(snapshot))
|
||||
}
|
||||
|
||||
return {
|
||||
getState() {
|
||||
return cloneSessionState(state)
|
||||
},
|
||||
hydrate() {
|
||||
const cachedSession = storage.get(storageKey)
|
||||
|
||||
if (cachedSession) {
|
||||
state = normalizeSession(cachedSession)
|
||||
emitChange()
|
||||
}
|
||||
|
||||
return this.getState()
|
||||
},
|
||||
/**
|
||||
* @param {SessionPayload} payload
|
||||
*/
|
||||
setSession(payload) {
|
||||
state = normalizeSession(payload)
|
||||
storage.set(storageKey, {
|
||||
token: state.token,
|
||||
userInfo: state.userInfo,
|
||||
permissions: state.permissions
|
||||
})
|
||||
emitChange()
|
||||
return this.getState()
|
||||
},
|
||||
clearSession() {
|
||||
state = normalizeSession()
|
||||
storage.remove(storageKey)
|
||||
emitChange()
|
||||
return this.getState()
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sessionStore = createSessionStore()
|
||||
|
||||
module.exports = {
|
||||
createSessionStore,
|
||||
sessionStore
|
||||
}
|
||||
41
tests/base-button.test.js
Normal file
41
tests/base-button.test.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const path = require('path')
|
||||
const simulate = require('miniprogram-simulate')
|
||||
|
||||
describe('app-button', () => {
|
||||
test('passes core props to TDesign and re-emits tap events', async () => {
|
||||
const id = simulate.load(path.join(process.cwd(), 'components/base/app-button/index'), {
|
||||
usingComponents: {
|
||||
't-button': path.join(
|
||||
process.cwd(),
|
||||
'node_modules/tdesign-miniprogram/miniprogram_dist/button/button'
|
||||
)
|
||||
}
|
||||
})
|
||||
const comp = simulate.render(id, {
|
||||
text: '进入工作台',
|
||||
theme: 'danger',
|
||||
variant: 'outline'
|
||||
})
|
||||
const parent = document.createElement('div')
|
||||
const tapHandler = jest.fn()
|
||||
|
||||
comp.attach(parent)
|
||||
comp.addEventListener('tap', tapHandler)
|
||||
const tree = comp.toJSON()
|
||||
const innerButton = tree.children[0]
|
||||
|
||||
expect(innerButton.tagName).toBe('t-button')
|
||||
expect(innerButton.attrs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'theme', value: 'danger' }),
|
||||
expect.objectContaining({ name: 'variant', value: 'outline' })
|
||||
])
|
||||
)
|
||||
expect(innerButton.children).toContain('进入工作台')
|
||||
|
||||
comp.instance.handleTap({ detail: { source: 'unit-test' } })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(tapHandler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
175
tests/request.test.js
Normal file
175
tests/request.test.js
Normal file
@@ -0,0 +1,175 @@
|
||||
const { createRequester, RequestError } = require('../services/request')
|
||||
|
||||
describe('createRequester', () => {
|
||||
test('normalizes successful responses', async () => {
|
||||
const requester = createRequester({
|
||||
requestAdapter: jest.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
data: {
|
||||
code: 0,
|
||||
data: { id: 1, name: 'Xuanzhi' },
|
||||
message: 'ok'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
requester({
|
||||
url: '/users/me',
|
||||
method: 'GET'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
code: 0,
|
||||
data: { id: 1, name: 'Xuanzhi' },
|
||||
message: 'ok'
|
||||
})
|
||||
})
|
||||
|
||||
test('retries retryable transport failures once', async () => {
|
||||
const requestAdapter = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('timeout'))
|
||||
.mockResolvedValueOnce({
|
||||
statusCode: 200,
|
||||
data: {
|
||||
code: 0,
|
||||
data: { ok: true },
|
||||
message: 'ok'
|
||||
}
|
||||
})
|
||||
|
||||
const requester = createRequester({
|
||||
requestAdapter,
|
||||
shouldRetry: error => error.message === 'timeout'
|
||||
})
|
||||
|
||||
await expect(
|
||||
requester({
|
||||
url: '/reports/slow-task',
|
||||
retry: 1
|
||||
})
|
||||
).resolves.toEqual({
|
||||
code: 0,
|
||||
data: { ok: true },
|
||||
message: 'ok'
|
||||
})
|
||||
|
||||
expect(requestAdapter).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test('rejects business errors with a normalized error instance', async () => {
|
||||
const requester = createRequester({
|
||||
requestAdapter: jest.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
data: {
|
||||
code: 50001,
|
||||
data: null,
|
||||
message: 'invalid payload'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
requester({
|
||||
url: '/orders',
|
||||
method: 'POST',
|
||||
data: { name: '' }
|
||||
})
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'RequestError',
|
||||
code: 50001,
|
||||
message: 'invalid payload'
|
||||
})
|
||||
)
|
||||
|
||||
expect(RequestError).toBeDefined()
|
||||
})
|
||||
|
||||
test('clears auth state and notifies unauthorized handler when token expires', async () => {
|
||||
const onUnauthorized = jest.fn()
|
||||
const sessionStore = {
|
||||
clearSession: jest.fn()
|
||||
}
|
||||
|
||||
const requester = createRequester({
|
||||
requestAdapter: jest.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
data: {
|
||||
code: 401,
|
||||
data: null,
|
||||
message: 'token expired'
|
||||
}
|
||||
}),
|
||||
authExpiredCodes: [401],
|
||||
onUnauthorized,
|
||||
sessionStore
|
||||
})
|
||||
|
||||
await expect(requester({ url: '/users/me' })).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
code: 401,
|
||||
message: 'token expired'
|
||||
})
|
||||
)
|
||||
|
||||
expect(sessionStore.clearSession).toHaveBeenCalledTimes(1)
|
||||
expect(onUnauthorized).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: 401,
|
||||
message: 'token expired'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test('deduplicates in-flight requests when dedupe is enabled', async () => {
|
||||
let resolveRequest
|
||||
const requestAdapter = jest.fn(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
resolveRequest = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const requester = createRequester({
|
||||
requestAdapter
|
||||
})
|
||||
|
||||
const firstPromise = requester({
|
||||
url: '/orders/submit',
|
||||
method: 'POST',
|
||||
data: { skuId: 1 },
|
||||
dedupe: true
|
||||
})
|
||||
const secondPromise = requester({
|
||||
url: '/orders/submit',
|
||||
method: 'POST',
|
||||
data: { skuId: 1 },
|
||||
dedupe: true
|
||||
})
|
||||
|
||||
resolveRequest({
|
||||
statusCode: 200,
|
||||
data: {
|
||||
code: 0,
|
||||
data: { orderId: 'A1001' },
|
||||
message: 'ok'
|
||||
}
|
||||
})
|
||||
|
||||
await expect(Promise.all([firstPromise, secondPromise])).resolves.toEqual([
|
||||
{
|
||||
code: 0,
|
||||
data: { orderId: 'A1001' },
|
||||
message: 'ok'
|
||||
},
|
||||
{
|
||||
code: 0,
|
||||
data: { orderId: 'A1001' },
|
||||
message: 'ok'
|
||||
}
|
||||
])
|
||||
|
||||
expect(requestAdapter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
87
tests/session.test.js
Normal file
87
tests/session.test.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const { createSessionStore } = require('../stores/modules/session')
|
||||
|
||||
describe('createSessionStore', () => {
|
||||
test('hydrates persisted session and notifies subscribers on updates', () => {
|
||||
const storage = new Map([
|
||||
[
|
||||
'session',
|
||||
{
|
||||
token: 'cached-token',
|
||||
userInfo: { id: 1, name: 'Xuanzhi' },
|
||||
permissions: ['dashboard:view']
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
const sessionStore = createSessionStore({
|
||||
storageKey: 'session',
|
||||
storage: {
|
||||
get(key) {
|
||||
return storage.get(key)
|
||||
},
|
||||
set(key, value) {
|
||||
storage.set(key, value)
|
||||
},
|
||||
remove(key) {
|
||||
storage.delete(key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const subscriber = jest.fn()
|
||||
sessionStore.subscribe(subscriber)
|
||||
|
||||
sessionStore.hydrate()
|
||||
sessionStore.setSession({
|
||||
token: 'next-token',
|
||||
userInfo: { id: 2, name: 'Architect' },
|
||||
permissions: ['dashboard:view', 'dashboard:edit']
|
||||
})
|
||||
|
||||
expect(sessionStore.getState()).toEqual({
|
||||
isLoggedIn: true,
|
||||
token: 'next-token',
|
||||
userInfo: { id: 2, name: 'Architect' },
|
||||
permissions: ['dashboard:view', 'dashboard:edit']
|
||||
})
|
||||
expect(storage.get('session')).toEqual({
|
||||
token: 'next-token',
|
||||
userInfo: { id: 2, name: 'Architect' },
|
||||
permissions: ['dashboard:view', 'dashboard:edit']
|
||||
})
|
||||
expect(subscriber).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('clears persisted state on logout', () => {
|
||||
const storage = new Map()
|
||||
const sessionStore = createSessionStore({
|
||||
storageKey: 'session',
|
||||
storage: {
|
||||
get(key) {
|
||||
return storage.get(key)
|
||||
},
|
||||
set(key, value) {
|
||||
storage.set(key, value)
|
||||
},
|
||||
remove(key) {
|
||||
storage.delete(key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
sessionStore.setSession({
|
||||
token: 'next-token',
|
||||
userInfo: { id: 2, name: 'Architect' },
|
||||
permissions: ['dashboard:view', 'dashboard:edit']
|
||||
})
|
||||
sessionStore.clearSession()
|
||||
|
||||
expect(sessionStore.getState()).toEqual({
|
||||
isLoggedIn: false,
|
||||
token: '',
|
||||
userInfo: null,
|
||||
permissions: []
|
||||
})
|
||||
expect(storage.has('session')).toBe(false)
|
||||
})
|
||||
})
|
||||
54
tests/user-api.test.js
Normal file
54
tests/user-api.test.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const { createUserApi } = require('../services/api/user')
|
||||
|
||||
describe('createUserApi', () => {
|
||||
test('calls the profile endpoint through the shared request layer', async () => {
|
||||
const request = jest.fn().mockResolvedValue({
|
||||
code: 0,
|
||||
data: { id: 1, name: 'Xuanzhi' },
|
||||
message: 'ok'
|
||||
})
|
||||
|
||||
const userApi = createUserApi({ request })
|
||||
|
||||
await expect(userApi.getProfile()).resolves.toEqual({
|
||||
code: 0,
|
||||
data: { id: 1, name: 'Xuanzhi' },
|
||||
message: 'ok'
|
||||
})
|
||||
|
||||
expect(request).toHaveBeenCalledWith({
|
||||
url: '/users/me',
|
||||
method: 'GET'
|
||||
})
|
||||
})
|
||||
|
||||
test('submits profile updates through the shared request layer', async () => {
|
||||
const request = jest.fn().mockResolvedValue({
|
||||
code: 0,
|
||||
data: { saved: true },
|
||||
message: 'ok'
|
||||
})
|
||||
|
||||
const userApi = createUserApi({ request })
|
||||
|
||||
await expect(
|
||||
userApi.updateProfile({
|
||||
nickname: 'Architect',
|
||||
mobile: '13800000000'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
code: 0,
|
||||
data: { saved: true },
|
||||
message: 'ok'
|
||||
})
|
||||
|
||||
expect(request).toHaveBeenCalledWith({
|
||||
url: '/users/me',
|
||||
method: 'PUT',
|
||||
data: {
|
||||
nickname: 'Architect',
|
||||
mobile: '13800000000'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
32
utils/util.js
Normal file
32
utils/util.js
Normal file
@@ -0,0 +1,32 @@
|
||||
function formatDateTime(date) {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
const hour = date.getHours()
|
||||
const minute = date.getMinutes()
|
||||
const second = date.getSeconds()
|
||||
|
||||
return `${[year, month, day].map(formatNumber).join('/')} ${[hour, minute, second].map(formatNumber).join(':')}`
|
||||
}
|
||||
|
||||
function formatNumber(n) {
|
||||
n = n.toString()
|
||||
return n[1] ? n : `0${n}`
|
||||
}
|
||||
|
||||
function maskToken(token) {
|
||||
if (!token) {
|
||||
return '未登录'
|
||||
}
|
||||
|
||||
if (token.length <= 10) {
|
||||
return token
|
||||
}
|
||||
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatDateTime,
|
||||
maskToken
|
||||
}
|
||||
Reference in New Issue
Block a user