first commit
All checks were successful
Deploy to Server / deploy (push) Successful in 19s

This commit is contained in:
爽哒哒
2026-03-20 23:30:57 +08:00
commit 290be5d5be
328 changed files with 37215 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '购物车'
})

View 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;
}
}
}

View 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>
)
}

View 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;
}
}
}
}

View 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>
)
}

View 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;
}
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '项目详情'
})

View 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;
}
}
}
}
}
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '参赛作品'
})

View 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;
}
}
}
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '体验详情'
})

View 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;
}
}
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: 'AR 体验馆'
})

View 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;
}

View 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>
)
}

View File

@@ -0,0 +1,7 @@
export default definePageConfig({
navigationBarTitleText: '开发者社区',
enablePullDownRefresh: true,
backgroundColor: '#000000',
navigationBarBackgroundColor: '#000000',
navigationBarTextStyle: 'white'
})

View 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;
}
}
}
}
}
}

View 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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '商品详情'
})

View 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;
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: 'Quant Speed Market'
})

View 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);
}
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '确认订单'
})

View 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;
}
}
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '订单详情'
})

View 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);
}
}
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '我的订单'
})

View 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;
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '订单支付'
})

View 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);
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '服务详情'
})

View 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;
}
}
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: 'AI 全栈解决方案'
})

View 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;
}
}

View 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>
)
}

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '个人中心',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark'
})

View 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;
}
}
}

View 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 SpeedQuant 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>
)
}

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '加载中...'
}

View 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} />
}