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