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 @@
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' })
}