init
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/index/index',
|
||||
],
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
navigationBarBackgroundColor: '#fff',
|
||||
navigationBarTitleText: 'WeChat',
|
||||
navigationBarTextStyle: 'black',
|
||||
},
|
||||
animation: false,
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
"component": true
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '首页'
|
||||
})
|
||||
@@ -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>
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '首页'
|
||||
})
|
||||
@@ -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>
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '登录',
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '首页'
|
||||
})
|
||||
@@ -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>
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '首页'
|
||||
})
|
||||
@@ -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>
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '首页'
|
||||
})
|
||||
@@ -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>
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
Reference in New Issue
Block a user