Compare commits
10 Commits
f992590c70
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 81add5d8b7 | |||
| 4ac9193ed5 | |||
| 3022990ea1 | |||
| c25cf0e8b3 | |||
| 97e7a6083c | |||
| 56aaf523ab | |||
| 2e22bc2226 | |||
| 85c83a1422 | |||
| cdd91f083b | |||
| 0a1c8cdff1 |
+2
-1
@@ -46,7 +46,8 @@
|
|||||||
"@tarojs/taro": "3.6.14",
|
"@tarojs/taro": "3.6.14",
|
||||||
"@tarojs/taro-h5": "3.6.14",
|
"@tarojs/taro-h5": "3.6.14",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antmjs/plugin-global-fix": "^2.3.21",
|
"@antmjs/plugin-global-fix": "^2.3.21",
|
||||||
|
|||||||
+2
-2
@@ -4,10 +4,10 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"appid": "wx7ed74d60503b5ee3",
|
"appid": "wx7ed74d60503b5ee3",
|
||||||
"setting": {
|
"setting": {
|
||||||
"urlCheck": true,
|
"urlCheck": false,
|
||||||
"es6": false,
|
"es6": false,
|
||||||
"postcss": false,
|
"postcss": false,
|
||||||
"minified": false,
|
"minified": true,
|
||||||
"enhance": false
|
"enhance": false
|
||||||
},
|
},
|
||||||
"compileType": "miniprogram"
|
"compileType": "miniprogram"
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
export default defineAppConfig({
|
export default defineAppConfig({
|
||||||
pages: [
|
pages: [
|
||||||
'pages/index/index',
|
'pages/index/index',
|
||||||
|
'pages/food-delivery/index',
|
||||||
|
'pages/shop-detail/index',
|
||||||
|
'pages/forum/index',
|
||||||
|
'pages/search/index',
|
||||||
'pages/tasks/index',
|
'pages/tasks/index',
|
||||||
|
'pages/task-detail/index',
|
||||||
|
'pages/publish-article/index',
|
||||||
|
'pages/publish-express/index',
|
||||||
|
'pages/publish-secondhand/index',
|
||||||
|
'pages/chat/index',
|
||||||
|
'pages/system-messages/index',
|
||||||
'pages/messages/index',
|
'pages/messages/index',
|
||||||
'pages/profile/index',
|
'pages/profile/index',
|
||||||
'pages/article/index',
|
'pages/article/index',
|
||||||
|
'pages/login/index',
|
||||||
|
'pages/profile-edit/index',
|
||||||
],
|
],
|
||||||
window: {
|
window: {
|
||||||
backgroundTextStyle: 'light',
|
backgroundTextStyle: 'light',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class App extends Component {
|
|||||||
|
|
||||||
// this.props.children 是将要会渲染的页面
|
// this.props.children 是将要会渲染的页面
|
||||||
render () {
|
render () {
|
||||||
|
// @ts-ignore
|
||||||
return this.props.children
|
return this.props.children
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta-read {
|
.meta-read {
|
||||||
flex: 1;
|
margin-left: auto;
|
||||||
text-align: right;
|
}
|
||||||
|
|
||||||
|
.meta-comment {
|
||||||
|
margin-left: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export default function ImageGridCard({ article }: ImageGridCardProps) {
|
|||||||
<Text className='meta-source'>{article.source}</Text>
|
<Text className='meta-source'>{article.source}</Text>
|
||||||
<Text className='meta-time'>{article.time}</Text>
|
<Text className='meta-time'>{article.time}</Text>
|
||||||
<Text className='meta-read'>{article.readCount}阅读</Text>
|
<Text className='meta-read'>{article.readCount}阅读</Text>
|
||||||
|
{typeof article.commentCount === 'number' && (
|
||||||
|
<Text className='meta-comment'>💬 {article.commentCount}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -56,8 +56,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta-read {
|
.meta-read {
|
||||||
flex: 1;
|
margin-left: auto;
|
||||||
text-align: right;
|
}
|
||||||
|
|
||||||
|
.meta-comment {
|
||||||
|
margin-left: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export default function ImageTextCard({ article }: ImageTextCardProps) {
|
|||||||
<Text className='meta-source'>{article.source}</Text>
|
<Text className='meta-source'>{article.source}</Text>
|
||||||
<Text className='meta-time'>{article.time}</Text>
|
<Text className='meta-time'>{article.time}</Text>
|
||||||
<Text className='meta-read'>{article.readCount}阅读</Text>
|
<Text className='meta-read'>{article.readCount}阅读</Text>
|
||||||
|
{typeof article.commentCount === 'number' && (
|
||||||
|
<Text className='meta-comment'>💬 {article.commentCount}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -37,8 +37,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta-read {
|
.meta-read {
|
||||||
flex: 1;
|
margin-left: auto;
|
||||||
text-align: right;
|
}
|
||||||
|
|
||||||
|
.meta-comment {
|
||||||
|
margin-left: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export default function TextCard({ article }: TextCardProps) {
|
|||||||
<Text className='meta-source'>{article.source}</Text>
|
<Text className='meta-source'>{article.source}</Text>
|
||||||
<Text className='meta-time'>{article.time}</Text>
|
<Text className='meta-time'>{article.time}</Text>
|
||||||
<Text className='meta-read'>{article.readCount}阅读</Text>
|
<Text className='meta-read'>{article.readCount}阅读</Text>
|
||||||
|
{typeof article.commentCount === 'number' && (
|
||||||
|
<Text className='meta-comment'>💬 {article.commentCount}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/* ========================================
|
||||||
|
论坛帖卡(社交化:小红书/即刻 风格)
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.forum-post-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px 24px 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 用户头 ========== */
|
||||||
|
.post-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-avatar-wrap {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f0f2f5;
|
||||||
|
|
||||||
|
.author-avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-avatar-fallback {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #1989fa, #5dabff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-letter {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-info {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.author-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-time {
|
||||||
|
display: block;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #969799;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: 1px solid #1989fa;
|
||||||
|
border-radius: 32px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
.follow-text {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #1989fa;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 内容区 ========== */
|
||||||
|
.post-body {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-excerpt {
|
||||||
|
display: block;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #646566;
|
||||||
|
line-height: 1.6;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 图片网格 ========== */
|
||||||
|
.post-images {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.images-1 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
.post-image-wrap {
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
max-height: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.images-2 {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
.post-image-wrap {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.images-3 {
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
.post-image-wrap {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-image-wrap {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-more-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-more-text {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 底部操作栏 ========== */
|
||||||
|
.post-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #f2f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 32px;
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-right: 6px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon.share {
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-count {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #646566;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { View, Text, Image } from '@tarojs/components'
|
||||||
|
import type { Article } from '@/types/article'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
post: Article
|
||||||
|
onClick?: (post: Article) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 数字超过 1000 显示为 1k 形式 */
|
||||||
|
function formatCount(n: number | undefined): string {
|
||||||
|
if (n === undefined) return '0'
|
||||||
|
if (n < 1000) return String(n)
|
||||||
|
if (n < 10000) return `${(n / 1000).toFixed(1)}k`
|
||||||
|
return `${(n / 10000).toFixed(1)}w`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForumPostCard({ post, onClick }: Props) {
|
||||||
|
const imageCount = post.images.length
|
||||||
|
// 单图时使用第一张;多图时最多展示 3 张
|
||||||
|
const displayImages = imageCount === 1 ? post.images : post.images.slice(0, 3)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='forum-post-card' onClick={() => onClick?.(post)}>
|
||||||
|
{/* ========== 用户头 ========== */}
|
||||||
|
<View className='post-header'>
|
||||||
|
<View className='author-avatar-wrap'>
|
||||||
|
{post.sourceAvatar ? (
|
||||||
|
<Image
|
||||||
|
className='author-avatar'
|
||||||
|
src={post.sourceAvatar}
|
||||||
|
mode='aspectFill'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='author-avatar-fallback'>
|
||||||
|
<Text className='avatar-letter'>
|
||||||
|
{post.source.charAt(0)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className='author-info'>
|
||||||
|
<Text className='author-name' numberOfLines={1}>
|
||||||
|
{post.source}
|
||||||
|
</Text>
|
||||||
|
<Text className='post-time'>{post.time}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className='follow-btn'
|
||||||
|
onClick={(e) => {
|
||||||
|
e?.stopPropagation?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='follow-text'>+ 关注</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ========== 内容区 ========== */}
|
||||||
|
<View className='post-body'>
|
||||||
|
{/* 标题 */}
|
||||||
|
<Text className='post-title' numberOfLines={2}>
|
||||||
|
{post.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 摘要 */}
|
||||||
|
<Text className='post-excerpt' numberOfLines={3}>
|
||||||
|
{post.excerpt}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 图片 */}
|
||||||
|
{imageCount > 0 && (
|
||||||
|
<View
|
||||||
|
className={`post-images images-${Math.min(imageCount, 3)}`}
|
||||||
|
>
|
||||||
|
{displayImages.map((img, idx) => (
|
||||||
|
<View key={idx} className='post-image-wrap'>
|
||||||
|
<Image
|
||||||
|
className='post-image'
|
||||||
|
src={img}
|
||||||
|
mode='aspectFill'
|
||||||
|
/>
|
||||||
|
{imageCount > 3 && idx === 2 && (
|
||||||
|
<View className='image-more-mask'>
|
||||||
|
<Text className='image-more-text'>+{imageCount - 3}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ========== 底部操作栏 ========== */}
|
||||||
|
<View className='post-footer'>
|
||||||
|
<View className='action-item'>
|
||||||
|
<Text className='action-icon'>💬</Text>
|
||||||
|
<Text className='action-count'>{formatCount(post.commentCount)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='action-item'>
|
||||||
|
<Text className='action-icon'>❤️</Text>
|
||||||
|
<Text className='action-count'>{formatCount(post.likeCount)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='action-item'>
|
||||||
|
<Text className='action-icon'>👁</Text>
|
||||||
|
<Text className='action-count'>{formatCount(post.readCount)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='action-item right'>
|
||||||
|
<Text className='action-icon share'>↗</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
.publish-popup {
|
.publish-popup {
|
||||||
padding: 32px 24px 0;
|
padding: 32px 24px 0;
|
||||||
background: #f7f8fa;
|
background: #f7f8fa;
|
||||||
|
z-index: 9999;
|
||||||
border-radius: 24px 24px 0 0;
|
border-radius: 24px 24px 0 0;
|
||||||
|
|
||||||
.popup-header {
|
.popup-header {
|
||||||
|
|||||||
@@ -29,32 +29,36 @@ export default function PublishPopup({ visible, onClose, onSelect }: PublishPopu
|
|||||||
show={visible}
|
show={visible}
|
||||||
position='bottom'
|
position='bottom'
|
||||||
round
|
round
|
||||||
|
rootPortal
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
closeable
|
overlayStyle={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
|
||||||
>
|
>
|
||||||
<View className='publish-popup'>
|
<View style={{ padding: '32rpx 24rpx 0', paddingBottom: '160rpx', background: '#f7f8fa' }}>
|
||||||
<View className='popup-header'>
|
<View style={{ textAlign: 'center', marginBottom: '32rpx' }}>
|
||||||
<Text className='popup-title'>发布</Text>
|
<Text style={{ fontSize: '36rpx', fontWeight: 600, color: '#1a1a1a', display: 'block' }}>发布</Text>
|
||||||
<Text className='popup-subtitle'>选择你要发布的内容类型</Text>
|
<Text style={{ fontSize: '26rpx', color: '#969799' }}>选择你要发布的内容类型</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='popup-grid'>
|
{PUBLISH_OPTIONS.map((option) => (
|
||||||
{PUBLISH_OPTIONS.map((option) => (
|
<View
|
||||||
<View
|
key={option.id}
|
||||||
key={option.id}
|
style={{
|
||||||
className='publish-card'
|
display: 'flex',
|
||||||
onClick={() => onSelect(option)}
|
alignItems: 'center',
|
||||||
>
|
backgroundColor: '#fff',
|
||||||
<Text className='card-icon'>{option.icon}</Text>
|
borderRadius: '16rpx',
|
||||||
<View className='card-info'>
|
padding: '28rpx 24rpx',
|
||||||
<Text className='card-label'>{option.label}</Text>
|
marginBottom: '20rpx',
|
||||||
<Text className='card-desc'>{option.desc}</Text>
|
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.04)'
|
||||||
</View>
|
}}
|
||||||
|
onClick={() => onSelect(option)}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: '48rpx', marginRight: '20rpx', flexShrink: 0 }}>{option.icon}</Text>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ display: 'block', fontSize: '32rpx', fontWeight: 500, color: '#1a1a1a', marginBottom: '4px' }}>{option.label}</Text>
|
||||||
|
<Text style={{ fontSize: '24rpx', color: '#969799' }}>{option.desc}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
</View>
|
||||||
</View>
|
))}
|
||||||
<View className='popup-cancel' onClick={onClose}>
|
|
||||||
<Text className='cancel-text'>取消</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</Popup>
|
</Popup>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/* ========================================
|
||||||
|
商家卡片(单列,左图右信息)
|
||||||
|
仿美团外卖商家卡片
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.shop-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 20px 16px;
|
||||||
|
background: #fff;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
& + .shop-card {
|
||||||
|
border-top: 1px solid #f2f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #f7f8fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 左侧图片 ========== */
|
||||||
|
.shop-image-wrap {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
margin-right: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.shop-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 右侧信息 ========== */
|
||||||
|
.shop-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 商家名 */
|
||||||
|
.shop-name-row {
|
||||||
|
.shop-name {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.2;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 评分 + 月售 + 配送时间 */
|
||||||
|
.shop-rating-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #646566;
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #ff6034;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.rating-star {
|
||||||
|
font-size: 22px;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-value {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sales {
|
||||||
|
color: #646566;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-time {
|
||||||
|
color: #1989fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-sep {
|
||||||
|
margin: 0 8px;
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 起送 + 配送费 */
|
||||||
|
.shop-fee-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #969799;
|
||||||
|
|
||||||
|
.fee-sep {
|
||||||
|
margin: 0 6px;
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签 */
|
||||||
|
.shop-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
.shop-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 商家介绍 */
|
||||||
|
.shop-desc {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #969799;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import { Image as VantImage, Tag } from '@antmjs/vantui'
|
||||||
|
import type { Shop } from '@/types/shop'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shop: Shop
|
||||||
|
onClick?: (shop: Shop) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShopCard({ shop, onClick }: Props) {
|
||||||
|
return (
|
||||||
|
<View className='shop-card' onClick={() => onClick?.(shop)}>
|
||||||
|
{/* 左侧:商家主图 */}
|
||||||
|
<View className='shop-image-wrap'>
|
||||||
|
<VantImage
|
||||||
|
className='shop-image'
|
||||||
|
src={shop.image}
|
||||||
|
fit='cover'
|
||||||
|
width='100%'
|
||||||
|
height='100%'
|
||||||
|
radius={8}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 右侧:商家信息 */}
|
||||||
|
<View className='shop-info'>
|
||||||
|
{/* 商家名 */}
|
||||||
|
<View className='shop-name-row'>
|
||||||
|
<Text className='shop-name'>{shop.name}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 评分 + 月售 + 配送时间 */}
|
||||||
|
<View className='shop-rating-row'>
|
||||||
|
<View className='rating'>
|
||||||
|
<Text className='rating-star'>⭐</Text>
|
||||||
|
<Text className='rating-value'>{shop.rating.toFixed(1)}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='rating-sep'>·</Text>
|
||||||
|
<Text className='sales'>月售 {shop.monthlySales}</Text>
|
||||||
|
<Text className='rating-sep'>·</Text>
|
||||||
|
<Text className='delivery-time'>{shop.deliveryTime}分钟</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 起送 + 配送费 + 距离 */}
|
||||||
|
<View className='shop-fee-row'>
|
||||||
|
<Text className='fee'>起送 ¥{shop.minOrder}</Text>
|
||||||
|
<Text className='fee-sep'>|</Text>
|
||||||
|
<Text className='fee'>配送费 ¥{shop.deliveryFee}</Text>
|
||||||
|
<Text className='fee-sep'>|</Text>
|
||||||
|
<Text className='distance'>{shop.distance}m</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 标签 */}
|
||||||
|
{shop.tags.length > 0 && (
|
||||||
|
<View className='shop-tags'>
|
||||||
|
{shop.tags.map((t) => {
|
||||||
|
const isPrimary = t === '招牌' || t === '推荐' || t === '热销' || t === '新店'
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
key={t}
|
||||||
|
type={isPrimary ? 'danger' : 'primary'}
|
||||||
|
plain
|
||||||
|
size='medium'
|
||||||
|
className='shop-tag'
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 商家介绍 */}
|
||||||
|
<Text className='shop-desc'>{shop.description}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
height: 100px;
|
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: rgba(255, 255, 255, 0.85);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import PublishPopup from '@/components/PublishPopup'
|
|||||||
import type { PublishOption } from '@/components/PublishPopup'
|
import type { PublishOption } from '@/components/PublishPopup'
|
||||||
import './index.less'
|
import './index.less'
|
||||||
|
|
||||||
/** Tab 项配置(顺序与 app.config.ts tabBar.list 一致) */
|
|
||||||
interface TabItem {
|
interface TabItem {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
@@ -21,22 +20,43 @@ const TAB_LIST: TabItem[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
interface CustomTabBarProps {
|
interface CustomTabBarProps {
|
||||||
/** 当前激活的 tab key,由页面传入 */
|
/** 当前激活的 tab key。H5 由页面传入;小程序由 Taro 自动渲染,props 为空 */
|
||||||
activeKey: string
|
activeKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从小程序路由推导激活 tab(Taro 自动渲染 custom-tab-bar 时不传 props)
|
||||||
|
*/
|
||||||
|
function deriveActiveKey(): string {
|
||||||
|
try {
|
||||||
|
const pages = Taro.getCurrentPages()
|
||||||
|
if (pages.length > 0) {
|
||||||
|
const route = pages[pages.length - 1]?.route || ''
|
||||||
|
for (const tab of TAB_LIST) {
|
||||||
|
if (route.includes(tab.key + '/index')) return tab.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return 'index'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取底部安全距离 */
|
/** 获取底部安全距离 */
|
||||||
function getSafeBottom(): number {
|
function getSafeBottom(): number {
|
||||||
try {
|
try {
|
||||||
|
if(process.env.TARO_ENV === 'h5') {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
const info = Taro.getSystemInfoSync()
|
const info = Taro.getSystemInfoSync()
|
||||||
if (info.safeArea) {
|
if (info.safeArea) {
|
||||||
return info.safeArea.bottom
|
return info.screenHeight - info.safeArea.bottom
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomTabBar({ activeKey }: CustomTabBarProps) {
|
export default function CustomTabBar({ activeKey: propKey }: CustomTabBarProps) {
|
||||||
|
// H5 走 props,小程序自动渲染时无 props,从路由推导
|
||||||
|
const activeKey = propKey || deriveActiveKey()
|
||||||
const safeBottom = getSafeBottom()
|
const safeBottom = getSafeBottom()
|
||||||
const [publishVisible, setPublishVisible] = useState(false)
|
const [publishVisible, setPublishVisible] = useState(false)
|
||||||
|
|
||||||
@@ -51,7 +71,17 @@ export default function CustomTabBar({ activeKey }: CustomTabBarProps) {
|
|||||||
const handlePublishClose = () => setPublishVisible(false)
|
const handlePublishClose = () => setPublishVisible(false)
|
||||||
const handlePublishSelect = (option: PublishOption) => {
|
const handlePublishSelect = (option: PublishOption) => {
|
||||||
setPublishVisible(false)
|
setPublishVisible(false)
|
||||||
Taro.showToast({ title: `${option.label}即将上线`, icon: 'none' })
|
const pageMap: Record<string, string> = {
|
||||||
|
article: '/pages/publish-article/index',
|
||||||
|
express: '/pages/publish-express/index',
|
||||||
|
goods: '/pages/publish-secondhand/index',
|
||||||
|
}
|
||||||
|
const url = pageMap[option.id]
|
||||||
|
if (url) {
|
||||||
|
Taro.navigateTo({ url })
|
||||||
|
} else {
|
||||||
|
Taro.showToast({ title: `${option.label}即将上线`, icon: 'none' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将 4 个 tab 拆分为 [0,1] + 发布按钮 + [2,3]
|
// 将 4 个 tab 拆分为 [0,1] + 发布按钮 + [2,3]
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export const banners: BannerItem[] = [
|
|||||||
|
|
||||||
/** 导航组数据 (2行 x 5列 = 10项) */
|
/** 导航组数据 (2行 x 5列 = 10项) */
|
||||||
export const navItems: NavItem[] = [
|
export const navItems: NavItem[] = [
|
||||||
{ id: '1', label: '课程表', icon: 'orders-o', path: '' },
|
{ id: '1', label: '校园外卖', icon: 'shop-o', path: '/pages/food-delivery/index' },
|
||||||
{ id: '2', label: '通知公告', icon: 'bullhorn-o', path: '' },
|
{ id: '2', label: '校园论坛', icon: 'comment-o', path: '/pages/forum/index' },
|
||||||
{ id: '3', label: '成绩查询', icon: 'chart-trending-o', path: '' },
|
{ id: '3', label: '成绩查询', icon: 'chart-trending-o', path: '' },
|
||||||
{ id: '4', label: '作业提交', icon: 'edit', path: '' },
|
{ id: '4', label: '作业提交', icon: 'edit', path: '' },
|
||||||
{ id: '5', label: '考试安排', icon: 'description', path: '' },
|
{ id: '5', label: '考试安排', icon: 'description', path: '' },
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/** 对话消息 */
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string
|
||||||
|
sender: 'me' | 'other'
|
||||||
|
content: string
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按聊天对象 ID 存储的对话记录 */
|
||||||
|
export const conversations: Record<string, ChatMessage[]> = {
|
||||||
|
// 张同学
|
||||||
|
chat1: [
|
||||||
|
{ id: 'm1', sender: 'other', content: '你好!看到你发布的代取快递任务,我有空可以帮忙', time: '10:25' },
|
||||||
|
{ id: 'm2', sender: 'me', content: '太好了!是南区菜鸟驿站3号柜的快递,你方便什么时间?', time: '10:26' },
|
||||||
|
{ id: 'm3', sender: 'other', content: '我今天下午都有空,2点左右可以吗?', time: '10:28' },
|
||||||
|
{ id: 'm4', sender: 'me', content: '可以的,那就下午2点,我把取件码发给你', time: '10:30' },
|
||||||
|
{ id: 'm5', sender: 'other', content: '好的,那明天下午3点图书馆见,我带教材过去', time: '10:32' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 李同学
|
||||||
|
chat2: [
|
||||||
|
{ id: 'm1', sender: 'other', content: '同学你好,我已经到菜鸟驿站了,取件码发我一下', time: '09:05' },
|
||||||
|
{ id: 'm2', sender: 'me', content: '好的,取件码是 3-2-8866,谢谢!', time: '09:07' },
|
||||||
|
{ id: 'm3', sender: 'other', content: '收到了,正在取件中', time: '09:10' },
|
||||||
|
{ id: 'm4', sender: 'other', content: '那个快递我已经帮你取了,放你宿舍一楼前台了', time: '09:15' },
|
||||||
|
{ id: 'm5', sender: 'me', content: '太感谢了!报酬我微信转给你', time: '09:16' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 王同学
|
||||||
|
chat3: [
|
||||||
|
{ id: 'm1', sender: 'other', content: '你好,想问一下你之前发布的那个二手教材还有吗?', time: '昨天 16:20' },
|
||||||
|
{ id: 'm2', sender: 'me', content: '还有的,高等数学和线性代数两本都在', time: '昨天 16:25' },
|
||||||
|
{ id: 'm3', sender: 'other', content: '太好了,能看一下实物照片吗?', time: '昨天 16:28' },
|
||||||
|
{ id: 'm4', sender: 'me', content: '[图片]', time: '昨天 16:30' },
|
||||||
|
{ id: 'm5', sender: 'other', content: '不错,我明天去西区图书馆找你拿可以吗?', time: '昨天 16:35' },
|
||||||
|
{ id: 'm6', sender: 'me', content: '没问题,明天下午我在图书馆自习室', time: '昨天 16:38' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 骑手-刘师傅
|
||||||
|
chat4: [
|
||||||
|
{ id: 'm1', sender: 'other', content: '你好,我已经接到你的代取订单了', time: '11:00' },
|
||||||
|
{ id: 'm2', sender: 'me', content: '好的师傅,取件码 5-1-3321,菜鸟驿站2号柜', time: '11:02' },
|
||||||
|
{ id: 'm3', sender: 'other', content: '收到,预计15分钟到驿站', time: '11:05' },
|
||||||
|
{ id: 'm4', sender: 'other', content: '好的,快递已取到,预计15分钟送到北门,请准时来取', time: '11:20' },
|
||||||
|
{ id: 'm5', sender: 'me', content: '好的,我这就下楼', time: '11:21' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 骑手-陈师傅
|
||||||
|
chat5: [
|
||||||
|
{ id: 'm1', sender: 'other', content: '同学,你的快递到了,我在你宿舍楼下', time: '昨天 15:30' },
|
||||||
|
{ id: 'm2', sender: 'me', content: '好的,马上下来', time: '昨天 15:31' },
|
||||||
|
{ id: 'm3', sender: 'other', content: '同学你好,我已经到楼下了,你下来拿一下吧', time: '昨天 15:35' },
|
||||||
|
{ id: 'm4', sender: 'me', content: '不好意思刚有事耽搁了,这就来', time: '昨天 15:38' },
|
||||||
|
{ id: 'm5', sender: 'other', content: '没关系,我等你', time: '昨天 15:39' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 校园咖啡馆
|
||||||
|
chat6: [
|
||||||
|
{ id: 'm1', sender: 'other', content: '您好,欢迎光临校园咖啡馆!我们今日推荐:冰拿铁 9.9 元', time: '08:30' },
|
||||||
|
{ id: 'm2', sender: 'me', content: '来一杯冰拿铁,少冰', time: '08:32' },
|
||||||
|
{ id: 'm3', sender: 'other', content: '好的,冰拿铁一杯少冰,预计5分钟做好', time: '08:33' },
|
||||||
|
{ id: 'm4', sender: 'other', content: '您的拿铁已经做好了,请来取餐,谢谢!', time: '08:45' },
|
||||||
|
{ id: 'm5', sender: 'me', content: '好的,马上来', time: '08:46' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 二手书店
|
||||||
|
chat7: [
|
||||||
|
{ id: 'm1', sender: 'other', content: '您好,请问您之前咨询的数据结构教材还需要吗?', time: '周一 14:00' },
|
||||||
|
{ id: 'm2', sender: 'me', content: '需要的,还有货吗?', time: '周一 14:30' },
|
||||||
|
{ id: 'm3', sender: 'other', content: '那本数据结构还有货,可以来店里看看,西门旁边', time: '周一 15:00' },
|
||||||
|
{ id: 'm4', sender: 'me', content: '好的,明天下午我过去看看', time: '周一 15:15' },
|
||||||
|
{ id: 'm5', sender: 'other', content: '没问题,到了说是微信联系的就行', time: '周一 15:20' },
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import type { Article, ForumSubCategory } from '@/types/article'
|
||||||
|
|
||||||
|
/** 论坛帖子(数据复用 Article 类型,category='forum',subCategory 区分板块) */
|
||||||
|
export const forumPosts: Article[] = [
|
||||||
|
// ===== 校园生活 =====
|
||||||
|
{
|
||||||
|
id: 'f1',
|
||||||
|
title: '学校南门新开了一家手打柠檬茶,超好喝!',
|
||||||
|
excerpt: '今天路过南门发现新开了一家柠檬茶店,买了一杯鸭屎香柠檬茶,8 块一杯,茶底是现煮的,柠檬也是现切,酸甜度刚好,强烈推荐给大家!',
|
||||||
|
source: '吃货小张',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av1/100/100',
|
||||||
|
time: '10 分钟前',
|
||||||
|
readCount: 234,
|
||||||
|
commentCount: 18,
|
||||||
|
likeCount: 56,
|
||||||
|
images: [
|
||||||
|
'https://picsum.photos/seed/lemon1/300/300',
|
||||||
|
'https://picsum.photos/seed/lemon2/300/300',
|
||||||
|
],
|
||||||
|
type: 'image-text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'life',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f2',
|
||||||
|
title: '求问校运会的时间和报名方式',
|
||||||
|
excerpt: '请问今年校运会什么时候开始?在哪里报名呢?看学院群里发的通知好像错过了,有同学知道吗?',
|
||||||
|
source: '新生小李',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av2/100/100',
|
||||||
|
time: '1 小时前',
|
||||||
|
readCount: 89,
|
||||||
|
commentCount: 12,
|
||||||
|
likeCount: 8,
|
||||||
|
images: [],
|
||||||
|
type: 'text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'life',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f3',
|
||||||
|
title: '分享一下我的毕业旅行 vlog 拍摄地',
|
||||||
|
excerpt: '毕业季到了,跟室友去了一趟川西,沿途的风景真的美哭了!把推荐的几个人少的拍摄点整理了一下,需要的学弟学妹可以参考。',
|
||||||
|
source: '应届毕业生',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av3/100/100',
|
||||||
|
time: '3 小时前',
|
||||||
|
readCount: 567,
|
||||||
|
commentCount: 45,
|
||||||
|
likeCount: 178,
|
||||||
|
images: [
|
||||||
|
'https://picsum.photos/seed/travel1/300/300',
|
||||||
|
'https://picsum.photos/seed/travel2/300/300',
|
||||||
|
'https://picsum.photos/seed/travel3/300/300',
|
||||||
|
],
|
||||||
|
type: 'image-grid',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'life',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f4',
|
||||||
|
title: '校园里的流浪猫越来越多了,建议学校组织救助',
|
||||||
|
excerpt: '每天去图书馆都能看到几只流浪猫,有的还很瘦。希望学校能联系相关公益组织,做一次集中的绝育和疫苗。',
|
||||||
|
source: '爱猫人士',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av4/100/100',
|
||||||
|
time: '昨天',
|
||||||
|
readCount: 412,
|
||||||
|
commentCount: 67,
|
||||||
|
likeCount: 234,
|
||||||
|
images: [],
|
||||||
|
type: 'text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'life',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 学习交流 =====
|
||||||
|
{
|
||||||
|
id: 'f5',
|
||||||
|
title: '高等数学期末复习资料分享(含思维导图)',
|
||||||
|
excerpt: '整理了高数老师上课的全部重点,配上思维导图和典型例题,有需要的同学可以下载,祝大家期末都能 90+!',
|
||||||
|
source: '数学学霸',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av5/100/100',
|
||||||
|
time: '20 分钟前',
|
||||||
|
readCount: 1023,
|
||||||
|
commentCount: 89,
|
||||||
|
likeCount: 567,
|
||||||
|
images: [],
|
||||||
|
type: 'text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'study',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f6',
|
||||||
|
title: '求推荐计算机专业研究生导师',
|
||||||
|
excerpt: '本人是计科大三学生,绩点 3.6,想考研但对导师方向不太了解。请问有没有熟悉图像处理和 CV 方向的老师推荐?',
|
||||||
|
source: '考研小白',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av6/100/100',
|
||||||
|
time: '2 小时前',
|
||||||
|
readCount: 256,
|
||||||
|
commentCount: 34,
|
||||||
|
likeCount: 28,
|
||||||
|
images: [],
|
||||||
|
type: 'text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'study',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f7',
|
||||||
|
title: '英语六级备考经验贴,2 个月从 420 到 568',
|
||||||
|
excerpt: '我英语底子一般,经过 2 个月的系统复习,最终六级 568 分。把我的复习方法、资料和刷题技巧全部分享出来。',
|
||||||
|
source: '六级过儿',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av7/100/100',
|
||||||
|
time: '4 小时前',
|
||||||
|
readCount: 892,
|
||||||
|
commentCount: 56,
|
||||||
|
likeCount: 312,
|
||||||
|
images: [
|
||||||
|
'https://picsum.photos/seed/english1/300/300',
|
||||||
|
'https://picsum.photos/seed/english2/300/300',
|
||||||
|
],
|
||||||
|
type: 'image-text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'study',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f8',
|
||||||
|
title: '高数线代答疑群,有问题来问',
|
||||||
|
excerpt: '我们几个同学建了一个数学答疑群,有不会的题目可以发出来互相讨论。群里 200 多人,气氛活跃,不收任何费用。',
|
||||||
|
source: '数学小组',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av8/100/100',
|
||||||
|
time: '2 天前',
|
||||||
|
readCount: 456,
|
||||||
|
commentCount: 23,
|
||||||
|
likeCount: 89,
|
||||||
|
images: [],
|
||||||
|
type: 'text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'study',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 失物招领 =====
|
||||||
|
{
|
||||||
|
id: 'f9',
|
||||||
|
title: '【寻物】昨天图书馆丢失蓝色保温杯',
|
||||||
|
excerpt: '昨天上午在三楼自习室落下一个蓝色的 Stanley 保温杯,杯身贴了一张小熊贴纸。有同学看到请联系本人,必有重谢!',
|
||||||
|
source: '焦急的小王',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av9/100/100',
|
||||||
|
time: '30 分钟前',
|
||||||
|
readCount: 178,
|
||||||
|
commentCount: 14,
|
||||||
|
likeCount: 12,
|
||||||
|
images: [
|
||||||
|
'https://picsum.photos/seed/cup1/300/300',
|
||||||
|
],
|
||||||
|
type: 'image-text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'lost-found',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f10',
|
||||||
|
title: '【招领】在操场捡到一串钥匙',
|
||||||
|
excerpt: '昨天晚上在西操场跑步时捡到一串钥匙,大概 3-4 把,带一个蓝色的小熊挂件。有丢失的同学可以私信我核对信息。',
|
||||||
|
source: '好心的同学',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av10/100/100',
|
||||||
|
time: '5 小时前',
|
||||||
|
readCount: 92,
|
||||||
|
commentCount: 8,
|
||||||
|
likeCount: 15,
|
||||||
|
images: [
|
||||||
|
'https://picsum.photos/seed/keys1/300/300',
|
||||||
|
],
|
||||||
|
type: 'image-text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'lost-found',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f11',
|
||||||
|
title: '【寻物】一卡通不见了,应该在教学楼附近',
|
||||||
|
excerpt: '今天中午在第二教学楼附近丢了一张一卡通,名字是张三,学号 2021****。如果捡到请联系 138****1234,必有红包感谢!',
|
||||||
|
source: '张三同学',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av11/100/100',
|
||||||
|
time: '昨天',
|
||||||
|
readCount: 134,
|
||||||
|
commentCount: 11,
|
||||||
|
likeCount: 6,
|
||||||
|
images: [],
|
||||||
|
type: 'text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'lost-found',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f12',
|
||||||
|
title: '【招领】食堂捡到一部黑色手机',
|
||||||
|
excerpt: '今天中午在第二食堂二楼捡到一部黑色 iPhone,已经交给食堂前台保管了。失主可以凭锁屏密码前往认领。',
|
||||||
|
source: '值班阿姨',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av12/100/100',
|
||||||
|
time: '2 天前',
|
||||||
|
readCount: 245,
|
||||||
|
commentCount: 19,
|
||||||
|
likeCount: 34,
|
||||||
|
images: [],
|
||||||
|
type: 'text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'lost-found',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 二手交易 =====
|
||||||
|
{
|
||||||
|
id: 'f13',
|
||||||
|
title: '出 9 成新 iPad Air 5,64G 太空灰,附原装保护壳',
|
||||||
|
excerpt: '去年 9 月购入,平常只用来记笔记和看剧,9 成新无磕碰。原价 4799,现 3200 出,送原装保护壳和钢化膜,可面交验机。',
|
||||||
|
source: '毕业学姐',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av13/100/100',
|
||||||
|
time: '15 分钟前',
|
||||||
|
readCount: 678,
|
||||||
|
commentCount: 42,
|
||||||
|
likeCount: 89,
|
||||||
|
images: [
|
||||||
|
'https://picsum.photos/seed/ipad1/300/300',
|
||||||
|
'https://picsum.photos/seed/ipad2/300/300',
|
||||||
|
'https://picsum.photos/seed/ipad3/300/300',
|
||||||
|
],
|
||||||
|
type: 'image-grid',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'secondhand',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f14',
|
||||||
|
title: '便宜出考研政治英语资料一套',
|
||||||
|
excerpt: '今年考研上岸,低价出政治英语全套复习资料,包含肖四肖八、考研真相等,原价 500 多,现在 80 元打包出。',
|
||||||
|
source: '上岸学长',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av14/100/100',
|
||||||
|
time: '1 小时前',
|
||||||
|
readCount: 312,
|
||||||
|
commentCount: 26,
|
||||||
|
likeCount: 45,
|
||||||
|
images: [],
|
||||||
|
type: 'text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'secondhand',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f15',
|
||||||
|
title: '出 9 成新小米手环 8,黑色',
|
||||||
|
excerpt: '用了 2 个月,9 成新,功能正常,配 2 根表带。原价 239,现 100 元,可小刀。',
|
||||||
|
source: '数码爱好者',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av15/100/100',
|
||||||
|
time: '3 小时前',
|
||||||
|
readCount: 89,
|
||||||
|
commentCount: 7,
|
||||||
|
likeCount: 12,
|
||||||
|
images: [
|
||||||
|
'https://picsum.photos/seed/band1/300/300',
|
||||||
|
],
|
||||||
|
type: 'image-text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'secondhand',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f16',
|
||||||
|
title: '毕业出闲置:台灯、收纳盒、衣架、教材等',
|
||||||
|
excerpt: '马上要搬走了,处理一批闲置物品:台灯、桌面收纳盒、晾衣架、几本教材、瑜伽垫等,价格都很便宜,列表如图。',
|
||||||
|
source: '毕业生小陈',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av16/100/100',
|
||||||
|
time: '昨天',
|
||||||
|
readCount: 567,
|
||||||
|
commentCount: 31,
|
||||||
|
likeCount: 67,
|
||||||
|
images: [
|
||||||
|
'https://picsum.photos/seed/items1/300/300',
|
||||||
|
'https://picsum.photos/seed/items2/300/300',
|
||||||
|
],
|
||||||
|
type: 'image-text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'secondhand',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 树洞 =====
|
||||||
|
{
|
||||||
|
id: 'f17',
|
||||||
|
title: '今天答辩终于过了,谢谢我的导师',
|
||||||
|
excerpt: '从开题到答辩,整整一年时间。中间崩溃过很多次,每次都想放弃。今天终于听到"同意授予学位"那句话,眼泪当场就下来了。',
|
||||||
|
source: '匿名',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av17/100/100',
|
||||||
|
time: '1 小时前',
|
||||||
|
readCount: 1245,
|
||||||
|
commentCount: 156,
|
||||||
|
likeCount: 678,
|
||||||
|
images: [],
|
||||||
|
type: 'text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'tree-hole',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f18',
|
||||||
|
title: '大学四年最遗憾的事',
|
||||||
|
excerpt: '快毕业了,回想这四年最遗憾的事,应该是没有好好谈一场恋爱吧。每天都在图书馆和实验室之间两点一线,错过了很多。',
|
||||||
|
source: '匿名',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av18/100/100',
|
||||||
|
time: '5 小时前',
|
||||||
|
readCount: 2345,
|
||||||
|
commentCount: 234,
|
||||||
|
likeCount: 1234,
|
||||||
|
images: [],
|
||||||
|
type: 'text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'tree-hole',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f19',
|
||||||
|
title: '谢谢那个在雨天给我撑伞的陌生人',
|
||||||
|
excerpt: '今天下午在图书馆门口突然下暴雨,我正发愁怎么回去,一个不认识的学姐默默走过来,把伞塞到我手里说"你先用",然后自己跑进了雨里。',
|
||||||
|
source: '温暖的小事',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av19/100/100',
|
||||||
|
time: '昨天',
|
||||||
|
readCount: 1567,
|
||||||
|
commentCount: 198,
|
||||||
|
likeCount: 892,
|
||||||
|
images: [],
|
||||||
|
type: 'text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'tree-hole',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f20',
|
||||||
|
title: '家里出了点事,最近压力很大',
|
||||||
|
excerpt: '不想发朋友圈,怕爸妈看到担心。在这里发泄一下吧。最近真的太累了,每天晚上都睡不着,但又不敢跟同学说。',
|
||||||
|
source: '匿名',
|
||||||
|
sourceAvatar: 'https://picsum.photos/seed/av20/100/100',
|
||||||
|
time: '2 天前',
|
||||||
|
readCount: 789,
|
||||||
|
commentCount: 87,
|
||||||
|
likeCount: 345,
|
||||||
|
images: [],
|
||||||
|
type: 'text',
|
||||||
|
category: 'forum',
|
||||||
|
subCategory: 'tree-hole',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 按子分类过滤论坛帖子 */
|
||||||
|
export function getForumPostsBySubCategory(sub: ForumSubCategory | 'all'): Article[] {
|
||||||
|
if (sub === 'all') return forumPosts
|
||||||
|
return forumPosts.filter((p) => p.subCategory === sub)
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import type { MenuItem, MenuCategory } from '@/types/shop'
|
||||||
|
|
||||||
|
/** 每个店铺的菜单分类(key=shopId) */
|
||||||
|
export const shopMenuCategories: Record<string, MenuCategory[]> = {
|
||||||
|
s1: [
|
||||||
|
{ id: 's1-c1', name: '招牌推荐' },
|
||||||
|
{ id: 's1-c2', name: '荤菜' },
|
||||||
|
{ id: 's1-c3', name: '素菜' },
|
||||||
|
{ id: 's1-c4', name: '主食' },
|
||||||
|
],
|
||||||
|
s2: [
|
||||||
|
{ id: 's2-c1', name: '招牌' },
|
||||||
|
{ id: 's2-c2', name: '小份配菜' },
|
||||||
|
{ id: 's2-c3', name: '饮品' },
|
||||||
|
],
|
||||||
|
s3: [
|
||||||
|
{ id: 's3-c1', name: '生日蛋糕' },
|
||||||
|
{ id: 's3-c2', name: '切块蛋糕' },
|
||||||
|
{ id: 's3-c3', name: '面包西点' },
|
||||||
|
],
|
||||||
|
s4: [
|
||||||
|
{ id: 's4-c1', name: '招牌奶茶' },
|
||||||
|
{ id: 's4-c2', name: '纯茶' },
|
||||||
|
{ id: 's4-c3', name: '加料' },
|
||||||
|
],
|
||||||
|
s5: [
|
||||||
|
{ id: 's5-c1', name: '热销零食' },
|
||||||
|
{ id: 's5-c2', name: '饮料' },
|
||||||
|
{ id: 's5-c3', name: '速食' },
|
||||||
|
{ id: 's5-c4', name: '日用品' },
|
||||||
|
],
|
||||||
|
s6: [
|
||||||
|
{ id: 's6-c1', name: '感冒发烧' },
|
||||||
|
{ id: 's6-c2', name: '肠胃护理' },
|
||||||
|
{ id: 's6-c3', name: '外用药品' },
|
||||||
|
{ id: 's6-c4', name: '保健营养' },
|
||||||
|
],
|
||||||
|
s7: [
|
||||||
|
{ id: 's7-c1', name: '时令水果' },
|
||||||
|
{ id: 's7-c2', name: '水果切盒' },
|
||||||
|
{ id: 's7-c3', name: '果汁' },
|
||||||
|
],
|
||||||
|
s8: [
|
||||||
|
{ id: 's8-c1', name: '招牌套餐' },
|
||||||
|
{ id: 's8-c2', name: '汉堡' },
|
||||||
|
{ id: 's8-c3', name: '小食' },
|
||||||
|
{ id: 's8-c4', name: '饮料' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 商家-菜单项映射(key=shopId) */
|
||||||
|
export const shopMenus: Record<string, MenuItem[]> = {
|
||||||
|
// === 张亮麻辣烫 ===
|
||||||
|
s1: [
|
||||||
|
{
|
||||||
|
id: 's1-1', name: '招牌骨汤麻辣烫', image: 'https://picsum.photos/seed/m1/300/300',
|
||||||
|
price: 25, originalPrice: 30, sales: 892, description: '骨汤熬制 4 小时,配菜任选 8 种',
|
||||||
|
tags: ['招牌', '热销'], categoryId: 's1-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's1-2', name: '麻辣牛肉串', image: 'https://picsum.photos/seed/m2/300/300',
|
||||||
|
price: 8, sales: 521, description: '秘制腌制,麻辣鲜香', categoryId: 's1-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's1-3', name: '鲜虾', image: 'https://picsum.photos/seed/m3/300/300',
|
||||||
|
price: 6, sales: 312, description: '新鲜活虾,去虾线', categoryId: 's1-c2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's1-4', name: '牛百叶', image: 'https://picsum.photos/seed/m4/300/300',
|
||||||
|
price: 12, sales: 256, description: '脆嫩爽口', categoryId: 's1-c2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's1-5', name: '土豆片', image: 'https://picsum.photos/seed/m5/300/300',
|
||||||
|
price: 4, sales: 845, description: '薄切易入味', categoryId: 's1-c3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's1-6', name: '金针菇', image: 'https://picsum.photos/seed/m6/300/300',
|
||||||
|
price: 5, sales: 612, description: '细嫩爽滑', categoryId: 's1-c3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's1-7', name: '白米饭', image: 'https://picsum.photos/seed/m7/300/300',
|
||||||
|
price: 2, sales: 1023, description: '东北珍珠米', categoryId: 's1-c4',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// === 黄焖鸡米饭 ===
|
||||||
|
s2: [
|
||||||
|
{
|
||||||
|
id: 's2-1', name: '黄焖鸡腿肉(小份)', image: 'https://picsum.photos/seed/m8/300/300',
|
||||||
|
price: 18, originalPrice: 22, sales: 723, description: '鸡腿肉 + 香菇 + 青椒,米饭另算',
|
||||||
|
tags: ['招牌'], categoryId: 's2-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's2-2', name: '黄焖排骨', image: 'https://picsum.photos/seed/m9/300/300',
|
||||||
|
price: 22, sales: 421, description: '猪肋排慢炖', categoryId: 's2-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's2-3', name: '香辣豆腐', image: 'https://picsum.photos/seed/m10/300/300',
|
||||||
|
price: 6, sales: 312, description: '嫩豆腐配辣椒', categoryId: 's2-c2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's2-4', name: '凉拌黄瓜', image: 'https://picsum.photos/seed/m11/300/300',
|
||||||
|
price: 5, sales: 245, description: '清爽开胃', categoryId: 's2-c2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's2-5', name: '酸梅汤', image: 'https://picsum.photos/seed/m12/300/300',
|
||||||
|
price: 5, sales: 568, description: '自制酸梅汤', categoryId: 's2-c3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// === 幸福西饼 ===
|
||||||
|
s3: [
|
||||||
|
{
|
||||||
|
id: 's3-1', name: '榴莲千层蛋糕(6寸)', image: 'https://picsum.photos/seed/m13/300/300',
|
||||||
|
price: 138, sales: 245, description: '纯动物奶油 + 泰国金枕榴莲',
|
||||||
|
tags: ['招牌'], categoryId: 's3-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's3-2', name: '草莓鲜奶蛋糕(8寸)', image: 'https://picsum.photos/seed/m14/300/300',
|
||||||
|
price: 168, sales: 189, description: '新鲜草莓 + 动物奶油', categoryId: 's3-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's3-3', name: '巧克力切块', image: 'https://picsum.photos/seed/m15/300/300',
|
||||||
|
price: 18, sales: 567, description: '70% 黑巧', categoryId: 's3-c2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's3-4', name: '葡式蛋挞(4 个)', image: 'https://picsum.photos/seed/m16/300/300',
|
||||||
|
price: 16, sales: 821, description: '酥皮层层', categoryId: 's3-c3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// === 一点点奶茶 ===
|
||||||
|
s4: [
|
||||||
|
{
|
||||||
|
id: 's4-1', name: '波霸奶茶(中杯)', image: 'https://picsum.photos/seed/m17/300/300',
|
||||||
|
price: 12, sales: 1253, description: '招牌波霸,茶味浓郁', tags: ['招牌', '热销'],
|
||||||
|
categoryId: 's4-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's4-2', name: '四季春玛奇朵', image: 'https://picsum.photos/seed/m18/300/300',
|
||||||
|
price: 14, sales: 856, description: '清茶 + 奶盖', categoryId: 's4-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's4-3', name: '茉莉绿妍', image: 'https://picsum.photos/seed/m19/300/300',
|
||||||
|
price: 8, sales: 421, description: '无糖纯茶', categoryId: 's4-c2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's4-4', name: '波霸', image: 'https://picsum.photos/seed/m20/300/300',
|
||||||
|
price: 2, sales: 1456, description: 'Q 弹有嚼劲', categoryId: 's4-c3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// === 校园便利超市 ===
|
||||||
|
s5: [
|
||||||
|
{
|
||||||
|
id: 's5-1', name: '乐事薯片(大)', image: 'https://picsum.photos/seed/m21/300/300',
|
||||||
|
price: 8, sales: 562, description: '原味/黄瓜味', tags: ['热销'], categoryId: 's5-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's5-2', name: '奥利奥饼干', image: 'https://picsum.photos/seed/m22/300/300',
|
||||||
|
price: 6, sales: 423, description: '经典原味', categoryId: 's5-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's5-3', name: '可口可乐(330ml)', image: 'https://picsum.photos/seed/m23/300/300',
|
||||||
|
price: 3, sales: 892, description: '冰镇更爽', categoryId: 's5-c2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's5-4', name: '康师傅红烧牛肉面', image: 'https://picsum.photos/seed/m24/300/300',
|
||||||
|
price: 4, sales: 1256, description: '宿舍夜宵必备', tags: ['热销'], categoryId: 's5-c3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's5-5', name: '抽纸 3 层 100 抽', image: 'https://picsum.photos/seed/m25/300/300',
|
||||||
|
price: 12, sales: 234, description: '宿舍装', categoryId: 's5-c4',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// === 老百姓大药房 ===
|
||||||
|
s6: [
|
||||||
|
{
|
||||||
|
id: 's6-1', name: '感冒灵颗粒(10 袋)', image: 'https://picsum.photos/seed/m26/300/300',
|
||||||
|
price: 15, sales: 312, description: '风寒感冒', tags: ['热销'], categoryId: 's6-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's6-2', name: '布洛芬缓释胶囊', image: 'https://picsum.photos/seed/m27/300/300',
|
||||||
|
price: 18, sales: 245, description: '退烧止痛', categoryId: 's6-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's6-3', name: '蒙脱石散', image: 'https://picsum.photos/seed/m28/300/300',
|
||||||
|
price: 22, sales: 156, description: '止泻', categoryId: 's6-c2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's6-4', name: '云南白药创可贴', image: 'https://picsum.photos/seed/m29/300/300',
|
||||||
|
price: 8, sales: 421, description: '100 片装', categoryId: 's6-c3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's6-5', name: '维生素 C 片', image: 'https://picsum.photos/seed/m30/300/300',
|
||||||
|
price: 25, sales: 189, description: '100 片装', categoryId: 's6-c4',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// === 鲜果时光 ===
|
||||||
|
s7: [
|
||||||
|
{
|
||||||
|
id: 's7-1', name: '海南金煌芒(1 个)', image: 'https://picsum.photos/seed/m31/300/300',
|
||||||
|
price: 12, sales: 567, description: '香甜无纤维', tags: ['招牌'], categoryId: 's7-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's7-2', name: '泰国山竹(5 个)', image: 'https://picsum.photos/seed/m32/300/300',
|
||||||
|
price: 28, sales: 245, description: '当季新鲜', categoryId: 's7-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's7-3', name: '水果切盒(大)', image: 'https://picsum.photos/seed/m33/300/300',
|
||||||
|
price: 25, sales: 892, description: '西瓜+哈密瓜+火龙果+芒果', tags: ['热销'],
|
||||||
|
categoryId: 's7-c2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's7-4', name: '鲜榨橙汁', image: 'https://picsum.photos/seed/m34/300/300',
|
||||||
|
price: 15, sales: 412, description: '现榨无添加', categoryId: 's7-c3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// === 肯德基 ===
|
||||||
|
s8: [
|
||||||
|
{
|
||||||
|
id: 's8-1', name: '香辣鸡腿堡套餐', image: 'https://picsum.photos/seed/m35/300/300',
|
||||||
|
price: 32, originalPrice: 38, sales: 856, description: '汉堡 + 中薯 + 中可乐',
|
||||||
|
tags: ['招牌', '套餐'], categoryId: 's8-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's8-2', name: '黄金鸡块(5 块)', image: 'https://picsum.photos/seed/m36/300/300',
|
||||||
|
price: 15, sales: 632, description: '外酥里嫩', categoryId: 's8-c1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's8-3', name: '劲脆鸡腿堡', image: 'https://picsum.photos/seed/m37/300/300',
|
||||||
|
price: 18, sales: 723, description: '全明星单品', tags: ['热销'], categoryId: 's8-c2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's8-4', name: '薯条(中)', image: 'https://picsum.photos/seed/m38/300/300',
|
||||||
|
price: 12, sales: 945, description: '金黄酥脆', categoryId: 's8-c3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's8-5', name: '百事可乐(中)', image: 'https://picsum.photos/seed/m39/300/300',
|
||||||
|
price: 8, sales: 756, description: '冰爽解腻', categoryId: 's8-c4',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取某店铺的菜单分类 */
|
||||||
|
export function getMenuCategories(shopId: string): MenuCategory[] {
|
||||||
|
return shopMenuCategories[shopId] || []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取某店铺的菜单项(按分类分组) */
|
||||||
|
export function getMenuItems(shopId: string): MenuItem[] {
|
||||||
|
return shopMenus[shopId] || []
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { Product } from '@/types/product'
|
||||||
|
|
||||||
|
export const products: Product[] = [
|
||||||
|
// === 数码 ===
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
title: '蓝牙无线耳机 降噪版',
|
||||||
|
desc: '高品质降噪蓝牙耳机,续航30小时,佩戴舒适',
|
||||||
|
price: 129,
|
||||||
|
originalPrice: 199,
|
||||||
|
image: 'https://picsum.photos/seed/earphone/400/400',
|
||||||
|
category: 'digital',
|
||||||
|
merchant: { name: '数码小店', avatar: 'https://picsum.photos/seed/ms1/100/100' },
|
||||||
|
sales: 326,
|
||||||
|
tags: ['热卖', '蓝牙5.3'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p2',
|
||||||
|
title: '机械键盘 青轴 RGB背光',
|
||||||
|
desc: '87键紧凑布局,青轴手感,RGB幻彩背光,Type-C键线分离',
|
||||||
|
price: 199,
|
||||||
|
originalPrice: 299,
|
||||||
|
image: 'https://picsum.photos/seed/keyboard/400/400',
|
||||||
|
category: 'digital',
|
||||||
|
merchant: { name: '外设天地', avatar: 'https://picsum.photos/seed/ms2/100/100' },
|
||||||
|
sales: 158,
|
||||||
|
tags: ['电竞', 'RGB'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// === 图书 ===
|
||||||
|
{
|
||||||
|
id: 'p3',
|
||||||
|
title: '数据结构(C语言版)教材',
|
||||||
|
desc: '计算机专业核心教材,全新正版,附习题答案',
|
||||||
|
price: 35,
|
||||||
|
originalPrice: 49,
|
||||||
|
image: 'https://picsum.photos/seed/dsbook/400/400',
|
||||||
|
category: 'book',
|
||||||
|
merchant: { name: '二手书店', avatar: 'https://picsum.photos/seed/ms3/100/100' },
|
||||||
|
sales: 82,
|
||||||
|
tags: ['教材', '九成新'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p4',
|
||||||
|
title: '三体全集 刘慈欣 典藏版',
|
||||||
|
desc: '科幻巨著三体三部曲全集合订本,刘慈欣代表作',
|
||||||
|
price: 68,
|
||||||
|
originalPrice: 93,
|
||||||
|
image: 'https://picsum.photos/seed/threebody/400/400',
|
||||||
|
category: 'book',
|
||||||
|
merchant: { name: '校园书社', avatar: 'https://picsum.photos/seed/ms4/100/100' },
|
||||||
|
sales: 215,
|
||||||
|
tags: ['科幻', '畅销'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// === 生活 ===
|
||||||
|
{
|
||||||
|
id: 'p5',
|
||||||
|
title: 'LED护眼台灯 三档调光',
|
||||||
|
desc: '无频闪护眼台灯,三档色温可调,USB充电,宿舍必备',
|
||||||
|
price: 49,
|
||||||
|
originalPrice: 79,
|
||||||
|
image: 'https://picsum.photos/seed/lamp/400/400',
|
||||||
|
category: 'life',
|
||||||
|
merchant: { name: '生活优选', avatar: 'https://picsum.photos/seed/ms5/100/100' },
|
||||||
|
sales: 487,
|
||||||
|
tags: ['宿舍', '护眼'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p6',
|
||||||
|
title: '宿舍桌面收纳盒 大容量',
|
||||||
|
desc: '多层分区收纳,节省桌面空间,简约白色,承重强',
|
||||||
|
price: 19.9,
|
||||||
|
image: 'https://picsum.photos/seed/storage/400/400',
|
||||||
|
category: 'life',
|
||||||
|
merchant: { name: '收纳专家', avatar: 'https://picsum.photos/seed/ms6/100/100' },
|
||||||
|
sales: 632,
|
||||||
|
tags: ['收纳', '爆款'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// === 美食 ===
|
||||||
|
{
|
||||||
|
id: 'p7',
|
||||||
|
title: '手冲咖啡礼盒 精品挂耳包',
|
||||||
|
desc: '埃塞俄比亚耶加雪菲挂耳咖啡,10包装,现磨新鲜',
|
||||||
|
price: 39.9,
|
||||||
|
originalPrice: 59,
|
||||||
|
image: 'https://picsum.photos/seed/coffee/400/400',
|
||||||
|
category: 'food',
|
||||||
|
merchant: { name: '校园咖啡馆', avatar: 'https://picsum.photos/seed/ms7/100/100' },
|
||||||
|
sales: 198,
|
||||||
|
tags: ['精品', '挂耳'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p8',
|
||||||
|
title: '进口零食大礼包 30包组合',
|
||||||
|
desc: '日韩进口零食混装,膨化食品+糖果+巧克力,适合追剧',
|
||||||
|
price: 29.9,
|
||||||
|
originalPrice: 45,
|
||||||
|
image: 'https://picsum.photos/seed/snack/400/400',
|
||||||
|
category: 'food',
|
||||||
|
merchant: { name: '零食铺子', avatar: 'https://picsum.photos/seed/ms8/100/100' },
|
||||||
|
sales: 891,
|
||||||
|
tags: ['进口', '追剧'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// === 服饰 ===
|
||||||
|
{
|
||||||
|
id: 'p9',
|
||||||
|
title: '校园文化T恤 纯棉短袖',
|
||||||
|
desc: '校名logo印花T恤,100%纯棉,多色可选,男女同款',
|
||||||
|
price: 39,
|
||||||
|
originalPrice: 69,
|
||||||
|
image: 'https://picsum.photos/seed/tshirt/400/400',
|
||||||
|
category: 'cloth',
|
||||||
|
merchant: { name: '校园文创', avatar: 'https://picsum.photos/seed/ms9/100/100' },
|
||||||
|
sales: 445,
|
||||||
|
tags: ['文创', '纯棉'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p10',
|
||||||
|
title: '帆布单肩包 学院风大容量',
|
||||||
|
desc: '加厚帆布材质,大容量设计,可装14寸笔记本,上课通勤两用',
|
||||||
|
price: 45,
|
||||||
|
image: 'https://picsum.photos/seed/bag/400/400',
|
||||||
|
category: 'cloth',
|
||||||
|
merchant: { name: '包袋工坊', avatar: 'https://picsum.photos/seed/ms10/100/100' },
|
||||||
|
sales: 273,
|
||||||
|
tags: ['帆布', '文艺'],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { Review } from '@/types/shop'
|
||||||
|
|
||||||
|
/** 商家-评价映射(key=shopId) */
|
||||||
|
export const shopReviews: Record<string, Review[]> = {
|
||||||
|
s1: [
|
||||||
|
{
|
||||||
|
id: 'r1', author: { name: '小王同学', avatar: 'https://picsum.photos/seed/u1/100/100' },
|
||||||
|
rating: 5, time: '2 小时前', content: '味道正宗,骨汤浓郁,配送也很快,包装完好,下次还会再点!',
|
||||||
|
images: [
|
||||||
|
'https://picsum.photos/seed/rv1/200/200',
|
||||||
|
'https://picsum.photos/seed/rv2/200/200',
|
||||||
|
],
|
||||||
|
dishes: ['招牌骨汤麻辣烫', '麻辣牛肉串'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r2', author: { name: '匿名用户', avatar: 'https://picsum.photos/seed/u2/100/100' },
|
||||||
|
rating: 4, time: '昨天', content: '份量挺足的,价格也合理,麻酱香味很浓。',
|
||||||
|
dishes: ['牛百叶', '土豆片'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r3', author: { name: '李同学', avatar: 'https://picsum.photos/seed/u3/100/100' },
|
||||||
|
rating: 5, time: '3 天前', content: '室友推荐的,确实不错,辣度可以自由选。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
s2: [
|
||||||
|
{
|
||||||
|
id: 'r4', author: { name: '王同学', avatar: 'https://picsum.photos/seed/u4/100/100' },
|
||||||
|
rating: 5, time: '1 小时前', content: '鸡腿肉很嫩,酱香浓郁,米饭再加一份才过瘾。',
|
||||||
|
images: ['https://picsum.photos/seed/rv3/200/200'],
|
||||||
|
dishes: ['黄焖鸡腿肉(小份)'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r5', author: { name: '张同学', avatar: 'https://picsum.photos/seed/u5/100/100' },
|
||||||
|
rating: 4, time: '昨天', content: '份量足,送达也快,酸梅汤也好喝。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
s3: [
|
||||||
|
{
|
||||||
|
id: 'r6', author: { name: '陈同学', avatar: 'https://picsum.photos/seed/u6/100/100' },
|
||||||
|
rating: 5, time: '30 分钟前', content: '朋友过生日订的,榴莲千层真的绝绝子,奶油不腻!',
|
||||||
|
images: [
|
||||||
|
'https://picsum.photos/seed/rv4/200/200',
|
||||||
|
'https://picsum.photos/seed/rv5/200/200',
|
||||||
|
'https://picsum.photos/seed/rv6/200/200',
|
||||||
|
],
|
||||||
|
dishes: ['榴莲千层蛋糕(6寸)'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r7', author: { name: '刘同学', avatar: 'https://picsum.photos/seed/u7/100/100' },
|
||||||
|
rating: 5, time: '2 天前', content: '蛋挞很香,酥皮层次分明。',
|
||||||
|
dishes: ['葡式蛋挞(4 个)'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
s4: [
|
||||||
|
{
|
||||||
|
id: 'r8', author: { name: '周同学', avatar: 'https://picsum.photos/seed/u8/100/100' },
|
||||||
|
rating: 5, time: '10 分钟前', content: '波霸 Q 弹,茶底清淡,甜度可以自由选太赞了。',
|
||||||
|
dishes: ['波霸奶茶(中杯)'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r9', author: { name: '吴同学', avatar: 'https://picsum.photos/seed/u9/100/100' },
|
||||||
|
rating: 4, time: '5 小时前', content: '送货快,包装也结实,奶盖略甜。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
s5: [
|
||||||
|
{
|
||||||
|
id: 'r10', author: { name: '钱同学', avatar: 'https://picsum.photos/seed/u10/100/100' },
|
||||||
|
rating: 5, time: '20 分钟前', content: '宿舍楼下的便利店太方便了,深夜还能送泡面。',
|
||||||
|
images: ['https://picsum.photos/seed/rv7/200/200'],
|
||||||
|
dishes: ['康师傅红烧牛肉面', '可口可乐(330ml)'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r11', author: { name: '孙同学', avatar: 'https://picsum.photos/seed/u11/100/100' },
|
||||||
|
rating: 4, time: '昨天', content: '送货快,价格合理,商品全。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
s6: [
|
||||||
|
{
|
||||||
|
id: 'r12', author: { name: '郑同学', avatar: 'https://picsum.photos/seed/u12/100/100' },
|
||||||
|
rating: 5, time: '1 小时前', content: '半夜发烧送的药,骑手很负责,还特别交代了服用方法。',
|
||||||
|
dishes: ['布洛芬缓释胶囊'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
s7: [
|
||||||
|
{
|
||||||
|
id: 'r13', author: { name: '冯同学', avatar: 'https://picsum.photos/seed/u13/100/100' },
|
||||||
|
rating: 5, time: '15 分钟前', content: '水果很新鲜,芒果超甜,切盒也很新鲜。',
|
||||||
|
images: [
|
||||||
|
'https://picsum.photos/seed/rv8/200/200',
|
||||||
|
'https://picsum.photos/seed/rv9/200/200',
|
||||||
|
],
|
||||||
|
dishes: ['水果切盒(大)', '海南金煌芒(1 个)'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
s8: [
|
||||||
|
{
|
||||||
|
id: 'r14', author: { name: '蒋同学', avatar: 'https://picsum.photos/seed/u14/100/100' },
|
||||||
|
rating: 5, time: '40 分钟前', content: '校园店出餐快,套餐价格比店里便宜。',
|
||||||
|
dishes: ['香辣鸡腿堡套餐'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r15', author: { name: '魏同学', avatar: 'https://picsum.photos/seed/u15/100/100' },
|
||||||
|
rating: 4, time: '昨天', content: '薯条有点凉了,不过整体还可以。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取某店铺的评价列表 */
|
||||||
|
export function getShopReviews(shopId: string): Review[] {
|
||||||
|
return shopReviews[shopId] || []
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import type { Shop, ShopCategory } from '@/types/shop'
|
||||||
|
|
||||||
|
/** 商家列表 */
|
||||||
|
export const shops: Shop[] = [
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
name: '张亮麻辣烫',
|
||||||
|
image: 'https://picsum.photos/seed/shop1/400/400',
|
||||||
|
logo: 'https://picsum.photos/seed/s1logo/120/120',
|
||||||
|
rating: 4.8,
|
||||||
|
monthlySales: 3280,
|
||||||
|
deliveryTime: 28,
|
||||||
|
deliveryFee: 3,
|
||||||
|
minOrder: 20,
|
||||||
|
distance: '320',
|
||||||
|
category: 'food',
|
||||||
|
tags: ['满减', '招牌', '推荐'],
|
||||||
|
description: '正宗骨汤熬制,食材新鲜,多种自选菜品,搭配麻酱一口香',
|
||||||
|
recommended: true,
|
||||||
|
address: '南门外商业街 B 座 12 号',
|
||||||
|
phone: '0571-8888-0001',
|
||||||
|
businessHours: '10:00-22:00',
|
||||||
|
deliveryHours: '立即配送',
|
||||||
|
services: ['可堂食', '支持开发票', '提供 WiFi'],
|
||||||
|
photos: [
|
||||||
|
'https://picsum.photos/seed/s1p1/600/400',
|
||||||
|
'https://picsum.photos/seed/s1p2/600/400',
|
||||||
|
'https://picsum.photos/seed/s1p3/600/400',
|
||||||
|
],
|
||||||
|
announcements: '满 30 减 5,满 50 减 10,配送费立减 1 元',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's2',
|
||||||
|
name: '黄焖鸡米饭',
|
||||||
|
image: 'https://picsum.photos/seed/shop2/400/400',
|
||||||
|
logo: 'https://picsum.photos/seed/s2logo/120/120',
|
||||||
|
rating: 4.6,
|
||||||
|
monthlySales: 2156,
|
||||||
|
deliveryTime: 25,
|
||||||
|
deliveryFee: 2,
|
||||||
|
minOrder: 15,
|
||||||
|
distance: '180',
|
||||||
|
category: 'food',
|
||||||
|
tags: ['爆款', '推荐'],
|
||||||
|
description: '秘制酱料焖制鸡腿肉,配米饭一绝,分量足、味道正',
|
||||||
|
recommended: true,
|
||||||
|
address: '东门小吃街 8 号',
|
||||||
|
phone: '0571-8888-0002',
|
||||||
|
businessHours: '09:30-21:30',
|
||||||
|
deliveryHours: '立即配送',
|
||||||
|
services: ['可堂食', '校园直送'],
|
||||||
|
photos: [
|
||||||
|
'https://picsum.photos/seed/s2p1/600/400',
|
||||||
|
'https://picsum.photos/seed/s2p2/600/400',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's3',
|
||||||
|
name: '幸福西饼',
|
||||||
|
image: 'https://picsum.photos/seed/shop3/400/400',
|
||||||
|
logo: 'https://picsum.photos/seed/s3logo/120/120',
|
||||||
|
rating: 4.9,
|
||||||
|
monthlySales: 1567,
|
||||||
|
deliveryTime: 35,
|
||||||
|
deliveryFee: 5,
|
||||||
|
minOrder: 30,
|
||||||
|
distance: '650',
|
||||||
|
category: 'dessert',
|
||||||
|
tags: ['新店', '推荐'],
|
||||||
|
description: '动物奶油新鲜现做,2 公里免费配送,生日蛋糕提前 6 小时预订',
|
||||||
|
recommended: true,
|
||||||
|
address: '校园中央商业广场 2 楼',
|
||||||
|
phone: '0571-8888-0003',
|
||||||
|
businessHours: '08:00-22:00',
|
||||||
|
deliveryHours: '立即配送',
|
||||||
|
services: ['支持开发票', '生日蛋糕预订', '免费配送'],
|
||||||
|
photos: [
|
||||||
|
'https://picsum.photos/seed/s3p1/600/400',
|
||||||
|
'https://picsum.photos/seed/s3p2/600/400',
|
||||||
|
'https://picsum.photos/seed/s3p3/600/400',
|
||||||
|
],
|
||||||
|
announcements: '新人首单立减 10 元',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's4',
|
||||||
|
name: '一点点奶茶',
|
||||||
|
image: 'https://picsum.photos/seed/shop4/400/400',
|
||||||
|
logo: 'https://picsum.photos/seed/s4logo/120/120',
|
||||||
|
rating: 4.7,
|
||||||
|
monthlySales: 4521,
|
||||||
|
deliveryTime: 22,
|
||||||
|
deliveryFee: 3,
|
||||||
|
minOrder: 18,
|
||||||
|
distance: '90',
|
||||||
|
category: 'dessert',
|
||||||
|
tags: ['满减', '热销'],
|
||||||
|
description: '现煮茶底,可加波霸/椰果/布丁,甜度冰度自由选',
|
||||||
|
address: '西门外大学路 23 号',
|
||||||
|
phone: '0571-8888-0004',
|
||||||
|
businessHours: '09:00-23:00',
|
||||||
|
deliveryHours: '立即配送',
|
||||||
|
services: ['可堂食', '校园直送'],
|
||||||
|
photos: [
|
||||||
|
'https://picsum.photos/seed/s4p1/600/400',
|
||||||
|
'https://picsum.photos/seed/s4p2/600/400',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's5',
|
||||||
|
name: '校园便利超市',
|
||||||
|
image: 'https://picsum.photos/seed/shop5/400/400',
|
||||||
|
logo: 'https://picsum.photos/seed/s5logo/120/120',
|
||||||
|
rating: 4.5,
|
||||||
|
monthlySales: 5842,
|
||||||
|
deliveryTime: 18,
|
||||||
|
deliveryFee: 1,
|
||||||
|
minOrder: 10,
|
||||||
|
distance: '50',
|
||||||
|
category: 'supermarket',
|
||||||
|
tags: ['满减', '24小时'],
|
||||||
|
description: '零食、饮料、日用品、文具全覆盖,宿舍楼下送达',
|
||||||
|
address: '宿舍区 3 号楼 1 层',
|
||||||
|
phone: '0571-8888-0005',
|
||||||
|
businessHours: '00:00-24:00',
|
||||||
|
deliveryHours: '立即配送',
|
||||||
|
services: ['24 小时营业', '校园直送', '货到付款'],
|
||||||
|
photos: [
|
||||||
|
'https://picsum.photos/seed/s5p1/600/400',
|
||||||
|
'https://picsum.photos/seed/s5p2/600/400',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's6',
|
||||||
|
name: '老百姓大药房',
|
||||||
|
image: 'https://picsum.photos/seed/shop6/400/400',
|
||||||
|
logo: 'https://picsum.photos/seed/s6logo/120/120',
|
||||||
|
rating: 4.8,
|
||||||
|
monthlySales: 892,
|
||||||
|
deliveryTime: 30,
|
||||||
|
deliveryFee: 4,
|
||||||
|
minOrder: 25,
|
||||||
|
distance: '420',
|
||||||
|
category: 'medicine',
|
||||||
|
tags: ['医保', '推荐'],
|
||||||
|
description: '常用药、感冒药、肠胃药、消暑药一应俱全,药师在线咨询',
|
||||||
|
address: '校医院旁 6 号',
|
||||||
|
phone: '0571-8888-0006',
|
||||||
|
businessHours: '08:00-22:00',
|
||||||
|
deliveryHours: '立即配送',
|
||||||
|
services: ['医保支付', '药师咨询', '24 小时送药'],
|
||||||
|
photos: [
|
||||||
|
'https://picsum.photos/seed/s6p1/600/400',
|
||||||
|
'https://picsum.photos/seed/s6p2/600/400',
|
||||||
|
],
|
||||||
|
announcements: '24 小时送药服务,夜班不间断',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's7',
|
||||||
|
name: '鲜果时光',
|
||||||
|
image: 'https://picsum.photos/seed/shop7/400/400',
|
||||||
|
logo: 'https://picsum.photos/seed/s7logo/120/120',
|
||||||
|
rating: 4.7,
|
||||||
|
monthlySales: 1245,
|
||||||
|
deliveryTime: 25,
|
||||||
|
deliveryFee: 2,
|
||||||
|
minOrder: 20,
|
||||||
|
distance: '210',
|
||||||
|
category: 'fruit',
|
||||||
|
tags: ['新鲜', '满减'],
|
||||||
|
description: '应季水果直采,产地直发,水果切盒下午茶首选',
|
||||||
|
address: '南门水果街 5 号',
|
||||||
|
phone: '0571-8888-0007',
|
||||||
|
businessHours: '07:00-22:00',
|
||||||
|
deliveryHours: '立即配送',
|
||||||
|
services: ['产地直采', '坏果包赔'],
|
||||||
|
photos: [
|
||||||
|
'https://picsum.photos/seed/s7p1/600/400',
|
||||||
|
'https://picsum.photos/seed/s7p2/600/400',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's8',
|
||||||
|
name: '肯德基(校园店)',
|
||||||
|
image: 'https://picsum.photos/seed/shop8/400/400',
|
||||||
|
logo: 'https://picsum.photos/seed/s8logo/120/120',
|
||||||
|
rating: 4.6,
|
||||||
|
monthlySales: 3210,
|
||||||
|
deliveryTime: 32,
|
||||||
|
deliveryFee: 5,
|
||||||
|
minOrder: 30,
|
||||||
|
distance: '480',
|
||||||
|
category: 'food',
|
||||||
|
tags: ['连锁', '品牌'],
|
||||||
|
description: '原味鸡、薯条、汉堡、可乐全套,校园专享优惠套餐',
|
||||||
|
address: '中央食堂 1 楼',
|
||||||
|
phone: '0571-8888-0008',
|
||||||
|
businessHours: '07:00-23:00',
|
||||||
|
deliveryHours: '立即配送',
|
||||||
|
services: ['可堂食', '品牌连锁', '支持开发票'],
|
||||||
|
photos: [
|
||||||
|
'https://picsum.photos/seed/s8p1/600/400',
|
||||||
|
'https://picsum.photos/seed/s8p2/600/400',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 根据 id 获取商家 */
|
||||||
|
export function getShopById(id: string): Shop | undefined {
|
||||||
|
return shops.find((s) => s.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首页推荐商家 */
|
||||||
|
export function getRecommendedShops(limit?: number): Shop[] {
|
||||||
|
const list = shops.filter((s) => s.recommended)
|
||||||
|
return limit ? list.slice(0, limit) : list
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按分类筛选商家 */
|
||||||
|
export function getShopsByCategory(category: ShopCategory | 'all'): Shop[] {
|
||||||
|
if (category === 'all') return shops
|
||||||
|
return shops.filter((s) => s.category === category)
|
||||||
|
}
|
||||||
@@ -306,11 +306,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 文章不存在 */
|
/* 文章不存在 */
|
||||||
.empty-article {
|
.empty-article-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100vh;
|
padding: 200px 32px 0;
|
||||||
font-size: 30px;
|
|
||||||
color: #969799;
|
.empty-article-icon {
|
||||||
|
font-size: 80px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-article-text {
|
||||||
|
font-size: 30px;
|
||||||
|
color: #969799;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-article-back-btn {
|
||||||
|
padding: 16px 48px;
|
||||||
|
background: #1989fa;
|
||||||
|
border-radius: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-article-back-text {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react'
|
import { useState, useMemo, useCallback } from 'react'
|
||||||
import Taro, { useRouter } from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { View, Text, Image, ScrollView, Input } from '@tarojs/components'
|
import { View, Text, Image, ScrollView, Input } from '@tarojs/components'
|
||||||
import { Icon } from '@antmjs/vantui'
|
import { Icon } from '@antmjs/vantui'
|
||||||
import { getArticleDetail } from '@/mock/articleDetail'
|
import { getArticleDetail } from '@/mock/articleDetail'
|
||||||
@@ -14,9 +14,7 @@ function getStatusBarH(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ArticleDetail() {
|
export default function ArticleDetail() {
|
||||||
// useRouter 在部分 Taro 版本首次渲染 params 为空,用 getCurrentInstance 兜底
|
const id = String(Taro.getCurrentInstance().router?.params?.id || 1)
|
||||||
const router = useRouter()
|
|
||||||
const id = router.params.id || Taro.getCurrentInstance().router?.params?.id
|
|
||||||
const article = useMemo(() => (id ? getArticleDetail(id) : null), [id])
|
const article = useMemo(() => (id ? getArticleDetail(id) : null), [id])
|
||||||
const [commentText, setCommentText] = useState('')
|
const [commentText, setCommentText] = useState('')
|
||||||
const [commentList, setCommentList] = useState<Comment[]>(comments)
|
const [commentList, setCommentList] = useState<Comment[]>(comments)
|
||||||
@@ -66,7 +64,27 @@ export default function ArticleDetail() {
|
|||||||
if (!article) {
|
if (!article) {
|
||||||
return (
|
return (
|
||||||
<View className='article-page'>
|
<View className='article-page'>
|
||||||
<View className='empty-article'>文章不存在</View>
|
{/* ========== 自定义导航栏(带返回) ========== */}
|
||||||
|
<View className='article-nav' style={{ paddingTop: `${statusBarH}px` }}>
|
||||||
|
<View className='nav-content'>
|
||||||
|
<View className='nav-back' onClick={handleBack}>
|
||||||
|
<Icon name='arrow-left' size={22} color='#323233' />
|
||||||
|
</View>
|
||||||
|
<Text className='nav-title'>文章详情</Text>
|
||||||
|
<View className='nav-placeholder' />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 占位 */}
|
||||||
|
<View style={{ height: `${statusBarH + 44}px` }} />
|
||||||
|
|
||||||
|
<View className='empty-article-wrap'>
|
||||||
|
<Text className='empty-article-icon'>📄</Text>
|
||||||
|
<Text className='empty-article-text'>文章不存在</Text>
|
||||||
|
<View className='empty-article-back-btn' onClick={handleBack}>
|
||||||
|
<Text className='empty-article-back-text'>返回</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '对话',
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
})
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/* ========================================
|
||||||
|
对话页
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.chat-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 导航栏 ========== */
|
||||||
|
.chat-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.nav-content {
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.nav-back {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-info {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.nav-name {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-role {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder {
|
||||||
|
width: 50px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 消息列表滚动区 ========== */
|
||||||
|
.chat-scroll {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 消息列表容器 ========== */
|
||||||
|
.message-list {
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 消息项 ========== */
|
||||||
|
.message-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
/* 接收消息:左对齐 */
|
||||||
|
&.received {
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
background: #fff;
|
||||||
|
color: #323233;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 发送消息:右对齐 */
|
||||||
|
&.sent {
|
||||||
|
align-items: flex-end;
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
background: linear-gradient(135deg, #1989fa 0%, #06b7f0 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 70%;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-time {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #c8c9cc;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 底部输入栏 ========== */
|
||||||
|
.chat-input-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #f2f3f5;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.input-wrap {
|
||||||
|
flex: 1;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 40px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
font-size: 28px;
|
||||||
|
height: 44px;
|
||||||
|
line-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: #1989fa;
|
||||||
|
border-radius: 40px;
|
||||||
|
|
||||||
|
.send-text {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 底部安全区占位 ========== */
|
||||||
|
.scroll-bottom-safe {
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
||||||
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
|
import { View, Text, ScrollView, Input } from '@tarojs/components'
|
||||||
|
import { Icon } from '@antmjs/vantui'
|
||||||
|
import { chatList } from '@/mock/messages'
|
||||||
|
import { conversations } from '@/mock/conversations'
|
||||||
|
import type { ChatMessage } from '@/mock/conversations'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
/** 角色标签映射 */
|
||||||
|
const ROLE_LABEL: Record<string, string> = {
|
||||||
|
rider: '骑手',
|
||||||
|
merchant: '商家',
|
||||||
|
user: '同学',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBarH(): number {
|
||||||
|
try { return Taro.getSystemInfoSync().statusBarHeight || 20 }
|
||||||
|
catch { return 20 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafeBottom(): number {
|
||||||
|
try {
|
||||||
|
const info = Taro.getSystemInfoSync()
|
||||||
|
return (info.screenHeight - info.safeArea!.bottom) || 0
|
||||||
|
} catch { return 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const id = String(Taro.getCurrentInstance().router?.params?.id || '')
|
||||||
|
|
||||||
|
/* ---- 查找聊天对象 ---- */
|
||||||
|
const partner = useMemo(() => chatList.find((c) => c.id === id), [id])
|
||||||
|
|
||||||
|
/* ---- 对话消息 ---- */
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
|
||||||
|
const statusBarH = useMemo(getStatusBarH, [])
|
||||||
|
const safeBottom = useMemo(getSafeBottom, [])
|
||||||
|
|
||||||
|
// 初始化消息
|
||||||
|
useDidShow(() => {
|
||||||
|
if (id && conversations[id]) {
|
||||||
|
setMessages(conversations[id])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ---- 事件 ---- */
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
Taro.navigateBack()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSend = useCallback(() => {
|
||||||
|
const text = inputValue.trim()
|
||||||
|
if (!text) return
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
const newMsg: ChatMessage = {
|
||||||
|
id: `m${Date.now()}`,
|
||||||
|
sender: 'me',
|
||||||
|
content: text,
|
||||||
|
time,
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, newMsg])
|
||||||
|
setInputValue('')
|
||||||
|
|
||||||
|
// 模拟对方自动回复(1.5 秒后)
|
||||||
|
if (partner) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const autoReply: ChatMessage = {
|
||||||
|
id: `m${Date.now() + 1}`,
|
||||||
|
sender: 'other',
|
||||||
|
content: '好的,收到你的消息了~',
|
||||||
|
time: `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes() + 1).padStart(2, '0')}`,
|
||||||
|
}
|
||||||
|
setMessages((prev) => [...prev, autoReply])
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
}, [inputValue, partner])
|
||||||
|
|
||||||
|
if (!partner) {
|
||||||
|
return (
|
||||||
|
<View className='chat-page'>
|
||||||
|
<View className='chat-nav' style={{ paddingTop: `${statusBarH}px` }}>
|
||||||
|
<View className='nav-content'>
|
||||||
|
<View className='nav-back' onClick={handleBack}>
|
||||||
|
<Icon name='arrow-left' size={22} color='#323233' />
|
||||||
|
</View>
|
||||||
|
<View className='nav-info'>
|
||||||
|
<Text className='nav-name'>未知用户</Text>
|
||||||
|
</View>
|
||||||
|
<View className='nav-placeholder' />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={{ height: `${statusBarH + 44}px` }} />
|
||||||
|
<View style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1 }}>
|
||||||
|
<Text style={{ color: '#969799', fontSize: '30px' }}>对话不存在</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleLabel = partner.role ? ROLE_LABEL[partner.role] : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='chat-page'>
|
||||||
|
{/* ===== 导航栏 ===== */}
|
||||||
|
<View className='chat-nav' style={{ paddingTop: `${statusBarH}px` }}>
|
||||||
|
<View className='nav-content'>
|
||||||
|
<View className='nav-back' onClick={handleBack}>
|
||||||
|
<Icon name='arrow-left' size={22} color='#323233' />
|
||||||
|
</View>
|
||||||
|
<View className='nav-info'>
|
||||||
|
<Text className='nav-name'>{partner.name}</Text>
|
||||||
|
{roleLabel && <Text className='nav-role'>{roleLabel}</Text>}
|
||||||
|
</View>
|
||||||
|
<View className='nav-placeholder' />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={{ height: `${statusBarH + 44}px` }} />
|
||||||
|
|
||||||
|
{/* ===== 消息列表 ===== */}
|
||||||
|
<ScrollView
|
||||||
|
className='chat-scroll'
|
||||||
|
scrollY
|
||||||
|
enhanced
|
||||||
|
showScrollbar={false}
|
||||||
|
scrollTop={99999}
|
||||||
|
scrollWithAnimation
|
||||||
|
>
|
||||||
|
<View className='message-list'>
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<View
|
||||||
|
key={msg.id}
|
||||||
|
className={`message-item ${msg.sender === 'me' ? 'sent' : 'received'}`}
|
||||||
|
>
|
||||||
|
<Text className='bubble'>{msg.content}</Text>
|
||||||
|
<Text className='msg-time'>{msg.time}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View className='scroll-bottom-safe' />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* ===== 底部输入栏 ===== */}
|
||||||
|
<View className='chat-input-bar' style={{ paddingBottom: `${safeBottom}px` }}>
|
||||||
|
<View className='input-wrap'>
|
||||||
|
<Input
|
||||||
|
className='chat-input'
|
||||||
|
value={inputValue}
|
||||||
|
placeholder='输入消息...'
|
||||||
|
placeholderStyle='color: #c8c9cc;'
|
||||||
|
onInput={(e) => setInputValue(String(e.detail.value))}
|
||||||
|
confirmType='send'
|
||||||
|
onConfirm={handleSend}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className='send-btn' onClick={handleSend}>
|
||||||
|
<Text className='send-text'>发送</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
navigationBarTitleText: '校园外卖',
|
||||||
|
})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/* ========================================
|
||||||
|
校园外卖首页
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.food-delivery-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 自定义导航栏(仅标题) ========== */
|
||||||
|
.custom-navbar {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-title {
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 主体滚动区域 ========== */
|
||||||
|
.page-scroll {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 主体内容区内的搜索栏 ========== */
|
||||||
|
.inline-search-section {
|
||||||
|
padding: 20px 24px 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 顶部 5 个分类导航 ========== */
|
||||||
|
.category-section {
|
||||||
|
margin: 0 24px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px 8px 8px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 列表头 ========== */
|
||||||
|
.shop-list-header {
|
||||||
|
padding: 16px 24px 8px;
|
||||||
|
|
||||||
|
.shop-list-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
|
||||||
|
.shop-list-count {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 商家列表 ========== */
|
||||||
|
.shop-list {
|
||||||
|
margin: 0 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-safe {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
|
import { Search, Grid, GridItem } from '@antmjs/vantui'
|
||||||
|
import ShopCard from '@/components/ShopCard'
|
||||||
|
import { shops } from '@/mock/shops'
|
||||||
|
import type { Shop, ShopCategory } from '@/types/shop'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
/** 获取状态栏 + 导航栏高度 */
|
||||||
|
function getNavBarHeight() {
|
||||||
|
const systemInfo = Taro.getSystemInfoSync()
|
||||||
|
const statusBarHeight = systemInfo.statusBarHeight || 20
|
||||||
|
const navBarHeight = 44
|
||||||
|
return { statusBarHeight, navBarHeight, totalHeight: statusBarHeight + navBarHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部 5 个分类导航 */
|
||||||
|
const CATEGORY_NAV: { id: ShopCategory; label: string; icon: string }[] = [
|
||||||
|
{ id: 'food', label: '美食', icon: '🍱' },
|
||||||
|
{ id: 'dessert', label: '甜点', icon: '🍰' },
|
||||||
|
{ id: 'supermarket', label: '超市便利', icon: '🛒' },
|
||||||
|
{ id: 'medicine', label: '买药', icon: '💊' },
|
||||||
|
{ id: 'fruit', label: '水果', icon: '🍎' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function FoodDelivery() {
|
||||||
|
const [activeCategory, setActiveCategory] = useState<ShopCategory>('food')
|
||||||
|
const navBarHeight = useMemo(() => getNavBarHeight(), [])
|
||||||
|
|
||||||
|
/** 按当前分类过滤商家 */
|
||||||
|
const filteredShops = useMemo(
|
||||||
|
() => shops.filter((s) => s.category === activeCategory),
|
||||||
|
[activeCategory],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 搜索 */
|
||||||
|
const handleSearch = () => {
|
||||||
|
Taro.navigateTo({ url: '/pages/search/index' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击商家 */
|
||||||
|
const handleShopClick = (shop: Shop) => {
|
||||||
|
Taro.navigateTo({ url: `/pages/shop-detail/index?id=${shop.id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='food-delivery-page'>
|
||||||
|
{/* ========== 自定义导航栏(仅标题) ========== */}
|
||||||
|
<View
|
||||||
|
className='custom-navbar'
|
||||||
|
style={{ paddingTop: `${navBarHeight.statusBarHeight}px` }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='navbar-content'
|
||||||
|
style={{ height: `${navBarHeight.navBarHeight}px` }}
|
||||||
|
>
|
||||||
|
<View className='navbar-title-wrap'>
|
||||||
|
<Text className='navbar-title'>校园外卖</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 占位 */}
|
||||||
|
<View style={{ height: `${navBarHeight.totalHeight}px` }} />
|
||||||
|
|
||||||
|
{/* ========== 主体内容(搜索 + 分类 + 列表)全部可滚动 ========== */}
|
||||||
|
<ScrollView
|
||||||
|
className='page-scroll'
|
||||||
|
scrollY
|
||||||
|
enhanced
|
||||||
|
showScrollbar={false}
|
||||||
|
>
|
||||||
|
{/* 搜索栏(主体内容区顶部) */}
|
||||||
|
<View className='inline-search-section'>
|
||||||
|
<Search
|
||||||
|
placeholder='搜索商家、菜品'
|
||||||
|
onClickInput={handleSearch}
|
||||||
|
shape='round'
|
||||||
|
background='#f2f3f5'
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 5 个分类导航 */}
|
||||||
|
<View className='category-section'>
|
||||||
|
<Grid columnNum={5} border={false} gutter={4}>
|
||||||
|
{CATEGORY_NAV.map((cat) => (
|
||||||
|
<GridItem
|
||||||
|
key={cat.id}
|
||||||
|
icon={cat.icon}
|
||||||
|
text={cat.label}
|
||||||
|
onClick={() => setActiveCategory(cat.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 列表头 */}
|
||||||
|
<View className='shop-list-header'>
|
||||||
|
<Text className='shop-list-title'>
|
||||||
|
{CATEGORY_NAV.find((c) => c.id === activeCategory)?.label || ''} ·{' '}
|
||||||
|
<Text className='shop-list-count'>{filteredShops.length} 家</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 商家列表 */}
|
||||||
|
<View className='shop-list'>
|
||||||
|
{filteredShops.map((shop) => (
|
||||||
|
<ShopCard key={shop.id} shop={shop} onClick={handleShopClick} />
|
||||||
|
))}
|
||||||
|
{filteredShops.length === 0 && (
|
||||||
|
<View className='empty-tip'>该分类下暂无商家</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className='bottom-safe' />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
navigationBarTitleText: '校园论坛',
|
||||||
|
})
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/* ========================================
|
||||||
|
校园论坛首页
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.forum-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 自定义导航栏(标题 + 发布) ========== */
|
||||||
|
.custom-navbar {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-title-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-title {
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-publish {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f7f8fa;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-icon {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 主体滚动区域 ========== */
|
||||||
|
.page-scroll {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 主体内容区内的搜索栏 ========== */
|
||||||
|
.inline-search-section {
|
||||||
|
padding: 20px 24px 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 顶部 5 个子分类导航 ========== */
|
||||||
|
.category-section {
|
||||||
|
margin: 0 24px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px 8px 16px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 列表头 ========== */
|
||||||
|
.post-list-header {
|
||||||
|
padding: 16px 24px 12px;
|
||||||
|
|
||||||
|
.post-list-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
|
||||||
|
.post-list-count {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 帖子列表(独立卡片,16px 间距) ========== */
|
||||||
|
.post-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-safe {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
|
import { Search, Grid, GridItem } from '@antmjs/vantui'
|
||||||
|
import ForumPostCard from '@/components/ForumPostCard'
|
||||||
|
import { getForumPostsBySubCategory } from '@/mock/forum'
|
||||||
|
import type { Article, ForumSubCategory } from '@/types/article'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
/** 获取状态栏 + 导航栏高度 */
|
||||||
|
function getNavBarHeight() {
|
||||||
|
const systemInfo = Taro.getSystemInfoSync()
|
||||||
|
const statusBarHeight = systemInfo.statusBarHeight || 20
|
||||||
|
const navBarHeight = 44
|
||||||
|
return { statusBarHeight, navBarHeight, totalHeight: statusBarHeight + navBarHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 论坛 5 个子分类 */
|
||||||
|
const FORUM_CATEGORIES: { id: ForumSubCategory; label: string; icon: string }[] = [
|
||||||
|
{ id: 'life', label: '校园生活', icon: '🌸' },
|
||||||
|
{ id: 'study', label: '学习交流', icon: '📚' },
|
||||||
|
{ id: 'lost-found', label: '失物招领', icon: '🔍' },
|
||||||
|
{ id: 'secondhand', label: '二手交易', icon: '🛍' },
|
||||||
|
{ id: 'tree-hole', label: '树洞', icon: '🌳' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Forum() {
|
||||||
|
const [activeCategory, setActiveCategory] = useState<ForumSubCategory>('life')
|
||||||
|
const navBarHeight = useMemo(() => getNavBarHeight(), [])
|
||||||
|
|
||||||
|
/** 按当前子分类过滤帖子 */
|
||||||
|
const filteredPosts = useMemo(
|
||||||
|
() => getForumPostsBySubCategory(activeCategory),
|
||||||
|
[activeCategory],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 搜索 */
|
||||||
|
const handleSearch = () => {
|
||||||
|
Taro.navigateTo({ url: '/pages/search/index' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 帖子点击 */
|
||||||
|
const handlePostClick = (post: Article) => {
|
||||||
|
Taro.navigateTo({ url: `/pages/article/index?id=${post.id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发布帖子 */
|
||||||
|
const handlePublish = () => {
|
||||||
|
Taro.showToast({ title: '发布功能即将上线', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='forum-page'>
|
||||||
|
{/* ========== 自定义导航栏(标题 + 发布) ========== */}
|
||||||
|
<View
|
||||||
|
className='custom-navbar'
|
||||||
|
style={{ paddingTop: `${navBarHeight.statusBarHeight}px` }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='navbar-content'
|
||||||
|
style={{ height: `${navBarHeight.navBarHeight}px` }}
|
||||||
|
>
|
||||||
|
<View className='navbar-title-wrap'>
|
||||||
|
<Text className='navbar-title'>校园论坛</Text>
|
||||||
|
</View>
|
||||||
|
<View className='navbar-publish' onClick={handlePublish}>
|
||||||
|
<Text className='publish-icon'>✏️</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 占位 */}
|
||||||
|
<View style={{ height: `${navBarHeight.totalHeight}px` }} />
|
||||||
|
|
||||||
|
{/* ========== 主体内容(搜索 + 分类 + 列表)全部可滚动 ========== */}
|
||||||
|
<ScrollView
|
||||||
|
className='page-scroll'
|
||||||
|
scrollY
|
||||||
|
enhanced
|
||||||
|
showScrollbar={false}
|
||||||
|
>
|
||||||
|
{/* 搜索栏(主体内容区顶部) */}
|
||||||
|
<View className='inline-search-section'>
|
||||||
|
<Search
|
||||||
|
placeholder='搜索帖子、用户'
|
||||||
|
onClickInput={handleSearch}
|
||||||
|
shape='round'
|
||||||
|
background='#f2f3f5'
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 5 个子分类导航 */}
|
||||||
|
<View className='category-section'>
|
||||||
|
<Grid columnNum={5} border={false} gutter={4}>
|
||||||
|
{FORUM_CATEGORIES.map((cat) => (
|
||||||
|
<GridItem
|
||||||
|
key={cat.id}
|
||||||
|
icon={cat.icon}
|
||||||
|
text={cat.label}
|
||||||
|
onClick={() => setActiveCategory(cat.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 列表头 */}
|
||||||
|
<View className='post-list-header'>
|
||||||
|
<Text className='post-list-title'>
|
||||||
|
{FORUM_CATEGORIES.find((c) => c.id === activeCategory)?.label || ''} ·{' '}
|
||||||
|
<Text className='post-list-count'>{filteredPosts.length} 条</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 帖子列表 */}
|
||||||
|
<View className='post-list'>
|
||||||
|
{filteredPosts.map((post) => (
|
||||||
|
<ForumPostCard
|
||||||
|
key={post.id}
|
||||||
|
post={post}
|
||||||
|
onClick={handlePostClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{filteredPosts.length === 0 && (
|
||||||
|
<View className='empty-tip'>该板块下暂无帖子</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className='bottom-safe' />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,4 +2,5 @@ export default definePageConfig({
|
|||||||
navigationBarTitleText: '首页',
|
navigationBarTitleText: '首页',
|
||||||
navigationStyle: 'custom',
|
navigationStyle: 'custom',
|
||||||
enablePullDownRefresh: false,
|
enablePullDownRefresh: false,
|
||||||
|
usingComponents: {},
|
||||||
})
|
})
|
||||||
|
|||||||
+83
-15
@@ -80,30 +80,95 @@
|
|||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 标签区域 ========== */
|
/* ========== 推荐商家 section ========== */
|
||||||
.tabs-section {
|
.recommended-shops-section {
|
||||||
|
margin: 0 24px 20px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0 24px 20px;
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
.van-tabs__wrap {
|
.section-header {
|
||||||
border-radius: 16px 16px 0 0;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 24px 12px;
|
||||||
|
|
||||||
|
.section-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-emoji {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #969799;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-more-text {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #1989fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-more-arrow {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #1989fa;
|
||||||
|
margin-left: 2px;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 文章列表 ========== */
|
.recommended-shops-list {
|
||||||
.article-list {
|
/* 卡片之间用 1px 顶边线分割 */
|
||||||
padding: 0 24px;
|
.shop-card {
|
||||||
|
padding: 20px 20px;
|
||||||
|
|
||||||
|
.shop-image-wrap {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 统一板块 ========== */
|
||||||
|
.main-tabs-section {
|
||||||
|
margin: 0 24px 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文章列表内容 */
|
||||||
|
.article-content {
|
||||||
|
padding: 0 20px 16px;
|
||||||
|
|
||||||
.article-item {
|
.article-item {
|
||||||
background: #fff;
|
padding: 24px 0;
|
||||||
border-radius: 16px;
|
|
||||||
padding: 24px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
& + .article-item {
|
||||||
|
border-top: 1px solid #f2f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
@@ -139,8 +204,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta-read {
|
.meta-read {
|
||||||
flex: 1;
|
margin-left: auto;
|
||||||
text-align: right;
|
}
|
||||||
|
|
||||||
|
.meta-comment {
|
||||||
|
margin-left: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-37
@@ -1,18 +1,18 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { View, ScrollView } from '@tarojs/components'
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
import { Search, Swiper, SwiperItem, Grid, GridItem, Tabs, Tab, Image as VantImage } from '@antmjs/vantui'
|
import { Search, Swiper, SwiperItem, Grid, GridItem, Tabs, Tab, Image as VantImage } from '@antmjs/vantui'
|
||||||
import ArticleCard from '@/components/ArticleCard'
|
import ArticleCard from '@/components/ArticleCard'
|
||||||
|
import ShopCard from '@/components/ShopCard'
|
||||||
import { banners, navItems, articles } from '@/mock/articles'
|
import { banners, navItems, articles } from '@/mock/articles'
|
||||||
import type { ArticleCategory } from '@/types/article'
|
import { getRecommendedShops } from '@/mock/shops'
|
||||||
import CustomTabBar from "@/custom-tab-bar";
|
import type { Shop } from '@/types/shop'
|
||||||
import './index.less'
|
import './index.less'
|
||||||
|
|
||||||
/** 获取状态栏和导航栏高度 */
|
/** 获取状态栏和导航栏高度 */
|
||||||
function getNavBarHeight() {
|
function getNavBarHeight() {
|
||||||
const systemInfo = Taro.getSystemInfoSync()
|
const systemInfo = Taro.getSystemInfoSync()
|
||||||
const statusBarHeight = systemInfo.statusBarHeight || 20
|
const statusBarHeight = systemInfo.statusBarHeight || 20
|
||||||
// 微信小程序导航栏高度一般为 44px,总高度 = 状态栏 + 导航栏
|
|
||||||
const navBarHeight = 44
|
const navBarHeight = 44
|
||||||
return {
|
return {
|
||||||
statusBarHeight,
|
statusBarHeight,
|
||||||
@@ -21,22 +21,28 @@ function getNavBarHeight() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分类标签映射 */
|
/** 文章板块标签(校园商城 Tab 已移除) */
|
||||||
const TAB_LIST = [
|
const MAIN_TABS = [
|
||||||
{ title: '最新', value: 'latest' as ArticleCategory },
|
{ title: '最新', value: 'latest' },
|
||||||
{ title: '推荐', value: 'recommend' as ArticleCategory },
|
{ title: '推荐', value: 'recommend' },
|
||||||
{ title: '热点', value: 'hot' as ArticleCategory },
|
{ title: '热点', value: 'hot' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const [activeTab, setActiveTab] = useState(0)
|
const [activeMainTab, setActiveMainTab] = useState(0)
|
||||||
const navBarHeight = useMemo(() => getNavBarHeight(), [])
|
const navBarHeight = useMemo(() => getNavBarHeight(), [])
|
||||||
|
|
||||||
/** 根据当前 tab 筛选文章 */
|
/** 当前选中的板块 */
|
||||||
const filteredArticles = useMemo(() => {
|
const currentTab = MAIN_TABS[activeMainTab]?.value || 'latest'
|
||||||
const category = TAB_LIST[activeTab]?.value || 'latest'
|
|
||||||
return articles.filter((a) => a.category === category)
|
/** 根据文章分类筛 */
|
||||||
}, [activeTab])
|
const filteredArticles = useMemo(
|
||||||
|
() => articles.filter((a) => a.category === currentTab),
|
||||||
|
[currentTab],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 推荐商家(首页显示前 3 个) */
|
||||||
|
const recommendedShops = useMemo(() => getRecommendedShops(3), [])
|
||||||
|
|
||||||
/** 搜索 */
|
/** 搜索 */
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
@@ -57,6 +63,16 @@ export default function Index() {
|
|||||||
Taro.navigateTo({ url: `/pages/article/index?id=${id}` })
|
Taro.navigateTo({ url: `/pages/article/index?id=${id}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 商家点击 */
|
||||||
|
const handleShopClick = (shop: Shop) => {
|
||||||
|
Taro.navigateTo({ url: `/pages/shop-detail/index?id=${shop.id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查看全部商家 */
|
||||||
|
const handleViewAllShops = () => {
|
||||||
|
Taro.navigateTo({ url: '/pages/food-delivery/index' })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='home-page'>
|
<View className='home-page'>
|
||||||
{/* ========== 自定义导航栏 (毛玻璃) ========== */}
|
{/* ========== 自定义导航栏 (毛玻璃) ========== */}
|
||||||
@@ -128,44 +144,64 @@ export default function Index() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ========== 文章标签导航 (最新 / 推荐 / 热点) ========== */}
|
{/* ========== 推荐商家 ========== */}
|
||||||
<View className='tabs-section'>
|
<View className='recommended-shops-section'>
|
||||||
|
<View className='section-header'>
|
||||||
|
<View className='section-title-wrap'>
|
||||||
|
<Text className='section-emoji'>🍱</Text>
|
||||||
|
<Text className='section-title'>附近商家</Text>
|
||||||
|
<Text className='section-subtitle'>校园外卖精选</Text>
|
||||||
|
</View>
|
||||||
|
<View className='section-more' onClick={handleViewAllShops}>
|
||||||
|
<Text className='section-more-text'>查看全部</Text>
|
||||||
|
<Text className='section-more-arrow'>›</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className='recommended-shops-list'>
|
||||||
|
{recommendedShops.map((shop) => (
|
||||||
|
<ShopCard key={shop.id} shop={shop} onClick={handleShopClick} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ========== 文章板块:最新 | 推荐 | 热点 ========== */}
|
||||||
|
<View className='main-tabs-section'>
|
||||||
<Tabs
|
<Tabs
|
||||||
active={activeTab}
|
active={activeMainTab}
|
||||||
onChange={(e) => setActiveTab(e.detail.index)}
|
onChange={(e) => setActiveMainTab(e.detail.index)}
|
||||||
sticky
|
sticky
|
||||||
offsetTop={navBarHeight.totalHeight}
|
offsetTop={navBarHeight.totalHeight}
|
||||||
color='#1989fa'
|
color='#1989fa'
|
||||||
titleActiveColor='#1989fa'
|
titleActiveColor='#1989fa'
|
||||||
titleInactiveColor='#646566'
|
titleInactiveColor='#646566'
|
||||||
|
animated
|
||||||
|
swipeable
|
||||||
>
|
>
|
||||||
{TAB_LIST.map((tab) => (
|
{MAIN_TABS.map((tab) => (
|
||||||
<Tab key={tab.value} title={tab.title} />
|
<Tab key={tab.value} title={tab.title} />
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* ========== 文章列表 ========== */}
|
{/* 文章列表内容 */}
|
||||||
<View className='article-list'>
|
<View className='article-content'>
|
||||||
{filteredArticles.map((article) => (
|
{filteredArticles.map((article) => (
|
||||||
<View
|
<View
|
||||||
key={article.id}
|
key={article.id}
|
||||||
className='article-item'
|
className='article-item'
|
||||||
onClick={() => handleArticleClick(article.id)}
|
onClick={() => handleArticleClick(article.id)}
|
||||||
>
|
>
|
||||||
<ArticleCard article={article} />
|
<ArticleCard article={article} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
{filteredArticles.length === 0 && (
|
{filteredArticles.length === 0 && (
|
||||||
<View className='empty-tip'>暂无文章</View>
|
<View className='empty-tip'>暂无文章</View>
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 底部安全距离 */}
|
{/* 底部安全距离 */}
|
||||||
<View className='bottom-safe' />
|
<View className='bottom-safe' />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<CustomTabBar activeKey='index' />
|
|
||||||
</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,148 @@
|
|||||||
|
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text, Button } from '@tarojs/components'
|
||||||
|
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
/** 状态栏高度 */
|
||||||
|
function getStatusBarHeight(): number {
|
||||||
|
try {
|
||||||
|
return Taro.getSystemInfoSync().statusBarHeight || 20
|
||||||
|
} catch {
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导航栏高度 */
|
||||||
|
const NAV_BAR_HEIGHT = 44
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const statusBarH = useMemo(getStatusBarHeight, [])
|
||||||
|
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 handleBack = useCallback(() => {
|
||||||
|
Taro.navigateBack()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 手机号授权登录 */
|
||||||
|
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'>
|
||||||
|
{/* ========== 自定义导航栏 ========== */}
|
||||||
|
<View className='login-navbar' style={{ paddingTop: `${statusBarH}px` }}>
|
||||||
|
<View className='navbar-inner' style={{ height: `${NAV_BAR_HEIGHT}px` }}>
|
||||||
|
<View className='navbar-back' onClick={handleBack}>
|
||||||
|
<Text className='back-arrow'>‹</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='navbar-title'>登录</Text>
|
||||||
|
<View className='navbar-placeholder' />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ========== 内容区域 ========== */}
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationBarTitleText: '消息',
|
navigationBarTitleText: '消息',
|
||||||
navigationStyle: 'custom',
|
usingComponents: {},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||||
import { Badge } from '@antmjs/vantui'
|
import { Badge } from '@antmjs/vantui'
|
||||||
import CustomTabBar from '@/custom-tab-bar'
|
|
||||||
import { chatList } from '@/mock/messages'
|
import { chatList } from '@/mock/messages'
|
||||||
import type { ChatItem } from '@/types/message'
|
import type { ChatItem } from '@/types/message'
|
||||||
import './index.less'
|
import './index.less'
|
||||||
@@ -15,7 +14,11 @@ const ROLE_LABEL: Record<string, { text: string; className: string }> = {
|
|||||||
|
|
||||||
export default function Messages() {
|
export default function Messages() {
|
||||||
const handleChatClick = (item: ChatItem) => {
|
const handleChatClick = (item: ChatItem) => {
|
||||||
Taro.showToast({ title: `${item.name || item.title} 聊天即将上线`, icon: 'none' })
|
if (item.type === 'system') {
|
||||||
|
Taro.navigateTo({ url: '/pages/system-messages/index' })
|
||||||
|
} else {
|
||||||
|
Taro.navigateTo({ url: `/pages/chat/index?id=${item.id}` })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分组:置顶系统消息 + 普通聊天
|
// 分组:置顶系统消息 + 普通聊天
|
||||||
@@ -86,7 +89,7 @@ export default function Messages() {
|
|||||||
|
|
||||||
{/* 未读数 */}
|
{/* 未读数 */}
|
||||||
{msg.unread ? (
|
{msg.unread ? (
|
||||||
<Badge content={msg.unread} max={99} />
|
<Badge content={msg.unread} />
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -94,8 +97,6 @@ export default function Messages() {
|
|||||||
|
|
||||||
<View className='bottom-safe' />
|
<View className='bottom-safe' />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<CustomTabBar activeKey='messages' />
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
navigationBarTitleText: '编辑资料',
|
||||||
|
})
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/* ========================================
|
||||||
|
编辑资料页面
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.profile-edit-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 自定义导航栏 ========== */
|
||||||
|
.profile-edit-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 加载状态 ========== */
|
||||||
|
.loading-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 400px;
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 表单区域 ========== */
|
||||||
|
.profile-edit-form {
|
||||||
|
margin-top: 16px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 28px 32px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 30px;
|
||||||
|
color: #323233;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 30px;
|
||||||
|
color: #323233;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-value-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-value {
|
||||||
|
font-size: 30px;
|
||||||
|
color: #323233;
|
||||||
|
|
||||||
|
&--readonly {
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头像行 */
|
||||||
|
.form-item--avatar {
|
||||||
|
.avatar-picker-btn {
|
||||||
|
position: relative;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-arrow {
|
||||||
|
position: absolute;
|
||||||
|
right: -8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
|
||||||
|
.arrow-icon {
|
||||||
|
font-size: 36px;
|
||||||
|
color: #c8c9cc;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 性别行 */
|
||||||
|
.form-item--gender {
|
||||||
|
.gender-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-tag {
|
||||||
|
padding: 8px 28px;
|
||||||
|
border-radius: 32px;
|
||||||
|
border: 2px solid #ebedf0;
|
||||||
|
background: #fff;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
.gender-tag-text {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #646566;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
border-color: #1989fa;
|
||||||
|
background: #eaf3ff;
|
||||||
|
|
||||||
|
.gender-tag-text {
|
||||||
|
color: #1989fa;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 保存按钮 ========== */
|
||||||
|
.profile-edit-footer {
|
||||||
|
padding: 48px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-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;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text, Button, Input, Image } from '@tarojs/components'
|
||||||
|
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||||
|
import { getUserInfoApi, updateUserProfileApi } from '@/services/user'
|
||||||
|
import type { Gender } from '@/types/user'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
/** 性别选项 */
|
||||||
|
const GENDER_OPTIONS: { value: Gender; label: string }[] = [
|
||||||
|
{ value: 0, label: '未知' },
|
||||||
|
{ value: 1, label: '男' },
|
||||||
|
{ value: 2, label: '女' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 默认头像占位图(灰色圆) */
|
||||||
|
const DEFAULT_AVATAR =
|
||||||
|
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iNTAiIGN5PSI1MCIgcj0iNTAiIGZpbGw9IiNFNUU1RTUiLz48L3N2Zz4='
|
||||||
|
|
||||||
|
/** 状态栏高度 */
|
||||||
|
function getStatusBarHeight(): number {
|
||||||
|
try {
|
||||||
|
return Taro.getSystemInfoSync().statusBarHeight || 20
|
||||||
|
} catch {
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导航栏高度 */
|
||||||
|
const NAV_BAR_HEIGHT = 44
|
||||||
|
|
||||||
|
export default function ProfileEditPage() {
|
||||||
|
const statusBarH = useMemo(getStatusBarHeight, [])
|
||||||
|
const storeUser = useAuthStore(s => s.user)
|
||||||
|
const setUser = useAuthStore(s => s.setUser)
|
||||||
|
|
||||||
|
// 本地编辑状态
|
||||||
|
const [avatar, setAvatar] = useState('')
|
||||||
|
const [nickname, setNickname] = useState('')
|
||||||
|
const [gender, setGender] = useState<Gender>(0)
|
||||||
|
const [mobile, setMobile] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
// 记录初始值用于判断是否有修改
|
||||||
|
const [initialAvatar, setInitialAvatar] = useState('')
|
||||||
|
const [initialNickname, setInitialNickname] = useState('')
|
||||||
|
const [initialGender, setInitialGender] = useState<Gender>(0)
|
||||||
|
|
||||||
|
/** 加载用户信息 */
|
||||||
|
useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
// 优先从 store 快速展示,再从 API 获取脱敏后的手机号
|
||||||
|
if (storeUser) {
|
||||||
|
setAvatar(storeUser.avatar || '')
|
||||||
|
setNickname(storeUser.nickname || '')
|
||||||
|
setGender(storeUser.gender)
|
||||||
|
setMobile(storeUser.mobile || '')
|
||||||
|
setInitialAvatar(storeUser.avatar || '')
|
||||||
|
setInitialNickname(storeUser.nickname || '')
|
||||||
|
setInitialGender(storeUser.gender)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 API 获取最新数据(含脱敏手机号)
|
||||||
|
const res = await getUserInfoApi()
|
||||||
|
const user = res.data
|
||||||
|
setAvatar(user.avatar || '')
|
||||||
|
setNickname(user.nickname || '')
|
||||||
|
setGender(user.gender)
|
||||||
|
setMobile(user.mobile || '')
|
||||||
|
setInitialAvatar(user.avatar || '')
|
||||||
|
setInitialNickname(user.nickname || '')
|
||||||
|
setInitialGender(user.gender)
|
||||||
|
} catch {
|
||||||
|
// API 失败则使用 store 数据(已展示)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
/** 是否已修改 */
|
||||||
|
const isModified = useMemo(
|
||||||
|
() =>
|
||||||
|
avatar !== initialAvatar ||
|
||||||
|
nickname !== initialNickname ||
|
||||||
|
gender !== initialGender,
|
||||||
|
[avatar, nickname, gender, initialAvatar, initialNickname, initialGender],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 返回上一页 */
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
Taro.navigateBack()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 选择头像 */
|
||||||
|
const handleChooseAvatar = useCallback((e: any) => {
|
||||||
|
const url = e.detail?.avatarUrl
|
||||||
|
if (url) {
|
||||||
|
setAvatar(url)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 昵称变化 */
|
||||||
|
const handleNicknameChange = useCallback((e: any) => {
|
||||||
|
setNickname(e.detail?.value || '')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 选择性别 */
|
||||||
|
const handleGenderChange = useCallback((value: Gender) => {
|
||||||
|
setGender(value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 保存 */
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (submitting) return
|
||||||
|
|
||||||
|
// 校验
|
||||||
|
if (nickname && nickname.length > 32) {
|
||||||
|
Taro.showToast({ title: '昵称不能超过 32 个字符', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (avatar && avatar.length > 500) {
|
||||||
|
Taro.showToast({ title: '头像地址过长', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const params: { nickname?: string; avatar?: string; gender?: Gender } = {
|
||||||
|
nickname, avatar, gender
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await updateUserProfileApi(params)
|
||||||
|
// 同步更新 store
|
||||||
|
setUser(res.data)
|
||||||
|
Taro.showToast({ title: '保存成功', icon: 'success' })
|
||||||
|
setTimeout(() => {
|
||||||
|
Taro.navigateBack()
|
||||||
|
}, 1200)
|
||||||
|
} catch {
|
||||||
|
Taro.showToast({ title: '保存失败,请重试', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
submitting,
|
||||||
|
avatar,
|
||||||
|
nickname,
|
||||||
|
gender,
|
||||||
|
initialAvatar,
|
||||||
|
initialNickname,
|
||||||
|
initialGender,
|
||||||
|
setUser,
|
||||||
|
])
|
||||||
|
|
||||||
|
/** 手机号展示(已脱敏或以 **** 显示) */
|
||||||
|
const mobileDisplay = useMemo(() => {
|
||||||
|
if (!mobile) return '未绑定'
|
||||||
|
return mobile
|
||||||
|
}, [mobile])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View className='profile-edit-page'>
|
||||||
|
<View className='profile-edit-navbar' style={{ paddingTop: `${statusBarH}px` }}>
|
||||||
|
<View className='navbar-inner' style={{ height: `${NAV_BAR_HEIGHT}px` }}>
|
||||||
|
<View className='navbar-back' onClick={handleBack}>
|
||||||
|
<Text className='back-arrow'>‹</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='navbar-title'>编辑资料</Text>
|
||||||
|
<View className='navbar-placeholder' />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className='loading-wrapper'>
|
||||||
|
<Text className='loading-text'>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='profile-edit-page'>
|
||||||
|
{/* ========== 自定义导航栏 ========== */}
|
||||||
|
<View className='profile-edit-navbar' style={{ paddingTop: `${statusBarH}px` }}>
|
||||||
|
<View className='navbar-inner' style={{ height: `${NAV_BAR_HEIGHT}px` }}>
|
||||||
|
<View className='navbar-back' onClick={handleBack}>
|
||||||
|
<Text className='back-arrow'>‹</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='navbar-title'>编辑资料</Text>
|
||||||
|
<View className='navbar-placeholder' />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ========== 表单区 ========== */}
|
||||||
|
<View className='profile-edit-form'>
|
||||||
|
{/* 头像 */}
|
||||||
|
<View className='form-item form-item--avatar'>
|
||||||
|
<Text className='form-label'>头像</Text>
|
||||||
|
<Button
|
||||||
|
className='avatar-picker-btn'
|
||||||
|
openType='chooseAvatar'
|
||||||
|
onChooseAvatar={handleChooseAvatar}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
className='avatar-img'
|
||||||
|
src={avatar || DEFAULT_AVATAR}
|
||||||
|
mode='aspectFill'
|
||||||
|
/>
|
||||||
|
<View className='avatar-arrow'>
|
||||||
|
<Text className='arrow-icon'>›</Text>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 昵称 */}
|
||||||
|
<View className='form-item'>
|
||||||
|
<Text className='form-label'>昵称</Text>
|
||||||
|
<View className='form-input-wrapper'>
|
||||||
|
<Input
|
||||||
|
className='form-input'
|
||||||
|
type='nickname'
|
||||||
|
placeholder='请输入昵称'
|
||||||
|
value={nickname}
|
||||||
|
onInput={handleNicknameChange}
|
||||||
|
onBlur={handleNicknameChange}
|
||||||
|
maxlength={32}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 性别 */}
|
||||||
|
<View className='form-item form-item--gender'>
|
||||||
|
<Text className='form-label'>性别</Text>
|
||||||
|
<View className='gender-options'>
|
||||||
|
{GENDER_OPTIONS.map(opt => (
|
||||||
|
<View
|
||||||
|
key={opt.value}
|
||||||
|
className={`gender-tag ${gender === opt.value ? 'gender-tag--active' : ''}`}
|
||||||
|
onClick={() => handleGenderChange(opt.value)}
|
||||||
|
>
|
||||||
|
<Text className='gender-tag-text'>{opt.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 手机号(只读) */}
|
||||||
|
<View className='form-item'>
|
||||||
|
<Text className='form-label'>手机号</Text>
|
||||||
|
<View className='form-value-wrapper'>
|
||||||
|
<Text className='form-value form-value--readonly'>{mobileDisplay}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 保存按钮 */}
|
||||||
|
<View className='profile-edit-footer'>
|
||||||
|
<Button
|
||||||
|
className={`save-btn ${!isModified || submitting ? 'save-btn--disabled' : ''}`}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isModified || submitting}
|
||||||
|
loading={submitting}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationBarTitleText: '我的',
|
navigationBarTitleText: '我的',
|
||||||
navigationStyle: 'custom',
|
navigationStyle: 'custom',
|
||||||
|
usingComponents: {},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -72,6 +72,18 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.4);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-left: 24px;
|
margin-left: 24px;
|
||||||
@@ -88,6 +100,11 @@
|
|||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-text {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.gender-tag {
|
.gender-tag {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
|
|||||||
+107
-25
@@ -1,8 +1,10 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo, useCallback } from 'react'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||||
import { Cell, CellGroup, Icon } from '@antmjs/vantui'
|
import { Cell, CellGroup, Icon } from '@antmjs/vantui'
|
||||||
import CustomTabBar from '@/custom-tab-bar'
|
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||||
|
import { GENDER_MAP } from '@/types/user'
|
||||||
|
import type { Gender } from '@/types/user'
|
||||||
import './index.less'
|
import './index.less'
|
||||||
|
|
||||||
/** 状态栏高度 */
|
/** 状态栏高度 */
|
||||||
@@ -14,13 +16,13 @@ function getStatusBarHeight(): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户信息(后续接入 API) */
|
/** 默认用户信息(未登录时展示占位) */
|
||||||
const USER_INFO = {
|
const DEFAULT_USER = {
|
||||||
avatar: 'https://picsum.photos/seed/avatar/200/200',
|
avatar: 'https://picsum.photos/seed/avatar/200/200',
|
||||||
name: '张三',
|
name: '未登录',
|
||||||
className: '计算机科学与技术 2022级',
|
className: '点击登录体验更多功能',
|
||||||
gender: '男' as const,
|
gender: '男' as const,
|
||||||
stats: { likes: 128, comments: 46, favorites: 89 },
|
stats: { likes: '-', comments: '-', favorites: '-' },
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 订单入口 */
|
/** 订单入口 */
|
||||||
@@ -38,32 +40,80 @@ const FEATURE_LIST = [
|
|||||||
{ key: 'address', label: '地址管理', icon: 'location-o' },
|
{ key: 'address', label: '地址管理', icon: 'location-o' },
|
||||||
{ key: 'about', label: '关于我们', icon: 'info-o' },
|
{ key: 'about', label: '关于我们', icon: 'info-o' },
|
||||||
{ key: 'feedback', label: '意见反馈', icon: 'chat-o' },
|
{ key: 'feedback', label: '意见反馈', icon: 'chat-o' },
|
||||||
{ key: 'settings', label: '系统设置', icon: 'setting-o' },
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
const statusBarH = useMemo(getStatusBarHeight, [])
|
const statusBarH = useMemo(getStatusBarHeight, [])
|
||||||
|
const user = useAuthStore(s => s.user)
|
||||||
|
const logout = useAuthStore(s => s.logout)
|
||||||
|
const isLoggedIn = useAuthStore(s => !!s.token && !!s.user)
|
||||||
|
|
||||||
|
/** 跳转登录页 */
|
||||||
|
const handleGoToLogin = useCallback(() => {
|
||||||
|
Taro.navigateTo({ url: '/pages/login/index' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSettings = () => {
|
const handleSettings = () => {
|
||||||
Taro.showToast({ title: '设置页面即将上线', icon: 'none' })
|
Taro.showToast({ title: '设置页面即将上线', icon: 'none' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditProfile = () => {
|
const handleEditProfile = () => {
|
||||||
Taro.showToast({ title: '编辑资料即将上线', icon: 'none' })
|
if (isLoggedIn) {
|
||||||
|
Taro.navigateTo({ url: '/pages/profile-edit/index' })
|
||||||
|
} else {
|
||||||
|
handleGoToLogin()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOrderTab = (key: string) => {
|
const handleOrderTab = (key: string) => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
handleGoToLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
Taro.showToast({ title: `${ORDER_TABS.find(t => t.key === key)?.label || ''}订单即将上线`, icon: 'none' })
|
Taro.showToast({ title: `${ORDER_TABS.find(t => t.key === key)?.label || ''}订单即将上线`, icon: 'none' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFeature = (item: typeof FEATURE_LIST[number]) => {
|
const handleFeature = (item: typeof FEATURE_LIST[number]) => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
handleGoToLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
Taro.showToast({ title: `${item.label}即将上线`, icon: 'none' })
|
Taro.showToast({ title: `${item.label}即将上线`, icon: 'none' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAllOrders = () => {
|
const handleAllOrders = () => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
handleGoToLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
Taro.showToast({ title: '全部订单即将上线', icon: 'none' })
|
Taro.showToast({ title: '全部订单即将上线', icon: 'none' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 退出登录 */
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
Taro.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '确定要退出登录吗?',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
logout()
|
||||||
|
Taro.showToast({ title: '已退出登录', icon: 'success' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [logout])
|
||||||
|
|
||||||
|
// 根据登录状态决定展示的用户信息
|
||||||
|
const displayUser = isLoggedIn && user
|
||||||
|
? {
|
||||||
|
avatar: user.avatar,
|
||||||
|
name: user.nickname,
|
||||||
|
className: '在校学生', // 后续可扩展班级字段
|
||||||
|
gender: GENDER_MAP[user.gender] || '未知',
|
||||||
|
stats: { likes: '-', comments: '-', favorites: '-' }, // 暂用占位,后续从 API 获取
|
||||||
|
}
|
||||||
|
: DEFAULT_USER
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='profile-page'>
|
<View className='profile-page'>
|
||||||
{/* ========== 背景图区域 ========== */}
|
{/* ========== 背景图区域 ========== */}
|
||||||
@@ -79,30 +129,50 @@ export default function Profile() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 头像 + 用户信息 */}
|
{/* 头像 + 用户信息 */}
|
||||||
<View className='user-card' onClick={handleEditProfile}>
|
<View
|
||||||
<Image className='avatar' src={USER_INFO.avatar} mode='aspectFill' />
|
className={`user-card ${!isLoggedIn ? 'user-card--login' : ''}`}
|
||||||
<View className='user-info'>
|
onClick={isLoggedIn ? handleEditProfile : handleGoToLogin}
|
||||||
<View className='name-row'>
|
>
|
||||||
<Text className='name'>{USER_INFO.name}</Text>
|
{isLoggedIn ? (
|
||||||
<Text className='gender-tag'>{USER_INFO.gender === '男' ? '♂' : '♀'}</Text>
|
<>
|
||||||
</View>
|
<Image className='avatar' src={displayUser.avatar} mode='aspectFill' />
|
||||||
<Text className='class-name'>{USER_INFO.className}</Text>
|
<View className='user-info'>
|
||||||
</View>
|
<View className='name-row'>
|
||||||
<Icon name='arrow' size={18} color='rgba(255,255,255,0.7)' />
|
<Text className='name'>{displayUser.name}</Text>
|
||||||
|
<Text className='gender-tag'>{displayUser.gender === '男' ? '♂' : displayUser.gender === '女' ? '♀' : ''}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='class-name'>{displayUser.className}</Text>
|
||||||
|
</View>
|
||||||
|
<Icon name='arrow' size={18} color='rgba(255,255,255,0.7)' />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View className='avatar-placeholder'>
|
||||||
|
<Icon name='user-circle-o' size={48} color='rgba(255,255,255,0.7)' />
|
||||||
|
</View>
|
||||||
|
<View className='user-info'>
|
||||||
|
<View className='name-row'>
|
||||||
|
<Text className='name login-text'>点击登录账号</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='class-name'>登录后体验更多功能</Text>
|
||||||
|
</View>
|
||||||
|
<Icon name='arrow' size={18} color='rgba(255,255,255,0.7)' />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 点赞/评论/收藏 */}
|
{/* 点赞/评论/收藏 */}
|
||||||
<View className='stats-row'>
|
<View className='stats-row'>
|
||||||
<View className='stat-item'>
|
<View className='stat-item'>
|
||||||
<Text className='stat-num'>{USER_INFO.stats.likes}</Text>
|
<Text className='stat-num'>{displayUser.stats.likes}</Text>
|
||||||
<Text className='stat-label'>点赞</Text>
|
<Text className='stat-label'>点赞</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='stat-item'>
|
<View className='stat-item'>
|
||||||
<Text className='stat-num'>{USER_INFO.stats.comments}</Text>
|
<Text className='stat-num'>{displayUser.stats.comments}</Text>
|
||||||
<Text className='stat-label'>评论</Text>
|
<Text className='stat-label'>评论</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='stat-item'>
|
<View className='stat-item'>
|
||||||
<Text className='stat-num'>{USER_INFO.stats.favorites}</Text>
|
<Text className='stat-num'>{displayUser.stats.favorites}</Text>
|
||||||
<Text className='stat-label'>收藏</Text>
|
<Text className='stat-label'>收藏</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -149,15 +219,27 @@ export default function Profile() {
|
|||||||
</CellGroup>
|
</CellGroup>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* ========== 退出登录(仅登录后显示) ========== */}
|
||||||
|
{isLoggedIn && (
|
||||||
|
<View className='section-card feature-list'>
|
||||||
|
<CellGroup inset>
|
||||||
|
<Cell
|
||||||
|
title='退出登录'
|
||||||
|
icon='cross'
|
||||||
|
isLink
|
||||||
|
border={false}
|
||||||
|
onClick={handleLogout}
|
||||||
|
/>
|
||||||
|
</CellGroup>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ========== 底部信息 ========== */}
|
{/* ========== 底部信息 ========== */}
|
||||||
<View className='footer-info'>
|
<View className='footer-info'>
|
||||||
<Text className='footer-icp'>湘ICP备2024000000号-1</Text>
|
<Text className='footer-icp'>湘ICP备2024000000号-1</Text>
|
||||||
<Text className='footer-version'>校园通 v1.0.0</Text>
|
<Text className='footer-version'>校园通 v1.0.0</Text>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* ========== TabBar ========== */}
|
|
||||||
<CustomTabBar activeKey='profile' />
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '发布文章',
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
})
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/* ========================================
|
||||||
|
发布文章页
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.publish-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 导航栏(复用 article 页样式) ========== */
|
||||||
|
.publish-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.nav-content {
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.nav-back {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder {
|
||||||
|
width: 50px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 滚动区域 ========== */
|
||||||
|
.publish-scroll {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 表单卡片 ========== */
|
||||||
|
.form-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin: 16px 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #323233;
|
||||||
|
padding: 24px 24px 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 图片上传区域 ========== */
|
||||||
|
.upload-section {
|
||||||
|
padding: 16px 24px 24px;
|
||||||
|
|
||||||
|
.upload-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #969799;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 底部提交栏 ========== */
|
||||||
|
.bottom-submit-bar {
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #f2f3f5;
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
:global(.van-button) {
|
||||||
|
height: 88px;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 底部安全区占位 ========== */
|
||||||
|
.scroll-bottom-safe {
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState, useMemo, useCallback } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
|
import { Field, Icon, Button, Uploader } from '@antmjs/vantui'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
function getStatusBarH(): number {
|
||||||
|
try { return Taro.getSystemInfoSync().statusBarHeight || 20 }
|
||||||
|
catch { return 20 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafeBottom(): number {
|
||||||
|
try {
|
||||||
|
const info = Taro.getSystemInfoSync()
|
||||||
|
return (info.screenHeight - info.safeArea!.bottom) || 0
|
||||||
|
} catch { return 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublishArticle() {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [images, setImages] = useState<any[]>([])
|
||||||
|
|
||||||
|
const statusBarH = useMemo(getStatusBarH, [])
|
||||||
|
const safeBottom = useMemo(getSafeBottom, [])
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => { Taro.navigateBack() }, [])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入文章标题', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!content.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入正文内容', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Taro.showToast({ title: '发布成功', icon: 'success' })
|
||||||
|
setTimeout(() => { Taro.navigateBack() }, 1500)
|
||||||
|
}, [title, content])
|
||||||
|
|
||||||
|
const handleUploadRead = useCallback((e: any) => {
|
||||||
|
const file = e.detail?.file || e.detail
|
||||||
|
if (file) {
|
||||||
|
setImages((prev) => [...prev, { url: file.path || file.url, name: file.name || 'image' }])
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleUploadDelete = useCallback((e: any) => {
|
||||||
|
const idx = e.detail?.index ?? e.detail
|
||||||
|
setImages((prev) => prev.filter((_, i) => i !== idx))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='publish-page'>
|
||||||
|
{/* 导航栏 */}
|
||||||
|
<View className='publish-nav' style={{ paddingTop: `${statusBarH}px` }}>
|
||||||
|
<View className='nav-content'>
|
||||||
|
<View className='nav-back' onClick={handleBack}>
|
||||||
|
<Icon name='arrow-left' size={22} color='#323233' />
|
||||||
|
</View>
|
||||||
|
<Text className='nav-title'>发布文章</Text>
|
||||||
|
<View className='nav-placeholder' />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={{ height: `${statusBarH + 44}px` }} />
|
||||||
|
|
||||||
|
<ScrollView className='publish-scroll' scrollY enhanced showScrollbar={false}>
|
||||||
|
{/* 基本信息卡片 */}
|
||||||
|
<View className='form-card'>
|
||||||
|
<Text className='card-title'>文章内容</Text>
|
||||||
|
<Field
|
||||||
|
value={title}
|
||||||
|
placeholder='请输入文章标题'
|
||||||
|
border={false}
|
||||||
|
required
|
||||||
|
onInput={(e) => setTitle(String(e.detail.value))}
|
||||||
|
/>
|
||||||
|
<View style={{ height: '1px', background: '#f2f3f5', margin: '0 24px' }} />
|
||||||
|
<Field
|
||||||
|
value={content}
|
||||||
|
placeholder='请输入正文内容'
|
||||||
|
type='textarea'
|
||||||
|
autosize
|
||||||
|
border={false}
|
||||||
|
maxlength={5000}
|
||||||
|
showWordLimit
|
||||||
|
onInput={(e) => setContent(String(e.detail.value))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 封面图片 */}
|
||||||
|
<View className='form-card'>
|
||||||
|
<Text className='card-title'>封面图片</Text>
|
||||||
|
<View className='upload-section'>
|
||||||
|
<Text className='upload-label'>支持 jpg/png,最多 3 张</Text>
|
||||||
|
<Uploader
|
||||||
|
fileList={images}
|
||||||
|
accept='image'
|
||||||
|
maxCount={3}
|
||||||
|
multiple
|
||||||
|
deletable
|
||||||
|
previewFullImage
|
||||||
|
uploadText='上传图片'
|
||||||
|
onAfterRead={handleUploadRead}
|
||||||
|
onDelete={handleUploadDelete}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='scroll-bottom-safe' />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 底部提交 */}
|
||||||
|
<View className='bottom-submit-bar' style={{ paddingBottom: `${safeBottom}px` }}>
|
||||||
|
<View className='submit-btn'>
|
||||||
|
<Button type='primary' round block onClick={handleSubmit}>
|
||||||
|
发布文章
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '快递代取',
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
})
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/* ========================================
|
||||||
|
快递代取发布页
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.publish-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 导航栏(复用 article 页样式) ========== */
|
||||||
|
.publish-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.nav-content {
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.nav-back {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder {
|
||||||
|
width: 50px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 滚动区域 ========== */
|
||||||
|
.publish-scroll {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 表单卡片 ========== */
|
||||||
|
.form-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin: 16px 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #323233;
|
||||||
|
padding: 24px 24px 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 金额单位提示 ========== */
|
||||||
|
.reward-hint {
|
||||||
|
padding: 0 24px 16px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 底部提交栏 ========== */
|
||||||
|
.bottom-submit-bar {
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #f2f3f5;
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
:global(.van-button) {
|
||||||
|
height: 88px;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 底部安全区占位 ========== */
|
||||||
|
.scroll-bottom-safe {
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useState, useMemo, useCallback } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
|
import { Field, Icon, Button } from '@antmjs/vantui'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
function getStatusBarH(): number {
|
||||||
|
try { return Taro.getSystemInfoSync().statusBarHeight || 20 }
|
||||||
|
catch { return 20 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafeBottom(): number {
|
||||||
|
try {
|
||||||
|
const info = Taro.getSystemInfoSync()
|
||||||
|
return (info.screenHeight - info.safeArea!.bottom) || 0
|
||||||
|
} catch { return 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublishExpress() {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [desc, setDesc] = useState('')
|
||||||
|
const [location, setLocation] = useState('')
|
||||||
|
const [reward, setReward] = useState('')
|
||||||
|
const [deadline, setDeadline] = useState('')
|
||||||
|
|
||||||
|
const statusBarH = useMemo(getStatusBarH, [])
|
||||||
|
const safeBottom = useMemo(getSafeBottom, [])
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => { Taro.navigateBack() }, [])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入任务标题', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!desc.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入任务描述', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!location.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入取件地点', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!reward.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入报酬金额', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Taro.showToast({ title: '发布成功', icon: 'success' })
|
||||||
|
setTimeout(() => { Taro.navigateBack() }, 1500)
|
||||||
|
}, [title, desc, location, reward])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='publish-page'>
|
||||||
|
{/* 导航栏 */}
|
||||||
|
<View className='publish-nav' style={{ paddingTop: `${statusBarH}px` }}>
|
||||||
|
<View className='nav-content'>
|
||||||
|
<View className='nav-back' onClick={handleBack}>
|
||||||
|
<Icon name='arrow-left' size={22} color='#323233' />
|
||||||
|
</View>
|
||||||
|
<Text className='nav-title'>快递代取</Text>
|
||||||
|
<View className='nav-placeholder' />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={{ height: `${statusBarH + 44}px` }} />
|
||||||
|
|
||||||
|
<ScrollView className='publish-scroll' scrollY enhanced showScrollbar={false}>
|
||||||
|
{/* 基本信息 */}
|
||||||
|
<View className='form-card'>
|
||||||
|
<Text className='card-title'>任务信息</Text>
|
||||||
|
<Field
|
||||||
|
value={title}
|
||||||
|
placeholder='简要描述代取任务,如:帮我取个快递'
|
||||||
|
border={false}
|
||||||
|
required
|
||||||
|
onInput={(e) => setTitle(String(e.detail.value))}
|
||||||
|
/>
|
||||||
|
<View style={{ height: '1px', background: '#f2f3f5', margin: '0 24px' }} />
|
||||||
|
<Field
|
||||||
|
value={desc}
|
||||||
|
placeholder='详细描述快递信息,如快递公司、柜号、包裹大小等'
|
||||||
|
type='textarea'
|
||||||
|
autosize
|
||||||
|
border={false}
|
||||||
|
maxlength={500}
|
||||||
|
showWordLimit
|
||||||
|
onInput={(e) => setDesc(String(e.detail.value))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 详细信息 */}
|
||||||
|
<View className='form-card'>
|
||||||
|
<Text className='card-title'>详细信息</Text>
|
||||||
|
<Field
|
||||||
|
value={location}
|
||||||
|
placeholder='如:南区菜鸟驿站3号柜'
|
||||||
|
label='取件地点'
|
||||||
|
border={false}
|
||||||
|
required
|
||||||
|
onInput={(e) => setLocation(String(e.detail.value))}
|
||||||
|
/>
|
||||||
|
<View style={{ height: '1px', background: '#f2f3f5', margin: '0 24px' }} />
|
||||||
|
<Field
|
||||||
|
value={reward}
|
||||||
|
placeholder='如:5'
|
||||||
|
label='报酬 (元)'
|
||||||
|
type='digit'
|
||||||
|
border={false}
|
||||||
|
required
|
||||||
|
onInput={(e) => setReward(String(e.detail.value))}
|
||||||
|
/>
|
||||||
|
{reward && (
|
||||||
|
<Text className='reward-hint'>报酬金额:¥{Number(reward).toFixed(2)}</Text>
|
||||||
|
)}
|
||||||
|
<View style={{ height: '1px', background: '#f2f3f5', margin: '0 24px' }} />
|
||||||
|
<Field
|
||||||
|
value={deadline}
|
||||||
|
placeholder='如:今天18:00前'
|
||||||
|
label='截止时间'
|
||||||
|
border={false}
|
||||||
|
onInput={(e) => setDeadline(String(e.detail.value))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='scroll-bottom-safe' />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 底部提交 */}
|
||||||
|
<View className='bottom-submit-bar' style={{ paddingBottom: `${safeBottom}px` }}>
|
||||||
|
<View className='submit-btn'>
|
||||||
|
<Button type='primary' round block onClick={handleSubmit}>
|
||||||
|
发布任务
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '发布二手',
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
})
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/* ========================================
|
||||||
|
发布二手页
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.publish-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 导航栏(复用 article 页样式) ========== */
|
||||||
|
.publish-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.nav-content {
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.nav-back {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder {
|
||||||
|
width: 50px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 滚动区域 ========== */
|
||||||
|
.publish-scroll {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 表单卡片 ========== */
|
||||||
|
.form-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin: 16px 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #323233;
|
||||||
|
padding: 24px 24px 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 图片上传区域 ========== */
|
||||||
|
.upload-section {
|
||||||
|
padding: 16px 24px 24px;
|
||||||
|
|
||||||
|
.upload-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #969799;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 标签提示 ========== */
|
||||||
|
.tag-hint {
|
||||||
|
padding: 0 24px 16px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 底部提交栏 ========== */
|
||||||
|
.bottom-submit-bar {
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #f2f3f5;
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
:global(.van-button) {
|
||||||
|
height: 88px;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 底部安全区占位 ========== */
|
||||||
|
.scroll-bottom-safe {
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState, useMemo, useCallback } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
|
import { Field, Icon, Button, Uploader } from '@antmjs/vantui'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
function getStatusBarH(): number {
|
||||||
|
try { return Taro.getSystemInfoSync().statusBarHeight || 20 }
|
||||||
|
catch { return 20 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafeBottom(): number {
|
||||||
|
try {
|
||||||
|
const info = Taro.getSystemInfoSync()
|
||||||
|
return (info.screenHeight - info.safeArea!.bottom) || 0
|
||||||
|
} catch { return 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublishSecondhand() {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [desc, setDesc] = useState('')
|
||||||
|
const [images, setImages] = useState<any[]>([])
|
||||||
|
const [price, setPrice] = useState('')
|
||||||
|
const [location, setLocation] = useState('')
|
||||||
|
const [tags, setTags] = useState('')
|
||||||
|
|
||||||
|
const statusBarH = useMemo(getStatusBarH, [])
|
||||||
|
const safeBottom = useMemo(getSafeBottom, [])
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => { Taro.navigateBack() }, [])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入商品标题', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!desc.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入商品描述', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!price.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入售价', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Taro.showToast({ title: '发布成功', icon: 'success' })
|
||||||
|
setTimeout(() => { Taro.navigateBack() }, 1500)
|
||||||
|
}, [title, desc, price])
|
||||||
|
|
||||||
|
const handleUploadRead = useCallback((e: any) => {
|
||||||
|
const file = e.detail?.file || e.detail
|
||||||
|
if (file) {
|
||||||
|
setImages((prev) => [...prev, { url: file.path || file.url, name: file.name || 'image' }])
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleUploadDelete = useCallback((e: any) => {
|
||||||
|
const idx = e.detail?.index ?? e.detail
|
||||||
|
setImages((prev) => prev.filter((_, i) => i !== idx))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='publish-page'>
|
||||||
|
{/* 导航栏 */}
|
||||||
|
<View className='publish-nav' style={{ paddingTop: `${statusBarH}px` }}>
|
||||||
|
<View className='nav-content'>
|
||||||
|
<View className='nav-back' onClick={handleBack}>
|
||||||
|
<Icon name='arrow-left' size={22} color='#323233' />
|
||||||
|
</View>
|
||||||
|
<Text className='nav-title'>发布二手</Text>
|
||||||
|
<View className='nav-placeholder' />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={{ height: `${statusBarH + 44}px` }} />
|
||||||
|
|
||||||
|
<ScrollView className='publish-scroll' scrollY enhanced showScrollbar={false}>
|
||||||
|
{/* 商品信息 */}
|
||||||
|
<View className='form-card'>
|
||||||
|
<Text className='card-title'>商品信息</Text>
|
||||||
|
<Field
|
||||||
|
value={title}
|
||||||
|
placeholder='简要描述商品,如:出手99新 iPad Air 5'
|
||||||
|
border={false}
|
||||||
|
required
|
||||||
|
onInput={(e) => setTitle(String(e.detail.value))}
|
||||||
|
/>
|
||||||
|
<View style={{ height: '1px', background: '#f2f3f5', margin: '0 24px' }} />
|
||||||
|
<Field
|
||||||
|
value={desc}
|
||||||
|
placeholder='详细描述商品状况、购买时间、配件等'
|
||||||
|
type='textarea'
|
||||||
|
autosize
|
||||||
|
border={false}
|
||||||
|
maxlength={1000}
|
||||||
|
showWordLimit
|
||||||
|
onInput={(e) => setDesc(String(e.detail.value))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 商品图片 */}
|
||||||
|
<View className='form-card'>
|
||||||
|
<Text className='card-title'>商品图片</Text>
|
||||||
|
<View className='upload-section'>
|
||||||
|
<Text className='upload-label'>支持 jpg/png,最多 6 张,第一张为封面</Text>
|
||||||
|
<Uploader
|
||||||
|
fileList={images}
|
||||||
|
accept='image'
|
||||||
|
maxCount={6}
|
||||||
|
multiple
|
||||||
|
deletable
|
||||||
|
previewFullImage
|
||||||
|
uploadText='上传图片'
|
||||||
|
onAfterRead={handleUploadRead}
|
||||||
|
onDelete={handleUploadDelete}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 交易信息 */}
|
||||||
|
<View className='form-card'>
|
||||||
|
<Text className='card-title'>交易信息</Text>
|
||||||
|
<Field
|
||||||
|
value={price}
|
||||||
|
placeholder='如:3200'
|
||||||
|
label='售价 (元)'
|
||||||
|
type='digit'
|
||||||
|
border={false}
|
||||||
|
required
|
||||||
|
onInput={(e) => setPrice(String(e.detail.value))}
|
||||||
|
/>
|
||||||
|
{price && (
|
||||||
|
<Text className='tag-hint'>售价金额:¥{Number(price).toFixed(2)}</Text>
|
||||||
|
)}
|
||||||
|
<View style={{ height: '1px', background: '#f2f3f5', margin: '0 24px' }} />
|
||||||
|
<Field
|
||||||
|
value={location}
|
||||||
|
placeholder='如:东区宿舍'
|
||||||
|
label='交易地点'
|
||||||
|
border={false}
|
||||||
|
onInput={(e) => setLocation(String(e.detail.value))}
|
||||||
|
/>
|
||||||
|
<View style={{ height: '1px', background: '#f2f3f5', margin: '0 24px' }} />
|
||||||
|
<Field
|
||||||
|
value={tags}
|
||||||
|
placeholder='如:数码,99新,在保'
|
||||||
|
label='商品标签'
|
||||||
|
border={false}
|
||||||
|
onInput={(e) => setTags(String(e.detail.value))}
|
||||||
|
/>
|
||||||
|
{tags && (
|
||||||
|
<Text className='tag-hint'>使用逗号分隔,如:数码,99新</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='scroll-bottom-safe' />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 底部提交 */}
|
||||||
|
<View className='bottom-submit-bar' style={{ paddingBottom: `${safeBottom}px` }}>
|
||||||
|
<View className='submit-btn'>
|
||||||
|
<Button type='primary' round block onClick={handleSubmit}>
|
||||||
|
发布商品
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
navigationBarTitleText: '搜索',
|
||||||
|
})
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/* ========================================
|
||||||
|
搜索页
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.search-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 自定义导航栏 ========== */
|
||||||
|
.custom-navbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 999;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-back {
|
||||||
|
width: 60px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.back-icon {
|
||||||
|
font-size: 56px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-search {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-search-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 8px;
|
||||||
|
|
||||||
|
.search-btn-text {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #1989fa;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 滚动区域 ========== */
|
||||||
|
.search-scroll {
|
||||||
|
height: calc(100vh - 100px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== section 通用 ========== */
|
||||||
|
.section {
|
||||||
|
margin: 20px 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
.clear-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 32px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hot {
|
||||||
|
background: #fff7e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ff6034;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-text {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #646566;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 搜索结果 ========== */
|
||||||
|
.results-section {
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 0 16px;
|
||||||
|
|
||||||
|
.results-title {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #646566;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-keyword {
|
||||||
|
color: #1989fa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-count {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #969799;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-post-wrap {
|
||||||
|
/* ForumPostCard 自带 padding 和 shadow */
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 120px 0;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 80px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-safe {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
|
import { Search, Tag } from '@antmjs/vantui'
|
||||||
|
import { shops } from '@/mock/shops'
|
||||||
|
import { forumPosts } from '@/mock/forum'
|
||||||
|
import ShopCard from '@/components/ShopCard'
|
||||||
|
import ForumPostCard from '@/components/ForumPostCard'
|
||||||
|
import type { Article } from '@/types/article'
|
||||||
|
import type { Shop } from '@/types/shop'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
/** 状态栏 + 导航栏高度 */
|
||||||
|
function getNavBarHeight() {
|
||||||
|
try {
|
||||||
|
const sys = Taro.getSystemInfoSync()
|
||||||
|
return { statusBarHeight: sys.statusBarHeight || 20, navBarHeight: 44 }
|
||||||
|
} catch {
|
||||||
|
return { statusBarHeight: 20, navBarHeight: 44 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索结果项的联合类型 */
|
||||||
|
type SearchResult =
|
||||||
|
| { kind: 'shop'; data: Shop }
|
||||||
|
| { kind: 'post'; data: Article }
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [submittedKeyword, setSubmittedKeyword] = useState('')
|
||||||
|
const [history, setHistory] = useState<string[]>(['麻辣烫', '二手 iPad', '考研资料'])
|
||||||
|
const [navBarHeight, setNavBarHeight] = useState({ statusBarHeight: 20, navBarHeight: 44 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNavBarHeight(getNavBarHeight())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 热门搜索 */
|
||||||
|
const hotKeywords = ['麻辣烫', '奶茶', '二手 iPad', '考研资料', '黄焖鸡', '失物招领', '六级', '外卖红包']
|
||||||
|
|
||||||
|
/** 执行搜索(综合匹配商家和帖子) */
|
||||||
|
const results = useMemo<SearchResult[]>(() => {
|
||||||
|
if (!submittedKeyword.trim()) return []
|
||||||
|
const kw = submittedKeyword.toLowerCase().trim()
|
||||||
|
|
||||||
|
const matchedShops: Shop[] = shops.filter(
|
||||||
|
(s) =>
|
||||||
|
s.name.toLowerCase().includes(kw) ||
|
||||||
|
s.description.toLowerCase().includes(kw) ||
|
||||||
|
s.tags.some((t) => t.toLowerCase().includes(kw)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const matchedPosts: Article[] = forumPosts.filter(
|
||||||
|
(p) =>
|
||||||
|
p.title.toLowerCase().includes(kw) ||
|
||||||
|
p.excerpt.toLowerCase().includes(kw) ||
|
||||||
|
p.source.toLowerCase().includes(kw),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
...matchedShops.map((s) => ({ kind: 'shop' as const, data: s })),
|
||||||
|
...matchedPosts.map((p) => ({ kind: 'post' as const, data: p })),
|
||||||
|
]
|
||||||
|
}, [submittedKeyword])
|
||||||
|
|
||||||
|
/** 返回 */
|
||||||
|
const handleBack = () => {
|
||||||
|
Taro.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击搜索 */
|
||||||
|
const handleSearch = () => {
|
||||||
|
const kw = keyword.trim()
|
||||||
|
if (!kw) {
|
||||||
|
Taro.showToast({ title: '请输入搜索关键词', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmittedKeyword(kw)
|
||||||
|
// 保存到历史(去重,最多 8 条)
|
||||||
|
setHistory((prev) => {
|
||||||
|
const next = [kw, ...prev.filter((h) => h !== kw)]
|
||||||
|
return next.slice(0, 8)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击历史/热门 */
|
||||||
|
const handleQuickKeyword = (kw: string) => {
|
||||||
|
setKeyword(kw)
|
||||||
|
setSubmittedKeyword(kw)
|
||||||
|
setHistory((prev) => {
|
||||||
|
const next = [kw, ...prev.filter((h) => h !== kw)]
|
||||||
|
return next.slice(0, 8)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空历史 */
|
||||||
|
const handleClearHistory = () => {
|
||||||
|
setHistory([])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 商家点击 */
|
||||||
|
const handleShopClick = (shop: Shop) => {
|
||||||
|
Taro.navigateTo({ url: `/pages/shop-detail/index?id=${shop.id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 帖子点击 */
|
||||||
|
const handlePostClick = (post: Article) => {
|
||||||
|
Taro.navigateTo({ url: `/pages/article/index?id=${post.id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='search-page'>
|
||||||
|
{/* ========== 自定义导航栏(带返回 + 搜索框 + 搜索按钮) ========== */}
|
||||||
|
<View
|
||||||
|
className='custom-navbar'
|
||||||
|
style={{ paddingTop: `${navBarHeight.statusBarHeight}px` }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='navbar-content'
|
||||||
|
style={{ height: `${navBarHeight.navBarHeight}px` }}
|
||||||
|
>
|
||||||
|
<View className='navbar-back' onClick={handleBack}>
|
||||||
|
<Text className='back-icon'>‹</Text>
|
||||||
|
</View>
|
||||||
|
<View className='navbar-search'>
|
||||||
|
<Search
|
||||||
|
value={keyword}
|
||||||
|
placeholder='搜索商家、菜品、帖子'
|
||||||
|
shape='round'
|
||||||
|
background='#f2f3f5'
|
||||||
|
onChange={(e) => setKeyword(e.detail.value)}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className='navbar-search-btn' onClick={handleSearch}>
|
||||||
|
<Text className='search-btn-text'>搜索</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 占位 */}
|
||||||
|
<View style={{ height: `${navBarHeight.statusBarHeight + navBarHeight.navBarHeight}px` }} />
|
||||||
|
|
||||||
|
<ScrollView className='search-scroll' scrollY enhanced showScrollbar={false}>
|
||||||
|
{/* ========== 搜索结果(已提交关键词时显示) ========== */}
|
||||||
|
{submittedKeyword ? (
|
||||||
|
<View className='results-section'>
|
||||||
|
<View className='results-header'>
|
||||||
|
<Text className='results-title'>
|
||||||
|
搜索 "<Text className='results-keyword'>{submittedKeyword}</Text>" 的结果
|
||||||
|
</Text>
|
||||||
|
<Text className='results-count'>共 {results.length} 条</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<View className='results-list'>
|
||||||
|
{results.map((r) =>
|
||||||
|
r.kind === 'shop' ? (
|
||||||
|
<ShopCard
|
||||||
|
key={`shop-${r.data.id}`}
|
||||||
|
shop={r.data}
|
||||||
|
onClick={handleShopClick}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View key={`post-${r.data.id}`} className='result-post-wrap'>
|
||||||
|
<ForumPostCard post={r.data} onClick={handlePostClick} />
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className='empty-tip'>
|
||||||
|
<Text className='empty-icon'>🔍</Text>
|
||||||
|
<Text className='empty-text'>未找到相关内容,换个词试试</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ========== 搜索历史 ========== */}
|
||||||
|
{history.length > 0 && (
|
||||||
|
<View className='section'>
|
||||||
|
<View className='section-header'>
|
||||||
|
<Text className='section-title'>搜索历史</Text>
|
||||||
|
<View className='clear-btn' onClick={handleClearHistory}>
|
||||||
|
<Text className='clear-icon'>🗑</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className='tag-list'>
|
||||||
|
{history.map((h) => (
|
||||||
|
<View
|
||||||
|
key={h}
|
||||||
|
className='tag-item'
|
||||||
|
onClick={() => handleQuickKeyword(h)}
|
||||||
|
>
|
||||||
|
<Text className='tag-text'>{h}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== 热门搜索 ========== */}
|
||||||
|
<View className='section'>
|
||||||
|
<View className='section-header'>
|
||||||
|
<Text className='section-title'>热门搜索</Text>
|
||||||
|
</View>
|
||||||
|
<View className='tag-list'>
|
||||||
|
{hotKeywords.map((kw, idx) => (
|
||||||
|
<View
|
||||||
|
key={kw}
|
||||||
|
className={`tag-item ${idx < 3 ? 'hot' : ''}`}
|
||||||
|
onClick={() => handleQuickKeyword(kw)}
|
||||||
|
>
|
||||||
|
{idx < 3 && <Text className='rank'>{idx + 1}</Text>}
|
||||||
|
<Text className='tag-text'>{kw}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<View className='bottom-safe' />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
navigationBarTitleText: '',
|
||||||
|
})
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
/* ========================================
|
||||||
|
店铺首页
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.shop-detail-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
padding-bottom: 120px; /* 留出 SubmitBar 空间 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 自定义导航栏 ========== */
|
||||||
|
.custom-navbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid #f2f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-back {
|
||||||
|
width: 60px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.back-icon {
|
||||||
|
font-size: 56px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-title-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.navbar-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-action {
|
||||||
|
width: 60px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
font-size: 36px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 店铺头 ========== */
|
||||||
|
.shop-header {
|
||||||
|
position: relative;
|
||||||
|
background: #fff;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bg {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.header-bg-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform: scale(1.2);
|
||||||
|
filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bg-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.2) 0%, rgba(255, 255, 255, 1) 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0 24px 20px;
|
||||||
|
margin-top: -50px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo-wrap {
|
||||||
|
width: 130px;
|
||||||
|
height: 130px;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 4px solid #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 20px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.header-name {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-rating-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #646566;
|
||||||
|
|
||||||
|
.rating-value {
|
||||||
|
color: #ff6034;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-sep {
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sales {
|
||||||
|
color: #646566;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-fee-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #969799;
|
||||||
|
|
||||||
|
.fee-sep {
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-announcement {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0 24px 20px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: #fff7e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
.announcement-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ff976a;
|
||||||
|
background: #fff;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #646566;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 点菜 Tab ========== */
|
||||||
|
.menu-tab {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-body {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 200px);
|
||||||
|
min-height: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-sidebar {
|
||||||
|
width: 200px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-list-scroll {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-category {
|
||||||
|
padding: 20px 20px 4px;
|
||||||
|
|
||||||
|
& + .menu-category {
|
||||||
|
border-top: 8px solid #f7f8fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0 16px;
|
||||||
|
|
||||||
|
.category-name {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-count {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
padding: 16px 0;
|
||||||
|
|
||||||
|
& + .menu-item {
|
||||||
|
border-top: 1px solid #f2f3f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-image-wrap {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-right: 16px;
|
||||||
|
|
||||||
|
.menu-item-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.menu-item-name {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-desc {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #969799;
|
||||||
|
margin-top: 6px;
|
||||||
|
line-height: 1.3;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-sales {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #969799;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.menu-item-price {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ee0a24;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-original {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #c8c9cc;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-btn {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #1989fa;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.minus {
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #1989fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.plus {
|
||||||
|
background: #1989fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-btn-text {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #1989fa;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.plus {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-qty {
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 评价 Tab ========== */
|
||||||
|
.reviews-tab {
|
||||||
|
background: #f7f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviews-scroll {
|
||||||
|
height: calc(100vh - 200px);
|
||||||
|
min-height: 600px;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviews-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px 32px;
|
||||||
|
margin: 16px 0;
|
||||||
|
|
||||||
|
.summary-rating {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding-right: 32px;
|
||||||
|
border-right: 1px solid #f2f3f5;
|
||||||
|
|
||||||
|
.summary-rating-value {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ff6034;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-rating-label {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #969799;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stats {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #969799;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviews-list {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-card {
|
||||||
|
padding: 24px;
|
||||||
|
|
||||||
|
& + .review-card {
|
||||||
|
border-top: 1px solid #f2f3f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-avatar {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-meta {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.review-name {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-rate-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-time {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-content {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-images {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-image-wrap {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.review-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-dishes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
|
||||||
|
.review-dish-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 商家 Tab ========== */
|
||||||
|
.merchant-tab {
|
||||||
|
background: #f7f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-scroll {
|
||||||
|
height: calc(100vh - 200px);
|
||||||
|
min-height: 600px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.merchant-section-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-photos {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-photo-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.merchant-photo {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.merchant-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-info-list {
|
||||||
|
.merchant-info-row {
|
||||||
|
display: flex;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f2f3f5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 140px;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-announcement {
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff7e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.announcement-text {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #646566;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 底部购物车条 ========== */
|
||||||
|
.submit-bar-wrap {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 100px;
|
||||||
|
background: #2c2c2c;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-icon-btn {
|
||||||
|
position: relative;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
background: #1989fa;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: -30px;
|
||||||
|
box-shadow: 0 4px 12px rgba(25, 137, 250, 0.4);
|
||||||
|
|
||||||
|
.cart-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #ee0a24;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 2px solid #2c2c2c;
|
||||||
|
|
||||||
|
.cart-badge-text {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-info {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 16px;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
.cart-price {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-fee {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #c8c9cc;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
height: 80px;
|
||||||
|
padding: 0 32px;
|
||||||
|
border-radius: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #1989fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background: #646566;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn-text {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 购物车 Popup ========== */
|
||||||
|
.cart-sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-sheet-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 32px 16px;
|
||||||
|
border-bottom: 1px solid #f2f3f5;
|
||||||
|
|
||||||
|
.cart-sheet-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-clear {
|
||||||
|
padding: 8px 0;
|
||||||
|
|
||||||
|
.cart-clear-text {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-sheet-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 600px;
|
||||||
|
padding: 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-bottom: 1px solid #f2f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-row-image-wrap {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-row-info {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0 20px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.cart-row-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-row-price {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #ee0a24;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-sheet-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-sheet-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 32px;
|
||||||
|
border-top: 1px solid #f2f3f5;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-sheet-total {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.cart-sheet-total-label {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-sheet-total-price {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ee0a24;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-sheet-total-fee {
|
||||||
|
display: block;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #969799;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-sheet-submit {
|
||||||
|
padding: 16px 40px;
|
||||||
|
border-radius: 32px;
|
||||||
|
background: #1989fa;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background: #c8c9cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-sheet-submit-text {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 通用 ========== */
|
||||||
|
.empty-tip {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-safe {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,643 @@
|
|||||||
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Sidebar,
|
||||||
|
SidebarItem,
|
||||||
|
Image as VantImage,
|
||||||
|
Tag,
|
||||||
|
Rate,
|
||||||
|
Stepper,
|
||||||
|
Popup,
|
||||||
|
} from '@antmjs/vantui'
|
||||||
|
import { getShopById } from '@/mock/shops'
|
||||||
|
import { getMenuCategories, getMenuItems } from '@/mock/menus'
|
||||||
|
import { getShopReviews } from '@/mock/reviews'
|
||||||
|
import type { Shop, MenuItem, MenuCategory, Review } from '@/types/shop'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
/** 状态栏 + 导航栏高度 */
|
||||||
|
function getNavBarHeight() {
|
||||||
|
const systemInfo = Taro.getSystemInfoSync()
|
||||||
|
const statusBarHeight = systemInfo.statusBarHeight || 20
|
||||||
|
const navBarHeight = 44
|
||||||
|
return { statusBarHeight, navBarHeight, totalHeight: statusBarHeight + navBarHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShopDetail() {
|
||||||
|
// === 路由参数 ===
|
||||||
|
const [shop, setShop] = useState<Shop | null>(null)
|
||||||
|
const [categories, setCategories] = useState<MenuCategory[]>([])
|
||||||
|
const [items, setItems] = useState<MenuItem[]>([])
|
||||||
|
const [reviews, setReviews] = useState<Review[]>([])
|
||||||
|
const [navBarHeight, setNavBarHeight] = useState({ statusBarHeight: 20, navBarHeight: 44, totalHeight: 64 })
|
||||||
|
|
||||||
|
// === UI 状态 ===
|
||||||
|
const [activeTab, setActiveTab] = useState(0)
|
||||||
|
const [activeCategoryIndex, setActiveCategoryIndex] = useState(0)
|
||||||
|
const [cart, setCart] = useState<Record<string, number>>({})
|
||||||
|
const [cartSheetVisible, setCartSheetVisible] = useState(false)
|
||||||
|
|
||||||
|
/** 加载数据 */
|
||||||
|
useEffect(() => {
|
||||||
|
const id = String(Taro.getCurrentInstance().router?.params?.id || '')
|
||||||
|
if (!id) {
|
||||||
|
Taro.showToast({ title: '参数错误', icon: 'none' })
|
||||||
|
Taro.navigateBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const found = getShopById(id)
|
||||||
|
if (!found) {
|
||||||
|
Taro.showToast({ title: '商家不存在', icon: 'none' })
|
||||||
|
Taro.navigateBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setShop(found)
|
||||||
|
setCategories(getMenuCategories(id))
|
||||||
|
setItems(getMenuItems(id))
|
||||||
|
setReviews(getShopReviews(id))
|
||||||
|
setNavBarHeight(getNavBarHeight())
|
||||||
|
// 动态设置导航栏标题
|
||||||
|
Taro.setNavigationBarTitle({ title: found.name })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// === 购物车派生数据 ===
|
||||||
|
const cartCount = useMemo(
|
||||||
|
() => Object.values(cart).reduce((sum, n) => sum + n, 0),
|
||||||
|
[cart],
|
||||||
|
)
|
||||||
|
|
||||||
|
const cartTotal = useMemo(() => {
|
||||||
|
return Object.entries(cart).reduce((sum, [itemId, qty]) => {
|
||||||
|
const item = items.find((i) => i.id === itemId)
|
||||||
|
return sum + (item ? item.price * qty : 0)
|
||||||
|
}, 0)
|
||||||
|
}, [cart, items])
|
||||||
|
|
||||||
|
const cartItems = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(cart)
|
||||||
|
.filter(([, qty]) => qty > 0)
|
||||||
|
.map(([itemId, qty]) => ({
|
||||||
|
item: items.find((i) => i.id === itemId)!,
|
||||||
|
qty,
|
||||||
|
}))
|
||||||
|
.filter((c) => c.item),
|
||||||
|
[cart, items],
|
||||||
|
)
|
||||||
|
|
||||||
|
// === 菜单按分类分组(保持原始顺序) ===
|
||||||
|
const groupedItems = useMemo(() => {
|
||||||
|
return categories.map((cat) => ({
|
||||||
|
category: cat,
|
||||||
|
list: items.filter((i) => i.categoryId === cat.id),
|
||||||
|
}))
|
||||||
|
}, [categories, items])
|
||||||
|
|
||||||
|
// === 加购 / 减购 ===
|
||||||
|
const handleAdd = (itemId: string) => {
|
||||||
|
setCart((prev) => ({ ...prev, [itemId]: (prev[itemId] || 0) + 1 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSub = (itemId: string) => {
|
||||||
|
setCart((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
if (!next[itemId] || next[itemId] <= 1) {
|
||||||
|
delete next[itemId]
|
||||||
|
} else {
|
||||||
|
next[itemId] -= 1
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearCart = () => {
|
||||||
|
setCart({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
Taro.showToast({ title: `已提交 ${cartCount} 件,¥${cartTotal.toFixed(1)}`, icon: 'success' })
|
||||||
|
setCart({})
|
||||||
|
setCartSheetVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCartIconClick = () => {
|
||||||
|
if (cartCount === 0) {
|
||||||
|
Taro.showToast({ title: '还没有选购商品', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCartSheetVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 渲染前等待 shop 加载 ===
|
||||||
|
if (!shop) {
|
||||||
|
return <View className='shop-detail-page' />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='shop-detail-page'>
|
||||||
|
{/* ========== 自定义导航栏 ========== */}
|
||||||
|
<View
|
||||||
|
className='custom-navbar'
|
||||||
|
style={{ paddingTop: `${navBarHeight.statusBarHeight}px` }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='navbar-content'
|
||||||
|
style={{ height: `${navBarHeight.navBarHeight}px` }}
|
||||||
|
>
|
||||||
|
<View className='navbar-back' onClick={() => Taro.navigateBack()}>
|
||||||
|
<Text className='back-icon'>‹</Text>
|
||||||
|
</View>
|
||||||
|
<View className='navbar-title-wrap'>
|
||||||
|
<Text className='navbar-title' numberOfLines={1}>
|
||||||
|
{shop.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='navbar-action'>
|
||||||
|
<Text className='action-icon'>⋯</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ height: `${navBarHeight.totalHeight}px` }} />
|
||||||
|
|
||||||
|
{/* ========== 店铺头(背景图 + logo + 信息) ========== */}
|
||||||
|
<View className='shop-header'>
|
||||||
|
<View className='header-bg'>
|
||||||
|
<VantImage
|
||||||
|
className='header-bg-image'
|
||||||
|
src={shop.image}
|
||||||
|
fit='cover'
|
||||||
|
width='100%'
|
||||||
|
height='100%'
|
||||||
|
/>
|
||||||
|
<View className='header-bg-mask' />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='header-content'>
|
||||||
|
<View className='header-logo-wrap'>
|
||||||
|
<VantImage
|
||||||
|
className='header-logo'
|
||||||
|
src={shop.logo}
|
||||||
|
fit='cover'
|
||||||
|
width='100%'
|
||||||
|
height='100%'
|
||||||
|
round
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='header-info'>
|
||||||
|
<View className='header-name-row'>
|
||||||
|
<Text className='header-name'>{shop.name}</Text>
|
||||||
|
{shop.tags.map((t) => (
|
||||||
|
<Tag key={t} type='danger' plain size='medium' className='header-tag'>
|
||||||
|
{t}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='header-rating-row'>
|
||||||
|
<Rate
|
||||||
|
value={shop.rating}
|
||||||
|
readonly
|
||||||
|
size={14}
|
||||||
|
color='#ff6034'
|
||||||
|
voidColor='#dcdee0'
|
||||||
|
/>
|
||||||
|
<Text className='rating-value'>{shop.rating.toFixed(1)}</Text>
|
||||||
|
<Text className='header-sep'>·</Text>
|
||||||
|
<Text className='sales'>月售 {shop.monthlySales}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='header-fee-row'>
|
||||||
|
<Text className='fee-item'>起送 ¥{shop.minOrder}</Text>
|
||||||
|
<Text className='fee-sep'>|</Text>
|
||||||
|
<Text className='fee-item'>配送费 ¥{shop.deliveryFee}</Text>
|
||||||
|
<Text className='fee-sep'>|</Text>
|
||||||
|
<Text className='fee-item'>{shop.deliveryTime}分钟</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 公告 */}
|
||||||
|
{shop.announcements && (
|
||||||
|
<View className='header-announcement'>
|
||||||
|
<Text className='announcement-label'>公告</Text>
|
||||||
|
<Text className='announcement-text' numberOfLines={2}>
|
||||||
|
{shop.announcements}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ========== Tabs: 点菜 / 评价 / 商家 ========== */}
|
||||||
|
<Tabs
|
||||||
|
active={activeTab}
|
||||||
|
onChange={(e) => setActiveTab(e.detail.index)}
|
||||||
|
sticky
|
||||||
|
offsetTop={navBarHeight.totalHeight}
|
||||||
|
color='#1989fa'
|
||||||
|
titleActiveColor='#1989fa'
|
||||||
|
titleInactiveColor='#646566'
|
||||||
|
animated
|
||||||
|
>
|
||||||
|
<Tab title='点菜' />
|
||||||
|
<Tab title={`评价(${reviews.length})`} />
|
||||||
|
<Tab title='商家' />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* ===== Tab 0: 点菜 ===== */}
|
||||||
|
{activeTab === 0 && (
|
||||||
|
<View className='menu-tab'>
|
||||||
|
<View className='menu-body'>
|
||||||
|
{/* 左侧 Sidebar */}
|
||||||
|
<Sidebar
|
||||||
|
activeKey={activeCategoryIndex}
|
||||||
|
onChange={(e) => setActiveCategoryIndex(e.detail ?? 0)}
|
||||||
|
className='menu-sidebar'
|
||||||
|
>
|
||||||
|
{groupedItems.map((g, idx) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={g.category.id}
|
||||||
|
title={`${g.category.name} (${g.list.length})`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Sidebar>
|
||||||
|
|
||||||
|
{/* 右侧菜单 */}
|
||||||
|
<ScrollView className='menu-list-scroll' scrollY enhanced showScrollbar={false}>
|
||||||
|
{groupedItems.map((g) => (
|
||||||
|
<View key={g.category.id} className='menu-category'>
|
||||||
|
<View className='category-header'>
|
||||||
|
<Text className='category-name'>{g.category.name}</Text>
|
||||||
|
<Text className='category-count'>共 {g.list.length} 道</Text>
|
||||||
|
</View>
|
||||||
|
{g.list.map((item) => {
|
||||||
|
const qty = cart[item.id] || 0
|
||||||
|
return (
|
||||||
|
<View key={item.id} className='menu-item'>
|
||||||
|
<View className='menu-item-image-wrap'>
|
||||||
|
<VantImage
|
||||||
|
className='menu-item-image'
|
||||||
|
src={item.image}
|
||||||
|
fit='cover'
|
||||||
|
width='100%'
|
||||||
|
height='100%'
|
||||||
|
radius={8}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className='menu-item-info'>
|
||||||
|
<View className='menu-item-name-row'>
|
||||||
|
<Text className='menu-item-name' numberOfLines={1}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
{item.tags?.map((t) => (
|
||||||
|
<Tag
|
||||||
|
key={t}
|
||||||
|
type='danger'
|
||||||
|
plain
|
||||||
|
size='medium'
|
||||||
|
className='menu-item-tag'
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text className='menu-item-desc' numberOfLines={1}>
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
<Text className='menu-item-sales'>月售 {item.sales}</Text>
|
||||||
|
<View className='menu-item-bottom'>
|
||||||
|
<View className='price-row'>
|
||||||
|
<Text className='menu-item-price'>
|
||||||
|
¥{item.price.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
{item.originalPrice && (
|
||||||
|
<Text className='menu-item-original'>
|
||||||
|
¥{item.originalPrice.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className='stepper-row'>
|
||||||
|
{qty > 0 && (
|
||||||
|
<View
|
||||||
|
className='stepper-btn minus'
|
||||||
|
onClick={() => handleSub(item.id)}
|
||||||
|
>
|
||||||
|
<Text className='stepper-btn-text'>−</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{qty > 0 && (
|
||||||
|
<Text className='stepper-qty'>{qty}</Text>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
className='stepper-btn plus'
|
||||||
|
onClick={() => handleAdd(item.id)}
|
||||||
|
>
|
||||||
|
<Text className='stepper-btn-text plus'>+</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<View className='bottom-safe' />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== Tab 1: 评价 ===== */}
|
||||||
|
{activeTab === 1 && (
|
||||||
|
<View className='reviews-tab'>
|
||||||
|
<ScrollView scrollY enhanced showScrollbar={false} className='reviews-scroll'>
|
||||||
|
<View className='reviews-summary'>
|
||||||
|
<View className='summary-rating'>
|
||||||
|
<Text className='summary-rating-value'>{shop.rating.toFixed(1)}</Text>
|
||||||
|
<Text className='summary-rating-label'>综合评分</Text>
|
||||||
|
</View>
|
||||||
|
<View className='summary-stats'>
|
||||||
|
<View className='stat-item'>
|
||||||
|
<Text className='stat-num'>{shop.monthlySales}</Text>
|
||||||
|
<Text className='stat-label'>月售</Text>
|
||||||
|
</View>
|
||||||
|
<View className='stat-item'>
|
||||||
|
<Text className='stat-num'>{shop.deliveryTime}分</Text>
|
||||||
|
<Text className='stat-label'>配送时长</Text>
|
||||||
|
</View>
|
||||||
|
<View className='stat-item'>
|
||||||
|
<Text className='stat-num'>{reviews.length}</Text>
|
||||||
|
<Text className='stat-label'>评价数</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='reviews-list'>
|
||||||
|
{reviews.length === 0 && (
|
||||||
|
<View className='empty-tip'>暂无评价</View>
|
||||||
|
)}
|
||||||
|
{reviews.map((r) => (
|
||||||
|
<View key={r.id} className='review-card'>
|
||||||
|
<View className='review-header'>
|
||||||
|
<VantImage
|
||||||
|
className='review-avatar'
|
||||||
|
src={r.author.avatar}
|
||||||
|
fit='cover'
|
||||||
|
width='100%'
|
||||||
|
height='100%'
|
||||||
|
round
|
||||||
|
/>
|
||||||
|
<View className='review-meta'>
|
||||||
|
<Text className='review-name'>{r.author.name}</Text>
|
||||||
|
<View className='review-rate-row'>
|
||||||
|
<Rate
|
||||||
|
value={r.rating}
|
||||||
|
readonly
|
||||||
|
size={12}
|
||||||
|
color='#ff6034'
|
||||||
|
voidColor='#dcdee0'
|
||||||
|
/>
|
||||||
|
<Text className='review-time'>{r.time}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text className='review-content'>{r.content}</Text>
|
||||||
|
{r.images && r.images.length > 0 && (
|
||||||
|
<View className='review-images'>
|
||||||
|
{r.images.map((img, idx) => (
|
||||||
|
<View key={idx} className='review-image-wrap'>
|
||||||
|
<VantImage
|
||||||
|
className='review-image'
|
||||||
|
src={img}
|
||||||
|
fit='cover'
|
||||||
|
width='100%'
|
||||||
|
height='100%'
|
||||||
|
radius={6}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{r.dishes && r.dishes.length > 0 && (
|
||||||
|
<View className='review-dishes'>
|
||||||
|
{r.dishes.map((d) => (
|
||||||
|
<Tag
|
||||||
|
key={d}
|
||||||
|
type='primary'
|
||||||
|
plain
|
||||||
|
size='medium'
|
||||||
|
className='review-dish-tag'
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View className='bottom-safe' />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== Tab 2: 商家 ===== */}
|
||||||
|
{activeTab === 2 && (
|
||||||
|
<View className='merchant-tab'>
|
||||||
|
<ScrollView scrollY enhanced showScrollbar={false} className='merchant-scroll'>
|
||||||
|
{/* 商家相册 */}
|
||||||
|
{shop.photos.length > 0 && (
|
||||||
|
<View className='merchant-section'>
|
||||||
|
<Text className='merchant-section-title'>商家相册</Text>
|
||||||
|
<View className='merchant-photos'>
|
||||||
|
{shop.photos.map((p, idx) => (
|
||||||
|
<View key={idx} className='merchant-photo-wrap'>
|
||||||
|
<VantImage
|
||||||
|
className='merchant-photo'
|
||||||
|
src={p}
|
||||||
|
fit='cover'
|
||||||
|
width='100%'
|
||||||
|
height='100%'
|
||||||
|
radius={8}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 商家标签 */}
|
||||||
|
{shop.tags.length > 0 && (
|
||||||
|
<View className='merchant-section'>
|
||||||
|
<Text className='merchant-section-title'>商家标签</Text>
|
||||||
|
<View className='merchant-tags'>
|
||||||
|
{shop.tags.map((t) => (
|
||||||
|
<Tag key={t} type='primary' plain size='medium' className='merchant-tag'>
|
||||||
|
{t}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 商家信息 */}
|
||||||
|
<View className='merchant-section'>
|
||||||
|
<Text className='merchant-section-title'>商家信息</Text>
|
||||||
|
<View className='merchant-info-list'>
|
||||||
|
<View className='merchant-info-row'>
|
||||||
|
<Text className='info-label'>地址</Text>
|
||||||
|
<Text className='info-value'>{shop.address}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='merchant-info-row'>
|
||||||
|
<Text className='info-label'>电话</Text>
|
||||||
|
<Text className='info-value'>{shop.phone}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='merchant-info-row'>
|
||||||
|
<Text className='info-label'>营业时间</Text>
|
||||||
|
<Text className='info-value'>{shop.businessHours}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='merchant-info-row'>
|
||||||
|
<Text className='info-label'>配送时间</Text>
|
||||||
|
<Text className='info-value'>{shop.deliveryHours}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 服务设施 */}
|
||||||
|
{shop.services.length > 0 && (
|
||||||
|
<View className='merchant-section'>
|
||||||
|
<Text className='merchant-section-title'>服务设施</Text>
|
||||||
|
<View className='merchant-tags'>
|
||||||
|
{shop.services.map((s) => (
|
||||||
|
<Tag key={s} type='success' plain size='medium' className='merchant-tag'>
|
||||||
|
{s}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 公告 */}
|
||||||
|
{shop.announcements && (
|
||||||
|
<View className='merchant-section'>
|
||||||
|
<Text className='merchant-section-title'>商家公告</Text>
|
||||||
|
<View className='merchant-announcement'>
|
||||||
|
<Text className='announcement-text'>{shop.announcements}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className='bottom-safe' />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== 底部购物车 SubmitBar(仅点菜 Tab 显示)========== */}
|
||||||
|
{activeTab === 0 && (
|
||||||
|
<View className='submit-bar-wrap'>
|
||||||
|
<View className='cart-icon-btn' onClick={handleCartIconClick}>
|
||||||
|
<Text className='cart-icon'>🛒</Text>
|
||||||
|
{cartCount > 0 && (
|
||||||
|
<View className='cart-badge'>
|
||||||
|
<Text className='cart-badge-text'>{cartCount}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className='cart-info'>
|
||||||
|
<Text className='cart-price'>
|
||||||
|
¥{cartTotal.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
<Text className='cart-fee'>
|
||||||
|
另需配送费 ¥{shop.deliveryFee}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`submit-btn ${cartCount > 0 && cartTotal >= shop.minOrder ? 'active' : 'disabled'}`}
|
||||||
|
onClick={cartCount > 0 ? handleSubmit : undefined}
|
||||||
|
>
|
||||||
|
<Text className='submit-btn-text'>
|
||||||
|
{cartCount === 0
|
||||||
|
? `¥${shop.minOrder} 起送`
|
||||||
|
: cartTotal < shop.minOrder
|
||||||
|
? `还差 ¥${(shop.minOrder - cartTotal).toFixed(1)}`
|
||||||
|
: '去结算'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== 购物车 Popup ========== */}
|
||||||
|
<Popup
|
||||||
|
position='bottom'
|
||||||
|
round
|
||||||
|
show={cartSheetVisible}
|
||||||
|
onClose={() => setCartSheetVisible(false)}
|
||||||
|
closeOnClickOverlay
|
||||||
|
safeAreaInsetBottom
|
||||||
|
>
|
||||||
|
<View className='cart-sheet'>
|
||||||
|
<View className='cart-sheet-header'>
|
||||||
|
<Text className='cart-sheet-title'>已选商品</Text>
|
||||||
|
<View className='cart-clear' onClick={handleClearCart}>
|
||||||
|
<Text className='cart-clear-text'>🗑 清空</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView scrollY enhanced showScrollbar={false} className='cart-sheet-list'>
|
||||||
|
{cartItems.map(({ item, qty }) => (
|
||||||
|
<View key={item.id} className='cart-row'>
|
||||||
|
<View className='cart-row-image-wrap'>
|
||||||
|
<VantImage
|
||||||
|
className='cart-row-image'
|
||||||
|
src={item.image}
|
||||||
|
fit='cover'
|
||||||
|
width='100%'
|
||||||
|
height='100%'
|
||||||
|
radius={6}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className='cart-row-info'>
|
||||||
|
<Text className='cart-row-name' numberOfLines={1}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Text className='cart-row-price'>¥{item.price.toFixed(1)}</Text>
|
||||||
|
</View>
|
||||||
|
<Stepper
|
||||||
|
value={qty}
|
||||||
|
min={0}
|
||||||
|
theme='round'
|
||||||
|
onPlus={() => handleAdd(item.id)}
|
||||||
|
onMinus={() => handleSub(item.id)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{cartItems.length === 0 && (
|
||||||
|
<View className='cart-sheet-empty'>还没有选购商品</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View className='cart-sheet-footer'>
|
||||||
|
<View className='cart-sheet-total'>
|
||||||
|
<Text className='cart-sheet-total-label'>合计</Text>
|
||||||
|
<Text className='cart-sheet-total-price'>¥{cartTotal.toFixed(1)}</Text>
|
||||||
|
<Text className='cart-sheet-total-fee'>
|
||||||
|
含配送费 ¥{shop.deliveryFee}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`cart-sheet-submit ${cartCount > 0 ? 'active' : 'disabled'}`}
|
||||||
|
onClick={cartCount > 0 ? handleSubmit : undefined}
|
||||||
|
>
|
||||||
|
<Text className='cart-sheet-submit-text'>提交订单</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Popup>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '系统消息',
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
})
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/* ========================================
|
||||||
|
系统消息页
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.system-msg-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 导航栏 ========== */
|
||||||
|
.sys-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.nav-content {
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.nav-back {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder {
|
||||||
|
width: 50px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 滚动区域 ========== */
|
||||||
|
.sys-scroll {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 消息列表 ========== */
|
||||||
|
.sys-list {
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 消息卡片 ========== */
|
||||||
|
.sys-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
.sys-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.sys-card-icon {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #1989fa 0%, #07c160 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-card-title {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-card-content {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #646566;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-card-time {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useMemo, useCallback } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
|
import { Icon } from '@antmjs/vantui'
|
||||||
|
import { chatList } from '@/mock/messages'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
function getStatusBarH(): number {
|
||||||
|
try { return Taro.getSystemInfoSync().statusBarHeight || 20 }
|
||||||
|
catch { return 20 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SystemMessages() {
|
||||||
|
const statusBarH = useMemo(getStatusBarH, [])
|
||||||
|
|
||||||
|
const systemMsgs = useMemo(
|
||||||
|
() => chatList.filter((m) => m.type === 'system'),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
Taro.navigateBack()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='system-msg-page'>
|
||||||
|
{/* ===== 导航栏 ===== */}
|
||||||
|
<View className='sys-nav' style={{ paddingTop: `${statusBarH}px` }}>
|
||||||
|
<View className='nav-content'>
|
||||||
|
<View className='nav-back' onClick={handleBack}>
|
||||||
|
<Icon name='arrow-left' size={22} color='#323233' />
|
||||||
|
</View>
|
||||||
|
<Text className='nav-title'>系统消息</Text>
|
||||||
|
<View className='nav-placeholder' />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={{ height: `${statusBarH + 44}px` }} />
|
||||||
|
|
||||||
|
{/* ===== 消息列表 ===== */}
|
||||||
|
<ScrollView className='sys-scroll' scrollY enhanced showScrollbar={false}>
|
||||||
|
<View className='sys-list'>
|
||||||
|
{systemMsgs.length > 0 ? (
|
||||||
|
systemMsgs.map((msg) => (
|
||||||
|
<View key={msg.id} className='sys-card'>
|
||||||
|
<View className='sys-card-header'>
|
||||||
|
<View className='sys-card-icon'>
|
||||||
|
<Text>{msg.title.slice(0, 2)}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='sys-card-title'>{msg.title}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='sys-card-content'>{msg.content}</Text>
|
||||||
|
<Text className='sys-card-time'>{msg.time}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View style={{ textAlign: 'center', padding: '80px 0' }}>
|
||||||
|
<Text style={{ fontSize: '28px', color: '#c8c9cc' }}>暂无系统消息</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '任务详情',
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
})
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
/* ========================================
|
||||||
|
任务详情页
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.task-detail-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 导航栏 ========== */
|
||||||
|
.detail-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.nav-content {
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.nav-back {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder {
|
||||||
|
width: 50px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 滚动区域 ========== */
|
||||||
|
.detail-scroll {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 已取消提示条 ========== */
|
||||||
|
.cancelled-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 16px 24px 0;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: #fff0f0;
|
||||||
|
border: 1px solid #ffcccc;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
.cancelled-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelled-text {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #ee0a24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 内容卡片通用 ========== */
|
||||||
|
.content-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin: 16px 24px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Steps 进度条区域 ========== */
|
||||||
|
.steps-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 28px 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 类型徽章 ========== */
|
||||||
|
.type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-express { background: #1989fa; }
|
||||||
|
.badge-secondhand { background: #ff976a; }
|
||||||
|
.badge-help { background: #ff8c00; }
|
||||||
|
.badge-activity { background: #6465e0; }
|
||||||
|
.badge-carpool { background: #07c160; }
|
||||||
|
|
||||||
|
/* ========== 状态徽章 ========== */
|
||||||
|
.status-badge {
|
||||||
|
font-size: 24px;
|
||||||
|
padding: 2px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-open { color: #07c160; background: #e8f8ef; }
|
||||||
|
.status-progress { color: #1989fa; background: #e8f3ff; }
|
||||||
|
.status-done { color: #969799; background: #f2f3f5; }
|
||||||
|
.status-cancelled { color: #ee0a24; background: #fff0f0; }
|
||||||
|
|
||||||
|
/* ========== 类型/状态行 ========== */
|
||||||
|
.type-status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.participants-count {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #646566;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 标题和描述 ========== */
|
||||||
|
.detail-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-desc {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #646566;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 分割线 ========== */
|
||||||
|
.detail-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #f2f3f5;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 信息行 ========== */
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #646566;
|
||||||
|
line-height: 1.6;
|
||||||
|
|
||||||
|
& + .info-row {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 报酬/价格 ========== */
|
||||||
|
.reward-large {
|
||||||
|
display: block;
|
||||||
|
font-size: 44px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ee0a24;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 标签行 ========== */
|
||||||
|
.tag-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 二手商品图片 ========== */
|
||||||
|
.image-gallery {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.gallery-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 420px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 单张图片(不需要 swiper) */
|
||||||
|
.single-image-wrap {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 拼车:报酬+余座行 ========== */
|
||||||
|
.reward-seat-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
|
||||||
|
.seats-info {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #ff8c00;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 16px;
|
||||||
|
background: #fff7e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 活动参与进度条 ========== */
|
||||||
|
.progress-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #646566;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-percent {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #1989fa;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
height: 12px;
|
||||||
|
background: #f2f3f5;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #1989fa, #06b7f0);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 内联操作按钮 ========== */
|
||||||
|
.action-section {
|
||||||
|
margin: 0 24px 16px;
|
||||||
|
|
||||||
|
.action-contact-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 30px;
|
||||||
|
color: #1989fa;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 发布者卡片 ========== */
|
||||||
|
.publisher-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.pub-avatar {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pub-info {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 20px;
|
||||||
|
|
||||||
|
.pub-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pub-time {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pub-contact-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 推荐任务区域 ========== */
|
||||||
|
.recommend-section {
|
||||||
|
padding: 0 24px 16px;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 6px solid #1989fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommend-card-wrap {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-recommend {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 底部操作栏 ========== */
|
||||||
|
.bottom-action-bar {
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #f2f3f5;
|
||||||
|
|
||||||
|
.cta-button {
|
||||||
|
:global(.van-button) {
|
||||||
|
height: 88px;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 底部安全区占位 ========== */
|
||||||
|
.scroll-bottom-safe {
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 空状态 ========== */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
font-size: 30px;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
import { useMemo, useCallback } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||||
|
import {
|
||||||
|
Steps,
|
||||||
|
Tag,
|
||||||
|
Icon,
|
||||||
|
Button,
|
||||||
|
Image as VantImage,
|
||||||
|
Swiper,
|
||||||
|
SwiperItem,
|
||||||
|
} from '@antmjs/vantui'
|
||||||
|
import type { Step } from '@antmjs/vantui/types/steps'
|
||||||
|
import type { Task, TaskType, TaskStatus } from '@/types/task'
|
||||||
|
import { tasks } from '@/mock/tasks'
|
||||||
|
import TaskCard from '@/components/TaskCard'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
常量映射
|
||||||
|
============================== */
|
||||||
|
|
||||||
|
/** 任务类型 → 中文名称 */
|
||||||
|
const TYPE_BADGE_LABEL: Record<TaskType, string> = {
|
||||||
|
express: '快递代取',
|
||||||
|
secondhand: '二手交易',
|
||||||
|
help: '求助',
|
||||||
|
activity: '校园活动',
|
||||||
|
carpool: '拼车出行',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务类型 → 徽章CSS类名 */
|
||||||
|
const TYPE_BADGE_CLASS: Record<TaskType, string> = {
|
||||||
|
express: 'badge-express',
|
||||||
|
secondhand: 'badge-secondhand',
|
||||||
|
help: 'badge-help',
|
||||||
|
activity: 'badge-activity',
|
||||||
|
carpool: 'badge-carpool',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务状态 → 中文 */
|
||||||
|
const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||||
|
open: '待接单',
|
||||||
|
progress: '进行中',
|
||||||
|
done: '已完成',
|
||||||
|
cancelled: '已取消',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 二手交易专用状态文案(与快递/求助不同) */
|
||||||
|
const SECONDHAND_STATUS_LABEL: Record<TaskStatus, string> = {
|
||||||
|
open: '待售',
|
||||||
|
progress: '已预订',
|
||||||
|
done: '已售出',
|
||||||
|
cancelled: '已取消',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 各类型 Steps 步骤配置 */
|
||||||
|
const STATUS_STEPS_MAP: Record<TaskType, Step[]> = {
|
||||||
|
express: [
|
||||||
|
{ text: '待接单', desc: '等待接单' },
|
||||||
|
{ text: '进行中', desc: '配送中' },
|
||||||
|
{ text: '已完成', desc: '任务完成' },
|
||||||
|
],
|
||||||
|
secondhand: [
|
||||||
|
{ text: '待售', desc: '商品待售' },
|
||||||
|
{ text: '已预订', desc: '已被预订' },
|
||||||
|
{ text: '已售出', desc: '交易完成' },
|
||||||
|
],
|
||||||
|
help: [
|
||||||
|
{ text: '待接单', desc: '等待响应' },
|
||||||
|
{ text: '进行中', desc: '帮助进行中' },
|
||||||
|
{ text: '已完成', desc: '帮助完成' },
|
||||||
|
],
|
||||||
|
activity: [
|
||||||
|
{ text: '待开始', desc: '活动待开始' },
|
||||||
|
{ text: '进行中', desc: '活动进行中' },
|
||||||
|
{ text: '已结束', desc: '活动已结束' },
|
||||||
|
],
|
||||||
|
carpool: [
|
||||||
|
{ text: '待出发', desc: '等待乘客' },
|
||||||
|
{ text: '进行中', desc: '行程途中' },
|
||||||
|
{ text: '已完成', desc: '行程结束' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态 → Steps active 索引 */
|
||||||
|
const STATUS_TO_STEP_INDEX: Record<TaskStatus, number> = {
|
||||||
|
open: 0,
|
||||||
|
progress: 1,
|
||||||
|
done: 2,
|
||||||
|
cancelled: -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
辅助函数
|
||||||
|
============================== */
|
||||||
|
|
||||||
|
function getStatusBarH(): number {
|
||||||
|
try {
|
||||||
|
return Taro.getSystemInfoSync().statusBarHeight || 20
|
||||||
|
} catch {
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafeBottom(): number {
|
||||||
|
try {
|
||||||
|
const info = Taro.getSystemInfoSync()
|
||||||
|
return (info.screenHeight - info.safeArea!.bottom) || 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前任务类型对应的状态文案 */
|
||||||
|
function getStatusLabel(task: Task): string {
|
||||||
|
if (task.type === 'secondhand') return SECONDHAND_STATUS_LABEL[task.status]
|
||||||
|
return STATUS_LABEL[task.status]
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
页面组件
|
||||||
|
============================== */
|
||||||
|
|
||||||
|
export default function TaskDetail() {
|
||||||
|
const id = String(Taro.getCurrentInstance().router?.params?.id || '')
|
||||||
|
|
||||||
|
/* ---- 数据 ---- */
|
||||||
|
const task = useMemo(() => tasks.find((t) => t.id === id), [id])
|
||||||
|
|
||||||
|
const statusBarH = useMemo(getStatusBarH, [])
|
||||||
|
const safeBottom = useMemo(getSafeBottom, [])
|
||||||
|
|
||||||
|
const stepsConfig = useMemo<Step[]>(() => {
|
||||||
|
if (!task) return []
|
||||||
|
return STATUS_STEPS_MAP[task.type] || []
|
||||||
|
}, [task])
|
||||||
|
|
||||||
|
const activeStepIndex = useMemo(() => {
|
||||||
|
if (!task) return 0
|
||||||
|
return STATUS_TO_STEP_INDEX[task.status] ?? 0
|
||||||
|
}, [task])
|
||||||
|
|
||||||
|
/** 推荐任务:同类型优先,不足时补充其他类型(排除当前任务),最多 3 条 */
|
||||||
|
const recommendedTasks = useMemo(() => {
|
||||||
|
if (!task) return []
|
||||||
|
const sameType = tasks.filter(
|
||||||
|
(t) => t.type === task.type && t.id !== task.id,
|
||||||
|
)
|
||||||
|
if (sameType.length >= 3) return sameType.slice(0, 3)
|
||||||
|
const others = tasks.filter(
|
||||||
|
(t) => t.id !== task.id && t.type !== task.type,
|
||||||
|
)
|
||||||
|
return [...sameType, ...others].slice(0, 3)
|
||||||
|
}, [task])
|
||||||
|
|
||||||
|
/* ---- 事件 ---- */
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
Taro.navigateBack()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleNavigateToDetail = useCallback((targetId: string) => {
|
||||||
|
Taro.navigateTo({ url: `/pages/task-detail/index?id=${targetId}` })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePrimaryAction = useCallback(() => {
|
||||||
|
Taro.showToast({ title: '功能开发中', icon: 'none' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleContact = useCallback(() => {
|
||||||
|
Taro.showToast({ title: '联系功能开发中', icon: 'none' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/* ---- 空状态 ---- */
|
||||||
|
if (!task) {
|
||||||
|
return (
|
||||||
|
<View className='task-detail-page'>
|
||||||
|
<View className='empty-state'>任务不存在</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 底部主按钮文案 ---- */
|
||||||
|
const getPrimaryBtnText = (): string => {
|
||||||
|
if (task.status === 'done') return '已完成'
|
||||||
|
if (task.status === 'cancelled') return '已取消'
|
||||||
|
if (task.status === 'open') {
|
||||||
|
return task.type === 'activity' ? '报名参加' : '接单'
|
||||||
|
}
|
||||||
|
// progress
|
||||||
|
return '标记完成'
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryBtnDisabled =
|
||||||
|
task.status === 'done' || task.status === 'cancelled'
|
||||||
|
|
||||||
|
/* ---- 类型信息渲染函数 ---- */
|
||||||
|
|
||||||
|
const renderExpressInfo = () => (
|
||||||
|
<View className='content-card'>
|
||||||
|
<View className='type-status-row'>
|
||||||
|
<Text className={`type-badge ${TYPE_BADGE_CLASS[task.type]}`}>
|
||||||
|
{TYPE_BADGE_LABEL[task.type]}
|
||||||
|
</Text>
|
||||||
|
<Text className={`status-badge status-${task.status}`}>
|
||||||
|
{getStatusLabel(task)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='detail-title'>{task.title}</Text>
|
||||||
|
<Text className='detail-desc'>{task.desc}</Text>
|
||||||
|
<Text className='reward-large'>{task.reward}</Text>
|
||||||
|
<View className='detail-divider' />
|
||||||
|
<View className='info-row'>
|
||||||
|
<Text className='info-icon'>📍</Text>
|
||||||
|
<Text>取件地点:{task.location}</Text>
|
||||||
|
</View>
|
||||||
|
{task.deadline && (
|
||||||
|
<View className='info-row'>
|
||||||
|
<Text className='info-icon'>⏰</Text>
|
||||||
|
<Text>截止时间:{task.deadline}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className='info-row'>
|
||||||
|
<Text className='info-icon'>🕐</Text>
|
||||||
|
<Text>发布时间:{task.time}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderSecondhandInfo = () => (
|
||||||
|
<View className='content-card'>
|
||||||
|
{/* 商品图片 */}
|
||||||
|
{task.images && task.images.length > 0 && (
|
||||||
|
<>
|
||||||
|
{task.images.length === 1 ? (
|
||||||
|
<View className='single-image-wrap'>
|
||||||
|
<VantImage
|
||||||
|
src={task.images[0]}
|
||||||
|
fit='cover'
|
||||||
|
width='100%'
|
||||||
|
height={420}
|
||||||
|
radius={12}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Swiper
|
||||||
|
className='image-gallery'
|
||||||
|
autoPlay={0}
|
||||||
|
loop
|
||||||
|
paginationVisible
|
||||||
|
paginationColor='#1989fa'
|
||||||
|
height={420}
|
||||||
|
>
|
||||||
|
{task.images.map((img, idx) => (
|
||||||
|
<SwiperItem key={idx}>
|
||||||
|
<VantImage
|
||||||
|
src={img}
|
||||||
|
fit='cover'
|
||||||
|
width='100%'
|
||||||
|
height={420}
|
||||||
|
/>
|
||||||
|
</SwiperItem>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className='type-status-row'>
|
||||||
|
<Text className={`type-badge ${TYPE_BADGE_CLASS[task.type]}`}>
|
||||||
|
{TYPE_BADGE_LABEL[task.type]}
|
||||||
|
</Text>
|
||||||
|
<Text className={`status-badge status-${task.status}`}>
|
||||||
|
{getStatusLabel(task)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='detail-title'>{task.title}</Text>
|
||||||
|
<Text className='detail-desc'>{task.desc}</Text>
|
||||||
|
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<View className='tag-row'>
|
||||||
|
{task.tags.map((t) => (
|
||||||
|
<Tag key={t} type='primary' plain>
|
||||||
|
{t}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text className='reward-large'>{task.reward}</Text>
|
||||||
|
|
||||||
|
<View className='detail-divider' />
|
||||||
|
<View className='info-row'>
|
||||||
|
<Text className='info-icon'>📍</Text>
|
||||||
|
<Text>交易地点:{task.location}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='info-row'>
|
||||||
|
<Text className='info-icon'>🕐</Text>
|
||||||
|
<Text>发布时间:{task.time}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderHelpInfo = () => (
|
||||||
|
<View className='content-card'>
|
||||||
|
<View className='type-status-row'>
|
||||||
|
<Text className={`type-badge ${TYPE_BADGE_CLASS[task.type]}`}>
|
||||||
|
{TYPE_BADGE_LABEL[task.type]}
|
||||||
|
</Text>
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<View className='tag-row' style={{ marginTop: 0 }}>
|
||||||
|
{task.tags.map((t) => (
|
||||||
|
<Tag key={t} type='warning' size='medium'>
|
||||||
|
{t}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text className={`status-badge status-${task.status}`}>
|
||||||
|
{getStatusLabel(task)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='detail-title'>{task.title}</Text>
|
||||||
|
<Text className='detail-desc'>{task.desc}</Text>
|
||||||
|
<Text className='reward-large'>{task.reward}</Text>
|
||||||
|
<View className='detail-divider' />
|
||||||
|
<View className='info-row'>
|
||||||
|
<Text className='info-icon'>📍</Text>
|
||||||
|
<Text>帮助地点:{task.location}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='info-row'>
|
||||||
|
<Text className='info-icon'>🕐</Text>
|
||||||
|
<Text>发布时间:{task.time}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderActivityInfo = () => {
|
||||||
|
const progress = task.maxParticipants
|
||||||
|
? Math.round(
|
||||||
|
((task.participants || 0) / task.maxParticipants) * 100,
|
||||||
|
)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='content-card'>
|
||||||
|
{/* 活动图片(可选) */}
|
||||||
|
{task.images && task.images.length > 0 && (
|
||||||
|
<>
|
||||||
|
{task.images.length === 1 ? (
|
||||||
|
<View className='single-image-wrap'>
|
||||||
|
<VantImage
|
||||||
|
src={task.images[0]}
|
||||||
|
fit='cover'
|
||||||
|
width='100%'
|
||||||
|
height={360}
|
||||||
|
radius={12}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Swiper
|
||||||
|
className='image-gallery'
|
||||||
|
autoPlay={0}
|
||||||
|
loop
|
||||||
|
paginationVisible
|
||||||
|
paginationColor='#1989fa'
|
||||||
|
height={360}
|
||||||
|
>
|
||||||
|
{task.images.map((img, idx) => (
|
||||||
|
<SwiperItem key={idx}>
|
||||||
|
<VantImage
|
||||||
|
src={img}
|
||||||
|
fit='cover'
|
||||||
|
width='100%'
|
||||||
|
height={360}
|
||||||
|
/>
|
||||||
|
</SwiperItem>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className='type-status-row'>
|
||||||
|
<Text className={`type-badge ${TYPE_BADGE_CLASS[task.type]}`}>
|
||||||
|
{TYPE_BADGE_LABEL[task.type]}
|
||||||
|
</Text>
|
||||||
|
<Text className={`status-badge status-${task.status}`}>
|
||||||
|
{getStatusLabel(task)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='detail-title'>{task.title}</Text>
|
||||||
|
<Text className='detail-desc'>{task.desc}</Text>
|
||||||
|
|
||||||
|
{/* 参与人数 + 进度条 */}
|
||||||
|
<View className='progress-section'>
|
||||||
|
<View className='progress-label'>
|
||||||
|
<Text className='progress-text'>
|
||||||
|
👥 已报名 {task.participants || 0}/{task.maxParticipants || 0} 人
|
||||||
|
</Text>
|
||||||
|
<Text className='progress-percent'>{progress}%</Text>
|
||||||
|
</View>
|
||||||
|
<View className='progress-track'>
|
||||||
|
<View
|
||||||
|
className='progress-bar'
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<View className='tag-row'>
|
||||||
|
{task.tags.map((t) => (
|
||||||
|
<Tag key={t} type='primary' plain>
|
||||||
|
{t}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className='detail-divider' />
|
||||||
|
<View className='info-row'>
|
||||||
|
<Text className='info-icon'>📍</Text>
|
||||||
|
<Text>活动地点:{task.location}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='info-row'>
|
||||||
|
<Text className='info-icon'>🕐</Text>
|
||||||
|
<Text>发布时间:{task.time}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCarpoolInfo = () => {
|
||||||
|
const remainingSeats =
|
||||||
|
task.participants != null && task.maxParticipants != null
|
||||||
|
? task.maxParticipants - task.participants
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='content-card'>
|
||||||
|
<View className='type-status-row'>
|
||||||
|
<Text className={`type-badge ${TYPE_BADGE_CLASS[task.type]}`}>
|
||||||
|
{TYPE_BADGE_LABEL[task.type]}
|
||||||
|
</Text>
|
||||||
|
<Text className={`status-badge status-${task.status}`}>
|
||||||
|
{getStatusLabel(task)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='detail-title'>{task.title}</Text>
|
||||||
|
<Text className='detail-desc'>{task.desc}</Text>
|
||||||
|
|
||||||
|
<View className='reward-seat-row'>
|
||||||
|
<Text className='reward-large' style={{ marginTop: 0 }}>
|
||||||
|
{task.reward}
|
||||||
|
</Text>
|
||||||
|
{remainingSeats !== null && (
|
||||||
|
<Text className='seats-info'>
|
||||||
|
🚗 余{remainingSeats}座
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='detail-divider' />
|
||||||
|
<View className='info-row'>
|
||||||
|
<Text className='info-icon'>📍</Text>
|
||||||
|
<Text>出发地点:{task.location}</Text>
|
||||||
|
</View>
|
||||||
|
{task.deadline && (
|
||||||
|
<View className='info-row'>
|
||||||
|
<Text className='info-icon'>⏰</Text>
|
||||||
|
<Text>出发时间:{task.deadline}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className='info-row'>
|
||||||
|
<Text className='info-icon'>🕐</Text>
|
||||||
|
<Text>发布时间:{task.time}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<View className='tag-row'>
|
||||||
|
{task.tags.map((t) => (
|
||||||
|
<Tag key={t} type='primary' plain>
|
||||||
|
{t}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderTaskInfo = () => {
|
||||||
|
switch (task.type) {
|
||||||
|
case 'express':
|
||||||
|
return renderExpressInfo()
|
||||||
|
case 'secondhand':
|
||||||
|
return renderSecondhandInfo()
|
||||||
|
case 'help':
|
||||||
|
return renderHelpInfo()
|
||||||
|
case 'activity':
|
||||||
|
return renderActivityInfo()
|
||||||
|
case 'carpool':
|
||||||
|
return renderCarpoolInfo()
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 主渲染 ---- */
|
||||||
|
return (
|
||||||
|
<View className='task-detail-page'>
|
||||||
|
{/* ===== 自定义导航栏 ===== */}
|
||||||
|
<View
|
||||||
|
className='detail-nav'
|
||||||
|
style={{ paddingTop: `${statusBarH}px` }}
|
||||||
|
>
|
||||||
|
<View className='nav-content'>
|
||||||
|
<View className='nav-back' onClick={handleBack}>
|
||||||
|
<Icon name='arrow-left' size={22} color='#323233' />
|
||||||
|
</View>
|
||||||
|
<Text className='nav-title'>任务详情</Text>
|
||||||
|
<View className='nav-placeholder' />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 导航栏占位 */}
|
||||||
|
<View style={{ height: `${statusBarH + 44}px` }} />
|
||||||
|
|
||||||
|
{/* ===== 可滚动内容 ===== */}
|
||||||
|
<ScrollView
|
||||||
|
className='detail-scroll'
|
||||||
|
scrollY
|
||||||
|
enhanced
|
||||||
|
showScrollbar={false}
|
||||||
|
>
|
||||||
|
{/* 已取消提示条 */}
|
||||||
|
{task.status === 'cancelled' && (
|
||||||
|
<View className='cancelled-banner'>
|
||||||
|
<Text className='cancelled-icon'>⚠️</Text>
|
||||||
|
<Text className='cancelled-text'>
|
||||||
|
该任务已取消,不再接受新的操作
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Steps 进度条 */}
|
||||||
|
{stepsConfig.length > 0 && (
|
||||||
|
<View className='content-card steps-section'>
|
||||||
|
<Steps
|
||||||
|
steps={stepsConfig}
|
||||||
|
active={
|
||||||
|
activeStepIndex >= 0 ? activeStepIndex : -1
|
||||||
|
}
|
||||||
|
direction='horizontal'
|
||||||
|
activeColor='#1989fa'
|
||||||
|
activeIcon='checked'
|
||||||
|
inactiveIcon='checked'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 类型相关信息 */}
|
||||||
|
{renderTaskInfo()}
|
||||||
|
|
||||||
|
{/* 联系发布者按钮(非已取消状态) */}
|
||||||
|
{task.status !== 'cancelled' && (
|
||||||
|
<View className='action-section' onClick={handleContact}>
|
||||||
|
<View className='action-contact-btn'>
|
||||||
|
<Icon name='chat-o' size={20} color='#1989fa' />
|
||||||
|
<Text style={{ marginLeft: '8px' }}>联系发布者</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 发布者信息卡 */}
|
||||||
|
<View className='content-card publisher-card'>
|
||||||
|
<Image
|
||||||
|
className='pub-avatar'
|
||||||
|
src={task.publisher.avatar}
|
||||||
|
mode='aspectFill'
|
||||||
|
/>
|
||||||
|
<View className='pub-info'>
|
||||||
|
<Text className='pub-name'>{task.publisher.name}</Text>
|
||||||
|
<Text className='pub-time'>发布于 {task.time}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='pub-contact-btn'>
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
plain
|
||||||
|
type='primary'
|
||||||
|
round
|
||||||
|
onClick={handleContact}
|
||||||
|
>
|
||||||
|
联系
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 推荐任务区 */}
|
||||||
|
<View className='recommend-section'>
|
||||||
|
<Text className='section-title'>推荐任务</Text>
|
||||||
|
{recommendedTasks.length > 0 ? (
|
||||||
|
recommendedTasks.map((t) => (
|
||||||
|
<View
|
||||||
|
key={t.id}
|
||||||
|
className='recommend-card-wrap'
|
||||||
|
onClick={() => handleNavigateToDetail(t.id)}
|
||||||
|
>
|
||||||
|
<TaskCard task={t} />
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text className='empty-recommend'>暂无推荐任务</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 底部安全区占位 */}
|
||||||
|
<View className='scroll-bottom-safe' />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* ===== 底部操作栏 ===== */}
|
||||||
|
<View
|
||||||
|
className='bottom-action-bar'
|
||||||
|
style={{ paddingBottom: `${safeBottom}px` }}
|
||||||
|
>
|
||||||
|
<View className='cta-button'>
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
round
|
||||||
|
block
|
||||||
|
disabled={primaryBtnDisabled}
|
||||||
|
onClick={handlePrimaryAction}
|
||||||
|
>
|
||||||
|
{getPrimaryBtnText()}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationBarTitleText: '校园任务',
|
navigationBarTitleText: '校园任务',
|
||||||
navigationStyle: 'custom',
|
usingComponents: {},
|
||||||
"usingComponents": {}
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { View, ScrollView } from '@tarojs/components'
|
import { View, ScrollView } from '@tarojs/components'
|
||||||
import { Search, Tabs, Tab } from '@antmjs/vantui'
|
import { Search } from '@antmjs/vantui'
|
||||||
import TaskCard from '@/components/TaskCard'
|
import TaskCard from '@/components/TaskCard'
|
||||||
import CustomTabBar from '@/custom-tab-bar'
|
|
||||||
import { tasks } from '@/mock/tasks'
|
import { tasks } from '@/mock/tasks'
|
||||||
import type { TaskType } from '@/types/task'
|
|
||||||
import './index.less'
|
import './index.less'
|
||||||
|
|
||||||
/** 筛选标签 */
|
/** 筛选标签 */
|
||||||
@@ -48,7 +46,7 @@ export default function Tasks() {
|
|||||||
}, [activeFilter, searchValue])
|
}, [activeFilter, searchValue])
|
||||||
|
|
||||||
const handleTaskClick = (id: string) => {
|
const handleTaskClick = (id: string) => {
|
||||||
Taro.showToast({ title: '任务详情即将上线', icon: 'none' })
|
Taro.navigateTo({ url: `/pages/task-detail/index?id=${id}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,8 +98,6 @@ export default function Tasks() {
|
|||||||
</View>
|
</View>
|
||||||
<View className='bottom-safe' />
|
<View className='bottom-safe' />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<CustomTabBar activeKey='tasks' />
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { post } from '@/utils/request'
|
||||||
|
import type { ApiResponse } from '@/types/api'
|
||||||
|
import type { User } from '@/types/user'
|
||||||
|
|
||||||
|
/** 登录请求参数 */
|
||||||
|
export interface WxLoginParams {
|
||||||
|
/** 前端调用 Taro.login() 获取的临时凭证(用于换取 openid/session_key) */
|
||||||
|
code: string
|
||||||
|
/**
|
||||||
|
* 手机号动态令牌(新版微信 API)
|
||||||
|
* openType="getPhoneNumber" 回调 e.detail.code
|
||||||
|
* 后端用此 code 调用微信接口换取手机号
|
||||||
|
*/
|
||||||
|
phoneCode?: string
|
||||||
|
/**
|
||||||
|
* 手机号加密数据(旧版微信 API,与 iv 配套使用)
|
||||||
|
* openType="getPhoneNumber" 回调 e.detail.encryptedData
|
||||||
|
*/
|
||||||
|
encryptedData?: string
|
||||||
|
/**
|
||||||
|
* 加密算法初始向量(旧版微信 API,与 encryptedData 配套使用)
|
||||||
|
* openType="getPhoneNumber" 回调 e.detail.iv
|
||||||
|
*/
|
||||||
|
iv?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录响应 data */
|
||||||
|
export interface WxLoginData {
|
||||||
|
token: string
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小程序登录
|
||||||
|
* POST /api/wx/login(公开接口,不附带 token)
|
||||||
|
*/
|
||||||
|
export function wxLoginApi(params: WxLoginParams): Promise<ApiResponse<WxLoginData>> {
|
||||||
|
return post<WxLoginData>('/api/wx/login', params, { skipToken: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { get, put } from '@/utils/request'
|
||||||
|
import type { ApiResponse } from '@/types/api'
|
||||||
|
import type { User, UpdateProfileParams } from '@/types/user'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户信息
|
||||||
|
* GET /api/user(需 Bearer token 认证)
|
||||||
|
*
|
||||||
|
* 注意:返回的 mobile 和 email 已脱敏(如 138****5678)
|
||||||
|
*/
|
||||||
|
export function getUserInfoApi(): Promise<ApiResponse<User>> {
|
||||||
|
return get<User>('/api/user')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑用户资料
|
||||||
|
* PUT /api/user/profile(需 Bearer token 认证)
|
||||||
|
*
|
||||||
|
* 三个字段均可选,只更新传入的字段。
|
||||||
|
* 返回完整用户数据(含未脱敏的手机号和邮箱)
|
||||||
|
*/
|
||||||
|
export function updateUserProfileApi(
|
||||||
|
params: UpdateProfileParams,
|
||||||
|
): Promise<ApiResponse<User>> {
|
||||||
|
return put<User>('/api/user/profile', params)
|
||||||
|
}
|
||||||
@@ -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,17 @@
|
|||||||
|
import Taro from "@tarojs/taro";
|
||||||
|
|
||||||
|
/** API 统一返回结构 */
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean
|
||||||
|
code: number
|
||||||
|
msg?: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求配置(扩展 Taro 原生配置) */
|
||||||
|
export interface RequestConfig extends Taro.request.Option {
|
||||||
|
/** 是否跳过 token 附加(如登录接口) */
|
||||||
|
skipToken?: boolean
|
||||||
|
/** 是否跳过错误提示(业务自行处理) */
|
||||||
|
skipErrorToast?: boolean
|
||||||
|
}
|
||||||
+17
-1
@@ -2,7 +2,15 @@
|
|||||||
export type ArticleCardType = 'text' | 'image-text' | 'image-grid'
|
export type ArticleCardType = 'text' | 'image-text' | 'image-grid'
|
||||||
|
|
||||||
/** 文章分类 */
|
/** 文章分类 */
|
||||||
export type ArticleCategory = 'latest' | 'recommend' | 'hot'
|
export type ArticleCategory = 'latest' | 'recommend' | 'hot' | 'forum'
|
||||||
|
|
||||||
|
/** 论坛帖子子分类(仅当 category='forum' 时使用) */
|
||||||
|
export type ForumSubCategory =
|
||||||
|
| 'life' // 校园生活
|
||||||
|
| 'study' // 学习交流
|
||||||
|
| 'lost-found' // 失物招领
|
||||||
|
| 'secondhand' // 二手交易
|
||||||
|
| 'tree-hole' // 树洞
|
||||||
|
|
||||||
/** 文章数据接口 */
|
/** 文章数据接口 */
|
||||||
export interface Article {
|
export interface Article {
|
||||||
@@ -15,6 +23,14 @@ export interface Article {
|
|||||||
images: string[]
|
images: string[]
|
||||||
type: ArticleCardType
|
type: ArticleCardType
|
||||||
category: ArticleCategory
|
category: ArticleCategory
|
||||||
|
/** 仅论坛帖子使用 */
|
||||||
|
subCategory?: ForumSubCategory
|
||||||
|
/** 评论数(论坛帖子会用到) */
|
||||||
|
commentCount?: number
|
||||||
|
/** 作者头像 URL(仅论坛帖子使用) */
|
||||||
|
sourceAvatar?: string
|
||||||
|
/** 点赞数(仅论坛帖子使用) */
|
||||||
|
likeCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 导航项 */
|
/** 导航项 */
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/** 商品分类 */
|
||||||
|
export type ProductCategory = 'digital' | 'book' | 'life' | 'food' | 'cloth'
|
||||||
|
|
||||||
|
/** 商品数据 */
|
||||||
|
export interface Product {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
desc: string
|
||||||
|
price: number
|
||||||
|
originalPrice?: number // 原价(划线展示)
|
||||||
|
image: string
|
||||||
|
category: ProductCategory
|
||||||
|
merchant: {
|
||||||
|
name: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
sales: number // 已售数量
|
||||||
|
tags?: string[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/** 商家分类(校园外卖首页导航组) */
|
||||||
|
export type ShopCategory = 'food' | 'dessert' | 'supermarket' | 'medicine' | 'fruit'
|
||||||
|
|
||||||
|
/** 商家信息 */
|
||||||
|
export interface Shop {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
/** 商家主图(卡片左侧、店铺头背景) */
|
||||||
|
image: string
|
||||||
|
/** 圆形 logo */
|
||||||
|
logo: string
|
||||||
|
/** 评分 0-5 */
|
||||||
|
rating: number
|
||||||
|
/** 月售 */
|
||||||
|
monthlySales: number
|
||||||
|
/** 配送时间(分钟) */
|
||||||
|
deliveryTime: number
|
||||||
|
/** 配送费(元) */
|
||||||
|
deliveryFee: number
|
||||||
|
/** 起送价(元) */
|
||||||
|
minOrder: number
|
||||||
|
/** 距离(米)字符串 */
|
||||||
|
distance: string
|
||||||
|
category: ShopCategory
|
||||||
|
/** 标签如"满减"、"新店" */
|
||||||
|
tags: string[]
|
||||||
|
/** 商家介绍(卡片右侧文案) */
|
||||||
|
description: string
|
||||||
|
/** 是否为首页推荐 */
|
||||||
|
recommended?: boolean
|
||||||
|
// === 商家 Tab 用 ===
|
||||||
|
address: string
|
||||||
|
phone: string
|
||||||
|
/** "08:00-22:00" */
|
||||||
|
businessHours: string
|
||||||
|
/** "立即配送" */
|
||||||
|
deliveryHours: string
|
||||||
|
/** ["可堂食","支持开发票",...] */
|
||||||
|
services: string[]
|
||||||
|
/** 商家相册 */
|
||||||
|
photos: string[]
|
||||||
|
/** 公告 */
|
||||||
|
announcements?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 菜单分类(左侧 Sidebar 一项) */
|
||||||
|
export interface MenuCategory {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 菜单商品 */
|
||||||
|
export interface MenuItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
image: string
|
||||||
|
price: number
|
||||||
|
originalPrice?: number
|
||||||
|
sales: number
|
||||||
|
description: string
|
||||||
|
tags?: string[]
|
||||||
|
/** 关联 MenuCategory.id */
|
||||||
|
categoryId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 评价 */
|
||||||
|
export interface Review {
|
||||||
|
id: string
|
||||||
|
author: { name: string; avatar: string }
|
||||||
|
rating: number
|
||||||
|
content: string
|
||||||
|
images?: string[]
|
||||||
|
time: string
|
||||||
|
/** 点了什么菜 */
|
||||||
|
dishes?: string[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/** 性别枚举 */
|
||||||
|
export type Gender = 0 | 1 | 2
|
||||||
|
|
||||||
|
/** 性别映射 */
|
||||||
|
export const GENDER_MAP: Record<Gender, string> = {
|
||||||
|
0: '未知',
|
||||||
|
1: '男',
|
||||||
|
2: '女',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户信息 */
|
||||||
|
export interface User {
|
||||||
|
/** 用户 ID */
|
||||||
|
id: number
|
||||||
|
/** 用户名(微信生成) */
|
||||||
|
username: string
|
||||||
|
/** 用户昵称 */
|
||||||
|
nickname: string
|
||||||
|
/** 用户头像 URL */
|
||||||
|
avatar: string
|
||||||
|
/** 手机号(脱敏后可能为 138****5678) */
|
||||||
|
mobile: string
|
||||||
|
/** 性别:0 未知 / 1 男 / 2 女 */
|
||||||
|
gender: Gender
|
||||||
|
/** 邮箱(脱敏后可能为 t***@example.com) */
|
||||||
|
email: string
|
||||||
|
/** 微信 openid */
|
||||||
|
openid: string
|
||||||
|
/** 微信 unionid(非必返) */
|
||||||
|
unionid?: string
|
||||||
|
/** 生日 */
|
||||||
|
birthday: string | null
|
||||||
|
/** 余额 */
|
||||||
|
balance: string
|
||||||
|
/** 状态:1 正常 */
|
||||||
|
status: number
|
||||||
|
/** 注册时间 */
|
||||||
|
created_at: string
|
||||||
|
/** 更新时间 */
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录返回结果 */
|
||||||
|
export interface AuthResult {
|
||||||
|
user: User
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑用户资料请求参数 */
|
||||||
|
export interface UpdateProfileParams {
|
||||||
|
/** 昵称,最长 32 字符 */
|
||||||
|
nickname?: string
|
||||||
|
/** 头像 URL,最长 500 字符 */
|
||||||
|
avatar?: string
|
||||||
|
/** 性别:0 未知 / 1 男 / 2 女 */
|
||||||
|
gender?: Gender
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
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
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
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' })
|
||||||
|
}
|
||||||
@@ -11538,6 +11538,11 @@ yup@^1.2.0:
|
|||||||
toposort "^2.0.2"
|
toposort "^2.0.2"
|
||||||
type-fest "^2.19.0"
|
type-fest "^2.19.0"
|
||||||
|
|
||||||
|
zustand@^5.0.14:
|
||||||
|
version "5.0.14"
|
||||||
|
resolved "https://registry.npmmirror.com/zustand/-/zustand-5.0.14.tgz#18216c24fcb980cf36898f9c57520e67b1f77855"
|
||||||
|
integrity sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==
|
||||||
|
|
||||||
zwitch@^1.0.0:
|
zwitch@^1.0.0:
|
||||||
version "1.0.5"
|
version "1.0.5"
|
||||||
resolved "https://registry.npmmirror.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"
|
resolved "https://registry.npmmirror.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"
|
||||||
|
|||||||
Reference in New Issue
Block a user