first commit

This commit is contained in:
liu
2026-07-13 14:44:13 +08:00
commit dcd32e9d4d
40 changed files with 12918 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)
}
+76
View File
@@ -0,0 +1,76 @@
{
"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"
},
"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": true,
"es6": false,
"postcss": false,
"minified": false,
"enhance": false
},
"compileType": "miniprogram"
}
+49
View File
@@ -0,0 +1,49 @@
import { createRequest } from '@/utils/request'
import type { ResponseStructure } from '@/utils/request'
// ==================== 类型定义 ====================
/** 图片资源 */
export interface ApiImage {
preview_url: string
file_name: string
id: number
}
/** 轮播图项 */
export interface CarouselItem {
id: number
title: string
image: ApiImage
link: string
status: number // 0: 启用
sort: number
}
/** 宫格导航项 */
export interface GridNavItem {
id: number
title: string
image: ApiImage
link: string
status: number // 0: 启用
sort: number
}
/** 首页数据 */
export interface HomeData {
carousel: CarouselItem[]
gridNav: GridNavItem[]
}
// ==================== API ====================
/**
* 获取首页数据(无需登录)
*/
export async function getHomeData(): Promise<ResponseStructure<HomeData>> {
return createRequest<HomeData>({
url: '/api/home',
method: 'GET',
})
}
+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
+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>
+5
View File
@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '校园美食',
navigationBarBackgroundColor: '#ff6b35',
navigationBarTextStyle: 'white',
})
+153
View File
@@ -0,0 +1,153 @@
.index {
position: relative;
min-height: 100vh;
overflow: hidden;
// 背景图层
.bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
z-index: 0;
}
// 内容层
.index-content {
position: relative;
z-index: 1;
}
// ========== 1. 搜索栏 ==========
.search-wrapper {
padding: 24px 32px 16px;
.van-search {
padding: 0;
border-radius: 36px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(255, 107, 53, 0.15);
}
.van-search__content {
border-radius: 36px;
}
}
// ========== 2. 轮播图 ==========
.swiper-wrapper {
margin: 8px 32px 20px;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
.swiper-img {
width: 100%;
height: 100%;
}
}
// ========== 3. 宫格导航 ==========
.grid-wrapper {
margin: 16px 32px 20px;
background: rgb(255 255 255);
border-radius: 20px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
padding-bottom: 24px;
.grid-title {
font-size: 32px;
font-weight: 600;
color: #333;
}
.van-grid-item__content {
padding-top: 24px;
padding-bottom: 0;
}
.van-icon--image {
width: 60px !important;
height: 60px !important;
}
.van-grid-item__text {
font-size: 22px;
color: #555;
margin-top: 8px;
}
}
// ========== 4. 底部推荐卡片 ==========
.bottom-section {
padding: 0 32px;
.section-card {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.9);
border-radius: 18px;
padding: 24px 28px;
margin-bottom: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
transition: transform 0.2s ease;
&:active {
transform: scale(0.98);
}
.card-icon {
font-size: 48px;
width: 72px;
height: 72px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 18px;
margin-right: 20px;
flex-shrink: 0;
}
.card-info {
flex: 1;
.card-title {
font-size: 30px;
font-weight: 600;
color: #333;
margin-bottom: 6px;
}
.card-desc {
font-size: 24px;
color: #999;
}
}
.card-arrow {
font-size: 40px;
color: #ccc;
flex-shrink: 0;
}
}
// 每张卡片图标不同底色
.card-hot .card-icon {
background: #fff0ed;
}
.card-discount .card-icon {
background: #fff7e6;
}
.card-new .card-icon {
background: #e6f7ff;
}
.card-order .card-icon {
background: #f0edff;
}
}
// 底部留白
.bottom-spacer {
height: 60px;
}
}
+180
View File
@@ -0,0 +1,180 @@
import { View, Image } from '@tarojs/components'
import { Search, Swiper, SwiperItem, Grid, GridItem } from '@antmjs/vantui'
import { useState, useEffect } from 'react'
import Taro from '@tarojs/taro'
import { getHomeData } from '@/api/home'
import type { CarouselItem, GridNavItem } from '@/api/home'
import './index.less'
// 背景图
import bgImg from '../../../static/img/bg.png'
// ==================== 本地兜底数据 ====================
// ==================== 工具函数 ====================
/** 排序 + 过滤启用的项 */
function prepareItems<T extends { status: number; sort: number }>(items: T[]): T[] {
return items
.filter((item) => item.status === 0)
.sort((a, b) => a.sort - b.sort)
}
/** 处理导航跳转 */
function handleNavigate(link: string): void {
if (!link) return
if(link.startsWith('http')) {
window.location.href = link
} else {
Taro.navigateTo({ url: link }).catch(() => {
Taro.switchTab({ url: link }).catch(() => {
console.warn('导航失败:', link)
})
})
}
}
// ==================== 页面组件 ====================
export default function Index() {
const [searchValue, setSearchValue] = useState('')
const [carousel, setCarousel] = useState<CarouselItem[]>([])
const [gridNav, setGridNav] = useState<GridNavItem[]>([])
const [loading, setLoading] = useState(true)
// 获取首页数据
useEffect(() => {
async function fetchHomeData() {
try {
const res = await getHomeData()
if (res.success && res.data) {
// 轮播图:API 有数据则用,否则兜底
if (res.data.carousel?.length > 0) {
setCarousel(prepareItems(res.data.carousel))
}
// 宫格导航:API 有数据则用,否则兜底
if (res.data.gridNav?.length > 0) {
setGridNav(prepareItems(res.data.gridNav))
}
}
} catch (err) {
console.warn('获取首页数据失败,使用本地兜底数据', err)
} finally {
setLoading(false)
}
}
fetchHomeData()
}, [])
return (
<View className='index'>
{/* 背景图层 */}
<Image className='bg-image' src={bgImg} mode='widthFix' />
{/* 内容层 */}
<View className='index-content'>
{/* 1. 顶部搜索栏 */}
<View className='search-wrapper'>
<Search
value={searchValue}
onChange={(e) => setSearchValue(e.detail)}
shape='round'
placeholder='搜索美食、餐厅...'
background='rgba(255,255,255,0.9)'
onSearch={() => {
// 搜索逻辑
}}
/>
</View>
{/* 2. 中间轮播图 */}
<View className='swiper-wrapper'>
{carousel.length > 0 ? (
<Swiper
autoPlay={3000}
loop
paginationVisible
paginationColor='#ffffff'
duration={500}
height={220}
>
{carousel.map((item) => (
<SwiperItem key={item.id}>
<Image
className='swiper-img'
src={item.image.preview_url}
mode='aspectFill'
onClick={() => handleNavigate(item.link)}
/>
</SwiperItem>
))}
</Swiper>
) : (
<View className='swiper-placeholder'></View>
)}
</View>
{/* 3. 宫格导航 */}
<View className='grid-wrapper'>
{gridNav.length > 0 ? (
<Grid columnNum={5} border={false} gutter={12} iconSize={48}>
{gridNav.map((item) => (
<GridItem
key={item.id}
icon={item.image.preview_url}
text={item.title}
onClick={() => handleNavigate(item.link)}
/>
))}
</Grid>
) : (
<View className='grid-placeholder'></View>
)}
</View>
{/* 4. 底部推荐卡片 */}
<View className='bottom-section'>
<View className='section-card card-hot'>
<View className='card-icon'>🔥</View>
<View className='card-info'>
<View className='card-title'></View>
<View className='card-desc'></View>
</View>
<View className='card-arrow'></View>
</View>
<View className='section-card card-discount'>
<View className='card-icon'>💰</View>
<View className='card-info'>
<View className='card-title'></View>
<View className='card-desc'>9.9</View>
</View>
<View className='card-arrow'></View>
</View>
<View className='section-card card-new'>
<View className='card-icon'></View>
<View className='card-info'>
<View className='card-title'></View>
<View className='card-desc'></View>
</View>
<View className='card-arrow'></View>
</View>
<View className='section-card card-order'>
<View className='card-icon'>📋</View>
<View className='card-info'>
<View className='card-title'></View>
<View className='card-desc'></View>
</View>
<View className='card-arrow'></View>
</View>
</View>
{/* 底部留白 */}
<View className='bottom-spacer' />
</View>
</View>
)
}
+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;
+221
View File
@@ -0,0 +1,221 @@
import Taro from '@tarojs/taro'
// ==================== 类型定义 ====================
/** 后端统一响应结构 */
export interface ResponseStructure<T = any> {
success: boolean
msg?: string
showType?: number // 0-2: Toast 提示; 3-5: Modal 通知; 99: 静默处理
description?: string
placement?: string
data?: T
}
/** 请求配置(兼容 Taro.request.Option */
export interface RequestConfig {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD' | 'TRACE' | 'CONNECT'
data?: any
header?: Record<string, any>
timeout?: number
dataType?: 'json' | 'text' | 'base64' | 'arraybuffer'
responseType?: 'text' | 'arraybuffer'
}
// ==================== 配置常量 ====================
/** 请求超时时间 */
const REQUEST_TIMEOUT = 10000
/** 登录页面 */
const LOGIN_PATH = '/pages/login/index'
/** API 基础地址 */
const BASE_URL = 'http://localhost:8000'
/**
* 解析请求 URL,自动拼接 BASE_URL
*/
function resolveUrl(url: string): string {
// 已经是完整 URL
if (/^https?:\/\//i.test(url)) {
return url
}
return BASE_URL + url
}
// ==================== 错误处理 ====================
/**
* 处理网络错误(HTTP 状态码错误)
*/
async function handleNetworkError(statusCode?: number): Promise<void> {
if (!statusCode) {
Taro.showToast({ title: '无法连接到服务器!', icon: 'none', duration: 2000 })
return
}
// 401 未授权
if (statusCode === 401) {
Taro.showToast({ title: '您未登录,或者登录已经超时,请先登录!', icon: 'none', duration: 2500 })
// 清除本地认证信息
Taro.removeStorageSync('token')
Taro.removeStorageSync('refresh_token')
Taro.removeStorageSync('auth-storage')
// 避免在登录页重复跳转
const pages = Taro.getCurrentPages()
const currentRoute = pages.length > 0 ? pages[pages.length - 1].route : ''
if (currentRoute !== LOGIN_PATH.replace(/^\//, '')) {
Taro.redirectTo({ url: LOGIN_PATH })
}
return
}
const errorMessages: Record<number, string> = {
302: '接口重定向了!',
400: '参数不正确!',
403: '您没有权限操作!',
404: '请求错误,未找到该资源!',
408: '请求超时!',
409: '系统已存在相同数据!',
500: '服务器内部错误!',
501: '服务未实现!',
502: '网关错误!',
503: '服务不可用!',
504: '服务暂时无法访问,请稍后再试!',
505: 'HTTP 版本不受支持!',
}
const message = errorMessages[statusCode] || `连接错误 (状态码: ${statusCode})`
Taro.showToast({ title: message, icon: 'none', duration: 2500 })
}
/**
* 处理业务错误(接口返回的业务逻辑错误)
*/
async function handleBusinessError(data: ResponseStructure): Promise<void> {
const { msg = '', showType = 0, description = '' } = data
switch (showType) {
case 99: // 静默处理
break
case 0: // Toast 成功
if (msg) Taro.showToast({ title: msg, icon: 'success', duration: 2000 })
break
case 1: // Toast 警告
if (msg) Taro.showToast({ title: msg, icon: 'none', duration: 2500 })
break
case 2: // Toast 错误
if (msg) Taro.showToast({ title: msg, icon: 'error', duration: 2500 })
break
case 3: // Modal 成功
if (msg) Taro.showModal({ title: msg, content: description || '', showCancel: false })
break
case 4: // Modal 警告
if (msg) Taro.showModal({ title: msg, content: description || '', showCancel: false })
break
case 5: // Modal 错误
if (msg) Taro.showModal({ title: msg, content: description || '', showCancel: false })
break
default:
if (msg) Taro.showToast({ title: msg, icon: 'none', duration: 2500 })
}
}
// ==================== 核心请求函数 ====================
/**
* 创建请求
* @param config - 请求配置
* @returns Promise<ResponseStructure<Data>>
*/
export async function createRequest<Data = any>(
config: RequestConfig,
): Promise<ResponseStructure<Data>> {
// 2. 构建 headers
const header: Record<string, any> = { ...(config.header || {}) }
// 附加国际化语言
try {
const language = Taro.getStorageSync('i18nextLng')
if (language) {
header['User-Language'] = language
}
} catch {
// storage 读取失败时忽略
}
// 附加 Token(排除登录接口)
try {
const token = Taro.getStorageSync('token')
if (token) {
header['Authorization'] = `Bearer ${token}`
}
} catch {
// storage 读取失败时忽略
}
try {
// 4. 发起请求
const task = Taro.request<ResponseStructure<Data>>({
url: resolveUrl(config.url),
method: config.method || 'GET',
data: config.data,
header,
timeout: config.timeout || REQUEST_TIMEOUT,
dataType: config.dataType || 'json',
responseType: config.responseType,
})
const response = await task
const body = response.data as ResponseStructure<Data>
if (body?.success) {
return body
}
// 8. 业务失败:显示错误提示并 reject
if (body) {
await handleBusinessError(body)
}
return Promise.reject(body)
} catch (err: any) {
// 请求被取消(去重或手动取消)
if (err?.errMsg?.includes('abort') || err?.errMsg?.includes('cancel')) {
console.warn('请求已取消:', err.errMsg)
return Promise.reject(err)
}
// 有 HTTP 状态码但非 2xx
if (err?.statusCode) {
await handleNetworkError(err.statusCode)
return Promise.reject(err)
}
// 网络超时
if (err?.errMsg?.includes('timeout')) {
Taro.showToast({ title: '请求超时,请稍后重试!', icon: 'none', duration: 2500 })
return Promise.reject(err)
}
// 网络连接失败
if (err?.errMsg?.includes('fail') || err?.message?.includes('Network Error')) {
Taro.showToast({ title: '网络连接失败,请检查网络!', icon: 'none', duration: 2500 })
return Promise.reject(err)
}
// 其他未知错误
Taro.showToast({
title: `网络错误: ${err?.errMsg || err?.message || '未知错误'}`,
icon: 'none',
duration: 2500,
})
return Promise.reject(err)
}
}
export default createRequest
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+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'
}
}
+11544
View File
File diff suppressed because it is too large Load Diff