This commit is contained in:
liu
2026-08-06 15:24:03 +08:00
commit c613b520a9
49 changed files with 13902 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
+7
View File
@@ -0,0 +1,7 @@
{
"extends": ["taro/react"],
"rules": {
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off"
}
}
+16
View File
@@ -0,0 +1,16 @@
dist/
weapp/
alipay/
kwai/
dd/
qq/
tt/
swan/
deploy_versions/
.swc/
.temp/
.rn_temp/
node_modules/
.DS_Store
.idea
build/
+12
View File
@@ -0,0 +1,12 @@
registry "https://registry.npm.taobao.org"
disturl "https://npm.taobao.org/dist"
sass_binary_site "https://npm.taobao.org/mirrors/node-sass/"
sentrycli_cdnurl "https://npm.taobao.org/mirrors/sentry-cli/"
electron_mirror "https://npm.taobao.org/mirrors/electron/"
phantomjs_cdnurl "https://npm.taobao.org/mirrors/phantomjs/"
chromedriver_cdnurl "https://npm.taobao.org/mirrors/chromedriver/"
canvas_binary_host_mirror "https://npm.taobao.org/mirrors/node-canvas-prebuilt/"
operadriver_cdnurl "https://npm.taobao.org/mirrors/operadriver"
selenium_cdnurl "https://npm.taobao.org/mirrors/selenium"
node_inspector_cdnurl "https://npm.taobao.org/mirrors/node-inspector"
fsevents_binary_host_mirror "http://npm.taobao.org/mirrors/fsevents/"
+155
View File
@@ -0,0 +1,155 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Development
```bash
# WeChat mini-program
npm run dev:weapp # Watch/dev build → weapp/
npm run build:weapp # Production build → weapp/
# H5 (web)
npm run dev:h5 # Watch/dev build → build/
npm run build:h5 # Production build → build/
```
Output directories are determined by `TARO_ENV`: for H5 it's `build/`, for all other platforms it matches the platform name (e.g., `weapp/`).
No test or lint scripts are configured. ESLint (`eslint-config-taro`) and Stylelint are installed but must be run manually.
## Architecture
This is a **Taro 3.6** cross-platform app using **React 18** (concurrent mode), **TypeScript**, and **Webpack 5**. It targets WeChat, Alipay, DingTalk, JD, Kwai, QQ, Baidu, Toutiao mini-programs, and H5 web.
### UI Library: `@antmjs/vantui`
The UI library is `@antmjs/vantui` (v3), a Taro-compatible port of Vant. Components are imported from `@antmjs/vantui` directly (e.g., `import { Button } from '@antmjs/vantui'`). The `babel-plugin-import` in `babel.config.js` handles on-demand style imports.
### Page Structure
Each page lives in `src/pages/<name>/` and consists of three files:
- `index.tsx` — page component (functional or class)
- `index.config.ts` — page-level config (title, navigation style, etc.)
- `index.less` — page styles
Pages must be registered in `src/app.config.ts` under the `pages` array.
### Path Alias
`@` maps to `src/` (configured in `config/index.js`).
### Theme & Global Styles
- `src/app.less` — global app styles (imported in `app.ts`)
- `src/styles/index.less` — theme variable overrides for Vant UI. It imports `@antmjs/vantui/es/style/var.less` and uncomments/redefines Less variables to customize the theme. This file is injected into every `.less` file via Less `modifyVars` (configured in `config/index.js` under both `mini.lessLoaderOption` and `h5.lessLoaderOption`).
### Webpack Customizations (`config/webpack/`)
- **`commonChain.js`** — Overrides Taro's default `script` rule `exclude` so that Babel also processes these `node_modules` packages: `taro`, `inversify`, `@antmjs`, `react-spring`, `recoil`, `buffer`, `qrcode`. Do NOT include "taro" in the project name or all node_modules will be recompiled.
- **`miniChain.js`** — Applied to mini-program builds. Adds:
- `MiniFixPlugin` — fixes path param encoding differences between WeChat/Douyin and Alipay/DingTalk
- `GlobalFixPlugin` — polyfills the `global` variable for Alipay, DingTalk, and Baidu mini-programs
- **`h5Chain.js`** — Applied to H5 builds. Adds `H5FixPlugin` for H5 compatibility fixes.
### Build Config Layers
`config/index.js` exports a function that merges the base config with `config/development.js` or `config/production.js` based on `NODE_ENV`. Environment variables:
| Variable | Purpose | Default |
|---|---|---|
| `TARO_ENV` | Target platform (`weapp`, `h5`, `alipay`, etc.) | `weapp` |
| `NODE_ENV` | Build mode | `production` |
| `API_ENV` | API environment selector | `real` |
- `designWidth: 750` — all px values are converted to rpx at this ratio
- `@tarojs/plugin-framework-react` is configured with `reactMode: "concurrent"` (React 18 concurrent features)
- `browserslist` targets iOS 9+ for production
### App Entry
- `src/app.ts` — class-based App component, renders `this.props.children` (the current page)
- `src/app.config.ts` — defines routes (`pages` array), window config, and app-level settings
- `src/index.html` — H5 entry HTML template
## VantUI Component API Reference (v3.7)
This project uses `@antmjs/vantui` v3.7. The official docs: https://antmjs.github.io/vantui/main/
**Always check the actual type definitions** in `node_modules/@antmjs/vantui/types/` when unsure about props. Key API notes for commonly used components:
### Search
- `shape`: `'square'` (default) | `'round'`
- `background`: background color string (default `#FFFFFF`)
- `onFocus`, `onBlur`, `onChange`, `onSearch`, `onClear`, `onCancel` — event callbacks
- `leftIcon`, `rightIcon` — icon name or image URL
- `showAction` + `actionText` — right-side action button
### Swiper (NOT the same as Taro's native Swiper)
- `autoPlay` — auto-play interval in ms (0 = disabled). **Not** `autoplay`
- `loop` — infinite loop mode. **Not** `circular`
- `paginationColor` — indicator dot color. **Not** `indicatorColor`
- `paginationVisible` — show/hide pagination dots
- `duration` — animation duration (default 500)
- `width` / `height` — card dimensions
- **No** `indicatorActiveColor` prop — only one `paginationColor`
- `onChange` callback: `(currPage: number) => void` (not an event object)
### SwiperItem
- Minimal component, only `className` and `children`. No special props.
### Grid / GridItem
- `Grid`: `columnNum`, `border`, `gutter`, `square`, `clickable`, `center`, `iconSize`
- `GridItem`: `icon` (string), `text` (ReactNode), `iconColor`, `dot`, `info`/`badge`, `url` + `linkType`
### Tabs / Tab (v3.7)
- `Tabs`:
- `active` — current tab index (number | string)
- `sticky` — enable sticky mode
- `offsetTop` — sticky offset in px. **Not** `stickyOffsetTop`
- `color`, `titleActiveColor`, `titleInactiveColor` — color props
- `type`: `'line'` | `'card'`
- `swipeable` — enable swipe gesture switching
- `animated` — enable tab switch animation
- `ellipsis` — text overflow ellipsis (default true)
- **No** `lineWidth` or `lineHeight` props
- `onChange`: `(e: { detail: { index: number; name?: string; title?: string } }) => void`
- `Tab`: `title` (ReactNode), `name` (identifier), `dot`, `info`, `disabled`, `titleStyle`
### Image (VantUI Image, not Taro's native Image)
- `src`, `fit` (`'contain' | 'cover' | 'fill' | 'widthFix' | 'heightFix' | 'none'`), `width`, `height`, `radius`
- `round` — circular image (boolean)
- `lazyLoad`, `showError`, `showLoading`
- `renderLoading`, `renderError` — custom render slots
### Common VantUI pattern: Taro's native components vs VantUI components
- Use `@tarojs/components` for: `View`, `Text`, `ScrollView`, `Image` (native, simpler)
- Use `@antmjs/vantui` for: styled UI components (Search, Swiper, Tabs, Grid, Button, etc.)
- VantUI component props extend Taro's `ViewProps`, so `onClick`/`onTap` and style props are always available
## Custom TabBar (`src/custom-tab-bar/`)
This project uses Taro's **custom tabBar** mechanism (`tabBar.custom: true` in `app.config.ts`). Key rules:
### Component location
- The custom tabBar component MUST be at `src/custom-tab-bar/index.tsx` — this is a Taro convention, not configurable
- Taro automatically renders this component on every tab page; do NOT import `<CustomTabBar />` in individual pages
### Do NOT use CoverView
- `CoverView` / `CoverImage` have known bugs on real devices: invisible, click events don't bubble
- Use regular `<View>` with `position: fixed; bottom: 0; z-index: 999` instead
### Active tab state
- Taro creates a **new instance** of the tabBar component on each page, so `useState` resets on every tab switch
- Derive the active tab from `Taro.getCurrentPages()` at render time — NOT from component state
- Pattern: `const route = Taro.getCurrentPages()[last]?.route; const activeKey = TAB_LIST.find(t => route.includes(t.key))`
### Tab switching
- Use `Taro.switchTab({ url: pagePath })` — this matches native tabBar behavior
- The `pagePath` format must match the `tabBar.list[].pagePath` in `app.config.ts`
### Center "publish" button
- The publish button is a UI-only element not in `tabBar.list`
- It triggers a VantUI `<Popup>` rendered as a sibling of the tabBar, NOT nested inside it
- The popup state can use `useState` since it's ephemeral and not shared across tabs
+26
View File
@@ -0,0 +1,26 @@
/* eslint-disable import/no-commonjs */
// babel-preset-taro 更多选项和默认值:
// https://github.com/NervJS/taro/blob/next/packages/babel-preset-taro/README.md
module.exports = {
presets: [
[
'taro',
{
framework: 'react',
ts: true,
useBuiltIns: false,
hot: false,
},
],
],
plugins: [
[
'import',
{
libraryName: '@antmjs/vantui',
libraryDirectory: 'es',
},
'@antmjs/vantui',
],
],
}
+9
View File
@@ -0,0 +1,9 @@
// eslint-disable-next-line import/no-commonjs
module.exports = {
env: {
NODE_ENV: '"development"',
},
defineConstants: {},
mini: {},
h5: {},
}
+138
View File
@@ -0,0 +1,138 @@
/* eslint-disable import/no-commonjs */
/* eslint-disable @typescript-eslint/no-var-requires */
const npath = require("path");
const pkg = require("../package.json");
const miniChain = require("./webpack/miniChain");
const h5Chain = require("./webpack/h5Chain");
process.env.TARO_ENV = process.env.TARO_ENV ?? "weapp"
process.env.NODE_ENV = process.env.NODE_ENV ?? 'production'
process.env.API_ENV = process.env.API_ENV ?? 'real'
const config = {
projectName: pkg.name,
date: "2022-8-10",
designWidth: 750,
deviceRatio: {
640: 2.34 / 2,
750: 1,
828: 1.81 / 2,
},
sourceRoot: "src",
outputRoot: process.env.TARO_ENV === "h5" ? "build" : process.env.TARO_ENV,
alias: {
"@": npath.resolve(process.cwd(), "src"),
},
defineConstants: {},
copy: {
patterns: [],
options: {},
},
framework: "react",
compiler: "webpack5",
cache: {
enable: false, // Webpack 持久化缓存配置,建议开启。默认配置请参考:https://docs.taro.zone/docs/config-detail#cache
},
mini: {
webpackChain(chain) {
miniChain(chain);
},
lessLoaderOption: {
lessOptions: {
modifyVars: {
hack: `true; @import "${npath.join(
process.cwd(),
"src/styles/index.less"
)}";`,
},
},
// 适用于全局引入样式
// additionalData: "@import '~/src/styles/index.less';",
},
postcss: {
pxtransform: {
enable: true,
config: {},
},
url: {
enable: true,
config: {
limit: 1024, // 设定转换尺寸上限
},
},
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
config: {
namingPattern: "module", // 转换模式,取值为 global/module
generateScopedName: "[name]__[local]___[hash:base64:5]",
},
},
},
miniCssExtractPluginOption: {
ignoreOrder: true,
},
},
h5: {
webpackChain(chain) {
h5Chain(chain);
if (process.env.NODE_ENV === "production") {
chain.performance.maxEntrypointSize(1000000).maxAssetSize(512000);
}
},
esnextModules: [/@antmjs[\\/]vantui/],
lessLoaderOption: {
lessOptions: {
modifyVars: {
// 或者可以通过 less 文件覆盖(文件路径为绝对路径)
hack: `true; @import "${npath.join(
process.cwd(),
"src/styles/index.less"
)}";`,
},
},
},
router: {
mode: "browser",
},
devServer: {
hot: false,
},
publicPath: "/",
staticDirectory: "static",
postcss: {
autoprefixer: {
enable: true,
config: {},
},
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
config: {
namingPattern: "module", // 转换模式,取值为 global/module
generateScopedName: "[name]__[local]___[hash:base64:5]",
},
},
},
miniCssExtractPluginOption: {
ignoreOrder: true,
filename: "assets/css/[name].css",
chunkFilename: "assets/css/chunk/[name].css",
},
},
rn: {
appName: "taroDemo",
postcss: {
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
},
},
},
plugins: [
["@tarojs/plugin-framework-react", { reactMode: "concurrent" }],
"@tarojs/plugin-platform-alipay-dd",
["@tarojs/plugin-platform-kwai"],
],
};
module.exports = function (merge) {
return merge({}, config, require(`./${process.env.NODE_ENV}`));
};
+36
View File
@@ -0,0 +1,36 @@
// eslint-disable-next-line import/no-commonjs
module.exports = {
env: {
NODE_ENV: '"production"',
},
defineConstants: {},
mini: {},
h5: {
/**
* WebpackChain 插件配置
* @docs https://github.com/neutrinojs/webpack-chain
*/
// webpackChain (chain) {
// /**
// * 如果 h5 端编译后体积过大,可以使用 webpack-bundle-analyzer 插件对打包体积进行分析。
// * @docs https://github.com/webpack-contrib/webpack-bundle-analyzer
// */
// chain.plugin('analyzer')
// .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [])
// /**
// * 如果 h5 端首屏加载时间过长,可以使用 prerender-spa-plugin 插件预加载首页。
// * @docs https://github.com/chrisvfritz/prerender-spa-plugin
// */
// const path = require('path')
// const Prerender = require('prerender-spa-plugin')
// const staticDir = path.join(__dirname, '..', 'dist')
// chain
// .plugin('prerender')
// .use(new Prerender({
// staticDir,
// routes: [ '/pages/index/index' ],
// postProcess: (context) => ({ ...context, outputPath: path.join(staticDir, 'index.html') })
// }))
// }
},
}
+23
View File
@@ -0,0 +1,23 @@
module.exports = function (chain) {
// taro内部的配置:scriptRule.exclude = [filename => /css-loader/.test(filename) || (/node_modules/.test(filename) && !(/taro/.test(filename)))];
// 下面重写exclude的配置,部分三方包需要babel,包括taro、@antmjs等
// 根据exclude可以看出,千万不要在项目名称上面带上taro字样,否则所有引用到node_modules的包都会重新被编译一次
// 以下配置将不再使用usage配置,因为根据小程序官方描述,ios9开始基本都已支持了,浏览器可以使用polyfill.io 国内可以用阿里云版的,index.html有引用
/*
* 如果babel.config.js设置useBuiltIns:usage
* /tarojs[\\/](runtime|shared|plugin-platform|components)/.test(filename) 应该被exculde
* /tarojs[\\/](runtime|shared|plugin-platform)/.test(filename) 应该单独babel 且设置useBuiltIns:false
*/
chain.module
.rule('script')
.exclude.clear()
.add(
(filename) =>
/css-loader/.test(filename) ||
(/node_modules/.test(filename) &&
!/(taro)|(inversify)|(@antmjs)|(react-spring)|(recoil)|(buffer)|(qrcode)/.test(
filename,
)),
)
}
+9
View File
@@ -0,0 +1,9 @@
/* eslint-disable import/no-commonjs */
/* eslint-disable @typescript-eslint/no-var-requires */
const H5FixPlugin = require('@antmjs/plugin-h5-fix')
const commonChain = require('./commonChain')
module.exports = function (chain) {
chain.plugin('H5FixPlugin').use(new H5FixPlugin())
commonChain(chain)
}
+16
View File
@@ -0,0 +1,16 @@
/* eslint-disable import/no-commonjs */
/* eslint-disable @typescript-eslint/no-var-requires */
const MiniFixPlugin = require('@antmjs/plugin-mini-fix')
const GlobalFixPlugin = require('@antmjs/plugin-global-fix')
const commonChain = require('./commonChain')
module.exports = function (chain) {
// add @antmjs/plugin-mini-fix and @antmjs/mini-fix
// 解决微信小程序和抖音小程序的path上的params没有自动decode的问题,支付宝和钉钉是有decode过的
// 这个问题是因为微信抖音和支付宝钉钉原生小程序的返回结果就是不一致的,Taro目前是没有去处理的
chain.plugin('MiniFixPlugin').use(new MiniFixPlugin())
//解决支付宝小程序、钉钉小程序、百度小程序没有暴露全局变量global的问题
chain.plugin('GlobalFixPlugin').use(new GlobalFixPlugin())
commonChain(chain)
}
+77
View File
@@ -0,0 +1,77 @@
{
"name": "pure-project-vantui",
"version": "1.0.0",
"private": true,
"description": "",
"templateInfo": {
"name": "default",
"typescript": true,
"css": "less"
},
"scripts": {
"dev:weapp": "npm run build:weapp -- --watch",
"dev:h5": "npm run build:h5 -- --watch",
"build:weapp": "taro build --type weapp",
"build:h5": "taro build --type h5"
},
"browserslist": {
"production": [
"ios >= 9"
],
"development": [
"last 1 version"
]
},
"author": "",
"dependencies": {
"@antmjs/mini-fix": "^2.3.21",
"@antmjs/vantui": "^3.1.6",
"@babel/runtime": "^7.7.7",
"@tarojs/components": "3.6.14",
"@tarojs/helper": "3.6.14",
"@tarojs/plugin-framework-react": "3.6.14",
"@tarojs/plugin-platform-alipay": "3.6.14",
"@tarojs/plugin-platform-alipay-dd": "^0.1.3",
"@tarojs/plugin-platform-h5": "3.6.14",
"@tarojs/plugin-platform-jd": "3.6.14",
"@tarojs/plugin-platform-kwai": "^2.0.0",
"@tarojs/plugin-platform-qq": "3.6.14",
"@tarojs/plugin-platform-swan": "3.6.14",
"@tarojs/plugin-platform-tt": "3.6.14",
"@tarojs/plugin-platform-weapp": "3.6.14",
"@tarojs/react": "3.6.14",
"@tarojs/router": "3.6.14",
"@tarojs/runtime": "3.6.14",
"@tarojs/shared": "3.6.14",
"@tarojs/taro": "3.6.14",
"@tarojs/taro-h5": "3.6.14",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@antmjs/plugin-global-fix": "^2.3.21",
"@antmjs/plugin-h5-fix": "^2.3.21",
"@antmjs/plugin-mini-fix": "^2.3.21",
"@babel/core": "^7.12.9",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.5",
"@tarojs/cli": "3.6.14",
"@tarojs/webpack5-runner": "3.6.14",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/webpack-env": "^1.13.6",
"@typescript-eslint/eslint-plugin": "^5.20.0",
"@typescript-eslint/parser": "^5.20.0",
"babel-plugin-import": "^1.13.5",
"babel-preset-taro": "3.6.14",
"eslint": "^8.12.0",
"eslint-config-taro": "3.6.14",
"eslint-plugin-import": "^2.12.0",
"eslint-plugin-react": "^7.8.2",
"eslint-plugin-react-hooks": "^4.2.0",
"react-refresh": "^0.11.0",
"stylelint": "^13.13.1",
"typescript": "^4.1.0",
"webpack": "5.69.0"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"miniprogramRoot": "./",
"projectname": "pure-project-vantui",
"description": "",
"appid": "wx7ed74d60503b5ee3",
"setting": {
"urlCheck": false,
"es6": false,
"postcss": false,
"minified": true,
"enhance": false
},
"compileType": "miniprogram"
}
+12
View File
@@ -0,0 +1,12 @@
export default defineAppConfig({
pages: [
'pages/index/index',
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: 'WeChat',
navigationBarTextStyle: 'black',
},
animation: false,
})
+45
View File
@@ -0,0 +1,45 @@
@import '@antmjs/vantui/es/style/var.less';
@import '@antmjs/vantui/lib/index.less';
page {
background: @page-back;
font-size: 28px;
font-family: @base-font-family;
color: @black;
}
view,
div {
box-sizing: border-box;
}
::-webkit-scrollbar {
display: none;
}
body,
html {
// NOTE: taro h5 ios上拉遮挡底部fixed元素
overflow: hidden !important;
}
.van-cell-group--inset {
background: @white;
box-shadow: 0 14px 80px 0 rgba(138, 149, 158, 0.2);
}
.van-cell__label {
line-height: 1.4;
}
// 可以用的类
// .van-hairline,
// .van-hairline--top,
// .van-hairline--left,
// .van-hairline--right,
// .van-hairline--bottom,
// .van-hairline--top-bottom,
// .van-hairline--surround
// .van-ellipsis
// .van-multi-ellipsis--l2
// .van-multi-ellipsis--l3
+20
View File
@@ -0,0 +1,20 @@
import { Component } from 'react'
import './app.less'
class App extends Component {
componentDidMount () {}
componentDidShow () {}
componentDidHide () {}
componentDidCatchError () {}
// this.props.children 是将要会渲染的页面
render () {
return this.props.children
}
}
export default App
+84
View File
@@ -0,0 +1,84 @@
import { useCallback } from 'react'
import Taro from '@tarojs/taro'
import { NavBar as VantNavBar } from '@antmjs/vantui'
import type { NavBarProps } from '@antmjs/vantui/types/nav-bar'
export interface CustomNavBarProps extends NavBarProps {
/**
* 自定义返回逻辑
* 不传则默认调用 Taro.navigateBack()
* 返回 false 可阻止默认行为(例如需要在返回前做判断)
*/
onBack?: () => void
}
/**
* 通用导航栏组件
*
* 用于所有非 tab-bar 页面的顶部导航,封装了 VantUI NavBar
* - 默认显示返回箭头 + "返回" 文字
* - 默认点击返回调用 navigateBack()
* - 通过 onBack 可自定义返回逻辑(如跳转到指定页面)
* - 通过 title 自定义标题,通过 renderTitle 可传入复杂标题内容
* - 通过 renderRight 可在右侧添加按钮/图标
*
* 使用前请确保页面 config 中设置了 navigationStyle: 'custom'
*/
export default function CustomNavBar(props: CustomNavBarProps) {
const {
title,
onBack,
leftArrow = true,
leftText = '返回',
fixed = true,
placeholder = true,
border = true,
safeAreaInsetTop = true,
renderTitle,
renderRight,
renderLeft,
rightText,
onClickRight,
children,
...rest
} = props
const handleClickLeft = useCallback(
(e: any) => {
if (onBack) {
onBack()
} else {
// 如果页面栈 > 1 则返回,否则跳转到首页
const pages = Taro.getCurrentPages()
if (pages.length > 1) {
Taro.navigateBack()
} else {
Taro.switchTab({ url: '/pages/index/index' })
}
}
},
[onBack],
)
return (
<VantNavBar
title={title}
style='box-sizing: content-box;'
leftArrow={leftArrow}
leftText={leftText}
fixed={fixed}
placeholder={placeholder}
border={border}
safeAreaInsetTop={safeAreaInsetTop}
renderTitle={renderTitle}
renderRight={renderRight}
renderLeft={renderLeft}
rightText={rightText}
onClickLeft={handleClickLeft}
onClickRight={onClickRight}
{...rest}
>
{children}
</VantNavBar>
)
}
+14
View File
@@ -0,0 +1,14 @@
import Taro from "@tarojs/taro";
import {useEffect, useState} from "react";
import {View} from "@tarojs/components";
export default () => {
const [safeBottom, setSafeBottom] = useState(0)
useEffect(() => {
const info = Taro.getSystemInfoSync()
setSafeBottom((info.screenHeight - info.safeArea!.bottom) || 0)
}, []);
return <View style={{ height: `${safeBottom}px` }}></View>
}
+3
View File
@@ -0,0 +1,3 @@
export default {
"component": true
}
+70
View File
@@ -0,0 +1,70 @@
.custom-tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: flex-start;
justify-content: space-around;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid rgba(0, 0, 0, 0.06);
z-index: 999;
box-sizing: border-box;
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 10px;
flex: 1;
position: relative;
.tab-icon {
font-size: 40px;
line-height: 1.2;
margin-bottom: 2px;
}
.tab-label {
font-size: 20px;
color: #969799;
line-height: 1.4;
}
&.active .tab-label {
color: #1989fa;
}
}
/* 中间发布按钮 */
.tab-publish {
justify-content: flex-start;
padding-top: 0;
.publish-btn {
width: 88px;
height: 88px;
border-radius: 50%;
background: linear-gradient(135deg, #1989fa 0%, #07c160 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(25, 137, 250, 0.4);
margin-top: -30px;
.publish-icon {
font-size: 44px;
color: #fff;
font-weight: 300;
line-height: 1;
}
}
.publish-label {
margin-top: 4px;
}
}
}
+60
View File
@@ -0,0 +1,60 @@
import Taro from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import './index.less'
interface CustomTabBarProps {
/** 当前激活的 tab key。H5 由页面传入;小程序由 Taro 自动渲染,props 为空 */
activeKey?: string
}
export default function CustomTabBar({ activeKey }: CustomTabBarProps) {
/** 切换 Tab */
const handleTabClick = (tab: string, path: string) => {
if (tab === activeKey) return
Taro.switchTab({ url: path })
}
return (
<>
<View className='custom-tab-bar'>
<View
className={`tab-item ${activeKey === 'index' ? 'active' : ''}`}
onClick={() => handleTabClick('index', '/index')}
>
<Text className='tab-icon'></Text>
<Text className='tab-label'></Text>
</View>
<View
className={`tab-item ${activeKey === 'index' ? 'active' : ''}`}
onClick={() => handleTabClick('index', '/index')}
>
<Text className='tab-icon'></Text>
<Text className='tab-label'></Text>
</View>
{/* 中间发布按钮 —— 不属于 tabBar list,纯 UI 元素 */}
<View className='tab-item tab-publish' onClick={() => handleTabClick('', '')}>
<View className='publish-btn'>
<Text className='publish-icon'></Text>
</View>
<Text className='tab-label publish-label'></Text>
</View>
<View
className={`tab-item ${activeKey === 'index' ? 'active' : ''}`}
onClick={() => handleTabClick('index', '/index')}
>
<Text className='tab-icon'></Text>
<Text className='tab-label'></Text>
</View>
<View
className={`tab-item ${activeKey === 'index' ? 'active' : ''}`}
onClick={() => handleTabClick('index', '/index')}
>
<Text className='tab-icon'></Text>
<Text className='tab-label'></Text>
</View>
</View>
</>
)
}
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<meta content="width=device-width,initial-scale=1,user-scalable=no" name="viewport">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-touch-fullscreen" content="yes">
<meta name="format-detection" content="telephone=no,address=no">
<meta name="apple-mobile-web-app-status-bar-style" content="white">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" >
<title>antmjs</title>
<script crossorigin="anonymous" src="https://polyfill.alicdn.com/polyfill.min.js?features=es2015%2Ces2016%2Ces2017%2Ces2018%2Ces2019%2Ces2020%2Ces2021%2Ces2022"></script>
<script><%= htmlWebpackPlugin.options.script %></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '首页'
})
View File
+34
View File
@@ -0,0 +1,34 @@
import { View } from '@tarojs/components'
import { Button } from '@antmjs/vantui'
import './index.less'
export default function Index() {
return (
<View className='index'>
<View><Button type='primary'>Hello world!</Button></View>
<View>src/styles/index.less</View>
</View>
)
}
// export default class Index extends Component {
// componentWillMount () { }
// componentDidMount () { }
// componentWillUnmount () { }
// componentDidShow () { }
// componentDidHide () { }
// render () {
// return (
// <View className='index'>
// <View><Button type='primary'>Hello world!</Button></View>
// <View>上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/style/index.less</View>
// </View>
// )
// }
// }
+3
View File
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '首页'
})
View File
+34
View File
@@ -0,0 +1,34 @@
import { View } from '@tarojs/components'
import { Button } from '@antmjs/vantui'
import './index.less'
export default function Index() {
return (
<View className='index'>
<View><Button type='primary'>Hello world!</Button></View>
<View>src/styles/index.less</View>
</View>
)
}
// export default class Index extends Component {
// componentWillMount () { }
// componentDidMount () { }
// componentWillUnmount () { }
// componentDidShow () { }
// componentDidHide () { }
// render () {
// return (
// <View className='index'>
// <View><Button type='primary'>Hello world!</Button></View>
// <View>上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/style/index.less</View>
// </View>
// )
// }
// }
+4
View File
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '登录',
})
+165
View File
@@ -0,0 +1,165 @@
/* ========================================
登录页面
======================================== */
.login-page {
min-height: 100vh;
background: #fff;
}
/* ========== 自定义导航栏 ========== */
.login-navbar {
background: #fff;
position: sticky;
top: 0;
z-index: 100;
.navbar-inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
position: relative;
}
.navbar-back {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.back-arrow {
font-size: 48px;
color: #323233;
line-height: 1;
font-weight: 300;
}
}
.navbar-title {
font-size: 32px;
font-weight: 500;
color: #323233;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.navbar-placeholder {
width: 60px;
height: 60px;
flex-shrink: 0;
}
}
/* ========== 内容区域 ========== */
.login-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 60px 0;
}
/* ========== 品牌区域 ========== */
.login-brand {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 40px;
.logo-wrapper {
width: 160px;
height: 160px;
border-radius: 50%;
background: linear-gradient(160deg, #1989fa 0%, #07c160 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(25, 137, 250, 0.3);
}
.logo-text {
font-size: 80px;
color: #fff;
font-weight: 700;
}
.app-name {
font-size: 44px;
font-weight: 600;
color: #323233;
margin-bottom: 12px;
}
.app-slogan {
font-size: 28px;
color: #969799;
}
}
/* ========== 功能介绍 ========== */
.login-features {
margin-bottom: 80px;
.feature-text {
font-size: 26px;
color: #c8c9cc;
letter-spacing: 2px;
}
}
/* ========== 登录操作区 ========== */
.login-actions {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.login-btn {
width: 100%;
height: 96px;
line-height: 96px;
background: linear-gradient(160deg, #1989fa 0%, #07c160 100%);
color: #fff;
font-size: 34px;
font-weight: 500;
border: none;
border-radius: 48px;
text-align: center;
padding: 0;
box-shadow: 0 6px 24px rgba(25, 137, 250, 0.35);
transition: opacity 0.2s;
/* 重置微信 Button 默认样式 */
&::after {
border: none;
}
}
.login-btn--loading {
opacity: 0.75;
}
/* ========== 协议文字 ========== */
.login-agreement {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
margin-top: 32px;
line-height: 1.6;
.agree-text {
font-size: 24px;
color: #c8c9cc;
}
.agree-link {
font-size: 24px;
color: #1989fa;
}
}
+123
View File
@@ -0,0 +1,123 @@
import { useState, useEffect, useCallback } from 'react'
import Taro from '@tarojs/taro'
import { View, Text, Button } from '@tarojs/components'
import CustomNavBar from '@/components/NavBar'
import useAuthStore from '@/stores/auth/useAuthStore'
import './index.less'
export default function LoginPage() {
const login = useAuthStore(s => s.login)
const isLoggedIn = useAuthStore(s => !!s.token && !!s.user)
const [submitting, setSubmitting] = useState(false)
// 已登录则自动返回
useEffect(() => {
if (isLoggedIn) {
Taro.navigateBack()
}
}, [isLoggedIn])
/** 手机号授权登录 */
const handleGetPhoneNumber = useCallback(
async (e: any) => {
if (submitting) return
const detail = e.detail || {}
// 用户拒绝授权
if (detail.errMsg && !detail.errMsg.includes(':ok')) {
Taro.showToast({ title: '需要授权手机号才能登录', icon: 'none' })
return
}
setSubmitting(true)
try {
// 1. 获取微信登录 code(用于换取 openid / session_key
const loginRes = await Taro.login()
if (!loginRes.code) {
Taro.showToast({ title: '获取登录凭证失败', icon: 'none' })
return
}
// 2. 调用后端登录接口(仅传 code + phoneCode
await login({
code: loginRes.code,
// 新版微信 API:动态令牌,后端直接调用微信接口换手机号
phoneCode: detail.code,
// 旧版微信 API:加密数据,后端用 session_key 解密
encryptedData: detail.encryptedData,
iv: detail.iv,
})
Taro.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
Taro.navigateBack()
}, 1200)
} catch {
Taro.showToast({ title: '登录失败,请重试', icon: 'none' })
} finally {
setSubmitting(false)
}
},
[login, submitting],
)
/** 查看用户协议 */
const handleShowAgreement = useCallback(() => {
Taro.showToast({ title: '用户协议即将上线', icon: 'none' })
}, [])
/** 查看隐私政策 */
const handleShowPrivacy = useCallback(() => {
Taro.showToast({ title: '隐私政策即将上线', icon: 'none' })
}, [])
return (
<View className='login-page'>
{/* ========== 导航栏 ========== */}
<CustomNavBar title="登录" />
{/* ========== 内容区域 ========== */}
<View className='login-content'>
{/* 品牌区域 */}
<View className='login-brand'>
<View className='logo-wrapper'>
<Text className='logo-text'></Text>
</View>
<Text className='app-name'></Text>
<Text className='app-slogan'></Text>
</View>
{/* 功能介绍 */}
<View className='login-features'>
<Text className='feature-text'> · · </Text>
</View>
{/* 登录操作 */}
<View className='login-actions'>
<Button
className={`login-btn ${submitting ? 'login-btn--loading' : ''}`}
openType='getPhoneNumber'
onGetPhoneNumber={handleGetPhoneNumber}
loading={submitting}
disabled={submitting}
>
{submitting ? '登录中...' : '微信手机号授权登录'}
</Button>
<View className='login-agreement'>
<Text className='agree-text'></Text>
<Text className='agree-link' onClick={handleShowAgreement}>
</Text>
<Text className='agree-text'></Text>
<Text className='agree-link' onClick={handleShowPrivacy}>
</Text>
</View>
</View>
</View>
</View>
)
}
+3
View File
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '首页'
})
View File
+34
View File
@@ -0,0 +1,34 @@
import { View } from '@tarojs/components'
import { Button } from '@antmjs/vantui'
import './index.less'
export default function Index() {
return (
<View className='index'>
<View><Button type='primary'>Hello world!</Button></View>
<View>src/styles/index.less</View>
</View>
)
}
// export default class Index extends Component {
// componentWillMount () { }
// componentDidMount () { }
// componentWillUnmount () { }
// componentDidShow () { }
// componentDidHide () { }
// render () {
// return (
// <View className='index'>
// <View><Button type='primary'>Hello world!</Button></View>
// <View>上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/style/index.less</View>
// </View>
// )
// }
// }
+3
View File
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '首页'
})
View File
+34
View File
@@ -0,0 +1,34 @@
import { View } from '@tarojs/components'
import { Button } from '@antmjs/vantui'
import './index.less'
export default function Index() {
return (
<View className='index'>
<View><Button type='primary'>Hello world!</Button></View>
<View>src/styles/index.less</View>
</View>
)
}
// export default class Index extends Component {
// componentWillMount () { }
// componentDidMount () { }
// componentWillUnmount () { }
// componentDidShow () { }
// componentDidHide () { }
// render () {
// return (
// <View className='index'>
// <View><Button type='primary'>Hello world!</Button></View>
// <View>上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/style/index.less</View>
// </View>
// )
// }
// }
+3
View File
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '首页'
})
View File
+34
View File
@@ -0,0 +1,34 @@
import { View } from '@tarojs/components'
import { Button } from '@antmjs/vantui'
import './index.less'
export default function Index() {
return (
<View className='index'>
<View><Button type='primary'>Hello world!</Button></View>
<View>src/styles/index.less</View>
</View>
)
}
// export default class Index extends Component {
// componentWillMount () { }
// componentDidMount () { }
// componentWillUnmount () { }
// componentDidShow () { }
// componentDidHide () { }
// render () {
// return (
// <View className='index'>
// <View><Button type='primary'>Hello world!</Button></View>
// <View>上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/style/index.less</View>
// </View>
// )
// }
// }
+84
View File
@@ -0,0 +1,84 @@
import { create } from 'zustand'
import Taro from '@tarojs/taro'
import { wxLoginApi } from '@/services/auth'
import type { WxLoginParams } from '@/services/auth'
import type { User } from '@/types/user'
/** 存储 key */
const STORAGE_KEYS = {
TOKEN: 'auth_token',
USER: 'auth_user',
} as const
/** 从本地存储恢复登录态 */
function loadFromStorage(): { user: User | null; token: string | null } {
try {
const storedToken = Taro.getStorageSync(STORAGE_KEYS.TOKEN)
const storedUser = Taro.getStorageSync(STORAGE_KEYS.USER)
if (storedToken && storedUser) {
return { token: storedToken, user: JSON.parse(storedUser) }
}
} catch {
// 存储数据损坏,清除并视为未登录
try { Taro.removeStorageSync(STORAGE_KEYS.TOKEN) } catch { /* noop */ }
try { Taro.removeStorageSync(STORAGE_KEYS.USER) } catch { /* noop */ }
}
return { user: null, token: null }
}
interface AuthState {
user: User | null
token: string | null
loading: boolean
login: (params: WxLoginParams) => Promise<void>
logout: () => void
/** 更新用户信息(用于编辑资料后同步 store) */
setUser: (user: User) => void
}
const useAuthStore = create<AuthState>((set) => {
// 初始化时从 storage 恢复
const initial = loadFromStorage()
return {
user: initial.user,
token: initial.token,
loading: !!(initial.token && initial.user), // 已恢复则立即 ready
/** 登录 */
login: async (params: WxLoginParams) => {
const res = await wxLoginApi(params)
const { token, user } = res.data
set({ user, token })
try {
Taro.setStorageSync(STORAGE_KEYS.TOKEN, token)
Taro.setStorageSync(STORAGE_KEYS.USER, JSON.stringify(user))
} catch {
// storage 写入失败不阻塞登录流程
}
},
/** 退出登录 */
logout: () => {
set({ user: null, token: null })
try {
Taro.removeStorageSync(STORAGE_KEYS.TOKEN)
Taro.removeStorageSync(STORAGE_KEYS.USER)
} catch {
// noop
}
},
/** 更新用户信息(编辑资料后同步 store + storage */
setUser: (user: User) => {
set({ user })
try {
Taro.setStorageSync(STORAGE_KEYS.USER, JSON.stringify(user))
} catch {
// storage 写入失败不阻塞
}
},
}
})
export default useAuthStore
+73
View File
@@ -0,0 +1,73 @@
@import '@antmjs/vantui/es/style/var.less';
// 这里可以重写主题
//@black: #1a1a1a;
//@white: #f7f7f7;
//@gray-1: #f7f8fa;
//@gray-2: #f2f3f5;
//@gray-3: #ededed;
//@gray-4: #dcdee0;
//@gray-5: #c8c9cc;
//@gray-6: #969799;
//@gray-7: #646566;
//@gray-8: #323233;
//@red: #ee0a24;
//@blue: #1989fa;
//@orange: #ff976a;
//@orange-dark: #ed6a0c;
//@orange-light: #fffbe8;
//@green: #0a4d2b;
//
//@pageBack: @gray-3;
//@navBack: rgba(237, 237, 237, 0.9);
//@popup-background-color: @gray-3;
//@backDropFilter: blur(20px);
//
//@popup-close-icon-color: @gray-5;
//@popup-close-icon-size: 40px;
//@popup-close-icon-margin: 24px;
//@button-plain-background-color: @gray-4;
//
// z-index
//@sticky-z-index: 800;
//@tabbar-z-index: 805;
//@navbar-z-index: 805;
//@goods-action-z-index: 806;
//@submit-bar-z-index: 806;
//@overlay-z-index: 1000;
//@dropdown-z-index: 1000;
//@popup-z-index: 1010;
//@popup-close-icon-z-index: 1010;
//@notify-z-index: 1500;
//
// Padding or Margin
//@padding-base: 8px;
//@padding-xs: @padding-base * 2;
//@padding-sm: @padding-base * 3;
//@padding-md: @padding-base * 4;
//@padding-lg: @padding-base * 6;
//@padding-xl: @padding-base * 8;
//
// Font
//@font-size-xs: 20px;
//@font-size-sm: 24px;
//@font-size-md: 28px;
//@font-size-lg: 32px;
//@font-weight-bold: 500;
//@line-height-xs: 28px;
//@line-height-sm: 36px;
//@line-height-md: 40px;
//@line-height-lg: 44px;
//@base-font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue',
// Helvetica, Segoe UI, Arial, Roboto, 'PingFang SC', 'miui', 'Hiragino Sans GB',
// 'Microsoft Yahei', sans-serif;
//@price-integer-font-family: Avenir-Heavy, PingFang SC, Helvetica Neue, Arial,
// sans-serif;
//
// Border
//@border-color: @gray-3;
//@border-width-base: 2px;
//@border-radius-sm: 4px;
//@border-radius-md: 8px;
//@border-radius-lg: 16px;
//@border-radius-max: 999px;
+50
View File
@@ -0,0 +1,50 @@
import { BASE_URL } from '@/utils/request'
/** 服务器源地址(BASE_URL 去掉 /index.php 后缀),用于拼接相对路径的头像等 */
export const SERVER_ORIGIN = BASE_URL.replace(/\/index\.php\/?$/, '')
/** 数字补零 */
function pad(n: number): string {
return n < 10 ? `0${n}` : `${n}`
}
/**
* 格式化时间
* - 今天 → HH:mm
* - 今年 → MM-DD HH:mm
* - 更早 → YYYY-MM-DD
*
* 后端时间形如 2026-08-03T10:00:00.000000Z
* iOS 无法解析 3 位以上小数秒,先归一化为毫秒
*/
export function formatTime(value?: string): string {
if (!value) return ''
const ts = new Date(value.replace(/\.\d+/, '.000')).getTime()
if (Number.isNaN(ts)) return ''
const date = new Date(ts)
const now = new Date()
const isSameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
if (isSameDay) {
return `${pad(date.getHours())}:${pad(date.getMinutes())}`
}
if (date.getFullYear() === now.getFullYear()) {
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
/**
* 解析头像地址:绝对地址直接用,相对路径拼接服务器域名
*/
export function resolveAvatarUrl(avatar?: string): string {
if (!avatar) return ''
if (/^https?:\/\//i.test(avatar)) return avatar
return `${SERVER_ORIGIN}${avatar.startsWith('/') ? '' : '/'}${avatar}`
}
+199
View File
@@ -0,0 +1,199 @@
import Taro from '@tarojs/taro'
import type { ApiResponse, RequestConfig } from '@/types/api'
/** 存储 key(与 AuthContext 保持一致) */
const STORAGE_TOKEN_KEY = 'auth_token'
/** 登录页路径 */
const LOGIN_PATH = '/pages/login/index'
/** 默认请求超时(ms */
const DEFAULT_TIMEOUT = 15000
/** 接口根地址(uploadFile 等原生请求同样使用) */
export const BASE_URL = "http://localhost:8000/index.php"
/**
* HTTP 状态码 → 错误提示映射
*/
const HTTP_ERROR_MAP: Record<number, string> = {
400: '参数不正确',
401: '登录已过期,请重新登录',
403: '您没有权限操作',
404: '请求的资源不存在',
408: '请求超时',
500: '服务器内部错误',
502: '网关错误',
503: '服务暂时不可用',
504: '网关超时',
}
/** 业务状态码常量 */
const BIZ_CODE = {
SUCCESS: 0,
} as const
/**
* 获取本地存储的 token
*/
export function getToken(): string | null {
try {
return Taro.getStorageSync(STORAGE_TOKEN_KEY) || null
} catch {
return null
}
}
/**
* 清除本地认证信息
*/
function clearAuth(): void {
try {
Taro.removeStorageSync(STORAGE_TOKEN_KEY)
Taro.removeStorageSync('auth_user')
} catch {
// noop
}
}
/**
* 处理 HTTP 状态码错误
* @param statusCode - HTTP 状态码
*/
function handleHttpError(statusCode: number): void {
// 401 → 清除登录态并跳转登录页
if (statusCode === 401) {
clearAuth()
Taro.showToast({ title: '登录已过期,请重新登录', icon: 'none' })
// 避免在登录页重复跳转
const pages = Taro.getCurrentPages()
const currentPage = pages[pages.length - 1]
if (currentPage?.route !== 'pages/login/index') {
setTimeout(() => {
Taro.navigateTo({ url: LOGIN_PATH })
}, 800)
}
return
}
const message = HTTP_ERROR_MAP[statusCode] || `请求失败 (状态码: ${statusCode})`
Taro.showToast({ title: message, icon: 'none' })
}
/**
* 处理业务错误
* @param data - 接口返回数据
*/
function handleBusinessError(data: ApiResponse): void {
const { msg } = data
if (msg) {
Taro.showToast({ title: msg, icon: 'none' })
}
}
/**
* 发起网络请求
*
* @example
* ```ts
* // GET 请求
* const res = await request({ url: '/api/user/info' })
*
* // POST 请求
* const res = await request({ url: '/api/order/create', method: 'POST', data: { id: 1 } })
*
* // 跳过 token(如登录接口)
* const res = await request({ url: '/api/auth/login', method: 'POST', skipToken: true })
* ```
*/
export function request<T = any>(config: RequestConfig): Promise<ApiResponse<T>> {
const { skipToken, skipErrorToast, ...restConfig } = config
// 构建请求头
const header: Record<string, string> = {
'Content-Type': 'application/json',
...((restConfig.header as Record<string, string>) || {}),
}
// 自动附加 token
if (!skipToken) {
const token = getToken()
if (token) {
header['Authorization'] = `Bearer ${token}`
}
}
return new Promise((resolve, reject) => {
Taro.request({
...restConfig,
url: BASE_URL + restConfig.url,
header,
timeout: restConfig.timeout || DEFAULT_TIMEOUT,
success(res) {
const { statusCode, data } = res
// HTTP 状态码异常
if (statusCode < 200 || statusCode >= 300) {
if (!skipErrorToast) {
handleHttpError(statusCode)
}
reject(res)
return
}
const responseData = data as ApiResponse<T>
// 业务成功
if (responseData.success) {
resolve(responseData)
return
}
// 业务失败
if (!skipErrorToast) {
handleBusinessError(responseData)
}
reject(responseData)
},
fail(err) {
// 网络错误 / 超时
const errMsg = err.errMsg || ''
if (errMsg.includes('timeout')) {
Taro.showToast({ title: '请求超时,请稍后重试', icon: 'none' })
} else if (errMsg.includes('fail')) {
Taro.showToast({ title: '网络连接失败,请检查网络', icon: 'none' })
} else {
Taro.showToast({ title: '网络错误,请稍后重试', icon: 'none' })
}
reject(err)
},
})
})
}
/**
* GET 请求快捷方法
*/
export function get<T = any>(url: string, config?: Omit<RequestConfig, 'url' | 'method'>) {
return request<T>({ ...config, url, method: 'GET' })
}
/**
* POST 请求快捷方法
*/
export function post<T = any>(url: string, data?: any, config?: Omit<RequestConfig, 'url' | 'method' | 'data'>) {
return request<T>({ ...config, url, method: 'POST', data })
}
/**
* PUT 请求快捷方法
*/
export function put<T = any>(url: string, data?: any, config?: Omit<RequestConfig, 'url' | 'method' | 'data'>) {
return request<T>({ ...config, url, method: 'PUT', data })
}
/**
* DELETE 请求快捷方法
*/
export function del<T = any>(url: string, config?: Omit<RequestConfig, 'url' | 'method'>) {
return request<T>({ ...config, url, method: 'DELETE' })
}
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"removeComments": false,
"preserveConstEnums": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"noImplicitAny": false,
"allowSyntheticDefaultImports": true,
"outDir": "lib",
"noUnusedLocals": false,
"noUnusedParameters": false,
"strictNullChecks": true,
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"rootDir": ".",
"jsx": "react-jsx",
"allowJs": true,
"resolveJsonModule": true,
"typeRoots": [
"node_modules/@types"
]
},
"include": ["./src", "./types"],
"compileOnSave": false
}
+19
View File
@@ -0,0 +1,19 @@
/// <reference types="@tarojs/taro" />
declare module '*.png';
declare module '*.gif';
declare module '*.jpg';
declare module '*.jpeg';
declare module '*.svg';
declare module '*.css';
declare module '*.less';
declare module '*.scss';
declare module '*.sass';
declare module '*.styl';
declare namespace NodeJS {
interface ProcessEnv {
TARO_ENV: 'weapp' | 'swan' | 'alipay' | 'h5' | 'rn' | 'tt' | 'quickapp' | 'qq' | 'jd'
}
}
+11549
View File
File diff suppressed because it is too large Load Diff
+545
View File
@@ -0,0 +1,545 @@
# 订货采购系统 · 小程序端 API 文档
> 版本:V1.0 更新日期:2026-08-06
> 适用:微信小程序门店端 / 供应商端;接口由后端 `app/Http/Controllers/Mini/` 提供(Laravel 12 + Sanctum)。
## 1. 通用说明
### 1.1 基础信息
| 项目 | 说明 |
|------|------|
| Base URL | `http://localhost:8000`(生产域名待定,通常为 HTTPS) |
| 数据格式 | JSON(请求/响应均 `Content-Type: application/json` |
| 金额字段 | 后端统一 `decimal` 字符串返回(如 `"13.00"`),下单/购物车金额**一律服务端重算**,前端传的金额字段会被忽略 |
### 1.2 认证方式
除「登录」接口外,全部接口需携带 `Authorization: Bearer <token>`(登录接口返回的 tokenSanctum plainTextToken)。
```http
Authorization: Bearer 1|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
token 附带 `abilities: ["mini"]` 仅作来源标记;后端按 `users` guard 解析用户。
### 1.3 统一响应格式
成功:
```json
{ "success": true, "data": { ... } }
```
成功带提示:
```json
{ "success": true, "data": { ... }, "msg": "下单成功" }
```
失败(业务错误/验证错误均返回 HTTP 200,`success=false`):
```json
{ "success": false, "msg": "尚未绑定门店,请联系客服处理", "showType": 1 }
```
分页数据统一结构(`data` 字段内):
```json
{
"success": true,
"data": {
"data": [ ... ],
"total": 35,
"pageSize": 10,
"current": 1
}
}
```
### 1.4 角色前置校验
| 接口域 | 前置要求 | 未满足时提示 |
|--------|----------|--------------|
| 商品/购物车/订单/对账单/门店设置 | 用户 `type=门店(1)` 且已绑定正常门店 | 「尚未绑定门店,请联系客服处理」 |
| 供应商采购单 | 用户 `type=供应商(2)` 且已绑定正常供应商 | 「尚未绑定供应商,请联系客服处理」 |
| 商品价格展示 | 门店已设置客户等级(`store.level_id > 0`) | 「门店未设置客户等级,无法展示价格,请联系客服」 |
> 登录后未绑定身份的用户 `type=0`(待绑定):可通过绑定手机号自动匹配门店/供应商,或由后台人工绑定。
### 1.5 价格体系
- 商品价格按「门店客户等级」展示,同一商品不同等级价格不同
- 等级价格支持两种计价类型:
- **固定价**`price_type=0`):直接存储实际单价
- **成本百分比**`price_type=1`):实际价 = 成本价 × (100 + 上浮百分点) / 100
- 小程序端接口返回的 `price` 均为**换算后的实际价**;成本价为商业敏感数据,**不会**下发到小程序端
---
## 2. 认证
### 2.1 微信登录(自动注册)
`POST /mini/auth/login`
`wx.login()` 获取的 code 换 openid,已注册用户直接登录,新用户自动注册并返回 token。
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| code | string | 是 | `wx.login` 的临时凭证 |
响应(`data`):
| 字段 | 类型 | 说明 |
|------|------|------|
| token | string | Bearer 令牌(后续请求头携带) |
| user.id | int | 用户ID |
| user.nickname | string | 昵称 |
| user.avatar | string | 头像 |
| user.phone | string | 手机号(未绑定为空) |
| user.type | int | 0 待绑定 / 1 门店 / 2 供应商 |
| user.store | object\|null | 绑定门店信息(含 `level`:客户等级 `{id,name}` |
| user.supplier | object\|null | 绑定供应商信息 |
响应示例:
```json
{
"success": true,
"data": {
"token": "1|abc...",
"user": {
"id": 5, "nickname": "微信用户1", "avatar": "", "phone": "",
"type": 1,
"store": { "id": 2, "name": "菜市场A店", "level": { "id": 1, "name": "一级客户" } },
"supplier": null
}
},
"msg": "登录成功"
}
```
错误:`code` 缺失 → 「缺少登录凭证 code」;账号被停用 → 「账号已被停用,请联系客服」。
### 2.2 绑定手机号
`POST /mini/auth/phone`(需登录)
用微信手机号授权码换手机号,并按手机号自动匹配门店/供应商(均未命中则保持待绑定,由后台处理)。
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| phoneCode | string | 是 | `wx.getPhoneNumber` 授权得到的 code |
响应(`data`):`user` 结构同 2.1。
### 2.3 当前用户信息
`GET /mini/auth/info`(需登录)
响应(`data`):`user` 结构同 2.1(含门店客户等级——小程序全局价格体系的依据)。
---
## 3. 商品
### 3.1 商品分类树
`GET /mini/product/categories`(需登录 + 门店)
返回分类树,**仅包含有上架商品的分类及其全部祖先**(保证树结构完整)。
响应(`data`):分类树数组,节点字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 分类ID |
| parent_id | int | 父级分类ID0 为顶级) |
| name | string | 分类名称 |
| children | array | 子分类(递归) |
### 3.2 商品列表
`GET /mini/product/list?category_id=&keyword=&page=&pageSize=`(需登录 + 门店 + 客户等级)
请求参数:
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| category_id | int | 否 | - | 分类ID过滤 |
| keyword | string | 否 | - | 搜索品名/规格(模糊) |
| page | int | 否 | 1 | 页码 |
| pageSize | int | 否 | 10 | 每页条数 |
响应(`data` 为分页结构),每项字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 商品ID |
| category_id | int | 分类ID |
| supplier_id | int | 默认供应商ID |
| name | string | 品名 |
| spec | string | 规格/包规 |
| unit | string | 计价单位 |
| content | string | 商品图文详情(HTML |
| **price** | string\|null | **当前门店等级的实际销售价**(未设等级价为 null |
| images_arr | array | 商品图片数组(`{id, file_url, ...}` |
| sort / shelf_life / stock / status | - | 排序 / 保质期 / 库存 / 状态(仅返回上架商品) |
> 注意:只返回上架商品;`cost_price`、计价类型、上浮百分点等成本信息不会下发。
---
## 4. 购物车
> 购物车为下单前的编辑容器,同商品重复加购自动合并数量;提交订单复用「5.1 下单」接口。
### 4.1 加购
`POST /mini/cart`(需登录 + 门店 + 客户等级)
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| product_id | int | 是 | 商品ID(须上架且已设本等级价格) |
| quantity | number | 是 | 数量(>0,最多 99999999.99 |
响应(`data`):
```json
{ "id": 12, "quantity": "2.50" }
```
提示:`已加入购物车`。错误:商品未设本等级价格 → 「商品「xx」未设置您所在等级的价格,无法加购」;超上限 → 「该商品在购物车中的数量已达上限」。
### 4.2 购物车列表
`GET /mini/cart`(需登录 + 门店)
响应(`data`):
| 字段 | 类型 | 说明 |
|------|------|------|
| items | array | 购物车项(倒序) |
| total_count | int | 总项数 |
| total_quantity | string | 可购项总数量 |
| total_amount | string | 可购项总金额 |
items 每项:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 购物车项ID |
| product_id | int | 商品ID |
| name / spec / unit | string | 商品快照 |
| image | string | 商品首图 URL |
| **price** | string\|null | **当前等级实际价**(商品下架或未设等级价为 null) |
| quantity | string | 数量 |
| amount | string\|null | 金额 = price × quantity(不可购为 null |
| status | int | 1 可购 / 0 商品下架、缺失或未设等级价 |
### 4.3 修改数量
`PUT /mini/cart/{id}`(需登录)
请求参数:`quantity`number,必填,>0)。
响应:`{ id, quantity }`,提示「已修改数量」。
### 4.4 删除单项
`DELETE /mini/cart/{id}`(需登录)
响应:`success=true`,提示「已删除」;不存在 → 「购物车项不存在」。
### 4.5 清空购物车
`DELETE /mini/cart`(需登录)
仅清空当前用户;响应:`success=true`,提示「购物车已清空」。
---
## 5. 门店订单
### 5.1 下单
`POST /mini/order`(需登录 + 门店 + 客户等级)
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| items | array | 是 | 订单明细(至少 1 行) |
| items[].product_id | int | 是 | 商品ID(须上架) |
| items[].quantity | number | 是 | 数量(>0 |
| remark | string | 否 | 订单备注(≤255 字符) |
> 金额不接受前端传入:服务端按商品当前等级**实际价**逐行快照并重算 `amount``total_amount`
响应(`data`):
```json
{ "id": 23, "order_no": "SO202608060001", "total_amount": "39.00" }
```
提示:「下单成功」。错误示例:存在已下架商品 → 「存在已下架或不存在的商品,请刷新后重试」;未设等级价 → 「商品「xx」未设置您所在等级的价格,无法下单」。
### 5.2 历史订单
`GET /mini/order?status=&page=&pageSize=`(需登录 + 门店,强制本店隔离)
请求参数:
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| status | int | 否 | - | 0 待汇总 / 1 已汇总 / 2 配送中 / 3 已完成 / 9 已取消 |
| page / pageSize | - | 否 | 1 / 10 | 分页 |
响应(`data` 分页结构),订单字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 订单ID |
| order_no | string | 单号(SO + 日期 + 序列) |
| order_date | string | 订货日期(Y-m-d |
| total_quantity / total_amount | string | 总数量 / 总金额 |
| status | int | 状态(见上) |
| remark | string | 备注 |
### 5.3 周期汇总
`GET /mini/order/summary?period=day|week|month`(需登录 + 门店)
请求参数:`period`day/week/month,默认 month)。
响应(`data`):
```json
{
"period": "month",
"groups": [
{ "period_label": "2026-07", "total_amount": "1280.50", "total_quantity": "86.00", "order_count": 12 }
]
}
```
> `period_label` 格式:day=`Y-m-d`、week=`Y-W+周数`、month=`Y-m`;不含已取消订单;最多返回 50 组。
### 5.4 订单详情
`GET /mini/order/{id}`(需登录 + 门店,校验本店归属)
响应(`data`):订单对象 + `items` 数组(明细字段见下表)。
明细字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| id / order_id | int | 明细ID / 订单ID |
| product_id / product_name / product_spec | - | 商品快照 |
| price | string | 下单时等级实际价快照 |
| quantity / weight | string | 数量 / 称重(默认 0 |
| amount | string | 金额 = price × quantity |
| remark | string | 行备注 |
### 5.5 取消订单
`PUT /mini/order/{id}/cancel`(需登录 + 门店)
仅「待汇总(0)」可取消;响应提示「订单已取消」;非待汇总 → 「仅待汇总的订单可以取消」。
---
## 6. 对账单(门店自助)
### 6.1 对账单列表
`GET /mini/statement?page=&pageSize=`(需登录 + 门店,仅本店)
响应(`data` 分页结构),对账单字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 对账单ID |
| statement_no | string | 单号(ST + 日期 + 序列) |
| period_start / period_end | string | 对账周期 |
| total_amount | string | 总金额 |
| payment_cycle_days | int | 生成时快照的回款周期 |
| settlement_date | string\|null | 应结算日期 = 周期结束 + 回款周期天 |
| status | int | 0 待对账 / 1 已对账 / 2 已结算 |
| reconciled_at / settled_at | string\|null | 对账 / 结算时间 |
| remark | string | 备注 |
### 6.2 生成对账单
`POST /mini/statement/generate`(需登录 + 门店)
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| period_start | string | 是 | 周期开始(Y-m-d |
| period_end | string | 是 | 周期结束(Y-m-d,不早于开始) |
响应(`data`):`{ id, statement_no, total_amount, settlement_date }`,提示「对账单已生成」。
> 快照当前回款周期计算结算日期。业务约束:周期内本店无订单 → 「周期内本店无订单数据,无法生成对账单」;周期内订单均已生成过对账单 → 「周期内的订单明细均已生成过对账单」。
### 6.3 对账单详情
`GET /mini/statement/{id}`(需登录 + 门店,校验归属)
响应(`data`):对账单对象 + `items` 数组,明细字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| order_id / order_item_id | int | 源订单 / 源明细ID |
| product_id / product_name | - | 商品快照 |
| price | string | 单价 |
| quantity / weight / amount | string | 数量 / 称重 / 金额 |
| is_reconciled | int | 0 未对账 / 1 已对账 |
| store_remark | string | 门店备注 |
### 6.4 导出对账单
`GET /mini/statement/{id}/export?format=xlsx|pdf`(需登录 + 门店,校验归属)
- `format` 默认 `xlsx`(支持 `xlsx` / `pdf`
- 返回文件流(附件下载,含中文文件名),非 JSON
---
## 7. 门店设置
### 7.1 修改回款周期
`PUT /mini/store/paymentCycle`(需登录 + 门店)
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| payment_cycle_days | int | 是 | 回款周期天数(≥0,无上限;0 = 当天结算) |
响应(`data`):`{ "payment_cycle_days": 1 }`,提示「回款周期已更新」。
> 该值影响后续生成对账单的 `settlement_date`(周期结束 + 回款周期天)。
---
## 8. 通知
### 8.1 通知列表
`GET /mini/notice?page=&pageSize=`(需登录)
返回本人通知 + 全员广播(本人已读的广播自动隐藏)。响应(`data` 分页结构 + 附加字段):
| 字段 | 类型 | 说明 |
|------|------|------|
| unread_count | int | 未读总数 |
| 分页内字段 | - | 标准分页结构 |
通知字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 通知ID |
| type | string | `order` 订单 / `price` 价格变更 / `system` 系统 |
| title / content | string | 标题 / 内容 |
| data | object | 附加数据(价格变更通知含 `product_ids``level_ids` |
| is_read | int | 0 未读 / 1 已读 |
| read_at | string\|null | 已读时间 |
### 8.2 标记已读
`PUT /mini/notice/{id}/read`(需登录)
- 个人通知:直接标记已读
- 全员广播:复制一条本人专属已读记录(原广播对他人仍为未读)
响应:`success=true`;通知不存在 → 「通知不存在」。
---
## 9. 供应商端
### 9.1 收到的采购单
`GET /mini/supplier/purchases?page=&pageSize=`(需登录 + 供应商)
返回**含本供应商已发送明细**`is_sent=1`)的采购单(去重,按日期倒序)。
响应(`data` 分页结构),采购单字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 采购单ID |
| purchase_no | string | 单号(PO + 日期 + 序列) |
| purchase_date | string | 采购日期 |
| status | int | 0 待发送 / 1 部分发送 / 2 全部发送 / 3 已完成 |
| total_quantity / estimate_amount / actual_amount | string | 总数量 / 估算金额 / 实际金额 |
| remark | string | 备注 |
### 9.2 采购单明细
`GET /mini/supplier/purchases/{id}`(需登录 + 供应商)
仅返回**本供应商且已发送**的明细行。响应(`data`):
```json
{
"id": 3, "purchase_no": "PO202608060001", "purchase_date": "2026-08-06", "remark": "",
"items": [
{ "id": 11, "product_id": 2, "product_name": "大白菜", "product_spec": "10斤/箱",
"price": "4.00", "quantity": "10.00", "weight": "0.000", "amount": "40.00",
"sort": 1, "is_sent": 1, "sent_at": "...", "supplier_confirmed_at": null }
]
}
```
无本供应商明细 → 「该采购单无贵司的采购明细」。
### 9.3 确认接单
`PUT /mini/supplier/purchases/{id}/confirm`(需登录 + 供应商)
批量记录本供应商全部已发送明细的 `supplier_confirmed_at`(幂等,已确认的跳过)。
响应(`data`):`{ "confirmed": 2 }`(本次新确认条数),提示「已确认接单」。
---
## 10. 状态字典汇总
| 枚举 | 值 | 含义 |
|------|-----|------|
| 用户类型 user.type | 0 / 1 / 2 | 待绑定 / 门店 / 供应商 |
| 门店订单 status | 0 / 1 / 2 / 3 / 9 | 待汇总 / 已汇总 / 配送中 / 已完成 / 已取消 |
| 采购单 status | 0 / 1 / 2 / 3 | 待发送 / 部分发送 / 全部发送 / 已完成 |
| 采购明细 is_sent | 0 / 1 | 未发送 / 已发送 |
| 对账单 status | 0 / 1 / 2 | 待对账 / 已对账 / 已结算 |
| 对账明细 is_reconciled | 0 / 1 | 未对账 / 已对账 |
| 通知 type | order / price / system | 订单 / 价格变更 / 系统 |
| 通知 is_read | 0 / 1 | 未读 / 已读 |
| 商品 status | 0 / 1 | 下架 / 上架 |
| 门店/供应商 status | 0 / 1 | 停用 / 正常 |
## 11. 常见错误提示
| 提示语 | 触发场景 |
|--------|----------|
| 尚未绑定门店,请联系客服处理 | 门店端接口但用户未绑定门店 |
| 尚未绑定供应商,请联系客服处理 | 供应商端接口但用户未绑定供应商 |
| 门店未设置客户等级,无法展示价格,请联系客服 | 门店 `level_id=0`(商品/购物车/下单) |
| 商品「xx」未设置您所在等级的价格,无法下单 | 下单商品缺本等级价格 |
| 存在已下架或不存在的商品,请刷新后重试 | 下单商品已下架 |
| 账号不存在或已被停用 | token 用户被停用 |
| 账号已被停用,请联系客服 | 登录时账号被停用 |