This commit is contained in:
3
miniprogram/src/pages/cart/cart.config.ts
Normal file
3
miniprogram/src/pages/cart/cart.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '购物车'
|
||||
})
|
||||
214
miniprogram/src/pages/cart/cart.scss
Normal file
214
miniprogram/src/pages/cart/cart.scss
Normal file
@@ -0,0 +1,214 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: #050505;
|
||||
color: #fff;
|
||||
padding-bottom: 120px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 28px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-list {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.checkbox-area {
|
||||
padding: 10px;
|
||||
margin-right: 10px;
|
||||
|
||||
.checkbox {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.checked {
|
||||
border-color: #00b96b;
|
||||
background: rgba(0, 185, 107, 0.2);
|
||||
color: #00b96b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-img {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 12px;
|
||||
margin-right: 20px;
|
||||
background: #000;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
height: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.item-name {
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
font-size: 24px;
|
||||
color: #888;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
|
||||
.price {
|
||||
font-size: 32px;
|
||||
color: #00b96b;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.quantity-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
|
||||
.btn-qty {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: #fff;
|
||||
|
||||
&:active { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.qty-num {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
padding: 10px;
|
||||
margin-left: 10px;
|
||||
color: #ff4d4f;
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 110px;
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 30px;
|
||||
z-index: 100;
|
||||
|
||||
.left-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.select-all-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 30px;
|
||||
|
||||
.checkbox {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #666;
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.checked {
|
||||
border-color: #00b96b;
|
||||
background: rgba(0, 185, 107, 0.2);
|
||||
color: #00b96b;
|
||||
}
|
||||
}
|
||||
|
||||
.label { font-size: 28px; color: #fff; }
|
||||
}
|
||||
|
||||
.total-info {
|
||||
.label { font-size: 24px; color: #888; margin-right: 10px; }
|
||||
.price { font-size: 40px; color: #00b96b; font-weight: bold; }
|
||||
}
|
||||
}
|
||||
|
||||
.btn-checkout {
|
||||
background: linear-gradient(135deg, #00b96b 0%, #00f0ff 100%);
|
||||
color: #000;
|
||||
border-radius: 40px;
|
||||
padding: 0 50px;
|
||||
height: 80px;
|
||||
line-height: 80px;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
box-shadow: 0 0 20px rgba(0, 185, 107, 0.3);
|
||||
|
||||
&:active { transform: scale(0.98); }
|
||||
&.disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
147
miniprogram/src/pages/cart/cart.tsx
Normal file
147
miniprogram/src/pages/cart/cart.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { View, Text, Image, ScrollView, Button } from '@tarojs/components'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { getCart, updateQuantity, removeItem, toggleSelect, toggleSelectAll, CartItem } from '../../utils/cart'
|
||||
import { checkLogin } from '../../utils/auth'
|
||||
import './cart.scss'
|
||||
|
||||
export default function Cart() {
|
||||
const [cartItems, setCartItems] = useState<CartItem[]>([])
|
||||
|
||||
useDidShow(() => {
|
||||
refreshCart()
|
||||
})
|
||||
|
||||
const refreshCart = () => {
|
||||
setCartItems(getCart())
|
||||
}
|
||||
|
||||
const handleUpdateQuantity = (id: number, delta: number) => {
|
||||
const item = cartItems.find(i => i.id === id)
|
||||
if (!item) return
|
||||
const newQty = item.quantity + delta
|
||||
if (newQty < 1) return
|
||||
const newCart = updateQuantity(id, newQty)
|
||||
setCartItems(newCart)
|
||||
}
|
||||
|
||||
const handleRemove = (id: number) => {
|
||||
Taro.showModal({
|
||||
title: '提示',
|
||||
content: '确定要删除该商品吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
const newCart = removeItem(id)
|
||||
setCartItems(newCart)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggle = (id: number) => {
|
||||
const newCart = toggleSelect(id)
|
||||
setCartItems(newCart)
|
||||
}
|
||||
|
||||
const isAllSelected = useMemo(() => {
|
||||
return cartItems.length > 0 && cartItems.every(i => i.selected)
|
||||
}, [cartItems])
|
||||
|
||||
const handleToggleAll = () => {
|
||||
const newCart = toggleSelectAll(!isAllSelected)
|
||||
setCartItems(newCart)
|
||||
}
|
||||
|
||||
const selectedCount = useMemo(() => {
|
||||
return cartItems.filter(i => i.selected).reduce((sum, i) => sum + i.quantity, 0)
|
||||
}, [cartItems])
|
||||
|
||||
const totalPrice = useMemo(() => {
|
||||
return cartItems.filter(i => i.selected).reduce((sum, i) => sum + i.price * i.quantity, 0)
|
||||
}, [cartItems])
|
||||
|
||||
const handleCheckout = () => {
|
||||
if (!checkLogin()) return
|
||||
if (selectedCount === 0) {
|
||||
Taro.showToast({ title: '请选择商品', icon: 'none' })
|
||||
return
|
||||
}
|
||||
Taro.navigateTo({
|
||||
url: '/pages/order/checkout?from=cart'
|
||||
})
|
||||
}
|
||||
|
||||
const goShopping = () => {
|
||||
Taro.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
{cartItems.length === 0 ? (
|
||||
<View className='empty-state'>
|
||||
<Text className='empty-icon'>🛒</Text>
|
||||
<Text className='empty-text'>购物车空空如也</Text>
|
||||
<Button onClick={goShopping} style={{marginTop: 20, background: '#00b96b', color: '#fff'}}>去逛逛</Button>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView scrollY className='cart-list'>
|
||||
{cartItems.map(item => (
|
||||
<View key={item.id} className='cart-item'>
|
||||
<View className='checkbox-area' onClick={() => handleToggle(item.id)}>
|
||||
<View className={`checkbox ${item.selected ? 'checked' : ''}`}>
|
||||
{item.selected && <Text>✓</Text>}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Image src={item.image} className='item-img' mode='aspectFill' />
|
||||
|
||||
<View className='item-info'>
|
||||
<View>
|
||||
<Text className='item-name'>{item.name}</Text>
|
||||
{/* <Text className='item-desc'>{item.description}</Text> */}
|
||||
</View>
|
||||
|
||||
<View className='price-row'>
|
||||
<Text className='price'>¥{item.price}</Text>
|
||||
|
||||
<View className='quantity-control'>
|
||||
<View className='btn-qty' onClick={() => handleUpdateQuantity(item.id, -1)}>−</View>
|
||||
<Text className='qty-num'>{item.quantity}</Text>
|
||||
<View className='btn-qty' onClick={() => handleUpdateQuantity(item.id, 1)}>+</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='btn-delete' onClick={() => handleRemove(item.id)}>×</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{cartItems.length > 0 && (
|
||||
<View className='bottom-bar'>
|
||||
<View className='left-section'>
|
||||
<View className='select-all-btn' onClick={handleToggleAll}>
|
||||
<View className={`checkbox ${isAllSelected ? 'checked' : ''}`}>
|
||||
{isAllSelected && <Text>✓</Text>}
|
||||
</View>
|
||||
<Text className='label'>全选</Text>
|
||||
</View>
|
||||
|
||||
<View className='total-info'>
|
||||
<Text className='label'>合计:</Text>
|
||||
<Text className='price'>¥{totalPrice}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
className={`btn-checkout ${selectedCount === 0 ? 'disabled' : ''}`}
|
||||
onClick={handleCheckout}
|
||||
>
|
||||
去结算({selectedCount})
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
378
miniprogram/src/pages/competition/detail.scss
Normal file
378
miniprogram/src/pages/competition/detail.scss
Normal file
@@ -0,0 +1,378 @@
|
||||
.comp-detail {
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 80px;
|
||||
|
||||
.banner {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 30px;
|
||||
background: #111;
|
||||
border-radius: 20px 20px 0 0;
|
||||
margin-top: -24px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.title {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 16px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
margin-left: 16px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.registration { background: #07c160; color: #fff; }
|
||||
&.submission { background: #1890ff; color: #fff; }
|
||||
&.judging { background: #faad14; color: #fff; }
|
||||
&.ended { background: #ff4d4f; color: #fff; }
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 1px solid #333;
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
color: #999;
|
||||
font-size: 18px;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 30px;
|
||||
height: 4px;
|
||||
background: #00b96b;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.project-list {
|
||||
.project-card {
|
||||
background: #1f1f1f;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
|
||||
.cover {
|
||||
width: 140px;
|
||||
height: 105px;
|
||||
background: #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
|
||||
.avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
background: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.score {
|
||||
color: #faad14;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 50px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ranking-list {
|
||||
.rank-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid #222;
|
||||
|
||||
.rank-num {
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
|
||||
&.top1 { color: #ffd700; }
|
||||
&.top2 { color: #c0c0c0; }
|
||||
&.top3 { color: #cd7f32; }
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
margin-right: 16px;
|
||||
background: #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.nickname {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
color: #00b96b;
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 50px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 40px;
|
||||
|
||||
.section-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
border-left: 5px solid #00b96b;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
/* Markdown styling borrowed from Forum */
|
||||
font-size: 18px;
|
||||
line-height: 1.8;
|
||||
color: #e0e0e0;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
image {
|
||||
max-width: 100%;
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 { margin-top: 30px; margin-bottom: 20px; color: #fff; font-weight: 700; line-height: 1.4; }
|
||||
h1 { font-size: 32px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 16px; }
|
||||
h2 { font-size: 28px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 12px; }
|
||||
h3 { font-size: 24px; }
|
||||
h4 { font-size: 20px; }
|
||||
h5 { font-size: 18px; color: #ddd; }
|
||||
|
||||
p { margin-bottom: 20px; }
|
||||
|
||||
strong { font-weight: 800; color: #fff; }
|
||||
em { font-style: italic; color: #aaa; }
|
||||
del { text-decoration: line-through; color: #666; }
|
||||
|
||||
ul, ol { margin-bottom: 20px; padding-left: 24px; }
|
||||
li { margin-bottom: 8px; list-style-position: outside; }
|
||||
ul li { list-style-type: disc; }
|
||||
ol li { list-style-type: decimal; }
|
||||
|
||||
li input[type="checkbox"] { margin-right: 12px; }
|
||||
|
||||
blockquote {
|
||||
border-left: 5px solid #00b96b;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 16px 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 6px;
|
||||
color: #bbb;
|
||||
font-size: 16px;
|
||||
font-style: italic;
|
||||
|
||||
p { margin-bottom: 0; }
|
||||
}
|
||||
|
||||
a { color: #00b96b; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
|
||||
|
||||
hr {
|
||||
height: 1px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: none;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
font-size: 16px;
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
|
||||
th, td {
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: rgba(255,255,255,0.05);
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
color: #ff7875;
|
||||
font-size: 16px;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #161616;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
overflow-x: auto;
|
||||
margin: 20px 0;
|
||||
border: 1px solid #333;
|
||||
box-shadow: inset 0 0 20px rgba(0,0,0,0.5);
|
||||
|
||||
code {
|
||||
background: transparent;
|
||||
color: #a6e22e;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-action {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1f1f1f;
|
||||
padding: 20px 30px;
|
||||
border-top: 1px solid #333;
|
||||
z-index: 100;
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
border-radius: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
background: #00b96b;
|
||||
border: none;
|
||||
|
||||
&.disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&.enrolled {
|
||||
background: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
318
miniprogram/src/pages/competition/detail.tsx
Normal file
318
miniprogram/src/pages/competition/detail.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { View, Text, Button, Image, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useLoad, useDidShow, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getCompetitionDetail, enrollCompetition, getMyCompetitionEnrollment, getProjects } from '../../api'
|
||||
import MarkdownReader from '../../components/MarkdownReader'
|
||||
import './detail.scss'
|
||||
|
||||
export default function CompetitionDetail() {
|
||||
const [detail, setDetail] = useState<any>(null)
|
||||
const [enrollment, setEnrollment] = useState<any>(null)
|
||||
const [projects, setProjects] = useState<any[]>([])
|
||||
const [myProject, setMyProject] = useState<any>(null)
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useLoad((options) => {
|
||||
const { id } = options
|
||||
if (id) {
|
||||
fetchDetail(id)
|
||||
fetchEnrollment(id)
|
||||
fetchProjects(id)
|
||||
}
|
||||
})
|
||||
|
||||
useDidShow(() => {
|
||||
// 每次显示页面时刷新一下我的项目信息(比如从编辑页返回)
|
||||
if (detail?.id) {
|
||||
fetchMyProject(detail.id)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享给朋友的功能
|
||||
*/
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: detail?.title || '赛事详情',
|
||||
path: `/pages/competition/detail?id=${detail?.id || ''}`,
|
||||
imageUrl: detail?.display_cover_image || ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享到朋友圈的功能
|
||||
*/
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: detail?.title || '赛事详情',
|
||||
query: `id=${detail?.id || ''}`,
|
||||
imageUrl: detail?.display_cover_image || ''
|
||||
}
|
||||
})
|
||||
|
||||
const fetchDetail = async (id) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getCompetitionDetail(id)
|
||||
setDetail(res)
|
||||
fetchMyProject(id)
|
||||
} catch (e) {
|
||||
Taro.showToast({ title: '加载详情失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMyProject = async (competitionId) => {
|
||||
try {
|
||||
const userInfo = Taro.getStorageSync('userInfo')
|
||||
if (!userInfo) return
|
||||
|
||||
const res = await getProjects({ competition: competitionId })
|
||||
const list = res.results || res
|
||||
// 尝试通过 enrollment.id 匹配,或者通过 user nickname 匹配(不够严谨但暂时可用)
|
||||
|
||||
if (enrollment) {
|
||||
const mine = list.find((p: any) => p.contestant === enrollment.id)
|
||||
if (mine) {
|
||||
setMyProject(mine)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use nickname match if enrollment not ready or failed
|
||||
const myProj = list.find((p: any) => p.contestant_info?.nickname === userInfo.nickname)
|
||||
if (myProj) setMyProject(myProj)
|
||||
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchEnrollment = async (id) => {
|
||||
try {
|
||||
const res = await getMyCompetitionEnrollment(id)
|
||||
setEnrollment(res)
|
||||
// 获取到 enrollment 后,去匹配 myProject
|
||||
if (projects.length > 0) {
|
||||
const mine = projects.find((p: any) => p.contestant === res.id)
|
||||
setMyProject(mine)
|
||||
}
|
||||
} catch (e) {
|
||||
// 没报名则无数据,忽略
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProjects = async (id) => {
|
||||
try {
|
||||
// 注意:这里我们去掉了 status='submitted',因为我们要找自己的 draft
|
||||
const res = await getProjects({ competition: id })
|
||||
const list = res.results || res
|
||||
const allProjects = Array.isArray(list) ? list : []
|
||||
|
||||
// 过滤出 submitted 的给列表显示
|
||||
const submittedProjects = allProjects.filter(p => p.status === 'submitted')
|
||||
setProjects(submittedProjects)
|
||||
} catch (e) {
|
||||
console.error('Fetch projects failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听变化设置 myProject
|
||||
useEffect(() => {
|
||||
if (enrollment && projects.length >= 0) { // projects could be empty
|
||||
fetchMySpecificProject(detail?.id, enrollment.id)
|
||||
}
|
||||
}, [enrollment])
|
||||
|
||||
const fetchMySpecificProject = async (compId, enrollId) => {
|
||||
if (!compId || !enrollId) return
|
||||
try {
|
||||
const res = await getProjects({ competition: compId })
|
||||
const list = res.results || res
|
||||
const mine = list.find((p: any) => p.contestant === enrollId)
|
||||
setMyProject(mine)
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const handleEnroll = async () => {
|
||||
if (!detail) return
|
||||
try {
|
||||
await enrollCompetition(detail.id, { role: 'contestant' })
|
||||
Taro.showToast({ title: '报名成功', icon: 'success' })
|
||||
fetchEnrollment(detail.id)
|
||||
} catch (e) {
|
||||
Taro.showToast({ title: e.message || '报名失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const map = {
|
||||
'registration': '报名中',
|
||||
'submission': '作品提交中',
|
||||
'judging': '评审中',
|
||||
'ended': '已结束',
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const getEmptyMessage = (visibility, enrollment) => {
|
||||
const role = enrollment?.status === 'approved' ? enrollment.role : null;
|
||||
|
||||
if (visibility === 'judge') {
|
||||
if (role === 'judge') return '暂无参赛项目';
|
||||
return '该比赛项目仅评委可见';
|
||||
}
|
||||
if (visibility === 'guest') {
|
||||
if (role === 'judge' || role === 'guest') return '暂无参赛项目';
|
||||
return '该比赛项目仅嘉宾/评委可见';
|
||||
}
|
||||
if (visibility === 'contestant') {
|
||||
if (role) return '暂无参赛项目';
|
||||
return '该比赛项目仅参赛选手可见,请先报名';
|
||||
}
|
||||
return '暂无参赛项目';
|
||||
}
|
||||
|
||||
if (loading || !detail) return <View className='loading'>加载中...</View>
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='comp-detail'>
|
||||
<Image
|
||||
className='banner'
|
||||
mode='aspectFill'
|
||||
src={detail.display_cover_image || 'https://via.placeholder.com/400x200'}
|
||||
/>
|
||||
|
||||
<View className='content'>
|
||||
<View className='header'>
|
||||
<Text className='title'>{detail.title}</Text>
|
||||
<Text className={`status ${detail.status}`}>{getStatusText(detail.status)}</Text>
|
||||
</View>
|
||||
|
||||
<View className='tabs'>
|
||||
{['详情', '参赛项目', '排行榜'].map((tab, index) => (
|
||||
<View
|
||||
key={index}
|
||||
className={`tab-item ${activeTab === index ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(index)}
|
||||
>
|
||||
{tab}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{activeTab === 0 && (
|
||||
<>
|
||||
<View className='section'>
|
||||
<Text className='section-title'>简介</Text>
|
||||
<MarkdownReader content={detail.description} />
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>规则</Text>
|
||||
<MarkdownReader content={detail.rule_description} />
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>参赛条件</Text>
|
||||
<MarkdownReader content={detail.condition_description} />
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 1 && (
|
||||
<View className='project-list'>
|
||||
{projects.map(project => (
|
||||
<View className='project-card' key={project.id} onClick={() => Taro.navigateTo({ url: `/pages/competition/project-detail?id=${project.id}` })}>
|
||||
<Image
|
||||
className='cover'
|
||||
mode='aspectFill'
|
||||
src={project.display_cover_image || 'https://via.placeholder.com/120x90'}
|
||||
/>
|
||||
<View className='info'>
|
||||
<Text className='title'>{project.title}</Text>
|
||||
<View className='author'>
|
||||
<View className='user'>
|
||||
<Image className='avatar' src={project.contestant_info?.avatar_url || ''} />
|
||||
<Text>{project.contestant_info?.nickname || '参赛者'}</Text>
|
||||
</View>
|
||||
{project.final_score > 0 && <Text className='score'>{project.final_score}分</Text>}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{projects.length === 0 && <View className='empty'>{getEmptyMessage(detail.project_visibility, enrollment)}</View>}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activeTab === 2 && (
|
||||
<View className='ranking-list'>
|
||||
{projects
|
||||
.filter(p => p.final_score > 0)
|
||||
.sort((a, b) => b.final_score - a.final_score)
|
||||
.map((project, index) => (
|
||||
<View className='rank-item' key={project.id} onClick={() => Taro.navigateTo({ url: `/pages/competition/project-detail?id=${project.id}` })}>
|
||||
<Text className={`rank-num top${index + 1}`}>{index + 1}</Text>
|
||||
<View className='info'>
|
||||
<Image className='avatar' src={project.contestant_info?.avatar_url || ''} />
|
||||
<View className='detail'>
|
||||
<Text className='nickname'>{project.contestant_info?.nickname || '参赛者'}</Text>
|
||||
<Text className='project-title'>{project.title}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='score'>{project.final_score}</Text>
|
||||
</View>
|
||||
))}
|
||||
{projects.filter(p => p.final_score > 0).length === 0 && <View className='empty'>暂无排名数据</View>}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='footer-action'>
|
||||
{enrollment ? (
|
||||
myProject ? (
|
||||
<View style={{ display: 'flex', width: '100%', gap: '10px' }}>
|
||||
<Button
|
||||
className='btn enrolled'
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/competition/project?id=${myProject.id}` })}
|
||||
>
|
||||
我的作品 ({myProject.status === 'submitted' ? '已提交' : '草稿'})
|
||||
</Button>
|
||||
<Button
|
||||
className='btn'
|
||||
style={{ width: '80px', background: '#fff', color: '#333', border: '1px solid #ddd', fontSize: '12px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/competition/project-detail?id=${myProject.id}` })}
|
||||
>
|
||||
评语
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
enrollment.status === 'approved' ? (
|
||||
<Button
|
||||
className='btn enrolled'
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/competition/project?competitionId=${detail.id}` })}
|
||||
>
|
||||
立即提交作品
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled className='btn enrolled'>
|
||||
报名审核中
|
||||
</Button>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<Button
|
||||
className='btn enroll'
|
||||
onClick={handleEnroll}
|
||||
disabled={detail.status !== 'registration'}
|
||||
>
|
||||
{detail.status === 'registration' ? '立即报名' : '报名未开始/已结束'}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
85
miniprogram/src/pages/competition/index.scss
Normal file
85
miniprogram/src/pages/competition/index.scss
Normal file
@@ -0,0 +1,85 @@
|
||||
.competition-page {
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
|
||||
.comp-list {
|
||||
.comp-card {
|
||||
background: #1f1f1f;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.info {
|
||||
padding: 16px;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
|
||||
&.registration { background: #07c160; color: #fff; }
|
||||
&.submission { background: #1890ff; color: #fff; }
|
||||
&.judging { background: #faad14; color: #fff; }
|
||||
&.ended { background: #ff4d4f; color: #fff; }
|
||||
}
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid #333;
|
||||
padding-top: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
110
miniprogram/src/pages/competition/index.tsx
Normal file
110
miniprogram/src/pages/competition/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useLoad, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getCompetitions } from '../../api'
|
||||
import './index.scss'
|
||||
|
||||
export default function CompetitionList() {
|
||||
const [competitions, setCompetitions] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [debugMsg, setDebugMsg] = useState('')
|
||||
|
||||
useLoad(() => {
|
||||
fetchCompetitions()
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享给朋友的功能
|
||||
*/
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: '赛事中心',
|
||||
path: '/pages/competition/index'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享到朋友圈的功能
|
||||
*/
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: '赛事中心',
|
||||
query: ''
|
||||
}
|
||||
})
|
||||
|
||||
const fetchCompetitions = async () => {
|
||||
setLoading(true)
|
||||
setDebugMsg('开始加载...')
|
||||
try {
|
||||
console.log('Fetching competitions...')
|
||||
const res = await getCompetitions()
|
||||
console.log('Competitions res:', res)
|
||||
setDebugMsg(`请求成功: 数量 ${res?.results?.length}`)
|
||||
|
||||
if (res && res.results) {
|
||||
setCompetitions(res.results)
|
||||
} else {
|
||||
setDebugMsg(`数据格式异常: ${JSON.stringify(res)}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fetch failed:', e)
|
||||
setDebugMsg(`请求失败: ${e.errMsg || JSON.stringify(e)}`)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goDetail = (id) => {
|
||||
Taro.navigateTo({ url: `/pages/competition/detail?id=${id}` })
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const map = {
|
||||
'published': '即将开始',
|
||||
'registration': '报名中',
|
||||
'submission': '作品提交中',
|
||||
'judging': '评审中',
|
||||
'ended': '已结束',
|
||||
'draft': '草稿'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='competition-page'>
|
||||
<ScrollView scrollY className='comp-list'>
|
||||
{competitions.map(item => (
|
||||
<View key={item.id} className='comp-card' onClick={() => goDetail(item.id)}>
|
||||
<Image
|
||||
className='cover'
|
||||
mode='aspectFill'
|
||||
src={item.display_cover_image || 'https://via.placeholder.com/400x200'}
|
||||
/>
|
||||
<View className='info'>
|
||||
<View className='header'>
|
||||
<Text className='title'>{item.title}</Text>
|
||||
<Text className={`status ${item.status}`}>{getStatusText(item.status)}</Text>
|
||||
</View>
|
||||
<Text className='desc'>{item.description}</Text>
|
||||
<View className='footer'>
|
||||
<Text className='time'>
|
||||
{item.start_time?.split('T')[0]} ~ {item.end_time?.split('T')[0]}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{!loading && competitions.length === 0 && (
|
||||
<View className='empty'>
|
||||
<Text>暂无比赛</Text>
|
||||
<View style={{ marginTop: 20, color: '#666', fontSize: 12, wordBreak: 'break-all', padding: 20 }}>
|
||||
调试信息: {debugMsg}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '项目详情'
|
||||
})
|
||||
288
miniprogram/src/pages/competition/project-detail.scss
Normal file
288
miniprogram/src/pages/competition/project-detail.scss
Normal file
@@ -0,0 +1,288 @@
|
||||
.project-detail {
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 60px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 30px;
|
||||
background: #111;
|
||||
border-radius: 24px 24px 0 0;
|
||||
margin-top: -30px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
min-height: 60vh;
|
||||
|
||||
.header {
|
||||
margin-bottom: 40px;
|
||||
.title {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
}
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 12px 20px;
|
||||
border-radius: 30px;
|
||||
display: inline-flex;
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
margin-right: 12px;
|
||||
background: #333;
|
||||
}
|
||||
.name {
|
||||
font-size: 18px;
|
||||
color: #ddd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 50px;
|
||||
|
||||
.section-title {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 24px;
|
||||
display: block;
|
||||
border-left: 6px solid #00b96b;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
font-size: 20px;
|
||||
color: #ccc;
|
||||
line-height: 1.8;
|
||||
background: #1f1f1f;
|
||||
padding: 24px;
|
||||
border-radius: 20px;
|
||||
|
||||
/* Markdown Styles */
|
||||
h1, h2, h3, h4, h5, h6 { margin-top: 40px; margin-bottom: 24px; color: #fff; font-weight: 700; line-height: 1.4; }
|
||||
h1 { font-size: 34px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 16px; }
|
||||
h2 { font-size: 30px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 12px; }
|
||||
h3 { font-size: 26px; }
|
||||
h4 { font-size: 24px; }
|
||||
h5 { font-size: 22px; color: #ddd; }
|
||||
|
||||
p { margin-bottom: 24px; }
|
||||
|
||||
strong { font-weight: 800; color: #fff; }
|
||||
em { font-style: italic; color: #aaa; }
|
||||
del { text-decoration: line-through; color: #666; }
|
||||
|
||||
ul, ol { margin-bottom: 24px; padding-left: 28px; }
|
||||
li { margin-bottom: 10px; list-style-position: outside; }
|
||||
ul li { list-style-type: disc; }
|
||||
ol li { list-style-type: decimal; }
|
||||
|
||||
li input[type="checkbox"] { margin-right: 12px; transform: scale(1.2); }
|
||||
|
||||
blockquote {
|
||||
border-left: 6px solid #00b96b;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 20px 24px;
|
||||
margin: 24px 0;
|
||||
border-radius: 8px;
|
||||
color: #bbb;
|
||||
font-size: 18px;
|
||||
font-style: italic;
|
||||
p { margin-bottom: 0; }
|
||||
}
|
||||
|
||||
a { color: #00b96b; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
|
||||
|
||||
hr {
|
||||
height: 1px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: none;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 24px 0;
|
||||
font-size: 18px;
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
|
||||
th, td {
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: rgba(255,255,255,0.05);
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
color: #ff7875;
|
||||
font-size: 18px;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #161616;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
overflow-x: auto;
|
||||
margin: 24px 0;
|
||||
border: 1px solid #333;
|
||||
box-shadow: inset 0 0 20px rgba(0,0,0,0.5);
|
||||
|
||||
code {
|
||||
background: transparent;
|
||||
color: #a6e22e;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
|
||||
image {
|
||||
max-width: 100%;
|
||||
border-radius: 16px;
|
||||
margin: 24px 0;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
display: block;
|
||||
padding: 40px 0;
|
||||
background: #1f1f1f;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
background: #1f1f1f;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid #333;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 18px;
|
||||
color: #ddd;
|
||||
flex: 1;
|
||||
margin-right: 20px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-action {
|
||||
font-size: 16px;
|
||||
color: #00b96b;
|
||||
padding: 8px 20px;
|
||||
border: 1px solid #00b96b;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
.comment-item {
|
||||
background: #1f1f1f;
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.judge-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
.judge-name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #00b96b;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.judge-score-box {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
.score-num {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.score-unit {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
.comment-content {
|
||||
font-size: 20px;
|
||||
color: #ccc;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
text-align: justify;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
miniprogram/src/pages/competition/project-detail.tsx
Normal file
191
miniprogram/src/pages/competition/project-detail.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { View, Text, Image, Button, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useLoad, useShareAppMessage, useShareTimeline, useRouter } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getProjectDetail, getComments } from '../../api'
|
||||
import MarkdownReader from '../../components/MarkdownReader'
|
||||
import './project-detail.scss'
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const [project, setProject] = useState<any>(null)
|
||||
const [comments, setComments] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
useLoad((options) => {
|
||||
const { id } = options
|
||||
if (id) {
|
||||
fetchProject(id)
|
||||
fetchComments(id)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享给朋友的功能
|
||||
*/
|
||||
useShareAppMessage(() => {
|
||||
const id = project?.id || router.params.id || ''
|
||||
return {
|
||||
title: project?.title || '项目详情',
|
||||
path: `/pages/competition/project-detail?id=${id}`,
|
||||
imageUrl: project?.display_cover_image || project?.cover_image_url || ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享到朋友圈的功能
|
||||
*/
|
||||
useShareTimeline(() => {
|
||||
const id = project?.id || router.params.id || ''
|
||||
return {
|
||||
title: project?.title || '项目详情',
|
||||
query: `id=${id}`,
|
||||
imageUrl: project?.display_cover_image || project?.cover_image_url || ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取项目详情
|
||||
* @param id 项目ID
|
||||
*/
|
||||
const fetchProject = async (id) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getProjectDetail(id)
|
||||
setProject(res)
|
||||
} catch (e) {
|
||||
Taro.showToast({ title: '加载项目详情失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目评语
|
||||
* @param id 项目ID
|
||||
*/
|
||||
const fetchComments = async (id) => {
|
||||
try {
|
||||
const res = await getComments({ project: id })
|
||||
const list = res.results || res.data || res || []
|
||||
setComments(Array.isArray(list) ? list : [])
|
||||
} catch (e) {
|
||||
console.error('获取评语失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开/下载附件
|
||||
* @param file 文件对象
|
||||
*/
|
||||
const handleOpenFile = (file) => {
|
||||
if (!file.file) return
|
||||
|
||||
// 如果是图片,预览
|
||||
if (file.file.match(/\.(jpg|jpeg|png|gif)$/i)) {
|
||||
Taro.previewImage({ urls: [file.file] })
|
||||
return
|
||||
}
|
||||
|
||||
// 其他文件尝试下载打开
|
||||
Taro.showLoading({ title: '下载中...' })
|
||||
Taro.downloadFile({
|
||||
url: file.file,
|
||||
success: (res) => {
|
||||
const filePath = res.tempFilePath
|
||||
Taro.openDocument({
|
||||
filePath,
|
||||
success: () => console.log('打开文档成功'),
|
||||
fail: (err) => {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '打开文件失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
Taro.showToast({ title: '下载文件失败', icon: 'none' })
|
||||
},
|
||||
complete: () => {
|
||||
Taro.hideLoading()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (loading || !project) return <View className='loading'>加载中...</View>
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='project-detail'>
|
||||
<Image
|
||||
className='cover'
|
||||
mode='aspectFill'
|
||||
src={project.display_cover_image || project.cover_image_url || 'https://via.placeholder.com/400x200'}
|
||||
/>
|
||||
|
||||
<View className='content'>
|
||||
<View className='header'>
|
||||
<Text className='title'>{project.title}</Text>
|
||||
<View className='author'>
|
||||
<Image className='avatar' src={project.contestant_info?.avatar_url || ''} />
|
||||
<Text className='name'>{project.contestant_info?.nickname || '参赛者'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>项目介绍</Text>
|
||||
<View className='text-content'>
|
||||
{project.description ? <MarkdownReader content={project.description} /> : <Text className='empty'>暂无介绍</Text>}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>团队介绍</Text>
|
||||
<View className='text-content'>
|
||||
<Text>{project.team_info || '暂无团队信息'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>项目附件</Text>
|
||||
{project.files && project.files.length > 0 ? (
|
||||
<View className='file-list'>
|
||||
{project.files.map((file, index) => (
|
||||
<View key={index} className='file-item' onClick={() => handleOpenFile(file)}>
|
||||
<Text className='file-name'>{file.name || '附件 ' + (index + 1)}</Text>
|
||||
<Text className='file-action'>查看</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text className='empty'>暂无附件</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='section comments-section'>
|
||||
<Text className='section-title'>评委评语</Text>
|
||||
{comments.length > 0 ? (
|
||||
<View className='comment-list'>
|
||||
{comments.map((c) => (
|
||||
<View key={c.id} className='comment-item'>
|
||||
<View className='comment-header'>
|
||||
<View className='judge-info'>
|
||||
<Text className='judge-name'>{c.judge_name || '评委'}</Text>
|
||||
{c.score && (
|
||||
<View className='judge-score-box'>
|
||||
<Text className='score-num'>{c.score}</Text>
|
||||
<Text className='score-unit'>分</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text className='comment-time'>{c.created_at?.substring(0, 16)}</Text>
|
||||
</View>
|
||||
<Text className='comment-content'>{c.content}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text className='empty'>暂无评语</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/competition/project.config.ts
Normal file
3
miniprogram/src/pages/competition/project.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '参赛作品'
|
||||
})
|
||||
93
miniprogram/src/pages/competition/project.scss
Normal file
93
miniprogram/src/pages/competition/project.scss
Normal file
@@ -0,0 +1,93 @@
|
||||
.project-edit {
|
||||
padding: 24px;
|
||||
background: #000;
|
||||
min-height: 100vh;
|
||||
color: #fff;
|
||||
padding-bottom: 100px;
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.label {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.input, .textarea, .picker {
|
||||
background: #1f1f1f;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
height: 200px;
|
||||
|
||||
&.small {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: #1f1f1f;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px dashed #333;
|
||||
|
||||
.preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-btns {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 16px 24px;
|
||||
background: #1f1f1f;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
z-index: 100;
|
||||
border-top: 1px solid #333;
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
border-radius: 24px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
margin: 0 8px;
|
||||
|
||||
&.save {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.submit {
|
||||
background: #00b96b;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
279
miniprogram/src/pages/competition/project.tsx
Normal file
279
miniprogram/src/pages/competition/project.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { View, Text, Button, Image, Input, Textarea, Picker } from '@tarojs/components'
|
||||
import Taro, { useLoad, useShareAppMessage, useShareTimeline, useRouter } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getProjectDetail, createProject, updateProject, uploadProjectFile, submitProject, uploadMedia, getCompetitions } from '../../api'
|
||||
import './project.scss'
|
||||
|
||||
export default function ProjectEdit() {
|
||||
const [project, setProject] = useState<any>({
|
||||
title: '',
|
||||
description: '',
|
||||
team_info: '',
|
||||
files: []
|
||||
})
|
||||
const [competitionId, setCompetitionId] = useState<string>('')
|
||||
const [competitions, setCompetitions] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
useLoad((options) => {
|
||||
fetchCompetitions()
|
||||
const { id, competitionId } = options
|
||||
if (id) {
|
||||
setIsEdit(true)
|
||||
fetchProject(id)
|
||||
} else if (competitionId) {
|
||||
setCompetitionId(competitionId)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享给朋友的功能
|
||||
*/
|
||||
useShareAppMessage(() => {
|
||||
const id = project?.id || router.params.id || ''
|
||||
const compId = competitionId || router.params.competitionId || ''
|
||||
return {
|
||||
title: project?.title || '提交作品',
|
||||
path: `/pages/competition/project?id=${id}&competitionId=${compId}`,
|
||||
imageUrl: project?.cover_image_url || project?.display_cover_image || ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 配置并监听分享到朋友圈的功能
|
||||
*/
|
||||
useShareTimeline(() => {
|
||||
const id = project?.id || router.params.id || ''
|
||||
const compId = competitionId || router.params.competitionId || ''
|
||||
return {
|
||||
title: project?.title || '提交作品',
|
||||
query: `id=${id}&competitionId=${compId}`,
|
||||
imageUrl: project?.cover_image_url || project?.display_cover_image || ''
|
||||
}
|
||||
})
|
||||
|
||||
const fetchCompetitions = async () => {
|
||||
try {
|
||||
const res = await getCompetitions()
|
||||
if (res && res.results) {
|
||||
setCompetitions(res.results)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取比赛列表失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProject = async (id) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getProjectDetail(id)
|
||||
setProject(res)
|
||||
setCompetitionId(res.competition)
|
||||
} catch (e) {
|
||||
Taro.showToast({ title: '加载项目失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInput = (key, value) => {
|
||||
setProject(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const handleUploadCover = async () => {
|
||||
try {
|
||||
const { tempFilePaths } = await Taro.chooseImage({ count: 1 })
|
||||
if (!tempFilePaths.length) return
|
||||
|
||||
Taro.showLoading({ title: '上传中...' })
|
||||
|
||||
const res = await uploadMedia(tempFilePaths[0], 'image')
|
||||
handleInput('cover_image_url', res.file) // 假设返回 { file: 'url...' }
|
||||
|
||||
Taro.hideLoading()
|
||||
} catch (e) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '上传失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadFile = async () => {
|
||||
if (!project.id) {
|
||||
Taro.showToast({ title: '请先保存草稿再上传附件', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await Taro.chooseMessageFile({ count: 1, type: 'file' })
|
||||
const tempFiles = res.tempFiles
|
||||
if (!tempFiles.length) return
|
||||
|
||||
Taro.showLoading({ title: '上传中...' })
|
||||
const file = tempFiles[0]
|
||||
|
||||
// @ts-ignore
|
||||
const result = await uploadProjectFile(file.path, project.id, file.name)
|
||||
|
||||
// Update file list
|
||||
setProject(prev => ({
|
||||
...prev,
|
||||
files: [...(prev.files || []), result]
|
||||
}))
|
||||
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '上传成功', icon: 'success' })
|
||||
} catch (e) {
|
||||
Taro.hideLoading()
|
||||
console.error(e)
|
||||
Taro.showToast({ title: '上传失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteFile = (fileId) => {
|
||||
// API call to delete file not implemented yet? Or just remove from list?
|
||||
// Usually we should call delete API. For now just remove from UI.
|
||||
// Ideally we should have deleteProjectFile API.
|
||||
// But user only asked to "optimize upload".
|
||||
setProject(prev => ({
|
||||
...prev,
|
||||
files: prev.files.filter(f => f.id !== fileId)
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = async (submit = false) => {
|
||||
if (!project.title) {
|
||||
Taro.showToast({ title: '请输入项目标题', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = {
|
||||
competition: competitionId,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
team_info: project.team_info,
|
||||
cover_image_url: project.cover_image_url
|
||||
}
|
||||
|
||||
let res
|
||||
if (isEdit) {
|
||||
res = await updateProject(project.id, data)
|
||||
} else {
|
||||
res = await createProject(data)
|
||||
}
|
||||
|
||||
if (submit) {
|
||||
await submitProject(res.id)
|
||||
Taro.showToast({ title: '提交成功', icon: 'success' })
|
||||
setTimeout(() => Taro.navigateBack(), 1500)
|
||||
} else {
|
||||
Taro.showToast({ title: '保存成功', icon: 'success' })
|
||||
if (!isEdit) {
|
||||
// 创建变编辑
|
||||
setIsEdit(true)
|
||||
setProject(res)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Taro.showToast({ title: e.message || '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !project.id && isEdit) return <View className='loading'>加载中...</View>
|
||||
|
||||
return (
|
||||
<View className='project-edit'>
|
||||
<View className='form-item'>
|
||||
<Text className='label'>所属比赛</Text>
|
||||
<Picker
|
||||
mode='selector'
|
||||
range={competitions}
|
||||
rangeKey='title'
|
||||
onChange={e => {
|
||||
const idx = Number(e.detail.value)
|
||||
const selected = competitions[idx]
|
||||
if (selected) {
|
||||
setCompetitionId(String(selected.id))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View className='picker'>
|
||||
{competitions.find(c => String(c.id) === String(competitionId))?.title || '请选择比赛'}
|
||||
</View>
|
||||
</Picker>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='label'>项目标题</Text>
|
||||
<Input
|
||||
className='input'
|
||||
placeholder='请输入项目标题'
|
||||
value={project.title}
|
||||
onInput={e => handleInput('title', e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='label'>封面图</Text>
|
||||
<View className='upload-box' onClick={handleUploadCover}>
|
||||
{project.cover_image_url || project.display_cover_image ? (
|
||||
<Image
|
||||
className='preview'
|
||||
mode='aspectFill'
|
||||
src={project.cover_image_url || project.display_cover_image}
|
||||
/>
|
||||
) : (
|
||||
<Text className='placeholder'>点击上传封面</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='label'>项目介绍</Text>
|
||||
<Textarea
|
||||
className='textarea'
|
||||
placeholder='请输入项目详细介绍'
|
||||
value={project.description}
|
||||
onInput={e => handleInput('description', e.detail.value)}
|
||||
maxlength={2000}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='label'>团队介绍</Text>
|
||||
<Textarea
|
||||
className='textarea small'
|
||||
placeholder='请输入团队成员信息'
|
||||
value={project.team_info}
|
||||
onInput={e => handleInput('team_info', e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<View className='label-row' style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
|
||||
<Text className='label' style={{ marginBottom: 0 }}>项目附件</Text>
|
||||
<Button size='mini' style={{ margin: 0, fontSize: '12px' }} onClick={handleUploadFile}>上传附件</Button>
|
||||
</View>
|
||||
<View className='file-list'>
|
||||
{project.files && project.files.map((file, index) => (
|
||||
<View key={index} className='file-item' style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', background: '#f8f8f8', marginBottom: '8px', borderRadius: '4px' }}>
|
||||
<Text className='file-name' style={{ flex: 1, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name || '未知文件'}</Text>
|
||||
{/* <Text className='delete' style={{ color: 'red', marginLeft: '10px' }} onClick={() => handleDeleteFile(file.id)}>删除</Text> */}
|
||||
</View>
|
||||
))}
|
||||
{(!project.files || project.files.length === 0) && <Text style={{ color: '#999', fontSize: '12px' }}>暂无附件 (PDF/PPT/视频)</Text>}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='footer-btns'>
|
||||
<Button className='btn save' onClick={() => handleSave(false)}>保存草稿</Button>
|
||||
<Button className='btn submit' onClick={() => handleSave(true)}>提交作品</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/courses/detail.config.ts
Normal file
3
miniprogram/src/pages/courses/detail.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '体验详情'
|
||||
})
|
||||
365
miniprogram/src/pages/courses/detail.scss
Normal file
365
miniprogram/src/pages/courses/detail.scss
Normal file
@@ -0,0 +1,365 @@
|
||||
.page-container {
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scroll-content {
|
||||
flex: 1;
|
||||
height: calc(100vh - 100px); /* 留出底部栏高度 */
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 30px;
|
||||
background: #000;
|
||||
border-radius: 30px 30px 0 0;
|
||||
margin-top: -30px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
margin-bottom: 40px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-bottom: 30px;
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tags-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.tag {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #aaa;
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 24px;
|
||||
|
||||
&.highlight {
|
||||
background: rgba(0, 240, 255, 0.2);
|
||||
color: #00f0ff;
|
||||
border: 1px solid rgba(0, 240, 255, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 48px;
|
||||
color: #00f0ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.video-section {
|
||||
.course-video {
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
border-radius: 16px;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.video-locked {
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
background-color: #111;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
.locked-bg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
opacity: 0.5;
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
.locked-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.lock-icon {
|
||||
font-size: 60px;
|
||||
margin-bottom: 20px;
|
||||
color: #00f0ff;
|
||||
}
|
||||
|
||||
.lock-text {
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
margin-bottom: 30px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-unlock {
|
||||
background: linear-gradient(90deg, #00f0ff, #0099ff);
|
||||
color: #000;
|
||||
font-size: 28px;
|
||||
padding: 0 40px;
|
||||
height: 80px;
|
||||
line-height: 80px;
|
||||
border-radius: 40px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 50px;
|
||||
|
||||
.section-title {
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 24px;
|
||||
display: block;
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 6px;
|
||||
background: #00f0ff;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.instructor-section {
|
||||
.instructor-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #111;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: #333;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
text {
|
||||
color: #666;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
margin-right: 20px;
|
||||
border: 2px solid #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.instructor-info {
|
||||
flex: 1;
|
||||
.name {
|
||||
color: #fff;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.title-tag {
|
||||
font-size: 20px;
|
||||
color: #000;
|
||||
background: #00f0ff;
|
||||
padding: 2px 10px;
|
||||
border-radius: 8px;
|
||||
margin-left: 10px;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
.desc {
|
||||
color: #888;
|
||||
font-size: 24px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: #111;
|
||||
padding: 30px;
|
||||
border-radius: 16px;
|
||||
|
||||
.grid-item {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #666;
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #fff;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-section {
|
||||
.schedule-box {
|
||||
background: #111;
|
||||
padding: 30px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 240, 255, 0.2);
|
||||
|
||||
.time-row {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
font-size: 28px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #888;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #00f0ff;
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.desc-text {
|
||||
color: #aaa;
|
||||
font-size: 28px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.detail-images {
|
||||
.detail-long-image {
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.placeholder-box {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
background: #111;
|
||||
border: 2px dashed #333;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
height: 120px;
|
||||
background: #111;
|
||||
border-top: 1px solid #222;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 30px;
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
|
||||
.price-container {
|
||||
flex: 1;
|
||||
|
||||
.label {
|
||||
color: #aaa;
|
||||
font-size: 24px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: #00f0ff;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-buy {
|
||||
width: 240px;
|
||||
height: 80px;
|
||||
line-height: 80px;
|
||||
background: linear-gradient(90deg, #00f0ff, #0099ff);
|
||||
color: #000;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
border-radius: 40px;
|
||||
border: none;
|
||||
margin: 0;
|
||||
|
||||
&.disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
258
miniprogram/src/pages/courses/detail.tsx
Normal file
258
miniprogram/src/pages/courses/detail.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { View, Text, Button, Image, ScrollView, Video } from '@tarojs/components'
|
||||
import Taro, { useLoad, useDidShow, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState, useRef } from 'react'
|
||||
import { getVBCourseDetail } from '../../api'
|
||||
import { login } from '../../utils/request'
|
||||
import { checkLogin } from '../../utils/auth'
|
||||
import './detail.scss'
|
||||
|
||||
export default function CourseDetail() {
|
||||
const [detail, setDetail] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const courseIdRef = useRef<string>('')
|
||||
|
||||
useLoad((options) => {
|
||||
if (options.id) {
|
||||
courseIdRef.current = options.id
|
||||
}
|
||||
})
|
||||
|
||||
useDidShow(async () => {
|
||||
if (!courseIdRef.current) return
|
||||
|
||||
// 检查并确保有 Token,以便获取最新的购买状态
|
||||
const token = Taro.getStorageSync('token')
|
||||
if (!token) {
|
||||
try {
|
||||
await login()
|
||||
} catch (e) {
|
||||
console.error('Silent login failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDetail(courseIdRef.current)
|
||||
})
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
software: '软件课程',
|
||||
hardware: '硬件课程',
|
||||
incubation: '产品商业孵化'
|
||||
}
|
||||
|
||||
const fetchDetail = async (id: string) => {
|
||||
try {
|
||||
// Add timestamp to prevent caching
|
||||
const res: any = await getVBCourseDetail(Number(id))
|
||||
console.log('Course detail:', res)
|
||||
setDetail(res)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLaunch = () => {
|
||||
if (!checkLogin()) return
|
||||
if (!detail) return
|
||||
Taro.navigateTo({
|
||||
url: `/pages/order/checkout?id=${detail.id}&type=course`
|
||||
})
|
||||
}
|
||||
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: detail?.title || 'VC 课程详情',
|
||||
path: `/pages/courses/detail?id=${detail?.id}`,
|
||||
imageUrl: detail?.cover_image_url
|
||||
}
|
||||
})
|
||||
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: detail?.title || 'VC 课程详情',
|
||||
query: `id=${detail?.id}`,
|
||||
imageUrl: detail?.cover_image_url
|
||||
}
|
||||
})
|
||||
|
||||
if (loading) return <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
|
||||
if (!detail) return <View className='page-container'><Text style={{color:'#fff'}}>Not Found</Text></View>
|
||||
|
||||
const formatDateTime = (dateStr: string) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const year = date.getFullYear()
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
const hour = date.getHours().toString().padStart(2, '0')
|
||||
const minute = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${year}/${month}/${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
const extractIframeSrc = (html: string) => {
|
||||
if (!html) return null
|
||||
const match = html.match(/src=["'](.*?)["']/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
const handleOpenWebview = (url: string) => {
|
||||
if (!url) return
|
||||
Taro.navigateTo({
|
||||
url: `/pages/webview/index?url=${encodeURIComponent(url)}`
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<ScrollView scrollY className='scroll-content'>
|
||||
{/* 封面图 */}
|
||||
{detail.cover_image_url && (
|
||||
<Image src={detail.cover_image_url} className='cover-image' mode='aspectFill' />
|
||||
)}
|
||||
|
||||
<View className='content-wrapper'>
|
||||
{/* 标题区 */}
|
||||
<View className='header-section'>
|
||||
<Text className='title'>{detail.title}</Text>
|
||||
<View className='tags-row'>
|
||||
<Text className='tag'>{typeMap[detail.course_type] || 'VC课程'}</Text>
|
||||
{detail.tag && <Text className='tag highlight'>{detail.tag}</Text>}
|
||||
</View>
|
||||
<Text className='price'>¥{detail.price}</Text>
|
||||
</View>
|
||||
|
||||
{/* 视频播放区域 */}
|
||||
{detail.is_video_course && (
|
||||
<View className='section video-section'>
|
||||
<Text className='section-title'>课程视频</Text>
|
||||
{detail.video_url ? (
|
||||
<Video
|
||||
src={detail.video_url}
|
||||
className='course-video'
|
||||
poster={detail.cover_image_url}
|
||||
controls
|
||||
autoplay={false}
|
||||
/>
|
||||
) : detail.video_embed_code ? (
|
||||
<View className='video-locked' onClick={() => {
|
||||
const src = extractIframeSrc(detail.video_embed_code)
|
||||
if (src) handleOpenWebview(src)
|
||||
else Taro.showToast({ title: '无法解析视频地址', icon: 'none' })
|
||||
}}>
|
||||
<Image src={detail.cover_image_url} className='locked-bg' mode='aspectFill' />
|
||||
<View className='locked-overlay'>
|
||||
<View className='lock-icon' style={{fontSize: '40px'}}>▶</View>
|
||||
<Text className='lock-text'>点击观看视频</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className='video-locked' onClick={handleLaunch}>
|
||||
<Image src={detail.cover_image_url} className='locked-bg' mode='aspectFill' />
|
||||
<View className='locked-overlay'>
|
||||
<View className='lock-icon'>🔒</View>
|
||||
<Text className='lock-text'>购买课程后解锁视频</Text>
|
||||
<Button className='btn-unlock'>立即解锁</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 讲师信息 */}
|
||||
<View className='section instructor-section'>
|
||||
<Text className='section-title'>讲师介绍</Text>
|
||||
<View className='instructor-row'>
|
||||
{detail.instructor_avatar_url ? (
|
||||
<Image src={detail.instructor_avatar_url} className='avatar' mode='aspectFill' />
|
||||
) : (
|
||||
<View className='avatar-placeholder'>
|
||||
<Text>讲师</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='instructor-info'>
|
||||
<Text className='name'>{detail.instructor} <Text className='title-tag'>{detail.instructor_title}</Text></Text>
|
||||
<Text className='desc'>{detail.instructor_desc}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 课程信息 */}
|
||||
<View className='section info-grid'>
|
||||
<View className='grid-item'>
|
||||
<Text className='label'>时长</Text>
|
||||
<Text className='value'>{detail.duration}</Text>
|
||||
</View>
|
||||
<View className='grid-item'>
|
||||
<Text className='label'>课时</Text>
|
||||
<Text className='value'>{detail.lesson_count}节</Text>
|
||||
</View>
|
||||
<View className='grid-item'>
|
||||
<Text className='label'>难度</Text>
|
||||
<Text className='value'>中级</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 开课时间 */}
|
||||
{detail.is_fixed_schedule && (detail.start_time || detail.end_time) && (
|
||||
<View className='section schedule-section'>
|
||||
<Text className='section-title'>开课时间</Text>
|
||||
<View className='schedule-box'>
|
||||
{detail.start_time && (
|
||||
<View className='time-row'>
|
||||
<Text className='label'>开始时间:</Text>
|
||||
<Text className='value'>{formatDateTime(detail.start_time)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{detail.end_time && (
|
||||
<View className='time-row'>
|
||||
<Text className='label'>结束时间:</Text>
|
||||
<Text className='value'>{formatDateTime(detail.end_time)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 课程简介 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>课程简介</Text>
|
||||
<Text className='desc-text'>{detail.description}</Text>
|
||||
</View>
|
||||
|
||||
{/* 详情长图 */}
|
||||
<View className='section detail-images'>
|
||||
<Text className='section-title'>课程详情</Text>
|
||||
{detail.display_detail_image || detail.detail_image_url ? (
|
||||
<Image
|
||||
src={detail.detail_image_url || detail.display_detail_image}
|
||||
className='detail-long-image'
|
||||
mode='widthFix'
|
||||
/>
|
||||
) : (
|
||||
<View className='placeholder-box'>
|
||||
<Text>暂无详情长图</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部栏 */}
|
||||
<View className='bottom-bar'>
|
||||
<View className='price-container'>
|
||||
<Text className='label'>总价:</Text>
|
||||
<Text className='amount'>¥{detail.price}</Text>
|
||||
</View>
|
||||
<Button
|
||||
className={`btn-buy ${detail.is_purchased ? 'disabled' : ''}`}
|
||||
onClick={() => !detail.is_purchased && handleLaunch()}
|
||||
disabled={detail.is_purchased}
|
||||
>
|
||||
{detail.is_purchased ? '已购买' : (detail.is_video_course ? '立即购买' : '立即报名')}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/courses/index.config.ts
Normal file
3
miniprogram/src/pages/courses/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: 'AR 体验馆'
|
||||
})
|
||||
148
miniprogram/src/pages/courses/index.scss
Normal file
148
miniprogram/src/pages/courses/index.scss
Normal file
@@ -0,0 +1,148 @@
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 4px;
|
||||
display: block;
|
||||
|
||||
.highlight {
|
||||
color: #00f0ff;
|
||||
}
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: #aaa;
|
||||
font-size: 28px;
|
||||
margin-top: 20px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.ar-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 30px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.ar-card {
|
||||
width: 100%; // Single column on small screens
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(0, 240, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.cover-box {
|
||||
height: 400px;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
.cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
color: #333;
|
||||
font-size: 80px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tag-container {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
background: rgba(0, 240, 255, 0.2);
|
||||
border: 1px solid #00f0ff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
.type-text {
|
||||
color: #00f0ff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&.special {
|
||||
background: rgba(255, 87, 34, 0.2);
|
||||
border: 1px solid #ff5722;
|
||||
.type-text {
|
||||
color: #ff5722;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 30px;
|
||||
|
||||
.item-title {
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 10px;
|
||||
.info-text {
|
||||
color: #aaa;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
color: #888;
|
||||
font-size: 26px;
|
||||
margin-bottom: 30px;
|
||||
min-height: 80px;
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn-start {
|
||||
background: transparent;
|
||||
border: 1px solid #00f0ff;
|
||||
color: #00f0ff;
|
||||
font-size: 28px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bg-decoration {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(circle at 50% 50%, rgba(0, 240, 255, 0.05) 0%, transparent 50%);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
81
miniprogram/src/pages/courses/index.tsx
Normal file
81
miniprogram/src/pages/courses/index.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { View, Text, Image, Button } from '@tarojs/components'
|
||||
import Taro, { useLoad, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getVBCourses } from '../../api'
|
||||
import './index.scss'
|
||||
|
||||
export default function CourseIndex() {
|
||||
const [courseList, setCourseList] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useLoad(() => {
|
||||
fetchCourses()
|
||||
})
|
||||
|
||||
const fetchCourses = async () => {
|
||||
try {
|
||||
const res: any = await getVBCourses()
|
||||
setCourseList(res.results || res)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: 'VC COURSES - 探索 VC 编程课程',
|
||||
path: '/pages/courses/index'
|
||||
}
|
||||
})
|
||||
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: 'VC COURSES - 探索 VC 编程课程'
|
||||
}
|
||||
})
|
||||
|
||||
const goDetail = (id: number) => {
|
||||
Taro.navigateTo({ url: `/pages/courses/detail?id=${id}` })
|
||||
}
|
||||
|
||||
if (loading) return <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='bg-decoration' />
|
||||
|
||||
<View className='header'>
|
||||
<Text className='title'>VC <Text className='highlight'>COURSES</Text></Text>
|
||||
<Text className='desc'>探索 VC 编程课程</Text>
|
||||
</View>
|
||||
|
||||
<View className='ar-grid'>
|
||||
{courseList.length === 0 ? (
|
||||
<View style={{ width: '100%', textAlign: 'center', color: '#666', marginTop: 50 }}>
|
||||
<Text>暂无 VC 课程内容</Text>
|
||||
</View>
|
||||
) : (
|
||||
courseList.map((item) => (
|
||||
<View key={item.id} className='ar-card' onClick={() => goDetail(item.id)}>
|
||||
<View className='cover-box'>
|
||||
{item.cover_image_url ? (
|
||||
<Image src={item.cover_image_url} className='cover-img' mode='aspectFill' />
|
||||
) : (
|
||||
<Text className='placeholder-icon'>VC</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className='content'>
|
||||
<Text className='item-title'>{item.title}</Text>
|
||||
<Text className='item-desc'>{item.description}</Text>
|
||||
<Button className='btn-start' onClick={(e) => { e.stopPropagation(); goDetail(item.id) }}>报名课程</Button>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
7
miniprogram/src/pages/forum/index.config.ts
Normal file
7
miniprogram/src/pages/forum/index.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '开发者社区',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundColor: '#000000',
|
||||
navigationBarBackgroundColor: '#000000',
|
||||
navigationBarTextStyle: 'white'
|
||||
})
|
||||
683
miniprogram/src/pages/forum/index.scss
Normal file
683
miniprogram/src/pages/forum/index.scss
Normal file
@@ -0,0 +1,683 @@
|
||||
.forum-page {
|
||||
min-height: 100vh;
|
||||
background-color: #121212; /* Darker background for modern feel */
|
||||
padding-bottom: 80px;
|
||||
color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
|
||||
/* Global Animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); box-shadow: 0 4px 12px rgba(0, 185, 107, 0.4); }
|
||||
50% { transform: scale(1.05); box-shadow: 0 8px 24px rgba(0, 185, 107, 0.6); }
|
||||
100% { transform: scale(1); box-shadow: 0 4px 12px rgba(0, 185, 107, 0.4); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
padding: 60px 20px 30px;
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.8) 0%, rgba(0,185,107,0.15) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
/* Subtle pattern overlay if desired */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-image: radial-gradient(#333 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
opacity: 0.1;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
font-size: 42px; /* Increased from 36px */
|
||||
font-weight: 800;
|
||||
margin-bottom: 16px;
|
||||
color: #fff;
|
||||
letter-spacing: -0.5px;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.5);
|
||||
z-index: 1;
|
||||
|
||||
.highlight {
|
||||
color: #00b96b;
|
||||
background: linear-gradient(45deg, #00b96b, #00ff9d);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
position: relative;
|
||||
color: #aaa;
|
||||
font-size: 19px; /* Increased from 17px */
|
||||
margin-bottom: 36px;
|
||||
font-weight: 500;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
z-index: 2;
|
||||
|
||||
.at-search-bar {
|
||||
flex: 1;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
|
||||
&::after { border-bottom: none; }
|
||||
|
||||
.at-search-bar__input-cnt {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 30px; /* More rounded */
|
||||
transition: all 0.3s ease;
|
||||
height: 56px; /* Taller touch target (from 48px) */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:focus-within {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
border-color: #00b96b;
|
||||
box-shadow: 0 0 0 2px rgba(0, 185, 107, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.at-search-bar__input {
|
||||
color: #fff;
|
||||
font-size: 18px; /* Larger input text (from 17px) */
|
||||
}
|
||||
|
||||
.at-search-bar__placeholder {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background: linear-gradient(135deg, #00b96b 0%, #009456 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
padding: 0 28px;
|
||||
font-size: 18px; /* Larger button text */
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 185, 107, 0.3);
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-container {
|
||||
margin: 0 16px 24px;
|
||||
background: #1e1e1e; /* Card background */
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
|
||||
.section-title {
|
||||
font-size: 20px; /* Increased from 16px */
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.announcement-swiper {
|
||||
height: 48px;
|
||||
|
||||
.announcement-item {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
border-left: 4px solid #ff4d4f;
|
||||
|
||||
.item-text {
|
||||
font-size: 16px; /* Increased from 14px */
|
||||
color: #ddd;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.star-users-scroll {
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
padding-bottom: 8px; /* Scrollbar space if visible */
|
||||
|
||||
.star-user-card {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-right: 18px;
|
||||
width: 90px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 16px 10px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 60px; /* Increased from 48px */
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffd700;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px; /* Increased from 12px */
|
||||
font-weight: 600;
|
||||
color: #eee;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-title {
|
||||
font-size: 12px; /* Increased from 10px */
|
||||
color: #888;
|
||||
background: rgba(255, 215, 0, 0.1);
|
||||
color: #ffd700;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-wrapper {
|
||||
background-color: transparent; /* Changed from black */
|
||||
margin-bottom: 16px;
|
||||
padding: 0 12px;
|
||||
|
||||
/* Override Taro UI default white background */
|
||||
.at-tabs {
|
||||
background-color: transparent;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.at-tabs__header {
|
||||
background-color: transparent;
|
||||
border-bottom: none; /* Removed border */
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.at-tabs__item {
|
||||
color: #888;
|
||||
font-size: 18px; /* Increased from 17px */
|
||||
padding: 16px 24px; /* Larger touch target */
|
||||
transition: all 0.3s;
|
||||
|
||||
&--active {
|
||||
color: #fff; /* White active text */
|
||||
font-weight: 700;
|
||||
font-size: 22px; /* Increased from 20px */
|
||||
text-shadow: 0 0 10px rgba(0, 185, 107, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.at-tabs__item-underline {
|
||||
background-color: #00b96b;
|
||||
height: 5px; /* Slightly thicker */
|
||||
border-radius: 3px;
|
||||
bottom: 6px;
|
||||
width: 32px !important; /* Short underline style */
|
||||
margin-left: calc(50% - 16px); /* Center specific width underline */
|
||||
}
|
||||
}
|
||||
|
||||
.topic-list {
|
||||
padding: 12px 18px;
|
||||
|
||||
.topic-card {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
border-radius: 20px;
|
||||
padding: 28px; /* Increased from 24px */
|
||||
margin-bottom: 28px; /* Increased spacing */
|
||||
position: relative;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
animation: fadeInUp 0.5s ease-out backwards; /* Apply animation */
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
&.pinned {
|
||||
border-color: rgba(0, 185, 107, 0.3);
|
||||
background: linear-gradient(180deg, rgba(0, 185, 107, 0.05) 0%, #1e1e1e 100%);
|
||||
box-shadow: 0 8px 20px rgba(0, 185, 107, 0.1);
|
||||
}
|
||||
|
||||
/* Animation delay for staggered effect - simplistic approach (nth-child logic is better in CSS-in-JS or fixed list) */
|
||||
&:nth-child(1) { animation-delay: 0.1s; }
|
||||
&:nth-child(2) { animation-delay: 0.2s; }
|
||||
&:nth-child(3) { animation-delay: 0.3s; }
|
||||
&:nth-child(4) { animation-delay: 0.4s; }
|
||||
&:nth-child(5) { animation-delay: 0.5s; }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
|
||||
.tag {
|
||||
font-size: 14px; /* Slightly larger */
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #aaa;
|
||||
|
||||
&.pinned-tag {
|
||||
background: rgba(255, 77, 79, 0.15);
|
||||
color: #ff4d4f;
|
||||
border: 1px solid rgba(255, 77, 79, 0.3);
|
||||
}
|
||||
|
||||
&.verified-tag {
|
||||
background: rgba(0, 185, 107, 0.15);
|
||||
color: #00b96b;
|
||||
border: 1px solid rgba(0, 185, 107, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 26px; /* Increased from 22px */
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex: 1;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
font-size: 19px; /* Increased from 17px */
|
||||
color: #ccc; /* Slightly brighter for better contrast */
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.8;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3; /* Show 3 lines */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
max-height: 240px; /* Taller image preview */
|
||||
object-fit: cover;
|
||||
display: block; /* Remove inline spacing */
|
||||
}
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 15px; /* Increased from 14px */
|
||||
color: #888;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
|
||||
.author-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.avatar {
|
||||
width: 48px; /* Larger avatar */
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.nickname {
|
||||
color: #ccc;
|
||||
font-weight: 500;
|
||||
|
||||
&.star {
|
||||
color: #ffd700;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.at-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
|
||||
&::before {
|
||||
content: '📭'; /* Simple icon */
|
||||
display: block;
|
||||
font-size: 50px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 30px;
|
||||
bottom: 60px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, #00b96b 0%, #009456 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 20px rgba(0, 185, 107, 0.5);
|
||||
z-index: 100;
|
||||
animation: pulse 3s infinite;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.at-icon {
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Expert Modal Styles - Tech & Dark Theme */
|
||||
.at-float-layout {
|
||||
.at-float-layout__overlay {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.at-float-layout__container {
|
||||
background-color: #0f1216 !important; /* Deep dark tech background */
|
||||
border-top: 1px solid rgba(0, 185, 107, 0.3); /* Tech green border */
|
||||
box-shadow: 0 -10px 40px rgba(0, 185, 107, 0.15);
|
||||
border-radius: 24px 24px 0 0; /* More rounded top */
|
||||
|
||||
.layout-header {
|
||||
background-color: #15191f;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 20px 28px;
|
||||
|
||||
.layout-header__title {
|
||||
color: #00b96b; /* Tech green */
|
||||
font-size: 20px; /* Increased from 18px */
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 0 0 10px rgba(0, 185, 107, 0.3);
|
||||
}
|
||||
|
||||
.layout-header__btn-close {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-body {
|
||||
background-color: #0f1216;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expert-modal-content {
|
||||
padding: 36px 28px 70px;
|
||||
color: #fff;
|
||||
background: radial-gradient(circle at 50% 10%, rgba(0, 185, 107, 0.08), transparent 60%);
|
||||
|
||||
.expert-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 40px;
|
||||
position: relative;
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
margin-bottom: 28px;
|
||||
|
||||
.expert-avatar {
|
||||
width: 120px; /* Increased from 100px */
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffd700; /* Gold for expert */
|
||||
box-shadow: 0 0 30px rgba(255, 215, 0, 0.25), inset 0 0 10px rgba(255, 215, 0, 0.2);
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.avatar-ring {
|
||||
position: absolute;
|
||||
top: -14px; left: -14px; right: -14px; bottom: -14px;
|
||||
border-radius: 50%;
|
||||
border: 1px dashed rgba(255, 215, 0, 0.5);
|
||||
animation: spin 12s linear infinite;
|
||||
z-index: 1;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px; left: -8px; right: -8px; bottom: -8px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 185, 107, 0.3); /* Outer green ring */
|
||||
animation: spin 8s reverse linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expert-info {
|
||||
text-align: center;
|
||||
|
||||
.expert-name {
|
||||
font-size: 30px; /* Increased from 26px */
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
margin-bottom: 14px;
|
||||
text-shadow: 0 0 15px rgba(0, 185, 107, 0.5);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.expert-title-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: linear-gradient(90deg, rgba(255, 215, 0, 0.15), rgba(255, 215, 0, 0.05));
|
||||
padding: 8px 20px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 215, 0, 0.4);
|
||||
box-shadow: 0 0 15px rgba(255, 215, 0, 0.15);
|
||||
|
||||
.at-icon {
|
||||
text-shadow: 0 0 5px #ffd700;
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: 16px; /* Increased from 14px */
|
||||
color: #ffd700;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expert-skills-section {
|
||||
margin-bottom: 36px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 24px;
|
||||
padding: 28px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
/* Tech corner accent */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 12px; height: 12px;
|
||||
border-top: 3px solid #00b96b;
|
||||
border-left: 3px solid #00b96b;
|
||||
border-radius: 4px 0 0 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0; right: 0;
|
||||
width: 12px; height: 12px;
|
||||
border-bottom: 3px solid #00b96b;
|
||||
border-right: 3px solid #00b96b;
|
||||
border-radius: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.label-text {
|
||||
font-size: 17px; /* Increased from 15px */
|
||||
font-weight: 700;
|
||||
color: #00b96b;
|
||||
margin-right: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.label-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(0, 185, 107, 0.5), transparent);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.skills-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
|
||||
.skill-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(0, 185, 107, 0.08);
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 185, 107, 0.25);
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
/* Left accent bar */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 4px; height: 100%;
|
||||
background: #00b96b;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(0, 185, 107, 0.2);
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 0 10px rgba(0, 185, 107, 0.2);
|
||||
}
|
||||
|
||||
.skill-icon {
|
||||
width: 24px; /* Increased from 20px */
|
||||
height: 24px;
|
||||
margin-right: 10px;
|
||||
filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));
|
||||
}
|
||||
|
||||
.skill-text {
|
||||
font-size: 15px; /* Increased from 13px */
|
||||
color: #e0e0e0;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
374
miniprogram/src/pages/forum/index.tsx
Normal file
374
miniprogram/src/pages/forum/index.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import Taro, { usePullDownRefresh, useReachBottom, useDidShow, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { View, Text, Image, Swiper, SwiperItem, ScrollView } from '@tarojs/components'
|
||||
import { AtSearchBar, AtTabs, AtIcon, AtActivityIndicator, AtFloatLayout } from 'taro-ui'
|
||||
import { getTopics, getAnnouncements, getStarUsers } from '../../api'
|
||||
import './index.scss'
|
||||
|
||||
const ForumList = () => {
|
||||
const [topics, setTopics] = useState<any[]>([])
|
||||
const [announcements, setAnnouncements] = useState<any[]>([])
|
||||
const [starUsers, setStarUsers] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [currentTab, setCurrentTab] = useState(0)
|
||||
const isMounted = useRef(false)
|
||||
|
||||
// Expert Detail
|
||||
const [showExpert, setShowExpert] = useState(false)
|
||||
const [selectedExpert, setSelectedExpert] = useState<any>(null)
|
||||
|
||||
const categories = [
|
||||
{ title: '全部话题', key: 'all' },
|
||||
{ title: '技术讨论', key: 'discussion' },
|
||||
{ title: '求助问答', key: 'help' },
|
||||
{ title: '经验分享', key: 'share' },
|
||||
{ title: '官方公告', key: 'notice' },
|
||||
]
|
||||
|
||||
const fetchExtraData = async () => {
|
||||
try {
|
||||
const [announceRes, starRes] = await Promise.all([
|
||||
getAnnouncements(),
|
||||
getStarUsers()
|
||||
])
|
||||
setAnnouncements(Array.isArray(announceRes) ? announceRes : (announceRes.results || announceRes.data || []))
|
||||
setStarUsers(Array.isArray(starRes) ? starRes : (starRes.data || []))
|
||||
} catch (err) {
|
||||
console.error('Fetch extra data failed', err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchList = async (reset = false) => {
|
||||
if (loading) return
|
||||
if (!reset && !hasMore) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const currentPage = reset ? 1 : page
|
||||
const params: any = {
|
||||
page: currentPage,
|
||||
search: searchText
|
||||
}
|
||||
|
||||
if (categories[currentTab].key !== 'all') {
|
||||
params.category = categories[currentTab].key
|
||||
}
|
||||
|
||||
const res = await getTopics(params)
|
||||
let newTopics: any[] = []
|
||||
let hasNextPage = false
|
||||
|
||||
if (Array.isArray(res)) {
|
||||
newTopics = res
|
||||
hasNextPage = false
|
||||
} else {
|
||||
newTopics = res.results || res.data || []
|
||||
hasNextPage = !!res.next
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
setTopics(newTopics)
|
||||
} else {
|
||||
setTopics(prev => [...prev, ...newTopics])
|
||||
}
|
||||
|
||||
setHasMore(hasNextPage)
|
||||
if (hasNextPage) {
|
||||
setPage(currentPage + 1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
Taro.stopPullDownRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
fetchList(true)
|
||||
fetchExtraData()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted.current) {
|
||||
isMounted.current = true
|
||||
return
|
||||
}
|
||||
fetchList(true)
|
||||
// fetchExtraData is covered by useDidShow usually, but if tab change needs it? likely not.
|
||||
}, [currentTab])
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
fetchList(true)
|
||||
fetchExtraData()
|
||||
})
|
||||
|
||||
useReachBottom(() => {
|
||||
fetchList(false)
|
||||
})
|
||||
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: 'Quant Speed Developer Community',
|
||||
path: '/pages/forum/index'
|
||||
}
|
||||
})
|
||||
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: 'Quant Speed Developer Community'
|
||||
}
|
||||
})
|
||||
|
||||
const handleSearch = (value) => {
|
||||
setSearchText(value)
|
||||
}
|
||||
|
||||
const onSearchConfirm = () => {
|
||||
fetchList(true)
|
||||
}
|
||||
|
||||
const handleTabClick = (value) => {
|
||||
setCurrentTab(value)
|
||||
// useEffect will trigger fetch
|
||||
}
|
||||
|
||||
const navigateToDetail = (id) => {
|
||||
Taro.navigateTo({
|
||||
url: `/subpackages/forum/detail/index?id=${id}`
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToCreate = () => {
|
||||
const token = Taro.getStorageSync('token')
|
||||
if (!token) {
|
||||
Taro.showToast({ title: '请先登录', icon: 'none' })
|
||||
// Optional: Trigger login flow
|
||||
return
|
||||
}
|
||||
Taro.navigateTo({
|
||||
url: '/subpackages/forum/create/index'
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToActivity = () => {
|
||||
Taro.navigateTo({
|
||||
url: '/subpackages/forum/activity/index'
|
||||
})
|
||||
}
|
||||
|
||||
const getCategoryLabel = (cat) => {
|
||||
const map = {
|
||||
'help': '求助',
|
||||
'share': '分享',
|
||||
'notice': '公告',
|
||||
'discussion': '讨论'
|
||||
}
|
||||
return map[cat] || '讨论'
|
||||
}
|
||||
|
||||
// Helper to extract first image from markdown
|
||||
const getCoverImage = (content) => {
|
||||
const match = content.match(/!\[.*?\]\((.*?)\)/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
const stripMarkdown = (content) => {
|
||||
return content.replace(/!\[.*?\]\(.*?\)/g, '[图片]').replace(/[#*`]/g, '')
|
||||
}
|
||||
|
||||
const showUserTitle = (e, user) => {
|
||||
e.stopPropagation()
|
||||
if (user.is_star || user.title) {
|
||||
setSelectedExpert(user)
|
||||
setShowExpert(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='forum-page'>
|
||||
<View className='hero-section'>
|
||||
<View className='title'>
|
||||
<Text className='highlight'>Quant Speed</Text> Developer Community
|
||||
</View>
|
||||
<View className='subtitle'>技术交流 · 硬件开发 · 官方支持 · 量迹生态</View>
|
||||
|
||||
<View className='search-box'>
|
||||
<AtSearchBar
|
||||
value={searchText}
|
||||
onChange={handleSearch}
|
||||
onActionClick={onSearchConfirm}
|
||||
onConfirm={onSearchConfirm}
|
||||
placeholder='搜索感兴趣的话题...'
|
||||
/>
|
||||
<View className='create-btn' onClick={navigateToCreate}>
|
||||
<AtIcon value='add' size='20' color='#fff' />
|
||||
<Text style={{marginLeft: '6px'}}>发布</Text>
|
||||
</View>
|
||||
<View className='create-btn' onClick={navigateToActivity} style={{marginLeft: '10px', background: 'linear-gradient(135deg, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0.05) 100%)', backdropFilter: 'blur(5px)'}}>
|
||||
<AtIcon value='calendar' size='20' color='#fff' />
|
||||
<Text style={{marginLeft: '6px'}}>活动</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Announcements Section */}
|
||||
{announcements.length > 0 && (
|
||||
<View className='section-container'>
|
||||
<View className='section-header'>
|
||||
<AtIcon value='volume-plus' size='16' color='#ff4d4f' />
|
||||
<Text className='section-title'>社区公告</Text>
|
||||
</View>
|
||||
<Swiper
|
||||
className='announcement-swiper'
|
||||
vertical
|
||||
autoplay
|
||||
circular
|
||||
interval={3000}
|
||||
>
|
||||
{announcements.map(item => (
|
||||
<SwiperItem key={item.id}>
|
||||
<View className='announcement-item'>
|
||||
<Text className='item-text'>{item.title}</Text>
|
||||
</View>
|
||||
</SwiperItem>
|
||||
))}
|
||||
</Swiper>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Star Users Section */}
|
||||
{starUsers.length > 0 && (
|
||||
<View className='section-container'>
|
||||
<View className='section-header'>
|
||||
<AtIcon value='star' size='16' color='#ffd700' />
|
||||
<Text className='section-title'>技术专家榜</Text>
|
||||
</View>
|
||||
<ScrollView scrollX className='star-users-scroll'>
|
||||
{starUsers.map(user => (
|
||||
<View key={user.id} className='star-user-card' onClick={(e) => showUserTitle(e, user)}>
|
||||
<Image src={user.avatar_url} className='user-avatar' />
|
||||
<Text className='user-name'>{user.nickname}</Text>
|
||||
<Text className='user-title'>{user.title || '专家'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='tabs-wrapper'>
|
||||
<AtTabs
|
||||
current={currentTab}
|
||||
tabList={categories}
|
||||
onClick={handleTabClick}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='topic-list'>
|
||||
{topics.map(item => (
|
||||
<View
|
||||
key={item.id}
|
||||
className={`topic-card ${item.is_pinned ? 'pinned' : ''}`}
|
||||
onClick={() => navigateToDetail(item.id)}
|
||||
>
|
||||
<View className='card-header'>
|
||||
{item.is_pinned && <Text className='tag pinned-tag'>置顶</Text>}
|
||||
<Text className='tag'>{getCategoryLabel(item.category)}</Text>
|
||||
{item.is_verified_owner && <Text className='tag verified-tag'>认证</Text>}
|
||||
<Text className='card-title'>{item.title}</Text>
|
||||
</View>
|
||||
|
||||
<View className='card-content'>
|
||||
{stripMarkdown(item.content)}
|
||||
</View>
|
||||
|
||||
{getCoverImage(item.content) && (
|
||||
<View className='card-image'>
|
||||
<Image src={getCoverImage(item.content)} mode='aspectFill' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='card-footer'>
|
||||
<View className='author-info'>
|
||||
<Image
|
||||
className='avatar'
|
||||
src={item.author_info?.avatar_url || 'https://via.placeholder.com/30'}
|
||||
onClick={(e) => showUserTitle(e, item.author_info)}
|
||||
/>
|
||||
<Text className={`nickname ${item.author_info?.is_star ? 'star' : ''}`}>
|
||||
{item.author_info?.nickname || '匿名'}
|
||||
</Text>
|
||||
<Text style={{color: '#555', fontSize: '10px'}}>•</Text>
|
||||
<Text style={{color: '#666', fontSize: '11px'}}>{new Date(item.created_at).toLocaleDateString()}</Text>
|
||||
</View>
|
||||
<View className='stats'>
|
||||
<View className='stat-item'>
|
||||
<AtIcon value='eye' size='14' color='#777' />
|
||||
<Text>{item.view_count || 0}</Text>
|
||||
</View>
|
||||
<View className='stat-item'>
|
||||
<AtIcon value='heart' size='14' color='#777' />
|
||||
<Text>{item.like_count || 0}</Text>
|
||||
</View>
|
||||
<View className='stat-item'>
|
||||
<AtIcon value='message' size='14' color='#777' />
|
||||
<Text>{item.replies?.length || 0}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{loading && <View style={{textAlign: 'center', padding: 10}}><AtActivityIndicator color='#00b96b' /></View>}
|
||||
{!loading && topics.length === 0 && <View className='empty-state'>暂无内容</View>}
|
||||
</View>
|
||||
|
||||
<View className='fab' onClick={navigateToCreate}>
|
||||
<AtIcon value='add' size='24' color='#fff' />
|
||||
</View>
|
||||
|
||||
<AtFloatLayout isOpened={showExpert} title="技术专家信息" onClose={() => setShowExpert(false)}>
|
||||
{selectedExpert && (
|
||||
<View className='expert-modal-content'>
|
||||
<View className='expert-header'>
|
||||
<View className='avatar-container'>
|
||||
<Image src={selectedExpert.avatar_url} className='expert-avatar' />
|
||||
<View className='avatar-ring'></View>
|
||||
</View>
|
||||
<View className='expert-info'>
|
||||
<View className='expert-name'>{selectedExpert.nickname}</View>
|
||||
<View className='expert-title-badge'>
|
||||
<AtIcon value='sketch' size='14' color='#ffd700' />
|
||||
<Text>{selectedExpert.title || '技术专家'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{selectedExpert.skills && selectedExpert.skills.length > 0 && (
|
||||
<View className='expert-skills-section'>
|
||||
<View className='section-label'>
|
||||
<Text className='label-text'>擅长技能</Text>
|
||||
<View className='label-line'></View>
|
||||
</View>
|
||||
<View className='skills-grid'>
|
||||
{selectedExpert.skills.map((skill, idx) => (
|
||||
<View key={idx} className='skill-tag'>
|
||||
{typeof skill === 'object' && skill.icon && <Image src={skill.icon} className='skill-icon' />}
|
||||
<Text className='skill-text'>{typeof skill === 'object' ? skill.text : skill}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</AtFloatLayout>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default ForumList
|
||||
3
miniprogram/src/pages/goods/detail.config.ts
Normal file
3
miniprogram/src/pages/goods/detail.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '商品详情'
|
||||
})
|
||||
365
miniprogram/src/pages/goods/detail.scss
Normal file
365
miniprogram/src/pages/goods/detail.scss
Normal file
@@ -0,0 +1,365 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: #050505;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.loading-screen, .error-screen {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #00f0ff;
|
||||
background: #000;
|
||||
font-size: 28px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding-bottom: 200px; // Ensure scroll space for bottom bar
|
||||
}
|
||||
|
||||
// Animations
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(40px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-20px); }
|
||||
100% { transform: translateY(0px); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0% { box-shadow: 0 0 10px rgba(0, 185, 107, 0.4); }
|
||||
50% { box-shadow: 0 0 25px rgba(0, 185, 107, 0.8), 0 0 10px rgba(0, 240, 255, 0.4); }
|
||||
100% { box-shadow: 0 0 10px rgba(0, 185, 107, 0.4); }
|
||||
}
|
||||
|
||||
@keyframes scanline {
|
||||
0% { top: -10%; opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { top: 110%; opacity: 0; }
|
||||
}
|
||||
|
||||
// Hero Section
|
||||
.hero-section {
|
||||
position: relative;
|
||||
margin-bottom: 40px;
|
||||
animation: fadeInUp 0.8s ease-out;
|
||||
|
||||
.image-container {
|
||||
width: 100%;
|
||||
min-height: 600px; // Slightly reduced to fit better
|
||||
background: radial-gradient(circle at center, rgba(0, 240, 255, 0.05) 0%, transparent 70%);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
// Scanline effect
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, transparent, rgba(0, 240, 255, 0.5), transparent);
|
||||
animation: scanline 3s linear infinite;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hero-img {
|
||||
width: 75%;
|
||||
height: auto;
|
||||
display: block;
|
||||
filter: drop-shadow(0 0 40px rgba(0, 240, 255, 0.2));
|
||||
animation: float 6s ease-in-out infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.placeholder-box {
|
||||
.icon-bolt { font-size: 150px; color: #00b96b; text-shadow: 0 0 30px rgba(0, 185, 107, 0.5); }
|
||||
}
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
padding: 0 40px;
|
||||
margin-top: -40px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
.hero-title {
|
||||
font-size: 60px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.1;
|
||||
text-shadow: 0 0 20px rgba(0, 240, 255, 0.3);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 28px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
margin-bottom: 32px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.tags-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
|
||||
.tag {
|
||||
padding: 10px 28px;
|
||||
border-radius: 4px; // Techy sharp corners
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(10px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// Tech border effect
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 4px; height: 100%;
|
||||
}
|
||||
|
||||
&.cyan {
|
||||
color: #00f0ff;
|
||||
background: rgba(0, 240, 255, 0.08);
|
||||
border: 1px solid rgba(0, 240, 255, 0.3);
|
||||
&::before { background: #00f0ff; }
|
||||
}
|
||||
&.blue {
|
||||
color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
&::before { background: #3b82f6; }
|
||||
}
|
||||
&.purple {
|
||||
color: #a855f7;
|
||||
background: rgba(168, 85, 247, 0.08);
|
||||
border: 1px solid rgba(168, 85, 247, 0.3);
|
||||
&::before { background: #a855f7; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stats Card (HUD Style)
|
||||
.stats-card {
|
||||
margin: 40px 40px 60px;
|
||||
padding: 30px !important;
|
||||
background: rgba(20, 20, 20, 0.6) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
animation: fadeInUp 0.8s ease-out 0.2s backwards;
|
||||
|
||||
// Corner accents
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px; left: -1px;
|
||||
width: 20px; height: 20px;
|
||||
border-top: 2px solid #00b96b;
|
||||
border-left: 2px solid #00b96b;
|
||||
border-top-left-radius: 12px;
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px; right: -1px;
|
||||
width: 20px; height: 20px;
|
||||
border-bottom: 2px solid #00b96b;
|
||||
border-right: 2px solid #00b96b;
|
||||
border-bottom-right-radius: 12px;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
.label { font-size: 24px; color: #666; flex: 1; text-transform: uppercase; letter-spacing: 1px; }
|
||||
}
|
||||
|
||||
.value-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: baseline;
|
||||
|
||||
.price-box {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
.symbol { font-size: 32px; color: #00b96b; font-weight: bold; margin-right: 4px; }
|
||||
.price {
|
||||
font-size: 72px;
|
||||
color: #00b96b;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 25px rgba(0, 185, 107, 0.4);
|
||||
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; // Ensure clean number font
|
||||
}
|
||||
}
|
||||
|
||||
.stock-box {
|
||||
.stock { font-size: 36px; color: #fff; font-weight: bold; }
|
||||
.unit { font-size: 24px; color: #666; margin-left: 6px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Features Section
|
||||
.features-section {
|
||||
padding: 0 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
margin-bottom: 60px;
|
||||
|
||||
.feature-card {
|
||||
display: flex;
|
||||
flex-direction: row; // Change to row for better list layout
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
background: rgba(255, 255, 255, 0.03) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05) !important;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
animation: fadeInUp 0.8s ease-out;
|
||||
// Stagger animations manually or via JS (here simplified)
|
||||
|
||||
.feature-icon-box {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-right: 30px;
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
.f-icon { font-size: 50px; color: #00b96b; }
|
||||
.f-icon-img { width: 60px; height: 60px; object-fit: contain; }
|
||||
}
|
||||
|
||||
.feature-text {
|
||||
flex: 1;
|
||||
.f-title { font-size: 32px; font-weight: bold; color: #fff; margin-bottom: 10px; display: block; }
|
||||
.f-desc { font-size: 24px; color: #888; line-height: 1.5; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-image-section {
|
||||
width: 100%;
|
||||
margin-bottom: 40px;
|
||||
position: relative;
|
||||
|
||||
// Decorative line top
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100px;
|
||||
height: 4px;
|
||||
background: #333;
|
||||
margin: 0 auto 40px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.long-detail-img { width: 100%; height: auto; display: block; }
|
||||
}
|
||||
|
||||
.footer-spacer { height: 200px; }
|
||||
|
||||
// Bottom Bar
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 30px;
|
||||
right: 30px;
|
||||
height: 110px;
|
||||
z-index: 100;
|
||||
border-radius: 55px; // Fully rounded capsule
|
||||
background: rgba(20, 20, 20, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 10px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.action-row {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.btn-add-cart {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
border-radius: 45px 0 0 45px;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
margin: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active { background: rgba(255, 255, 255, 0.2); }
|
||||
}
|
||||
|
||||
.btn-buy-now {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
border-radius: 0 45px 45px 0;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
border: none;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #00b96b 0%, #00f0ff 100%);
|
||||
color: #000; // Black text for high contrast on neon
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: pulse-glow 3s infinite;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.cart-icon {
|
||||
font-size: 36px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
192
miniprogram/src/pages/goods/detail.tsx
Normal file
192
miniprogram/src/pages/goods/detail.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { View, Text, Image, ScrollView, Button } from '@tarojs/components'
|
||||
import Taro, { useRouter, useLoad, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getConfigDetail } from '../../api'
|
||||
import { checkLogin } from '../../utils/auth'
|
||||
import ParticleBackground from '../../components/ParticleBackground'
|
||||
import { addToCart } from '../../utils/cart'
|
||||
import './detail.scss'
|
||||
//
|
||||
export default function Detail() {
|
||||
const router = useRouter()
|
||||
const { id } = router.params
|
||||
const [product, setProduct] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useLoad(() => {
|
||||
if (id) fetchDetail(id)
|
||||
})
|
||||
|
||||
const fetchDetail = async (id) => {
|
||||
try {
|
||||
const res = await getConfigDetail(id)
|
||||
setProduct(res)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: product?.name || '商品详情',
|
||||
path: `/pages/goods/detail?id=${product?.id}`,
|
||||
imageUrl: product?.static_image_url
|
||||
}
|
||||
})
|
||||
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: product?.name || '商品详情',
|
||||
query: `id=${product?.id}`,
|
||||
imageUrl: product?.static_image_url
|
||||
}
|
||||
})
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!product) return
|
||||
addToCart(product)
|
||||
}
|
||||
|
||||
const buyNow = () => {
|
||||
if (!checkLogin()) return
|
||||
if (!product) return
|
||||
Taro.navigateTo({
|
||||
url: `/pages/order/checkout?id=${product.id}&quantity=1`
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) return <View className='loading-screen'><Text>Loading...</Text></View>
|
||||
if (!product) return <View className='error-screen'><Text>Product Not Found</Text></View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<ParticleBackground />
|
||||
<ScrollView scrollY className='content'>
|
||||
{/* Hero Section */}
|
||||
<View className='hero-section'>
|
||||
<View className='image-container'>
|
||||
{product.static_image_url ? (
|
||||
<Image src={product.static_image_url} mode='widthFix' className='hero-img' />
|
||||
) : (
|
||||
<View className='placeholder-box'>
|
||||
<Text className='icon-bolt'>⚡</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='hero-overlay' />
|
||||
</View>
|
||||
|
||||
<View className='hero-content'>
|
||||
<Text className='hero-title'>{product.name}</Text>
|
||||
<Text className='hero-desc'>{product.description}</Text>
|
||||
|
||||
<View className='tags-row'>
|
||||
<View className='tag cyan'><Text>{product.chip_type}</Text></View>
|
||||
{product.has_camera && <View className='tag blue'><Text>高清摄像头</Text></View>}
|
||||
{product.has_microphone && <View className='tag purple'><Text>阵列麦克风</Text></View>}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stats Section */}
|
||||
<View className='stats-card'>
|
||||
<View className='label-row'>
|
||||
<Text className='label'>售价</Text>
|
||||
<Text className='label' style={{textAlign: 'right'}}>库存</Text>
|
||||
</View>
|
||||
<View className='value-row'>
|
||||
<View className='price-box'>
|
||||
<Text className='symbol'>¥</Text>
|
||||
<Text className='price'>{product.price}</Text>
|
||||
</View>
|
||||
<View className='stock-box'>
|
||||
<Text className='stock'>{product.stock}</Text>
|
||||
<Text className='unit'>件</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Features Section */}
|
||||
<View className='features-section'>
|
||||
{product.features && product.features.length > 0 ? (
|
||||
product.features.map((f, idx) => {
|
||||
let iconContent
|
||||
if (f.display_icon) {
|
||||
iconContent = <Image src={f.display_icon} className='f-icon-img' />
|
||||
} else if (f.icon_url) {
|
||||
iconContent = <Image src={f.icon_url} className='f-icon-img' />
|
||||
} else {
|
||||
let iconChar = '⭐'
|
||||
let iconColor = '#00b96b'
|
||||
switch(f.icon_name) {
|
||||
case 'SafetyCertificate': iconChar = '🛡'; break;
|
||||
case 'Eye': iconChar = '👁'; iconColor = '#3b82f6'; break;
|
||||
case 'Thunderbolt': iconChar = '⚡'; iconColor = '#faad14'; break;
|
||||
default: break;
|
||||
}
|
||||
iconContent = <Text className='f-icon' style={{color: iconColor}}>{iconChar}</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={idx} className='feature-card'>
|
||||
<View className='feature-icon-box'>
|
||||
{iconContent}
|
||||
</View>
|
||||
<View className='feature-text'>
|
||||
<Text className='f-title'>{f.title}</Text>
|
||||
<Text className='f-desc'>{f.description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<View className='feature-card'>
|
||||
<View className='feature-icon-box'>
|
||||
<Text className='f-icon'>🛡</Text>
|
||||
</View>
|
||||
<View className='feature-text'>
|
||||
<Text className='f-title'>工业级安全标准</Text>
|
||||
<Text className='f-desc'>采用军工级加密芯片,保障您的数据隐私安全。无论是边缘计算还是云端同步,全程加密传输。</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='feature-card'>
|
||||
<View className='feature-icon-box'>
|
||||
<Text className='f-icon' style={{color: '#3b82f6'}}>👁</Text>
|
||||
</View>
|
||||
<View className='feature-text'>
|
||||
<Text className='f-title' style={{color: '#3b82f6'}}>超清视觉感知</Text>
|
||||
<Text className='f-desc'>搭载 4K 高清摄像头与 AI 视觉算法,实时捕捉每一个细节。支持人脸识别、物体检测等。</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Detail Image */}
|
||||
{(product.display_detail_image || product.detail_image_url) && (
|
||||
<View className='detail-image-section'>
|
||||
<Image src={product.display_detail_image || product.detail_image_url} mode='widthFix' className='long-detail-img' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='footer-spacer' />
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<View className='bottom-bar'>
|
||||
<View className='action-row'>
|
||||
<Button className='btn-add-cart' onClick={handleAddToCart}>
|
||||
<Text>加入购物车</Text>
|
||||
</Button>
|
||||
<Button className='btn-buy-now' onClick={buyNow}>
|
||||
<Text className='cart-icon'>🛒</Text>
|
||||
<Text>立即购买</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/index/index.config.ts
Normal file
3
miniprogram/src/pages/index/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: 'Quant Speed Market'
|
||||
})
|
||||
570
miniprogram/src/pages/index/index.scss
Normal file
570
miniprogram/src/pages/index/index.scss
Normal file
@@ -0,0 +1,570 @@
|
||||
.page-container {
|
||||
height: 100vh;
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-main);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
// Ambient Light 1 (Cyan)
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -10%;
|
||||
left: -10%;
|
||||
width: 60%;
|
||||
height: 40%;
|
||||
background: radial-gradient(circle, rgba(0, 240, 255, 0.15) 0%, transparent 70%);
|
||||
filter: blur(80px);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Ambient Light 2 (Green/Purple mix)
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 10%;
|
||||
right: -10%;
|
||||
width: 50%;
|
||||
height: 40%;
|
||||
background: radial-gradient(circle, rgba(189, 0, 255, 0.1) 0%, transparent 70%);
|
||||
filter: blur(80px);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.content-scroll {
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.scroll-inner {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 80px 24px 60px; // 增加头部留白
|
||||
position: relative;
|
||||
|
||||
.logo-box {
|
||||
margin-bottom: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.logo-img {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
margin-bottom: 20px;
|
||||
filter: drop-shadow(0 0 25px rgba(0, 240, 255, 0.5));
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 48px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
letter-spacing: 8px;
|
||||
text-shadow: 0 0 30px rgba(0, 240, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.title-container {
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-cyan);
|
||||
text-shadow: 0 0 20px rgba(0, 240, 255, 0.4);
|
||||
}
|
||||
|
||||
.cursor {
|
||||
font-size: 40px;
|
||||
color: #fff;
|
||||
margin-left: 8px;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 28px;
|
||||
line-height: 1.8; // 增加行高
|
||||
display: block;
|
||||
padding: 0 40px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
// News Ticker Styles
|
||||
.news-section {
|
||||
padding: 0 32px 40px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
.news-inner {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.news-icon-box {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 16px;
|
||||
|
||||
.news-icon {
|
||||
font-size: 32px;
|
||||
animation: pulse-icon 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.news-swiper {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.news-item {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.news-tag {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #000;
|
||||
background: var(--primary-cyan);
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-size: 26px;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-icon {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.8; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
// Activity Banner Styles
|
||||
.activity-section {
|
||||
padding-bottom: 60px;
|
||||
|
||||
.section-header {
|
||||
padding: 0 32px;
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
|
||||
.section-title {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 20px rgba(0, 240, 255, 0.2);
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.arrow {
|
||||
margin-left: 8px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
&:active .arrow {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.activity-swiper {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.activity-swiper-item {
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.activity-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #111;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
|
||||
.activity-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.activity-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.4) 50%, transparent 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.activity-info {
|
||||
transform: translateY(0);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.activity-status {
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
background: var(--primary-purple);
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
align-self: flex-start;
|
||||
box-shadow: 0 0 10px rgba(189, 0, 255, 0.4);
|
||||
}
|
||||
|
||||
.activity-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 24px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48px; // 增加卡片间距
|
||||
}
|
||||
|
||||
// 玻璃态卡片升级版
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: 32px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 20px 40px rgba(0, 0, 0, 0.4),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.05); // 内描边增强质感
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
position: relative;
|
||||
|
||||
// 高光反射效果
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
box-shadow:
|
||||
0 10px 20px rgba(0, 0, 0, 0.4),
|
||||
0 0 30px rgba(0, 240, 255, 0.1); // 按压发光
|
||||
border-color: rgba(0, 240, 255, 0.3);
|
||||
}
|
||||
|
||||
&-cover {
|
||||
height: 400px; // 加大图片区域
|
||||
background: #111;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.card-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.6s ease;
|
||||
}
|
||||
|
||||
.placeholder-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at center, #1a1a1a, #050505);
|
||||
|
||||
.radar-scan {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: 2px solid rgba(0, 240, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--primary-cyan);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px var(--primary-cyan);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(from 0deg, transparent 0%, transparent 60%, rgba(0, 240, 255, 0.4) 100%);
|
||||
animation: radar-spin 2s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 60%;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.9), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&-body {
|
||||
padding: 40px 32px;
|
||||
}
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.card-title {
|
||||
font-size: 40px; // 加大标题
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex: 1;
|
||||
margin-right: 20px;
|
||||
line-height: 1.2;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 36px;
|
||||
color: var(--primary-cyan); // 统一用青色或根据产品类型变化
|
||||
font-weight: 800;
|
||||
text-shadow: 0 0 20px rgba(0, 240, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&-desc {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 32px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.tag {
|
||||
padding: 10px 24px;
|
||||
border-radius: 16px;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
&.cyan {
|
||||
color: var(--primary-cyan);
|
||||
background: rgba(0, 240, 255, 0.08);
|
||||
border: 1px solid rgba(0, 240, 255, 0.2);
|
||||
}
|
||||
&.blue {
|
||||
color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
&.purple {
|
||||
color: var(--primary-purple);
|
||||
background: rgba(189, 0, 255, 0.08);
|
||||
border: 1px solid rgba(189, 0, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-footer {
|
||||
.btn-buy {
|
||||
background: linear-gradient(90deg, var(--primary-green), var(--primary-cyan));
|
||||
color: #000;
|
||||
font-weight: 800;
|
||||
font-size: 30px;
|
||||
border-radius: 60px; // 更圆润
|
||||
border: none;
|
||||
height: 90px;
|
||||
line-height: 90px;
|
||||
box-shadow: 0 10px 30px rgba(0, 185, 107, 0.25);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// 流光效果
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
|
||||
animation: shimmer 3s infinite;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 5px 15px rgba(0, 185, 107, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { left: -100%; }
|
||||
20% { left: 100%; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
|
||||
@keyframes radar-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.footer-spacer {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
// 骨架屏样式
|
||||
.skeleton-wrapper {
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 700px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 32px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.03), transparent);
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
// 列表入场动画
|
||||
.fade-in-up {
|
||||
animation: fadeInUp 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
258
miniprogram/src/pages/index/index.tsx
Normal file
258
miniprogram/src/pages/index/index.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { View, Text, Image, ScrollView, Button, Swiper, SwiperItem } from '@tarojs/components'
|
||||
import Taro, { useLoad, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getConfigs, getAnnouncements, getActivities } from '../../api'
|
||||
import ParticleBackground from '../../components/ParticleBackground'
|
||||
import './index.scss'
|
||||
|
||||
export default function Index() {
|
||||
const [products, setProducts] = useState<any[]>([])
|
||||
const [announcements, setAnnouncements] = useState<any[]>([])
|
||||
const [activities, setActivities] = useState<any[]>([])
|
||||
const [typedText, setTypedText] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const fullText = "未来已来 AI 核心驱动"
|
||||
|
||||
useLoad(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let i = 0
|
||||
const interval = setInterval(() => {
|
||||
i++
|
||||
setTypedText(fullText.slice(0, i))
|
||||
if (i >= fullText.length) clearInterval(interval)
|
||||
}, 150)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
// Parallel fetch for better performance
|
||||
const [productsRes, announcementsRes, activitiesRes] = await Promise.all([
|
||||
getConfigs().catch(() => ({ results: [] })),
|
||||
getAnnouncements().catch(() => ({ results: [] })),
|
||||
getActivities().catch(() => ({ results: [] }))
|
||||
])
|
||||
|
||||
const productList = Array.isArray(productsRes) ? productsRes : (productsRes?.results || productsRes?.data || [])
|
||||
setProducts(productList)
|
||||
|
||||
const announcementList = Array.isArray(announcementsRes) ? announcementsRes : (announcementsRes?.results || announcementsRes?.data || [])
|
||||
// Mock data for demo if empty
|
||||
if (announcementList.length === 0) {
|
||||
announcementList.push(
|
||||
{ id: 101, title: 'Quant Speed AI 开发者大会即将开启报名' },
|
||||
{ id: 102, title: '新品发布:AI小智 V2 性能提升300%' },
|
||||
{ id: 103, title: '社区活动:分享你的边缘计算项目赢大奖' }
|
||||
)
|
||||
}
|
||||
setAnnouncements(announcementList)
|
||||
|
||||
const activityList = Array.isArray(activitiesRes) ? activitiesRes : (activitiesRes?.results || activitiesRes?.data || [])
|
||||
// Mock data for demo if empty
|
||||
if (activityList.length === 0) {
|
||||
activityList.push({
|
||||
id: 201,
|
||||
title: '2025 AI 硬件黑客马拉松',
|
||||
start_time: '2025-05-20T10:00:00',
|
||||
cover_image: 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?q=80&w=2070&auto=format&fit=crop'
|
||||
}, {
|
||||
id: 202,
|
||||
title: '边缘计算实战训练营',
|
||||
start_time: '2025-06-15T09:00:00',
|
||||
cover_image: 'https://images.unsplash.com/photo-1518770660439-4636190af475?q=80&w=2070&auto=format&fit=crop'
|
||||
})
|
||||
}
|
||||
setActivities(activityList)
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
setError(err.errMsg || '加载失败,请检查网络')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: 'Quant Speed - AI 核心驱动',
|
||||
path: '/pages/index/index'
|
||||
}
|
||||
})
|
||||
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: 'Quant Speed - AI 核心驱动'
|
||||
}
|
||||
})
|
||||
|
||||
const goToDetail = (id: number) => {
|
||||
Taro.navigateTo({ url: `/pages/goods/detail?id=${id}` })
|
||||
}
|
||||
|
||||
const goToAnnouncement = (id: number) => {
|
||||
// Assuming generic topic detail or specific announcement page
|
||||
Taro.navigateTo({ url: `/subpackages/forum/detail/detail?id=${id}` })
|
||||
}
|
||||
|
||||
const goToActivity = (id: number) => {
|
||||
Taro.navigateTo({ url: `/subpackages/forum/activity/detail?id=${id}` })
|
||||
}
|
||||
|
||||
const goToActivityList = () => {
|
||||
Taro.navigateTo({ url: `/subpackages/forum/activity/index` })
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<ParticleBackground />
|
||||
|
||||
<ScrollView scrollY className='content-scroll'>
|
||||
<View className='scroll-inner'>
|
||||
<View className='header'>
|
||||
<View className='logo-box'>
|
||||
<Image src='../../assets/logo.svg' className='logo-img' mode='widthFix' />
|
||||
<Text className='logo-text'>QUANT SPEED</Text>
|
||||
</View>
|
||||
|
||||
<View className='title-container'>
|
||||
<Text className='title-text'>{typedText}</Text>
|
||||
<Text className='cursor'>|</Text>
|
||||
</View>
|
||||
<Text className='subtitle'>量迹 AI 硬件为您提供最强大的边缘计算能力</Text>
|
||||
</View>
|
||||
|
||||
{/* News Ticker */}
|
||||
{!loading && announcements.length > 0 && (
|
||||
<View className='news-section fade-in-up'>
|
||||
<View className='news-inner'>
|
||||
<View className='news-icon-box'>
|
||||
<Text className='news-icon'>⚡</Text>
|
||||
</View>
|
||||
<Swiper
|
||||
className='news-swiper'
|
||||
vertical
|
||||
circular
|
||||
autoplay
|
||||
interval={4000}
|
||||
duration={600}
|
||||
>
|
||||
{announcements.map(item => (
|
||||
<SwiperItem key={item.id} onClick={() => goToAnnouncement(item.id)}>
|
||||
<View className='news-item'>
|
||||
<Text className='news-tag'>NEWS</Text>
|
||||
<Text className='news-title'>{item.title}</Text>
|
||||
</View>
|
||||
</SwiperItem>
|
||||
))}
|
||||
</Swiper>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Activity Banner */}
|
||||
{!loading && activities.length > 0 && (
|
||||
<View className='activity-section fade-in-up' style={{ animationDelay: '0.1s' }}>
|
||||
<View className='section-header'>
|
||||
<Text className='section-title'>近期活动 / EVENTS</Text>
|
||||
<View className='more-btn' onClick={goToActivityList}>
|
||||
<Text>MORE</Text>
|
||||
<Text className='arrow'>→</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Swiper
|
||||
className='activity-swiper'
|
||||
circular
|
||||
autoplay
|
||||
interval={5000}
|
||||
duration={600}
|
||||
previousMargin='30px'
|
||||
nextMargin='30px'
|
||||
>
|
||||
{activities.map(item => (
|
||||
<SwiperItem key={item.id} className='activity-swiper-item' onClick={() => goToActivity(item.id)}>
|
||||
<View className='activity-card'>
|
||||
<Image
|
||||
src={item.display_banner_url || item.banner_url || item.cover_image || 'https://via.placeholder.com/600x300'}
|
||||
mode='aspectFill'
|
||||
className='activity-img'
|
||||
/>
|
||||
<View className='activity-overlay'>
|
||||
<View className='activity-info'>
|
||||
<Text className='activity-status'>报名中</Text>
|
||||
<Text className='activity-title'>{item.title}</Text>
|
||||
<Text className='activity-time'>{item.start_time ? item.start_time.split('T')[0] : 'TBD'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</SwiperItem>
|
||||
))}
|
||||
</Swiper>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<View className='skeleton-wrapper'>
|
||||
{[1, 2, 3].map(i => (
|
||||
<View key={i} className='skeleton-card' />
|
||||
))}
|
||||
</View>
|
||||
) : error ? (
|
||||
<View className='status-box'>
|
||||
<Text className='error-text'>{error}</Text>
|
||||
<Button className='btn-retry' onClick={fetchData}>重试</Button>
|
||||
</View>
|
||||
) : (
|
||||
<View className='product-grid'>
|
||||
{products.map((item, index) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className='card fade-in-up'
|
||||
style={{ animationDelay: `${0.2 + index * 0.1}s` }}
|
||||
onClick={() => goToDetail(item.id)}
|
||||
>
|
||||
<View className='card-cover'>
|
||||
{item.static_image_url ? (
|
||||
<Image src={item.static_image_url} mode='aspectFill' className='card-img' />
|
||||
) : (
|
||||
<View className='placeholder-img'>
|
||||
<View className='radar-scan'></View>
|
||||
</View>
|
||||
)}
|
||||
<View className='card-overlay' />
|
||||
</View>
|
||||
|
||||
<View className='card-body'>
|
||||
<View className='card-header'>
|
||||
<Text className='card-title'>{item.name}</Text>
|
||||
<Text className='price'>¥{item.price}</Text>
|
||||
</View>
|
||||
|
||||
<Text className='card-desc'>{item.description}</Text>
|
||||
|
||||
<View className='tags'>
|
||||
<View className='tag cyan'><Text>{item.chip_type}</Text></View>
|
||||
{item.has_camera && <View className='tag blue'><Text>Camera</Text></View>}
|
||||
{item.has_microphone && <View className='tag purple'><Text>Mic</Text></View>}
|
||||
</View>
|
||||
|
||||
<View className='card-footer'>
|
||||
<Button className='btn-buy'>立即购买</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='footer-spacer' />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/order/checkout.config.ts
Normal file
3
miniprogram/src/pages/order/checkout.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '确认订单'
|
||||
})
|
||||
191
miniprogram/src/pages/order/checkout.scss
Normal file
191
miniprogram/src/pages/order/checkout.scss
Normal file
@@ -0,0 +1,191 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: #050505;
|
||||
color: #fff;
|
||||
padding-bottom: 120px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin: 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
backdrop-filter: blur(10px);
|
||||
position: relative;
|
||||
|
||||
.section-title {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 24px;
|
||||
background: #00b96b;
|
||||
margin-right: 12px;
|
||||
vertical-align: middle;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delivery-type-section {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
|
||||
.type-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
font-size: 28px;
|
||||
color: #888;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.active {
|
||||
background: #00b96b;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.address-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.address-info {
|
||||
flex: 1;
|
||||
.user-info {
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
.phone { margin-left: 20px; color: #888; font-weight: normal; font-size: 26px; }
|
||||
}
|
||||
.address-text {
|
||||
font-size: 26px;
|
||||
color: #aaa;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.placeholder {
|
||||
color: #00b96b;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 30px;
|
||||
color: #666;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.product-section {
|
||||
padding: 0; // Remove padding for list
|
||||
overflow: hidden;
|
||||
|
||||
.section-title { margin: 24px 24px 10px; }
|
||||
|
||||
.product-item {
|
||||
display: flex;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
.p-img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
margin-right: 20px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.p-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.p-name { font-size: 28px; color: #fff; font-weight: bold; }
|
||||
.p-desc { font-size: 24px; color: #888; }
|
||||
|
||||
.p-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.p-price { font-size: 30px; color: #00b96b; font-weight: bold; }
|
||||
.p-qty { font-size: 26px; color: #888; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
font-size: 28px;
|
||||
color: #888;
|
||||
|
||||
&.total {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
font-size: 32px;
|
||||
.price { color: #00b96b; font-size: 40px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 110px;
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 30px;
|
||||
z-index: 100;
|
||||
|
||||
.total-label { font-size: 28px; color: #fff; margin-right: 20px; }
|
||||
.total-price { font-size: 40px; color: #00b96b; font-weight: bold; margin-right: 30px; }
|
||||
|
||||
.btn-submit {
|
||||
background: linear-gradient(135deg, #00b96b 0%, #00f0ff 100%);
|
||||
color: #000;
|
||||
border-radius: 40px;
|
||||
padding: 0 60px;
|
||||
height: 80px;
|
||||
line-height: 80px;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
box-shadow: 0 0 20px rgba(0, 185, 107, 0.3);
|
||||
|
||||
&:active { transform: scale(0.98); }
|
||||
&.disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
265
miniprogram/src/pages/order/checkout.tsx
Normal file
265
miniprogram/src/pages/order/checkout.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { View, Text, Image, ScrollView, Button } from '@tarojs/components'
|
||||
import Taro, { useRouter, useLoad } from '@tarojs/taro'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { getConfigDetail, createOrder, getVBCourseDetail } from '../../api'
|
||||
import { getSelectedItems, removeItem } from '../../utils/cart'
|
||||
import './checkout.scss'
|
||||
|
||||
export default function Checkout() {
|
||||
const router = useRouter()
|
||||
const params = router.params
|
||||
const [items, setItems] = useState<any[]>([])
|
||||
const [address, setAddress] = useState<any>(null)
|
||||
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery')
|
||||
const [userAddress, setUserAddress] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const PICKUP_ADDRESS = {
|
||||
userName: '云南量迹科技有限公司',
|
||||
telNumber: '18585164448',
|
||||
provinceName: '云南省',
|
||||
cityName: '昆明市',
|
||||
countyName: '西山区',
|
||||
detailInfo: '永昌街道办事处云纺国际商厦 B 座 1406 号'
|
||||
}
|
||||
|
||||
useLoad(async () => {
|
||||
if (params.from === 'cart') {
|
||||
const cartItems = getSelectedItems()
|
||||
if (cartItems.length === 0) {
|
||||
Taro.navigateBack()
|
||||
return
|
||||
}
|
||||
setItems(cartItems)
|
||||
setLoading(false)
|
||||
} else if (params.id) {
|
||||
try {
|
||||
let res: any = null
|
||||
if (params.type === 'course') {
|
||||
res = await getVBCourseDetail(Number(params.id))
|
||||
setItems([{
|
||||
id: res.id,
|
||||
name: res.title,
|
||||
price: res.price,
|
||||
image: res.cover_image_url || res.detail_image_url,
|
||||
quantity: 1,
|
||||
description: res.description
|
||||
}])
|
||||
} else {
|
||||
res = await getConfigDetail(params.id)
|
||||
setItems([{
|
||||
id: res.id,
|
||||
name: res.name,
|
||||
price: res.price,
|
||||
image: res.static_image_url || res.detail_image_url,
|
||||
quantity: Number(params.quantity) || 1,
|
||||
description: res.description
|
||||
}])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '商品加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const chooseAddress = async () => {
|
||||
if (deliveryType === 'pickup') return
|
||||
try {
|
||||
const res = await Taro.chooseAddress()
|
||||
setAddress(res)
|
||||
setUserAddress(res)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
// User cancelled or auth denied
|
||||
}
|
||||
}
|
||||
|
||||
const handleTypeChange = (type: 'delivery' | 'pickup') => {
|
||||
if (type === deliveryType) return
|
||||
setDeliveryType(type)
|
||||
if (type === 'pickup') {
|
||||
setAddress(PICKUP_ADDRESS)
|
||||
} else {
|
||||
setAddress(userAddress)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPrice = useMemo(() => {
|
||||
return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
}, [items])
|
||||
|
||||
const submitOrder = async () => {
|
||||
// 免费课程不需要地址
|
||||
const isFreeCourse = params.type === 'course' && items.length > 0 && Number(items[0].price) === 0
|
||||
|
||||
if (!address && !isFreeCourse) {
|
||||
// 尝试调用 chooseAddress
|
||||
try {
|
||||
await chooseAddress()
|
||||
if (!address) {
|
||||
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是免费课程且没有地址,使用默认值
|
||||
const orderAddress = address || {
|
||||
userName: '免费课程学员',
|
||||
telNumber: '13800000000',
|
||||
provinceName: '',
|
||||
cityName: '',
|
||||
countyName: '',
|
||||
detailInfo: '线上课程'
|
||||
}
|
||||
|
||||
Taro.showLoading({ title: '提交中...' })
|
||||
|
||||
try {
|
||||
const orderPromises = items.map(item => {
|
||||
const type = params.type || 'config'
|
||||
|
||||
// 构造订单数据
|
||||
const orderData: any = {
|
||||
quantity: item.quantity,
|
||||
customer_name: orderAddress.userName,
|
||||
phone_number: orderAddress.telNumber,
|
||||
shipping_address: `${orderAddress.provinceName}${orderAddress.cityName}${orderAddress.countyName}${orderAddress.detailInfo}`,
|
||||
ref_code: Taro.getStorageSync('ref_code') || ''
|
||||
}
|
||||
|
||||
if (type === 'course') {
|
||||
orderData.course = item.id
|
||||
} else {
|
||||
orderData.config = item.id
|
||||
}
|
||||
|
||||
return createOrder(orderData)
|
||||
})
|
||||
|
||||
const results = await Promise.all(orderPromises)
|
||||
|
||||
// If from cart, remove bought items
|
||||
if (params.from === 'cart') {
|
||||
items.forEach(item => removeItem(item.id))
|
||||
}
|
||||
|
||||
Taro.hideLoading()
|
||||
|
||||
if (results.length === 1) {
|
||||
// Single order, go to payment
|
||||
const orderId = results[0].id
|
||||
Taro.redirectTo({
|
||||
url: `/pages/order/payment?id=${orderId}`
|
||||
})
|
||||
} else {
|
||||
// Multiple orders
|
||||
Taro.showModal({
|
||||
title: '下单成功',
|
||||
content: `成功创建 ${results.length} 个订单,请前往订单列表支付`,
|
||||
showCancel: false,
|
||||
confirmText: '去支付',
|
||||
success: () => {
|
||||
Taro.redirectTo({ url: '/pages/order/list' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
Taro.hideLoading()
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '下单失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <View className='page-container'><View className='section'><Text>Loading...</Text></View></View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<ScrollView scrollY style={{height: 'calc(100vh - 120px)'}}>
|
||||
{/* Delivery Type Section */}
|
||||
<View className='section delivery-type-section'>
|
||||
<View
|
||||
className={`type-item ${deliveryType === 'delivery' ? 'active' : ''}`}
|
||||
onClick={() => handleTypeChange('delivery')}
|
||||
>
|
||||
快递配送
|
||||
</View>
|
||||
<View
|
||||
className={`type-item ${deliveryType === 'pickup' ? 'active' : ''}`}
|
||||
onClick={() => handleTypeChange('pickup')}
|
||||
>
|
||||
门店自提
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Address Section */}
|
||||
<View className='section address-section' onClick={chooseAddress}>
|
||||
{address ? (
|
||||
<View className='address-info'>
|
||||
<View className='user-info'>
|
||||
<Text>{address.userName}</Text>
|
||||
<Text className='phone'>{address.telNumber}</Text>
|
||||
</View>
|
||||
<View className='address-text'>
|
||||
{address.provinceName}{address.cityName}{address.countyName}{address.detailInfo}
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className='address-info'>
|
||||
<Text className='placeholder'>+ 添加收货地址</Text>
|
||||
</View>
|
||||
)}
|
||||
{deliveryType === 'delivery' && <Text className='arrow'>›</Text>}
|
||||
</View>
|
||||
|
||||
{/* Products Section */}
|
||||
<View className='section product-section'>
|
||||
<Text className='section-title'>商品信息</Text>
|
||||
{items.map((item, idx) => (
|
||||
<View key={idx} className='product-item'>
|
||||
<Image src={item.image} className='p-img' mode='aspectFill' />
|
||||
<View className='p-info'>
|
||||
<Text className='p-name'>{item.name}</Text>
|
||||
<Text className='p-desc'>{item.description}</Text>
|
||||
<View className='p-meta'>
|
||||
<Text className='p-price'>¥{item.price}</Text>
|
||||
<Text className='p-qty'>x{item.quantity}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Summary Section */}
|
||||
<View className='section summary-section'>
|
||||
<View className='row'>
|
||||
<Text>商品总价</Text>
|
||||
<Text>¥{totalPrice}</Text>
|
||||
</View>
|
||||
<View className='row'>
|
||||
<Text>运费</Text>
|
||||
<Text>¥0</Text>
|
||||
</View>
|
||||
<View className='row total'>
|
||||
<Text>合计</Text>
|
||||
<Text className='price'>¥{totalPrice}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<View className='bottom-bar'>
|
||||
<Text className='total-label'>共{items.length}件</Text>
|
||||
<Text className='total-price'>¥{totalPrice}</Text>
|
||||
<Button className='btn-submit' onClick={submitOrder}>提交订单</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/order/detail.config.ts
Normal file
3
miniprogram/src/pages/order/detail.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '订单详情'
|
||||
})
|
||||
86
miniprogram/src/pages/order/detail.scss
Normal file
86
miniprogram/src/pages/order/detail.scss
Normal file
@@ -0,0 +1,86 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-dark);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
|
||||
.status-text {
|
||||
font-size: 28px; /* 放大 */
|
||||
font-weight: bold;
|
||||
color: var(--primary-green);
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
|
||||
&.pending { color: var(--primary-cyan); }
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-size: 48px; /* 放大 */
|
||||
font-weight: bold;
|
||||
color: var(--text-main);
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.section-title {
|
||||
font-size: 18px; /* 放大 */
|
||||
font-weight: bold;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
font-size: 16px; /* 放大 */
|
||||
align-items: flex-start;
|
||||
|
||||
&:last-child { margin-bottom: 0; }
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
width: 90px; /* 加宽 label */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
flex: 1;
|
||||
color: var(--text-main);
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-area {
|
||||
margin-top: 40px;
|
||||
|
||||
.btn {
|
||||
border-radius: 30px;
|
||||
font-size: 18px; /* 放大 */
|
||||
font-weight: 500;
|
||||
height: 56px; /* 加高 */
|
||||
line-height: 56px;
|
||||
|
||||
&.btn-primary {
|
||||
background: var(--primary-green);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 20px rgba(0, 185, 107, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
143
miniprogram/src/pages/order/detail.tsx
Normal file
143
miniprogram/src/pages/order/detail.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { View, Text, Button, Image } from '@tarojs/components'
|
||||
import Taro, { useRouter, useLoad } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getOrder, prepayMiniprogram } from '../../api'
|
||||
import { checkLogin } from '../../utils/auth'
|
||||
import './detail.scss'
|
||||
|
||||
export default function OrderDetail() {
|
||||
const router = useRouter()
|
||||
const { id } = router.params
|
||||
const [order, setOrder] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useLoad(async () => {
|
||||
if (id) {
|
||||
fetchOrder(Number(id))
|
||||
}
|
||||
})
|
||||
|
||||
const fetchOrder = async (orderId: number) => {
|
||||
try {
|
||||
const res = await getOrder(orderId)
|
||||
setOrder(res)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
Taro.showToast({ title: '获取订单失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!checkLogin()) return
|
||||
if (!order) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = await prepayMiniprogram(order.id)
|
||||
|
||||
await Taro.requestPayment({
|
||||
timeStamp: params.timeStamp,
|
||||
nonceStr: params.nonceStr,
|
||||
package: params.package,
|
||||
signType: params.signType,
|
||||
paySign: params.paySign
|
||||
})
|
||||
|
||||
Taro.showToast({ title: '支付成功', icon: 'success' })
|
||||
fetchOrder(order.id) // Refresh order status
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
if (err.errMsg && err.errMsg.indexOf('cancel') > -1) {
|
||||
Taro.showToast({ title: '取消支付', icon: 'none' })
|
||||
} else {
|
||||
Taro.showToast({ title: '支付失败', icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!order) return <View className='page-container'><Text>Loading...</Text></View>
|
||||
|
||||
const isPending = order.status === 'pending'
|
||||
const isPaid = order.status === 'paid'
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='status-header'>
|
||||
<Text className={`status-text ${order.status}`}>
|
||||
{isPending ? '待支付' : isPaid ? '已支付' : order.status}
|
||||
</Text>
|
||||
<Text className='amount'>¥{order.total_price}</Text>
|
||||
</View>
|
||||
|
||||
{/* Product Info */}
|
||||
<View className='section-card'>
|
||||
<View className='section-title'>商品信息</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>商品名称</Text>
|
||||
<Text className='value'>{order.config_name || order.course_title || (order.activity_title ? `报名活动:${order.activity_title}` : '未知商品')}</Text>
|
||||
</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>数量</Text>
|
||||
<Text className='value'>x {order.quantity}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Shipping Info - Only show if available */}
|
||||
{(order.customer_name || order.shipping_address) && (
|
||||
<View className='section-card'>
|
||||
<View className='section-title'>收货信息</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>收货人</Text>
|
||||
<Text className='value'>{order.customer_name}</Text>
|
||||
</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>联系电话</Text>
|
||||
<Text className='value'>{order.phone_number}</Text>
|
||||
</View>
|
||||
{order.shipping_address && (
|
||||
<View className='info-row'>
|
||||
<Text className='label'>收货地址</Text>
|
||||
<Text className='value'>{order.shipping_address}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Logistics Info - Only show if shipped */}
|
||||
{(order.courier_name || order.tracking_number) && (
|
||||
<View className='section-card'>
|
||||
<View className='section-title'>物流信息</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>快递公司</Text>
|
||||
<Text className='value'>{order.courier_name || '-'}</Text>
|
||||
</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>运单号</Text>
|
||||
<Text className='value'>{order.tracking_number || '-'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Order Info */}
|
||||
<View className='section-card'>
|
||||
<View className='section-title'>订单信息</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>订单编号</Text>
|
||||
<Text className='value'>{order.wechat_trade_no || order.id}</Text>
|
||||
</View>
|
||||
<View className='info-row'>
|
||||
<Text className='label'>下单时间</Text>
|
||||
<Text className='value'>{order.created_at?.replace('T', ' ').substring(0, 19)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isPending && (
|
||||
<View className='btn-area safe-area-bottom'>
|
||||
<Button className='btn btn-primary' onClick={handlePay} loading={loading}>立即支付</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/order/list.config.ts
Normal file
3
miniprogram/src/pages/order/list.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '我的订单'
|
||||
})
|
||||
98
miniprogram/src/pages/order/list.scss
Normal file
98
miniprogram/src/pages/order/list.scss
Normal file
@@ -0,0 +1,98 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-dark);
|
||||
padding: 20px; /* 加大页面边距 */
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px; /* 更圆润 */
|
||||
padding: 24px; /* 加大卡片内边距 */
|
||||
margin-bottom: 24px; /* 加大卡片间距 */
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 16px; /* 基础字体放大 */
|
||||
color: var(--text-secondary);
|
||||
|
||||
.status {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
&.pending { color: var(--primary-cyan); }
|
||||
&.paid { color: var(--primary-green); }
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
align-items: flex-start; /* 对齐方式微调 */
|
||||
|
||||
.img {
|
||||
width: 110px; /* 图片加大 */
|
||||
height: 110px;
|
||||
border-radius: 12px;
|
||||
background: #333;
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 110px;
|
||||
|
||||
.name {
|
||||
font-size: 20px; /* 名称显著放大 */
|
||||
color: var(--text-main);
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.qty {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 24px; /* 价格加大 */
|
||||
font-weight: bold;
|
||||
color: var(--text-main);
|
||||
align-self: flex-end; /* 价格靠下对齐或根据设计调整,这里保持原位或微调 */
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
padding-top: 10px; /* 增加一点间隔 */
|
||||
|
||||
.btn-pay {
|
||||
border: 1px solid var(--primary-green);
|
||||
color: var(--primary-green);
|
||||
padding: 10px 24px; /* 按钮加大 */
|
||||
border-radius: 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
background: rgba(0, 185, 107, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding-top: 150px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
}
|
||||
63
miniprogram/src/pages/order/list.tsx
Normal file
63
miniprogram/src/pages/order/list.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { View, Text, ScrollView, Image } from '@tarojs/components'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getMyOrders } from '../../api'
|
||||
import './list.scss'
|
||||
|
||||
export default function OrderList() {
|
||||
const [orders, setOrders] = useState<any[]>([])
|
||||
|
||||
useDidShow(() => {
|
||||
fetchOrders()
|
||||
})
|
||||
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const res = await getMyOrders()
|
||||
setOrders(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const goDetail = (id) => Taro.navigateTo({ url: `/pages/order/detail?id=${id}` })
|
||||
const goPay = (e, id) => {
|
||||
e.stopPropagation()
|
||||
Taro.navigateTo({ url: `/pages/order/payment?id=${id}` })
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<ScrollView scrollY className='list'>
|
||||
{orders.map(order => (
|
||||
<View key={order.id} className='card' onClick={() => goDetail(order.id)}>
|
||||
<View className='header'>
|
||||
<Text className='time'>{order.created_at?.substring(0, 10)}</Text>
|
||||
<Text className={`status ${order.status}`}>
|
||||
{order.status === 'pending' ? '待支付' : order.status === 'paid' ? '已支付' : order.status}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='body'>
|
||||
<Image src={order.config_image || 'https://via.placeholder.com/80'} className='img' mode='aspectFill' />
|
||||
<View className='info'>
|
||||
<Text className='name'>
|
||||
{order.config_name || order.course_title || (order.activity_title ? `报名活动:${order.activity_title}` : '未知商品')}
|
||||
</Text>
|
||||
<Text className='qty'>x {order.quantity}</Text>
|
||||
</View>
|
||||
<View className='price'>
|
||||
<Text>¥{order.total_price}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='footer'>
|
||||
{order.status === 'pending' && (
|
||||
<View className='btn-pay' onClick={(e) => goPay(e, order.id)}>去支付</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{orders.length === 0 && <View className='empty'>暂无订单</View>}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/order/payment.config.ts
Normal file
3
miniprogram/src/pages/order/payment.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '订单支付'
|
||||
})
|
||||
70
miniprogram/src/pages/order/payment.scss
Normal file
70
miniprogram/src/pages/order/payment.scss
Normal file
@@ -0,0 +1,70 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-dark);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
|
||||
.amount {
|
||||
font-size: 64px; /* 超大金额 */
|
||||
font-weight: bold;
|
||||
color: var(--primary-green);
|
||||
display: block;
|
||||
text-shadow: 0 0 25px rgba(0, 185, 107, 0.4);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 20px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
margin-bottom: 50px;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
align-items: center;
|
||||
|
||||
&:last-child { margin-bottom: 0; }
|
||||
|
||||
.label { color: var(--text-secondary); font-size: 18px; }
|
||||
.value { color: var(--text-main); font-size: 18px; font-weight: 500; }
|
||||
}
|
||||
}
|
||||
|
||||
.btn-area {
|
||||
.btn-pay {
|
||||
background: var(--primary-green);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
height: 64px; /* 更高的按钮 */
|
||||
line-height: 64px;
|
||||
box-shadow: 0 8px 30px rgba(0, 185, 107, 0.5);
|
||||
|
||||
&:active {
|
||||
opacity: 0.9;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
102
miniprogram/src/pages/order/payment.tsx
Normal file
102
miniprogram/src/pages/order/payment.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import Taro, { useRouter, useLoad } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getOrder, prepayMiniprogram, queryOrderStatus } from '../../api'
|
||||
import './payment.scss'
|
||||
|
||||
export default function Payment() {
|
||||
const router = useRouter()
|
||||
const { id } = router.params
|
||||
const [order, setOrder] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useLoad(async () => {
|
||||
if (id) {
|
||||
try {
|
||||
const res = await getOrder(Number(id))
|
||||
setOrder(res)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!order) return
|
||||
|
||||
setLoading(true)
|
||||
|
||||
// 如果是免费订单,直接显示成功并跳转
|
||||
if (parseFloat(order.total_price) <= 0) {
|
||||
Taro.showToast({ title: '报名成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: '/pages/order/list' })
|
||||
}, 1500)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const params = await prepayMiniprogram(order.id)
|
||||
|
||||
await Taro.requestPayment({
|
||||
timeStamp: params.timeStamp,
|
||||
nonceStr: params.nonceStr,
|
||||
package: params.package,
|
||||
signType: params.signType,
|
||||
paySign: params.paySign
|
||||
})
|
||||
|
||||
Taro.showToast({ title: '支付成功', icon: 'success' })
|
||||
|
||||
// 主动查询订单状态,确保后台已更新
|
||||
try {
|
||||
await queryOrderStatus(order.id)
|
||||
} catch (e) {
|
||||
console.error('Query status failed', e)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: '/pages/order/list' })
|
||||
}, 1500)
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
if (err.errMsg && err.errMsg.indexOf('cancel') > -1) {
|
||||
Taro.showToast({ title: '取消支付', icon: 'none' })
|
||||
} else {
|
||||
Taro.showToast({ title: '支付失败', icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!order) return <View>Loading...</View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='status-header'>
|
||||
<Text className='amount'>¥{order.total_price}</Text>
|
||||
<Text className='desc'>订单待支付</Text>
|
||||
</View>
|
||||
|
||||
<View className='info-card'>
|
||||
<View className='row'>
|
||||
<Text className='label'>订单编号</Text>
|
||||
<Text className='value'>{order.out_trade_no || order.id}</Text>
|
||||
</View>
|
||||
<View className='row'>
|
||||
<Text className='label'>商品名称</Text>
|
||||
<Text className='value'>{order.config_name} x {order.quantity}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='btn-area safe-area-bottom'>
|
||||
<Button className='btn-pay' onClick={handlePay} loading={loading}>
|
||||
{parseFloat(order.total_price) <= 0 ? '确认报名' : '微信支付'}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/services/detail.config.ts
Normal file
3
miniprogram/src/pages/services/detail.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '服务详情'
|
||||
})
|
||||
245
miniprogram/src/pages/services/detail.scss
Normal file
245
miniprogram/src/pages/services/detail.scss
Normal file
@@ -0,0 +1,245 @@
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 120px; // Space for bottom bar
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
margin-bottom: 30px;
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: #888;
|
||||
font-size: 28px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: rgba(255,255,255,0.03);
|
||||
padding: 30px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
margin-bottom: 40px;
|
||||
|
||||
.card-title {
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.bar {
|
||||
width: 6px;
|
||||
height: 24px;
|
||||
border-radius: 3px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-item {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.label {
|
||||
color: #888;
|
||||
font-size: 24px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.value {
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-image-box {
|
||||
width: 100%;
|
||||
background: #111;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.detail-img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.price-card {
|
||||
background: #1f1f1f;
|
||||
padding: 30px;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.price-title {
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.price-val {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.price-unit {
|
||||
color: #888;
|
||||
font-size: 24px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.tag {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 24px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1f1f1f;
|
||||
padding: 20px 30px;
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
|
||||
.btn-buy {
|
||||
width: 100%;
|
||||
height: 90px;
|
||||
line-height: 90px;
|
||||
font-weight: bold;
|
||||
font-size: 32px;
|
||||
border-radius: 45px;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
// Modal Styles
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #2c2c2c;
|
||||
border-top-left-radius: 24px;
|
||||
border-top-right-radius: 24px;
|
||||
padding: 40px;
|
||||
padding-bottom: calc(40px + constant(safe-area-inset-bottom));
|
||||
padding-bottom: calc(40px + env(safe-area-inset-bottom));
|
||||
z-index: 1000;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease-out;
|
||||
|
||||
&.visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
color: #fff;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal-desc {
|
||||
color: #999;
|
||||
font-size: 26px;
|
||||
margin-bottom: 30px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 25px;
|
||||
|
||||
.label {
|
||||
color: #ccc;
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input {
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #444;
|
||||
border-radius: 12px;
|
||||
height: 80px;
|
||||
padding: 0 20px;
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #444;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
height: 160px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 40px;
|
||||
|
||||
.btn-cancel {
|
||||
flex: 1;
|
||||
background: #444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
flex: 2;
|
||||
background: #00b96b;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
176
miniprogram/src/pages/services/detail.tsx
Normal file
176
miniprogram/src/pages/services/detail.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { View, Text, Image, Button, Input, Textarea } from '@tarojs/components'
|
||||
import Taro, { useLoad, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getServiceDetail, createServiceOrder } from '../../api'
|
||||
import { checkLogin } from '../../utils/auth'
|
||||
import './detail.scss'
|
||||
|
||||
export default function ServiceDetail() {
|
||||
const [service, setService] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
customer_name: '',
|
||||
company_name: '',
|
||||
phone_number: '',
|
||||
email: '',
|
||||
requirements: ''
|
||||
})
|
||||
|
||||
useLoad((options) => {
|
||||
if (options.id) {
|
||||
fetchDetail(options.id)
|
||||
}
|
||||
})
|
||||
|
||||
const fetchDetail = async (id: string) => {
|
||||
try {
|
||||
const res: any = await getServiceDetail(Number(id))
|
||||
setService(res)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: service?.title || '服务详情',
|
||||
path: `/pages/services/detail?id=${service?.id}`,
|
||||
imageUrl: service?.cover_image_url || service?.icon_url
|
||||
}
|
||||
})
|
||||
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: service?.title || '服务详情',
|
||||
query: `id=${service?.id}`,
|
||||
imageUrl: service?.cover_image_url || service?.icon_url
|
||||
}
|
||||
})
|
||||
|
||||
const handleInput = (key: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.customer_name || !formData.phone_number) {
|
||||
Taro.showToast({ title: '请填写姓名和电话', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Taro.showLoading({ title: '提交中...' })
|
||||
await createServiceOrder({
|
||||
service: service.id,
|
||||
...formData,
|
||||
ref_code: Taro.getStorageSync('ref_code') || ''
|
||||
})
|
||||
Taro.hideLoading()
|
||||
setModalVisible(false)
|
||||
Taro.showModal({
|
||||
title: '提交成功',
|
||||
content: '需求已提交,我们的销售顾问将尽快与您联系!',
|
||||
showCancel: false
|
||||
})
|
||||
} catch (err) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '提交失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
|
||||
if (!service) return <View className='page-container'><Text style={{color:'#fff'}}>Service not found</Text></View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='detail-header'>
|
||||
<Text className='title'>{service.title}</Text>
|
||||
<Text className='desc'>{service.description}</Text>
|
||||
</View>
|
||||
|
||||
<View className='info-card'>
|
||||
<View className='card-title'>
|
||||
<View className='bar' style={{ background: service.color }} />
|
||||
<Text>服务详情</Text>
|
||||
</View>
|
||||
<View className='info-item'>
|
||||
<Text className='label'>交付周期</Text>
|
||||
<Text className='value'>{service.delivery_time || '待沟通'}</Text>
|
||||
</View>
|
||||
<View className='info-item'>
|
||||
<Text className='label'>交付内容</Text>
|
||||
<Text className='value'>{service.delivery_content || '根据需求定制'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{service.detail_image_url && (
|
||||
<View className='detail-image-box' style={{ boxShadow: `0 10px 40px ${service.color}22` }}>
|
||||
<Image src={service.detail_image_url} className='detail-img' mode='widthFix' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='price-card'>
|
||||
<Text className='price-title'>服务报价</Text>
|
||||
<View className='price-row'>
|
||||
<Text className='price-val' style={{ color: service.color }}>¥{service.price}</Text>
|
||||
<Text className='price-unit'>/ {service.unit} 起</Text>
|
||||
</View>
|
||||
<View className='tags'>
|
||||
{service.features && service.features.split('\n').map((feat: string, i: number) => (
|
||||
<View key={i} className='tag' style={{
|
||||
background: `${service.color}11`,
|
||||
color: service.color,
|
||||
border: `1px solid ${service.color}66`
|
||||
}}>
|
||||
<Text>{feat}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='bottom-bar'>
|
||||
<Button
|
||||
className='btn-buy'
|
||||
style={{ background: service.color }}
|
||||
onClick={() => {
|
||||
if (checkLogin()) {
|
||||
setModalVisible(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
立即咨询 / 购买
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Modal Layer */}
|
||||
{modalVisible && (
|
||||
<View className='modal-mask' onClick={() => setModalVisible(false)} />
|
||||
)}
|
||||
<View className={`modal-content ${modalVisible ? 'visible' : ''}`}>
|
||||
<Text className='modal-title'>咨询/购买</Text>
|
||||
<Text className='modal-desc'>请填写您的联系方式,我们将尽快联系您</Text>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='label'>您的姓名 *</Text>
|
||||
<Input className='input' placeholder='请输入姓名' value={formData.customer_name} onInput={(e) => handleInput('customer_name', e.detail.value)} />
|
||||
</View>
|
||||
<View className='form-item'>
|
||||
<Text className='label'>联系电话 *</Text>
|
||||
<Input className='input' type='number' placeholder='请输入电话' value={formData.phone_number} onInput={(e) => handleInput('phone_number', e.detail.value)} />
|
||||
</View>
|
||||
<View className='form-item'>
|
||||
<Text className='label'>需求描述</Text>
|
||||
<Textarea className='textarea' placeholder='请简单描述您的需求...' value={formData.requirements} onInput={(e) => handleInput('requirements', e.detail.value)} />
|
||||
</View>
|
||||
|
||||
<View className='modal-actions'>
|
||||
<Button className='btn-cancel' onClick={() => setModalVisible(false)}>取消</Button>
|
||||
<Button className='btn-submit' onClick={handleSubmit}>提交需求</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/services/index.config.ts
Normal file
3
miniprogram/src/pages/services/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: 'AI 全栈解决方案'
|
||||
})
|
||||
414
miniprogram/src/pages/services/index.scss
Normal file
414
miniprogram/src/pages/services/index.scss
Normal file
@@ -0,0 +1,414 @@
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
|
||||
.highlight {
|
||||
color: #00f0ff;
|
||||
text-shadow: 0 0 10px rgba(0,240,255,0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #888;
|
||||
font-size: 28px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.vc-promo-container {
|
||||
margin-top: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.vc-info-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
padding: 20px 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.05), transparent);
|
||||
transform: translateX(-100%);
|
||||
transition: 0.5s;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
|
||||
&::before {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
font-size: 40px;
|
||||
margin-right: 20px;
|
||||
filter: drop-shadow(0 0 10px rgba(255, 255, 0, 0.3));
|
||||
}
|
||||
|
||||
.info-content {
|
||||
text-align: left;
|
||||
|
||||
.info-title {
|
||||
color: #fff;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.info-desc {
|
||||
color: #ccc;
|
||||
font-size: 24px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: linear-gradient(90deg, #00b96b, #00f0ff);
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
font-size: 28px;
|
||||
padding: 0 50px;
|
||||
height: 90px;
|
||||
line-height: 90px;
|
||||
border-radius: 45px;
|
||||
border: none;
|
||||
box-shadow: 0 0 20px rgba(0, 185, 107, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
transition: all 0.3s ease;
|
||||
margin: 0;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 10px rgba(0, 185, 107, 0.2);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 32px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.service-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.hud-corner {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
&.tl { top: 0; left: 0; border-top: 2px solid; border-left: 2px solid; }
|
||||
&.br { bottom: 0; right: 0; border-bottom: 2px solid; border-right: 2px solid; }
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.icon-box {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 15px;
|
||||
|
||||
.icon-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.icon-placeholder {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #ccc;
|
||||
font-size: 26px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.features {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 24px;
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-more {
|
||||
color: #fff;
|
||||
font-size: 26px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
|
||||
&:after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.process-section {
|
||||
margin-top: 60px;
|
||||
padding: 40px 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// Background Tech Grid
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 185, 107, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 185, 107, 0.03) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 60px;
|
||||
display: block;
|
||||
text-shadow: 0 0 15px rgba(0, 185, 107, 0.8);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
letter-spacing: 2px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
background: #00b96b;
|
||||
margin: 15px auto 0;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 10px #00b96b;
|
||||
}
|
||||
}
|
||||
|
||||
.process-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0 20px;
|
||||
|
||||
// Vertical connecting line
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
bottom: 20px;
|
||||
left: 60px; // Center of the icon (40px + padding)
|
||||
width: 2px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
// Moving signal on the line
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 60px;
|
||||
width: 2px;
|
||||
height: 100px;
|
||||
background: linear-gradient(to bottom, transparent, #00b96b, transparent);
|
||||
animation: signalFlow 3s infinite linear;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 40px;
|
||||
position: relative;
|
||||
|
||||
&:last-child { margin-bottom: 0; }
|
||||
|
||||
.step-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 20px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(0, 185, 107, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #00b96b;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
margin-right: 30px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-shadow: 0 0 15px rgba(0, 185, 107, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
// Pulse effect for icon
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -5px; bottom: -5px; left: -5px; right: -5px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(0, 185, 107, 0.3);
|
||||
animation: pulseBorder 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// Content Card
|
||||
.step-content-wrapper {
|
||||
flex: 1;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-left: 4px solid #00b96b;
|
||||
padding: 20px 24px;
|
||||
border-radius: 0 16px 16px 0;
|
||||
backdrop-filter: blur(5px);
|
||||
transform: translateX(0);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.step-title {
|
||||
color: #fff;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
text-shadow: 0 0 5px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
color: #888;
|
||||
font-size: 24px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes signalFlow {
|
||||
0% { top: 0; opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
80% { opacity: 1; }
|
||||
100% { top: 100%; opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes pulseBorder {
|
||||
0% { transform: scale(1); opacity: 0.5; }
|
||||
100% { transform: scale(1.15); opacity: 0; }
|
||||
}
|
||||
|
||||
.ai-badge {
|
||||
background: rgba(0, 185, 107, 0.1);
|
||||
border: 1px solid rgba(0, 185, 107, 0.3);
|
||||
padding: 8px 20px;
|
||||
border-radius: 30px;
|
||||
margin: 15px auto;
|
||||
display: inline-block;
|
||||
backdrop-filter: blur(5px);
|
||||
box-shadow: 0 0 10px rgba(0, 185, 107, 0.1);
|
||||
|
||||
text {
|
||||
color: #00b96b;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 0 0 5px rgba(0, 185, 107, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.compliance-footer {
|
||||
text-align: center;
|
||||
padding: 30px 20px 50px;
|
||||
margin-top: 40px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.5));
|
||||
|
||||
.compliance-text {
|
||||
color: #444;
|
||||
font-size: 22px;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
143
miniprogram/src/pages/services/index.tsx
Normal file
143
miniprogram/src/pages/services/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { View, Text, Image, Button } from '@tarojs/components'
|
||||
import Taro, { useLoad, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getServices } from '../../api'
|
||||
import './index.scss'
|
||||
|
||||
export default function ServicesIndex() {
|
||||
const [services, setServices] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useLoad(() => {
|
||||
fetchServices()
|
||||
})
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const res: any = await getServices()
|
||||
// Adapt API response if needed (res.data vs res)
|
||||
setServices(res.results || res)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: 'AI 全栈解决方案',
|
||||
path: '/pages/services/index'
|
||||
}
|
||||
})
|
||||
|
||||
useShareTimeline(() => {
|
||||
return {
|
||||
title: 'AI 全栈解决方案'
|
||||
}
|
||||
})
|
||||
|
||||
const goDetail = (id: number) => {
|
||||
Taro.navigateTo({ url: `/pages/services/detail?id=${id}` })
|
||||
}
|
||||
|
||||
if (loading) return <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='header'>
|
||||
<Text className='title'>AI 全栈<Text className='highlight'>解决方案</Text></Text>
|
||||
<View className='ai-badge'>
|
||||
<Text>生成式AI生成内容</Text>
|
||||
</View>
|
||||
|
||||
<Text className='subtitle'>从数据处理到模型部署,我们为您提供一站式 AI 基础设施服务。</Text>
|
||||
|
||||
<View className='vc-promo-container'>
|
||||
<View className='vc-info-card' onClick={() => Taro.navigateTo({ url: '/pages/courses/index' })}>
|
||||
<View className='info-icon'>💡</View>
|
||||
<View className='info-content'>
|
||||
<Text className='info-title'>AI + VC 课程</Text>
|
||||
<Text className='info-desc'>深度解析 AI 如何赋能创投,掌握技术变现的核心逻辑</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
className='nav-btn'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/courses/index' })}
|
||||
>
|
||||
探索 VC 课程
|
||||
<Text className='arrow'>→</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='service-grid'>
|
||||
{services.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className='service-card'
|
||||
style={{
|
||||
border: `1px solid ${item.color}33`,
|
||||
boxShadow: `0 0 20px ${item.color}11`
|
||||
}}
|
||||
onClick={() => goDetail(item.id)}
|
||||
>
|
||||
<View className='hud-corner tl' style={{ borderColor: item.color }} />
|
||||
<View className='hud-corner br' style={{ borderColor: item.color }} />
|
||||
|
||||
<View className='card-header'>
|
||||
<View className='icon-box' style={{ background: `${item.color}22` }}>
|
||||
{item.icon_url ? (
|
||||
<Image src={item.icon_url} className='icon-img' mode='aspectFit' />
|
||||
) : (
|
||||
<View className='icon-placeholder' style={{ background: item.color }} />
|
||||
)}
|
||||
</View>
|
||||
<Text className='title'>{item.title}</Text>
|
||||
</View>
|
||||
|
||||
<Text className='description'>{item.description}</Text>
|
||||
|
||||
<View className='features'>
|
||||
{item.features && item.features.split('\n').map((feat: string, i: number) => (
|
||||
<View key={i} className='feature-item' style={{ color: item.color }}>
|
||||
<View className='dot' style={{ background: item.color }} />
|
||||
<Text>{feat}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Button className='btn-more'>了解更多 {'>'}</Button>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className='process-section'>
|
||||
<Text className='section-title'>服务流程</Text>
|
||||
<View className='process-steps'>
|
||||
{[
|
||||
{ title: '需求分析', desc: '深度沟通需求', id: 1 },
|
||||
{ title: '数据准备', desc: '高效数据处理', id: 2 },
|
||||
{ title: '模型训练', desc: '高性能算力', id: 3 },
|
||||
{ title: '测试验证', desc: '多维精度测试', id: 4 },
|
||||
{ title: '私有化部署', desc: '全栈落地部署', id: 5 }
|
||||
].map((step) => (
|
||||
<View key={step.id} className='step-item'>
|
||||
<View className='step-icon'><Text>{step.id}</Text></View>
|
||||
<View className='step-content-wrapper'>
|
||||
<Text className='step-title'>{step.title}</Text>
|
||||
<Text className='step-desc'>{step.desc}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='compliance-footer'>
|
||||
<Text className='compliance-text'>深度合成-AI问答类目</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
5
miniprogram/src/pages/user/index.config.ts
Normal file
5
miniprogram/src/pages/user/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '个人中心',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark'
|
||||
})
|
||||
443
miniprogram/src/pages/user/index.scss
Normal file
443
miniprogram/src/pages/user/index.scss
Normal file
@@ -0,0 +1,443 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: #050505;
|
||||
color: #fff;
|
||||
padding: 30px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(0, 185, 107, 0.4); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(0, 185, 107, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(0, 185, 107, 0); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.card-bg-effect {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -20%;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: radial-gradient(circle, rgba(0, 185, 107, 0.2) 0%, transparent 70%);
|
||||
filter: blur(40px);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
margin-right: 30px;
|
||||
z-index: 1;
|
||||
|
||||
.avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 60px;
|
||||
border: 2px solid rgba(0, 185, 107, 0.5);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.online-dot {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
right: 5px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #00b96b;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #111;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.info-col {
|
||||
flex: 1;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.nickname {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 8px;
|
||||
text-shadow: 0 0 10px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.badges-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
backdrop-filter: blur(5px);
|
||||
|
||||
.badge-icon { margin-right: 6px; font-size: 22px; }
|
||||
|
||||
&.star {
|
||||
background: rgba(255, 215, 0, 0.15);
|
||||
border: 1px solid rgba(255, 215, 0, 0.6);
|
||||
color: #ffd700;
|
||||
box-shadow: 0 0 15px rgba(255, 215, 0, 0.1);
|
||||
}
|
||||
|
||||
&.admin {
|
||||
background: rgba(255, 71, 87, 0.15);
|
||||
border: 1px solid rgba(255, 71, 87, 0.6);
|
||||
color: #ff4757;
|
||||
box-shadow: 0 0 15px rgba(255, 71, 87, 0.1);
|
||||
}
|
||||
|
||||
&.web {
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.active {
|
||||
background: rgba(30, 144, 255, 0.15);
|
||||
border: 1px solid rgba(30, 144, 255, 0.6);
|
||||
color: #1e90ff;
|
||||
box-shadow: 0 0 15px rgba(30, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #666;
|
||||
filter: grayscale(1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.uid {
|
||||
font-size: 24px;
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
background: rgba(0, 185, 107, 0.2);
|
||||
border: 1px solid #00b96b;
|
||||
color: #00b96b;
|
||||
font-size: 24px;
|
||||
border-radius: 30px;
|
||||
padding: 0 30px;
|
||||
height: 60px;
|
||||
line-height: 58px;
|
||||
margin: 0;
|
||||
width: fit-content;
|
||||
|
||||
&:active { background: rgba(0, 185, 107, 0.3); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 30px;
|
||||
padding: 0 10px;
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.stat-val { font-size: 36px; font-weight: bold; color: #fff; margin-bottom: 5px; }
|
||||
.stat-lbl { font-size: 24px; color: #666; }
|
||||
}
|
||||
}
|
||||
|
||||
.service-container {
|
||||
padding-bottom: 40px;
|
||||
|
||||
.service-group {
|
||||
margin-bottom: 40px;
|
||||
|
||||
.group-title {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 20px;
|
||||
padding-left: 10px;
|
||||
border-left: 4px solid #00b96b;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.grid-layout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
|
||||
.grid-item {
|
||||
width: calc(33.33% - 14px); // 3 items per row, accounting for gap
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 20px;
|
||||
padding: 30px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 185, 107, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.icon { font-size: 40px; }
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 26px;
|
||||
color: #ddd;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.contact-overlay {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.version-info {
|
||||
margin-top: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
text {
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
/* Login Modal Styles */
|
||||
.login-modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(5px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-modal-content {
|
||||
width: 600px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 30px;
|
||||
padding: 50px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||
|
||||
.modal-header {
|
||||
margin-bottom: 60px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
|
||||
.modal-title {
|
||||
display: block;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
font-size: 28px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
right: -10px;
|
||||
font-size: 48px;
|
||||
color: #666;
|
||||
padding: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
width: 100%;
|
||||
|
||||
.btn-modal-login {
|
||||
width: 100%;
|
||||
height: 90px;
|
||||
line-height: 90px;
|
||||
border-radius: 45px;
|
||||
font-size: 32px;
|
||||
margin-bottom: 20px;
|
||||
background: #333;
|
||||
color: #888;
|
||||
border: none;
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(90deg, #00b96b 0%, #009959 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 20px rgba(0, 185, 107, 0.3);
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-modal-cancel {
|
||||
width: 100%;
|
||||
height: 90px;
|
||||
line-height: 90px;
|
||||
border-radius: 45px;
|
||||
font-size: 30px;
|
||||
margin-bottom: 30px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-sizing: border-box;
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
&::after { border: none; }
|
||||
}
|
||||
|
||||
.agreement-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
|
||||
.agreement-checkbox {
|
||||
transform: scale(0.7);
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.agreement-text {
|
||||
font-size: 24px;
|
||||
color: #888;
|
||||
line-height: 1.4;
|
||||
|
||||
.link {
|
||||
color: #00b96b;
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Agreement Detail Modal */
|
||||
.agreement-modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.agreement-content {
|
||||
width: 600px;
|
||||
height: 80vh;
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.agreement-title {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.agreement-scroll {
|
||||
flex: 1;
|
||||
padding: 30px;
|
||||
overflow-y: auto;
|
||||
|
||||
.p {
|
||||
margin-bottom: 20px;
|
||||
font-size: 28px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
height: 100px;
|
||||
line-height: 100px;
|
||||
text-align: center;
|
||||
background: #f5f5f5;
|
||||
color: #00b96b;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
border-radius: 0;
|
||||
|
||||
&:active {
|
||||
background: #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
517
miniprogram/src/pages/user/index.tsx
Normal file
517
miniprogram/src/pages/user/index.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
import { View, Text, Image, Button, Checkbox, CheckboxGroup, RichText } from '@tarojs/components'
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { login as silentLogin } from '../../utils/request'
|
||||
import { getMyEnrollments, getProjects } from '../../api'
|
||||
import './index.scss'
|
||||
|
||||
export default function UserIndex() {
|
||||
const [userInfo, setUserInfo] = useState<any>(null)
|
||||
const [showLoginModal, setShowLoginModal] = useState(false)
|
||||
const [isAgreed, setIsAgreed] = useState(false)
|
||||
const [showAgreement, setShowAgreement] = useState(false) // For showing agreement content
|
||||
const [myEnrollments, setMyEnrollments] = useState<any[]>([])
|
||||
const [myProjects, setMyProjects] = useState<any[]>([])
|
||||
|
||||
useDidShow(() => {
|
||||
const info = Taro.getStorageSync('userInfo')
|
||||
if (info) {
|
||||
setUserInfo(info)
|
||||
fetchData()
|
||||
}
|
||||
})
|
||||
|
||||
usePullDownRefresh(async () => {
|
||||
try {
|
||||
const res = await silentLogin()
|
||||
if (res) {
|
||||
setUserInfo(res)
|
||||
fetchData()
|
||||
}
|
||||
Taro.stopPullDownRefresh()
|
||||
} catch (e) {
|
||||
Taro.stopPullDownRefresh()
|
||||
Taro.showToast({ title: '刷新失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [enrollRes, projectRes] = await Promise.all([
|
||||
getMyEnrollments(),
|
||||
getProjects()
|
||||
])
|
||||
|
||||
let enrollments: any[] = []
|
||||
if (Array.isArray(enrollRes)) {
|
||||
enrollments = enrollRes
|
||||
setMyEnrollments(enrollRes)
|
||||
}
|
||||
|
||||
const allProjects = (projectRes.results || projectRes) as any[]
|
||||
if (Array.isArray(allProjects) && enrollments.length > 0) {
|
||||
// 筛选出属于我的项目 (通过 enrollment id 匹配)
|
||||
const myEnrollmentIds = enrollments.map(e => e.id)
|
||||
const mine = allProjects.filter(p => myEnrollmentIds.includes(p.contestant))
|
||||
setMyProjects(mine)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fetch data failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
const goOrders = () => Taro.navigateTo({ url: '/pages/order/list' })
|
||||
const goDistributor = () => Taro.navigateTo({ url: '/subpackages/distributor/index' })
|
||||
const goInvite = () => Taro.navigateTo({ url: '/subpackages/distributor/invite' })
|
||||
const goWithdraw = () => Taro.navigateTo({ url: '/subpackages/distributor/withdraw' })
|
||||
|
||||
const goActivityList = (tab = 'all') => Taro.navigateTo({ url: `/subpackages/forum/activity/index?tab=${tab}` })
|
||||
const goCompetitionList = () => Taro.navigateTo({ url: '/pages/competition/index' })
|
||||
|
||||
const goUploadProject = () => {
|
||||
// 找到所有有效的选手报名
|
||||
const contestantEnrollments = myEnrollments.filter(e => e.role === 'contestant')
|
||||
|
||||
if (contestantEnrollments.length === 1) {
|
||||
const enrollment = contestantEnrollments[0]
|
||||
// 查找该报名对应的项目
|
||||
const project = myProjects.find(p => p.contestant === enrollment.id)
|
||||
|
||||
if (project) {
|
||||
// 已有项目,去编辑
|
||||
Taro.navigateTo({ url: `/pages/competition/project?id=${project.id}` })
|
||||
} else {
|
||||
// 无项目,去新建
|
||||
Taro.navigateTo({ url: `/pages/competition/project?competitionId=${enrollment.competition}` })
|
||||
}
|
||||
} else {
|
||||
// 多个比赛或无比赛,去列表页让用户选
|
||||
Taro.navigateTo({ url: '/pages/competition/index' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddress = async () => {
|
||||
try {
|
||||
const res = await Taro.chooseAddress()
|
||||
// 同步地址信息到后端
|
||||
const token = Taro.getStorageSync('token')
|
||||
if (token) {
|
||||
await Taro.request({
|
||||
url: 'https://market.quant-speed.com/api/wechat/update/',
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: {
|
||||
province: res.provinceName,
|
||||
city: res.cityName,
|
||||
country: '中国' // 默认中国,chooseAddress通常返回国内地址
|
||||
}
|
||||
})
|
||||
// 更新本地 userInfo
|
||||
const updatedInfo = { ...userInfo, province: res.provinceName, city: res.cityName, country: '中国' }
|
||||
setUserInfo(updatedInfo)
|
||||
Taro.setStorageSync('userInfo', updatedInfo)
|
||||
Taro.showToast({ title: '地址信息已同步', icon: 'success' })
|
||||
}
|
||||
} catch(e) {
|
||||
// 用户取消或其他错误,忽略
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarClick = async () => {
|
||||
if (!userInfo) return
|
||||
|
||||
try {
|
||||
const { tempFilePaths } = await Taro.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'] })
|
||||
if (!tempFilePaths.length) return
|
||||
|
||||
Taro.showLoading({ title: '上传中...' })
|
||||
|
||||
const token = Taro.getStorageSync('token')
|
||||
const uploadRes = await Taro.uploadFile({
|
||||
url: 'https://market.quant-speed.com/api/upload/image/',
|
||||
filePath: tempFilePaths[0],
|
||||
name: 'file',
|
||||
header: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (uploadRes.statusCode !== 200) {
|
||||
throw new Error('上传失败')
|
||||
}
|
||||
|
||||
const data = JSON.parse(uploadRes.data)
|
||||
const newAvatarUrl = data.url
|
||||
|
||||
// 更新后端用户信息
|
||||
await Taro.request({
|
||||
url: 'https://market.quant-speed.com/api/wechat/update/',
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: {
|
||||
avatar_url: newAvatarUrl
|
||||
}
|
||||
})
|
||||
|
||||
// 更新本地 userInfo
|
||||
const updatedInfo = { ...userInfo, avatar_url: newAvatarUrl }
|
||||
setUserInfo(updatedInfo)
|
||||
Taro.setStorageSync('userInfo', updatedInfo)
|
||||
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '头像更新成功', icon: 'success' })
|
||||
|
||||
} catch (e) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '头像更新失败', icon: 'none' })
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNicknameClick = () => {
|
||||
if (!userInfo) return
|
||||
|
||||
Taro.showModal({
|
||||
title: '修改昵称',
|
||||
content: userInfo.nickname || '',
|
||||
// @ts-ignore
|
||||
editable: true,
|
||||
placeholderText: '请输入新昵称',
|
||||
success: async function (res) {
|
||||
if (res.confirm && (res as any).content) {
|
||||
const newNickname = (res as any).content
|
||||
if (newNickname === userInfo.nickname) return
|
||||
|
||||
try {
|
||||
Taro.showLoading({ title: '更新中...' })
|
||||
const token = Taro.getStorageSync('token')
|
||||
|
||||
await Taro.request({
|
||||
url: 'https://market.quant-speed.com/api/wechat/update/',
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: {
|
||||
nickname: newNickname
|
||||
}
|
||||
})
|
||||
|
||||
// 更新本地 userInfo
|
||||
const updatedInfo = { ...userInfo, nickname: newNickname }
|
||||
setUserInfo(updatedInfo)
|
||||
Taro.setStorageSync('userInfo', updatedInfo)
|
||||
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '昵称已更新', icon: 'success' })
|
||||
} catch (e) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '更新失败', icon: 'none' })
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
Taro.showModal({
|
||||
title: '提示',
|
||||
content: '确定要退出登录吗?',
|
||||
success: function (res) {
|
||||
if (res.confirm) {
|
||||
Taro.removeStorageSync('token')
|
||||
Taro.removeStorageSync('userInfo')
|
||||
setUserInfo(null)
|
||||
Taro.showToast({ title: '已退出登录', icon: 'success' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const login = async () => {
|
||||
try {
|
||||
// 1. 获取微信登录 Code
|
||||
const { code } = await Taro.login()
|
||||
if (!code) throw new Error('登录失败:无法获取 Code')
|
||||
|
||||
// 2. 调用后端登录 (仅 Code)
|
||||
const res = await Taro.request({
|
||||
url: 'https://market.quant-speed.com/api/wechat/login/',
|
||||
method: 'POST',
|
||||
data: { code }
|
||||
})
|
||||
|
||||
console.log('code:', code)
|
||||
|
||||
if (res.statusCode === 200 && res.data.token) {
|
||||
console.log('登录成功,后端返回用户信息:', res.data)
|
||||
Taro.setStorageSync('token', res.data.token)
|
||||
Taro.setStorageSync('userInfo', res.data)
|
||||
setUserInfo(res.data)
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
} else {
|
||||
throw new Error(res.data.error || '登录请求失败')
|
||||
}
|
||||
} catch (e) {
|
||||
Taro.showToast({ title: e.message || '登录失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const getPhoneNumber = async (e) => {
|
||||
const { code: phoneCode, errMsg } = e.detail
|
||||
if (errMsg !== "getPhoneNumber:ok") {
|
||||
Taro.showToast({ title: '获取手机号失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Taro.showLoading({ title: '登录中...' })
|
||||
// 1. 获取登录 Code
|
||||
const { code: loginCode } = await Taro.login()
|
||||
|
||||
// 2. 调用后端登录 (Code + PhoneCode)
|
||||
console.log('loginCode:', loginCode)
|
||||
console.log('phoneCode:', phoneCode)
|
||||
const res = await Taro.request({
|
||||
url: 'https://market.quant-speed.com/api/wechat/login/',
|
||||
method: 'POST',
|
||||
data: {
|
||||
code: loginCode,
|
||||
phone_code: phoneCode
|
||||
}
|
||||
})
|
||||
|
||||
Taro.hideLoading()
|
||||
|
||||
if (res.statusCode === 200 && res.data.token) {
|
||||
console.log('手机号登录成功,后端返回用户信息:', res.data)
|
||||
Taro.setStorageSync('token', res.data.token)
|
||||
Taro.setStorageSync('userInfo', res.data)
|
||||
setUserInfo(res.data)
|
||||
setShowLoginModal(false) // Close modal on success
|
||||
Taro.showToast({ title: '授权登录成功', icon: 'success' })
|
||||
} else {
|
||||
throw new Error(res.data.error || '登录失败')
|
||||
}
|
||||
} catch(err) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: err.message || '系统异常', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const isContestant = myEnrollments.some(e => e.role === 'contestant')
|
||||
|
||||
const serviceGroups = [
|
||||
{
|
||||
title: '基础服务',
|
||||
items: [
|
||||
{ title: '我的订单', icon: '📦', action: goOrders },
|
||||
{ title: '地址管理', icon: '📝', action: handleAddress },
|
||||
{ title: '活动管理', icon: '⌚️', action: () => goActivityList('mine') },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '比赛服务',
|
||||
items: [
|
||||
{ title: '赛事中心', icon: '🏆', action: goCompetitionList },
|
||||
...(isContestant ? [{ title: '上传比赛资料', icon: '📤', action: goUploadProject }] : [])
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '分销中心',
|
||||
items: [
|
||||
{ title: '分销首页', icon: '⚡', action: goDistributor },
|
||||
{ title: '推广邀请', icon: '🤝', action: goInvite },
|
||||
{ title: '佣金提现', icon: '💰', action: goWithdraw },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '其他',
|
||||
items: [
|
||||
{ title: '联系客服', icon: '🎧', isContact: true },
|
||||
...(userInfo ? [{ title: '退出登录', icon: '🚪', action: handleLogout }] : [])
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const stats = [
|
||||
{ label: '余额', value: '0.00' },
|
||||
{ label: '积分', value: '0' },
|
||||
{ label: '优惠券', value: '0' }
|
||||
]
|
||||
|
||||
const handleAgreementCheck = (e) => {
|
||||
setIsAgreed(!!e.detail.value.length)
|
||||
}
|
||||
|
||||
const handleShowAgreement = (e) => {
|
||||
e.stopPropagation()
|
||||
setShowAgreement(true)
|
||||
}
|
||||
|
||||
const handleLoginBtnClick = () => {
|
||||
if (!isAgreed) {
|
||||
Taro.showToast({ title: '请先阅读并同意用户协议', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// If agreed, the button openType='getPhoneNumber' handles it.
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
{/* Profile Card */}
|
||||
<View className='profile-card'>
|
||||
<View className='avatar-container' onClick={handleAvatarClick}>
|
||||
<Image
|
||||
src={userInfo?.avatar_url || `https://api.dicebear.com/7.x/miniavs/svg?seed=${userInfo?.id || 'guest'}`}
|
||||
className='avatar'
|
||||
/>
|
||||
{userInfo && <View className='online-dot' />}
|
||||
</View>
|
||||
<View className='info-col'>
|
||||
<Text className='nickname' onClick={handleNicknameClick}>{userInfo?.nickname || '未登录用户'}</Text>
|
||||
|
||||
{userInfo && (
|
||||
<View className='badges-row'>
|
||||
{/* 管理员 */}
|
||||
{userInfo.is_admin && (
|
||||
<View className='badge admin'>
|
||||
<Text className='badge-icon'>🛡️</Text>
|
||||
<Text className='badge-text'>管理员</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 明星技术用户/专家 */}
|
||||
{userInfo.is_star && (
|
||||
<View className='badge star'>
|
||||
<Text className='badge-icon'>🌟</Text>
|
||||
<Text className='badge-text'>{userInfo.title || '技术专家'}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 网页用户徽章 */}
|
||||
{(userInfo.has_web_badge || userInfo.has_web_account) && (
|
||||
<View className='badge web active'>
|
||||
<Text className='badge-icon'>🌐</Text>
|
||||
<Text className='badge-text'>网页用户</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text className='uid'>ID: {userInfo?.phone_number || '未绑定手机号'}</Text>
|
||||
{!userInfo?.phone_number && (
|
||||
<View className='login-btns'>
|
||||
<Button
|
||||
className='btn-login primary'
|
||||
onClick={() => setShowLoginModal(true)}
|
||||
>
|
||||
{userInfo ? '绑定手机号' : '立即登录'}
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className='card-bg-effect' />
|
||||
</View>
|
||||
|
||||
{/* Stats Row */}
|
||||
<View className='stats-row'>
|
||||
{stats.map((item, idx) => (
|
||||
<View key={idx} className='stat-item'>
|
||||
<Text className='stat-val'>{item.value}</Text>
|
||||
<Text className='stat-lbl'>{item.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Service Groups */}
|
||||
<View className='service-container'>
|
||||
{serviceGroups.map((group, gIdx) => (
|
||||
<View key={gIdx} className='service-group'>
|
||||
<Text className='group-title'>{group.title}</Text>
|
||||
<View className='grid-layout'>
|
||||
{group.items.map((item, idx) => (
|
||||
<View key={idx} className='grid-item' onClick={item.action}>
|
||||
<View className='icon-box'>
|
||||
<Text className='icon'>{item.icon}</Text>
|
||||
</View>
|
||||
<Text className='item-title'>{item.title}</Text>
|
||||
{item.isContact && <Button openType='contact' className='contact-overlay' />}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className='version-info'>
|
||||
<Text>Quant Speed Market v1.0.0</Text>
|
||||
<Text>Powered by Taro & React</Text>
|
||||
</View>
|
||||
|
||||
{/* Login Modal */}
|
||||
{showLoginModal && (
|
||||
<View className='login-modal-mask' onClick={() => setShowLoginModal(false)}>
|
||||
<View className='login-modal-content' onClick={e => e.stopPropagation()}>
|
||||
<View className='modal-header'>
|
||||
<Text className='modal-title'>欢迎登录 Quant Speed</Text>
|
||||
<Text className='modal-subtitle'>登录后享受更多权益</Text>
|
||||
<View className='close-icon' onClick={() => setShowLoginModal(false)}>×</View>
|
||||
</View>
|
||||
|
||||
<View className='modal-body'>
|
||||
<Button
|
||||
className={`btn-modal-login ${isAgreed ? 'primary' : 'disabled'}`}
|
||||
openType={isAgreed ? 'getPhoneNumber' : undefined}
|
||||
onGetPhoneNumber={getPhoneNumber}
|
||||
onClick={handleLoginBtnClick}
|
||||
>
|
||||
手机号快捷登录
|
||||
</Button>
|
||||
|
||||
<Button className='btn-modal-cancel' onClick={() => setShowLoginModal(false)}>
|
||||
暂不登录
|
||||
</Button>
|
||||
|
||||
<View className='agreement-box'>
|
||||
<CheckboxGroup onChange={handleAgreementCheck}>
|
||||
<Checkbox value='agree' checked={isAgreed} color='#00b96b' className='agreement-checkbox' />
|
||||
</CheckboxGroup>
|
||||
<Text className='agreement-text'>
|
||||
我已阅读并同意 <Text className='link' onClick={handleShowAgreement}>《用户协议》</Text> 与 <Text className='link' onClick={handleShowAgreement}>《隐私政策》</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Agreement Detail Modal */}
|
||||
{showAgreement && (
|
||||
<View className='agreement-modal-mask'>
|
||||
<View className='agreement-content'>
|
||||
<Text className='agreement-title'>用户协议与隐私政策</Text>
|
||||
<View className='agreement-scroll'>
|
||||
<View className='p'><Text>1. 特别提示</Text></View>
|
||||
<View className='p'><Text>在此特别提醒您(用户)在注册成为用户之前,请认真阅读本《用户协议》(以下简称“协议”),确保您充分理解本协议中各条款。请您审慎阅读并选择接受或不接受本协议。除非您接受本协议所有条款,否则您无权注册、登录或使用本协议所涉服务。您的注册、登录、使用等行为将视为对本协议的接受,并同意接受本协议各项条款的约束。</Text></View>
|
||||
<View className='p'><Text>2. 账号注册</Text></View>
|
||||
<View className='p'><Text>2.1 鉴于“Quant Speed”账号的绑定注册方式,您同意在注册时将您的手机号码及微信账号信息提供给“Quant Speed”用于注册。</Text></View>
|
||||
<View className='p'><Text>3. 隐私保护</Text></View>
|
||||
<View className='p'><Text>3.1 本小程序将严格保护您的个人信息安全。我们使用各种安全技术和程序来保护您的个人信息不被未经授权的访问、使用或泄漏。</Text></View>
|
||||
</View>
|
||||
<Button className='btn-close' onClick={() => setShowAgreement(false)}>我知道了</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/webview/index.config.ts
Normal file
3
miniprogram/src/pages/webview/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '加载中...'
|
||||
}
|
||||
14
miniprogram/src/pages/webview/index.tsx
Normal file
14
miniprogram/src/pages/webview/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { WebView } from '@tarojs/components'
|
||||
import { useRouter } from '@tarojs/taro'
|
||||
|
||||
export default function WebViewPage() {
|
||||
const router = useRouter()
|
||||
const { url } = router.params
|
||||
|
||||
if (!url) return null
|
||||
|
||||
// Ensure url has protocol if missing (e.g. starts with //)
|
||||
const fullUrl = url.startsWith('//') ? `https:${url}` : url
|
||||
|
||||
return <WebView src={fullUrl} />
|
||||
}
|
||||
Reference in New Issue
Block a user