This commit is contained in:
2026-04-22 18:54:52 +08:00
commit bc8986e3b2
49 changed files with 20987 additions and 0 deletions

9
.editorconfig Normal file
View 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
View 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
View File

@@ -0,0 +1,5 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "none"
}

135
README.md Normal file
View 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
View 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
View 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
View 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;
}

View 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)
}
}
})

View File

@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"t-button": "tdesign-miniprogram/button/button"
}
}

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

View File

@@ -0,0 +1,7 @@
:host {
display: block;
}
.app-button {
width: 100%;
}

View 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
})
}
}
})

View File

@@ -0,0 +1,7 @@
{
"component": true,
"usingComponents": {
"app-button": "../../base/app-button/index",
"t-tag": "tdesign-miniprogram/tag/tag"
}
}

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

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
module.exports = {
rootDir: '.',
testEnvironment: 'jsdom',
testMatch: ['<rootDir>/tests/**/*.test.js']
}

11
jsconfig.json Normal file
View 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

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View 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"
}
}

View 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
})
}
})

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "工作台",
"usingComponents": {
"entry-card": "../../../../components/biz/entry-card/index",
"t-tag": "tdesign-miniprogram/tag/tag"
}
}

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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
const { userApi } = require('./user')
module.exports = {
userApi
}

40
services/api/user.js Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
const { sessionStore } = require('./modules/session')
module.exports = {
sessionStore
}

128
stores/modules/session.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
}