feat: 初始化世界杯观赛助手小程序

- uni-app (Vue3) + Vite 框架
- 7个页面:首页、赛程、比赛详情、球队、球队详情、球员、个人中心
- API 接口配置 (开发/生产环境)
- 状态管理 (Pinia)
This commit is contained in:
hello-dd-code
2026-04-28 15:55:35 +08:00
commit baeb5bd179
25 changed files with 14040 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
<script>
import { useUserStore } from '@/stores/user'
export default {
onLaunch() {
const userStore = useUserStore()
userStore.silentLogin().catch(() => {})
}
}
</script>
<style lang="scss">
@import '@/styles/base.scss';
</style>
+59
View File
@@ -0,0 +1,59 @@
import { BASE_URL } from '@/config'
import { storage, TOKEN_KEY } from '@/utils/storage'
function isInvalidToken(res) {
if (res.statusCode === 401) return true
const body = res.data
return body && body.code === 401
}
export const request = {
async request(options) {
const token = storage.get(TOKEN_KEY)
const baseUrl = options.baseUrl || BASE_URL
return new Promise((resolve, reject) => {
uni.request({
url: baseUrl + options.url,
method: options.method || 'GET',
data: options.data,
header: {
'Content-Type': 'application/json',
Authorization: token ? `Bearer ${token}` : ''
},
success: async (res) => {
if (isInvalidToken(res) && options.auth !== false) {
storage.remove(TOKEN_KEY)
const message = res.data?.message || '登录已过期'
uni.showToast({ title: message, icon: 'none' })
reject(new Error(message))
return
}
if (res.statusCode < 200 || res.statusCode >= 300) {
const message = res.data?.message || res.data?.msg || '请求失败'
uni.showToast({ title: message, icon: 'none' })
reject(new Error(message))
return
}
resolve(res.data)
},
fail: (err) => {
uni.showToast({ title: '网络不可用', icon: 'none' })
reject(err)
}
})
})
},
get(url, params, options = {}) {
return this.request({ url, method: 'GET', data: params, ...options })
},
post(url, data, options = {}) {
return this.request({ url, method: 'POST', data, ...options })
},
delete(url, params, options = {}) {
const query = params ? '?' + Object.keys(params).map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&') : ''
return this.request({ url: url + query, method: 'DELETE', ...options })
}
}
+58
View File
@@ -0,0 +1,58 @@
import { request } from './request'
export function login(data) {
return request.post('/worldcup/auth/login', data, { auth: false })
}
export function devLogin(data) {
return request.post('/worldcup/auth/dev-login', data, { auth: false })
}
export function getHome() {
return request.get('/worldcup/home', {}, { auth: false })
}
export function getMatches(params = {}) {
return request.get('/worldcup/matches', params, { auth: false })
}
export function getMatchDetail(id) {
return request.get(`/worldcup/matches/${id}`, {}, { auth: false })
}
export function getTeams(params = {}) {
return request.get('/worldcup/teams', params, { auth: false })
}
export function getTeamDetail(id) {
return request.get(`/worldcup/teams/${id}`, {}, { auth: false })
}
export function getTeamCards(id, params = {}) {
return request.get(`/worldcup/teams/${id}/cards`, params, { auth: false })
}
export function getPlayers(params = {}) {
return request.get('/worldcup/players', params, { auth: false })
}
export function getPlayerDetail(id) {
return request.get(`/worldcup/players/${id}`, {}, { auth: false })
}
export function addFavorite(data) {
return request.post('/worldcup/favorites', data)
}
export function removeFavorite(params) {
return request.delete('/worldcup/favorites', params)
}
export function addReminder(data) {
return request.post('/worldcup/reminders', data)
}
export function removeReminder(id) {
return request.delete(`/worldcup/reminders/${id}`)
}
+24
View File
@@ -0,0 +1,24 @@
const ENV = {
development: {
BASE_URL: 'http://127.0.0.1:8080/api/v1',
MINI_PROGRAM_ID: 1
},
production: {
BASE_URL: 'https://wx.nepiedg.top/api/v1',
MINI_PROGRAM_ID: 1
}
}
const env = process.env.NODE_ENV || 'development'
const currentEnv = ENV[env]
let baseURL = currentEnv.BASE_URL
// #ifdef H5
if (env === 'development') {
baseURL = '/api/v1'
}
// #endif
export const BASE_URL = baseURL
export const MINI_PROGRAM_ID = currentEnv.MINI_PROGRAM_ID
+25
View File
@@ -0,0 +1,25 @@
import App from './App'
import pinia from './stores'
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
Vue.config.productionTip = false
App.mpType = 'app'
const app = new Vue({
...App
})
app.$mount()
// #endif
// #ifdef VUE3
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
app.use(pinia)
return {
app
}
}
// #endif
+19
View File
@@ -0,0 +1,19 @@
{
"name": "世界杯观赛助手",
"appid": "__UNI__WORLDCUP",
"description": "2026 美加墨世界杯赛程、球队外号、梗图和观赛提醒",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false,
"es6": true,
"postcss": true,
"minified": true
},
"usingComponents": true
}
}
+56
View File
@@ -0,0 +1,56 @@
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/schedule/index",
"style": {
"navigationBarTitleText": "世界杯赛程"
}
},
{
"path": "pages/match-detail/index",
"style": {
"navigationBarTitleText": "比赛详情"
}
},
{
"path": "pages/teams/index",
"style": {
"navigationBarTitleText": "球队百科"
}
},
{
"path": "pages/team-detail/index",
"style": {
"navigationBarTitleText": "球队详情"
}
},
{
"path": "pages/players/index",
"style": {
"navigationBarTitleText": "球员名单"
}
},
{
"path": "pages/profile/index",
"style": {
"navigationBarTitleText": "我的关注"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "世界杯观赛助手",
"navigationBarBackgroundColor": "#F7F4EA",
"backgroundColor": "#F7F4EA",
"backgroundColorTop": "#F7F4EA",
"backgroundColorBottom": "#EDF3F2"
},
"uniIdRouter": {}
}
+295
View File
@@ -0,0 +1,295 @@
<template>
<view class="page home-page safe-bottom">
<view class="hero">
<view class="hero-copy">
<text class="hero-kicker">2026 美加墨世界杯</text>
<text class="hero-title">看赛程也看懂每支球队</text>
<text class="hero-desc">球队外号历史成绩赛前梗图和北京时间提醒一起放进观赛清单</text>
</view>
<view class="hero-ball">
<text>26</text>
</view>
</view>
<view class="quick-grid">
<button class="quick-item" @tap="go('/pages/schedule/index')">
<text class="quick-icon"></text>
<text>赛程</text>
</button>
<button class="quick-item" @tap="go('/pages/teams/index')">
<text class="quick-icon"></text>
<text>球队</text>
</button>
<button class="quick-item" @tap="go('/pages/players/index')">
<text class="quick-icon"></text>
<text>球员</text>
</button>
<button class="quick-item" @tap="go('/pages/profile/index')">
<text class="quick-icon"></text>
<text>关注</text>
</button>
</view>
<view class="section">
<view class="section-head">
<text class="section-title">热门球队梗图</text>
<text class="section-action" @tap="go('/pages/teams/index')">全部球队</text>
</view>
<scroll-view scroll-x class="card-scroll" v-if="hotCards.length">
<view class="meme-card card" v-for="card in hotCards" :key="card.id">
<text class="tag">{{ card.type }}</text>
<text class="meme-title">{{ card.title }}</text>
<text class="meme-copy">{{ card.copywriting || card.subtitle }}</text>
</view>
</scroll-view>
<view class="meme-fallback" v-else>
<view class="meme-card card" v-for="card in fallbackCards" :key="card.title">
<text class="tag">{{ card.label }}</text>
<text class="meme-title">{{ card.title }}</text>
<text class="meme-copy">{{ card.copy }}</text>
</view>
</view>
</view>
<view class="section">
<view class="section-head">
<text class="section-title">今日比赛</text>
<text class="section-action" @tap="go('/pages/schedule/index')">完整赛程</text>
</view>
<view v-if="todayMatches.length">
<view class="match-card card" v-for="match in todayMatches" :key="match.id" @tap="openMatch(match.id)">
<view class="match-time">
<text>{{ compactDateTime(match.kickoff_beijing) }}</text>
<text>{{ stageText(match.stage) }}</text>
</view>
<view class="match-main">
<text class="team">{{ teamName(match.home_team, match.home_placeholder) }}</text>
<text class="score">{{ scoreText(match) }}</text>
<text class="team right">{{ teamName(match.away_team, match.away_placeholder) }}</text>
</view>
<text class="match-venue">{{ match.venue?.city_zh || match.venue?.city_en || '场馆待定' }}</text>
</view>
</view>
<view class="card empty" v-else>今日暂无比赛先收藏你关注的球队</view>
</view>
<view class="section" v-if="tomorrowMatches.length">
<view class="section-head">
<text class="section-title">明日预告</text>
</view>
<view class="compact-match card" v-for="match in tomorrowMatches" :key="match.id" @tap="openMatch(match.id)">
<text>{{ compactDateTime(match.kickoff_beijing) }}</text>
<text>{{ teamName(match.home_team, match.home_placeholder) }} vs {{ teamName(match.away_team, match.away_placeholder) }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getHome } from '@/api/worldcup'
import { compactDateTime, scoreText, stageText, teamName } from '@/utils/format'
const hotCards = ref([])
const todayMatches = ref([])
const tomorrowMatches = ref([])
const fallbackCards = [
{ label: '外号', title: '潘帕斯雄鹰', copy: '阿根廷队的南美想象:技术、速度和自由。' },
{ label: '历史', title: '五星巴西', copy: '五次世界杯冠军,是最醒目的强队标签。' },
{ label: '风格', title: '德国战车', copy: '纪律、力量和效率,是中文球迷的经典称呼。' }
]
onShow(loadHome)
async function loadHome() {
try {
const res = await getHome()
const data = res.data || {}
hotCards.value = data.hot_cards || []
todayMatches.value = data.today_matches || []
tomorrowMatches.value = data.tomorrow_matches || []
} catch (e) {}
}
function go(url) {
uni.navigateTo({ url })
}
function openMatch(id) {
uni.navigateTo({ url: `/pages/match-detail/index?id=${id}` })
}
</script>
<style lang="scss" scoped>
.hero {
display: flex;
gap: 24rpx;
align-items: stretch;
min-height: 360rpx;
padding: 76rpx 30rpx 30rpx;
border-radius: 0 0 8rpx 8rpx;
background:
linear-gradient(135deg, rgba(11, 107, 83, 0.96), rgba(25, 44, 68, 0.96)),
linear-gradient(90deg, #f2dfad, #c8493a);
color: #ffffff;
}
.hero-copy {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.hero-kicker {
font-size: 24rpx;
color: #f2dfad;
font-weight: 800;
}
.hero-title {
margin-top: 18rpx;
font-size: 54rpx;
line-height: 1.12;
font-weight: 900;
}
.hero-desc {
margin-top: 18rpx;
max-width: 460rpx;
color: rgba(255, 255, 255, 0.82);
font-size: 26rpx;
line-height: 1.55;
}
.hero-ball {
align-self: flex-end;
display: flex;
align-items: center;
justify-content: center;
width: 132rpx;
height: 132rpx;
border: 8rpx solid #f2dfad;
border-radius: 50%;
color: #f2dfad;
font-size: 42rpx;
font-weight: 900;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14rpx;
margin-top: 24rpx;
}
.quick-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 116rpx;
border-radius: 8rpx;
background: #ffffff;
color: #17211b;
font-size: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(26, 41, 32, 0.06);
}
.quick-icon {
margin-bottom: 10rpx;
color: #c8493a;
font-size: 34rpx;
font-weight: 900;
}
.card-scroll {
white-space: nowrap;
width: 100%;
}
.meme-fallback {
display: flex;
gap: 18rpx;
overflow: hidden;
}
.meme-card {
display: inline-flex;
flex-direction: column;
width: 430rpx;
min-height: 230rpx;
margin-right: 18rpx;
padding: 24rpx;
vertical-align: top;
white-space: normal;
}
.meme-title {
margin-top: 20rpx;
font-size: 36rpx;
font-weight: 900;
}
.meme-copy {
margin-top: 12rpx;
color: #66736d;
font-size: 25rpx;
line-height: 1.45;
}
.match-card {
padding: 24rpx;
margin-bottom: 18rpx;
}
.match-time,
.compact-match {
display: flex;
align-items: center;
justify-content: space-between;
color: #66736d;
font-size: 24rpx;
}
.match-main {
display: grid;
grid-template-columns: 1fr 110rpx 1fr;
align-items: center;
gap: 16rpx;
margin-top: 18rpx;
}
.team {
font-size: 31rpx;
font-weight: 800;
}
.team.right {
text-align: right;
}
.score {
color: #c8493a;
font-size: 30rpx;
font-weight: 900;
text-align: center;
}
.match-venue {
display: block;
margin-top: 16rpx;
color: #66736d;
font-size: 24rpx;
}
.compact-match {
min-height: 82rpx;
padding: 0 22rpx;
margin-bottom: 14rpx;
color: #17211b;
}
</style>
+225
View File
@@ -0,0 +1,225 @@
<template>
<view class="page safe-bottom">
<view class="detail-head card">
<text class="tag">{{ stageText(match.stage) }}</text>
<view class="score-row">
<text class="team">{{ teamName(match.home_team, match.home_placeholder) }}</text>
<text class="score">{{ scoreText(match) }}</text>
<text class="team right">{{ teamName(match.away_team, match.away_placeholder) }}</text>
</view>
<text class="status">{{ statusText(match.status) }}</text>
</view>
<view class="section">
<view class="section-head">
<text class="section-title">比赛时间</text>
</view>
<view class="info-card card">
<view class="info-line">
<text>北京时间</text>
<text>{{ match.kickoff_beijing || '待定' }}</text>
</view>
<view class="info-line">
<text>当地时间</text>
<text>{{ match.kickoff_local || '待定' }}</text>
</view>
<view class="info-line">
<text>场馆</text>
<text>{{ venueText }}</text>
</view>
</view>
</view>
<view class="action-row">
<button class="btn-primary" @tap="saveFavorite"> 收藏比赛</button>
<button class="btn-ghost" @tap="addCalendarReminder"> 加入日历</button>
</view>
<view class="section" v-if="homeTeam || awayTeam">
<view class="section-head">
<text class="section-title">双方球队</text>
</view>
<view class="team-link card" v-if="homeTeam" @tap="openTeam(homeTeam.id)">
<text>{{ homeTeam.name_zh }}</text>
<text>{{ (homeTeam.nicknames || []).join(' / ') || '查看球队百科' }}</text>
</view>
<view class="team-link card" v-if="awayTeam" @tap="openTeam(awayTeam.id)">
<text>{{ awayTeam.name_zh }}</text>
<text>{{ (awayTeam.nicknames || []).join(' / ') || '查看球队百科' }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { addFavorite, addReminder, getMatchDetail } from '@/api/worldcup'
import { useUserStore } from '@/stores/user'
import { scoreText, stageText, statusText, teamName } from '@/utils/format'
const userStore = useUserStore()
const matchId = ref(0)
const match = ref({})
const homeTeam = ref(null)
const awayTeam = ref(null)
const venueText = computed(() => {
const venue = match.value?.venue || {}
return venue.name_zh || venue.name_en || venue.city_zh || venue.city_en || '场馆待定'
})
onLoad((query) => {
matchId.value = Number(query.id || 0)
loadDetail()
})
async function loadDetail() {
if (!matchId.value) return
try {
const res = await getMatchDetail(matchId.value)
const data = res.data || {}
match.value = data.match || {}
homeTeam.value = data.home_team
awayTeam.value = data.away_team
} catch (e) {}
}
async function saveFavorite() {
await userStore.silentLogin()
await addFavorite({ target_type: 'match', target_id: matchId.value })
uni.showToast({ title: '已收藏', icon: 'success' })
loadDetail()
}
async function addCalendarReminder() {
if (!match.value?.kickoff_utc) {
uni.showToast({ title: '比赛时间待定', icon: 'none' })
return
}
const title = `世界杯:${teamName(match.value.home_team, match.value.home_placeholder)} vs ${teamName(match.value.away_team, match.value.away_placeholder)}`
const startTime = Number(match.value.kickoff_utc)
const endTime = startTime + 2 * 60 * 60
let calendarAdded = false
// #ifdef MP-WEIXIN
try {
await new Promise((resolve, reject) => {
wx.addPhoneCalendar({
title,
startTime,
endTime,
location: venueText.value,
description: `北京时间 ${match.value.kickoff_beijing} 开赛`,
alarm: true,
alarmOffset: 30 * 60,
success: resolve,
fail: reject
})
})
calendarAdded = true
} catch (e) {
uni.showToast({ title: '日历写入被取消', icon: 'none' })
}
// #endif
// #ifndef MP-WEIXIN
calendarAdded = true
uni.showToast({ title: 'H5 已模拟日历提醒', icon: 'none' })
// #endif
await userStore.silentLogin()
await addReminder({
match_id: matchId.value,
channel: 'calendar',
remind_before_minutes: 30,
calendar_added: calendarAdded
})
uni.showToast({ title: calendarAdded ? '已加入日历' : '提醒已保存', icon: 'success' })
}
function openTeam(id) {
uni.navigateTo({ url: `/pages/team-detail/index?id=${id}` })
}
</script>
<style lang="scss" scoped>
.detail-head {
margin-top: 72rpx;
padding: 30rpx 24rpx;
}
.score-row {
display: grid;
grid-template-columns: 1fr 130rpx 1fr;
gap: 16rpx;
align-items: center;
margin-top: 30rpx;
}
.team {
font-size: 34rpx;
font-weight: 900;
}
.right {
text-align: right;
}
.score {
color: #c8493a;
font-size: 36rpx;
font-weight: 900;
text-align: center;
}
.status {
display: block;
margin-top: 20rpx;
color: #66736d;
text-align: center;
font-size: 24rpx;
}
.info-card {
padding: 22rpx;
}
.info-line {
display: flex;
justify-content: space-between;
gap: 28rpx;
padding: 16rpx 0;
color: #17211b;
font-size: 26rpx;
}
.info-line text:first-child {
color: #66736d;
}
.action-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
margin-top: 24rpx;
}
.team-link {
display: flex;
justify-content: space-between;
gap: 20rpx;
padding: 24rpx;
margin-bottom: 14rpx;
font-size: 28rpx;
font-weight: 800;
}
.team-link text:last-child {
color: #66736d;
font-size: 24rpx;
font-weight: 500;
text-align: right;
}
</style>
+142
View File
@@ -0,0 +1,142 @@
<template>
<view class="page safe-bottom">
<view class="nav-title">球员名单</view>
<view class="filter-row">
<button class="filter" v-for="item in positions" :key="item.value" :class="{ active: position === item.value }" @tap="setPosition(item.value)">
{{ item.label }}
</button>
</view>
<view v-if="players.length">
<view class="player-card card" v-for="player in players" :key="player.id">
<view class="avatar">
<text>{{ player.shirt_number || '-' }}</text>
</view>
<view class="player-main">
<text class="player-name">{{ player.name_zh || player.name_en }}</text>
<text class="player-meta">{{ positionText(player.position) }} · {{ player.club_name || '俱乐部待补充' }}</text>
</view>
<text class="status">{{ rosterText(player.roster_status) }}</text>
</view>
</view>
<view class="card empty" v-else>球员名单等待官方确认当前页面会展示号码位置俱乐部和原创头像</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getPlayers } from '@/api/worldcup'
const teamId = ref(0)
const position = ref('')
const players = ref([])
const positions = [
{ label: '全部', value: '' },
{ label: '门将', value: 'goalkeeper' },
{ label: '后卫', value: 'defender' },
{ label: '中场', value: 'midfielder' },
{ label: '前锋', value: 'forward' }
]
onLoad((query) => {
teamId.value = Number(query.team_id || 0)
loadPlayers()
})
onPullDownRefresh(async () => {
await loadPlayers()
uni.stopPullDownRefresh()
})
async function loadPlayers() {
try {
const res = await getPlayers({
team_id: teamId.value || '',
position: position.value,
page_size: 100
})
players.value = res.data?.items || []
} catch (e) {}
}
function setPosition(value) {
position.value = value
loadPlayers()
}
function positionText(value) {
const map = { goalkeeper: '门将', defender: '后卫', midfielder: '中场', forward: '前锋' }
return map[value] || value || '位置待定'
}
function rosterText(value) {
const map = { provisional: '待确认', official: '官方名单', replaced: '已替换', injured: '伤病', unavailable: '暂无' }
return map[value] || '待确认'
}
</script>
<style lang="scss" scoped>
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: 24rpx;
}
.filter {
height: 62rpx;
padding: 0 22rpx;
border-radius: 8rpx;
color: #66736d;
background: #ffffff;
font-size: 24rpx;
}
.filter.active {
color: #ffffff;
background: #0b6b53;
}
.player-card {
display: grid;
grid-template-columns: 76rpx 1fr 112rpx;
gap: 18rpx;
align-items: center;
min-height: 112rpx;
padding: 18rpx;
margin-bottom: 14rpx;
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
color: #ffffff;
background: #192c44;
font-weight: 900;
}
.player-name {
display: block;
font-size: 29rpx;
font-weight: 900;
}
.player-meta {
display: block;
margin-top: 8rpx;
color: #66736d;
font-size: 23rpx;
}
.status {
color: #c8493a;
font-size: 23rpx;
text-align: right;
}
</style>
+89
View File
@@ -0,0 +1,89 @@
<template>
<view class="page safe-bottom">
<view class="nav-title">我的关注</view>
<view class="profile-card card">
<text class="profile-title">观赛助手已启用</text>
<text class="profile-copy">收藏比赛和球队后首页会优先展示关注内容比赛详情页可以一键加入手机日历</text>
<button class="btn-primary" @tap="loginNow">静默登录</button>
</view>
<view class="section">
<view class="section-head">
<text class="section-title">快捷入口</text>
</view>
<view class="link-list">
<button class="link-card card" @tap="go('/pages/schedule/index')">
<text>完整赛程</text>
<text>按日期和阶段筛选</text>
</button>
<button class="link-card card" @tap="go('/pages/teams/index')">
<text>球队百科</text>
<text>外号历史成绩梗图卡片</text>
</button>
<button class="link-card card" @tap="go('/pages/players/index')">
<text>球员名单</text>
<text>官方名单确认后动态更新</text>
</button>
</view>
</view>
</view>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
async function loginNow() {
await userStore.silentLogin(true)
uni.showToast({ title: '登录成功', icon: 'success' })
}
function go(url) {
uni.navigateTo({ url })
}
</script>
<style lang="scss" scoped>
.profile-card {
padding: 28rpx;
}
.profile-title {
display: block;
font-size: 36rpx;
font-weight: 900;
}
.profile-copy {
display: block;
margin: 16rpx 0 24rpx;
color: #66736d;
font-size: 25rpx;
line-height: 1.5;
}
.link-card {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
width: 100%;
min-height: 112rpx;
padding: 0 24rpx;
margin-bottom: 14rpx;
}
.link-card text:first-child {
font-size: 29rpx;
font-weight: 900;
}
.link-card text:last-child {
margin-top: 10rpx;
color: #66736d;
font-size: 23rpx;
}
</style>
+154
View File
@@ -0,0 +1,154 @@
<template>
<view class="page safe-bottom">
<view class="nav-title">赛程</view>
<scroll-view scroll-x class="filter-row">
<button class="filter" :class="{ active: date === '' }" @tap="setDate('')">全部</button>
<button class="filter" :class="{ active: date === today }" @tap="setDate(today)">今天</button>
<button class="filter" :class="{ active: date === tomorrow }" @tap="setDate(tomorrow)">明天</button>
</scroll-view>
<scroll-view scroll-x class="filter-row">
<button class="filter" v-for="item in stages" :key="item.value" :class="{ active: stage === item.value }" @tap="setStage(item.value)">
{{ item.label }}
</button>
</scroll-view>
<view class="match-list" v-if="matches.length">
<view class="match-card card" v-for="match in matches" :key="match.id" @tap="openMatch(match.id)">
<view class="match-meta">
<text>{{ compactDateTime(match.kickoff_beijing) }}</text>
<text>{{ stageText(match.stage) }}</text>
</view>
<view class="match-main">
<text>{{ teamName(match.home_team, match.home_placeholder) }}</text>
<text class="score">{{ scoreText(match) }}</text>
<text class="right">{{ teamName(match.away_team, match.away_placeholder) }}</text>
</view>
<view class="match-foot">
<text>{{ statusText(match.status) }}</text>
<text>{{ match.venue?.name_zh || match.venue?.name_en || '场馆待定' }}</text>
</view>
</view>
</view>
<view class="card empty" v-else>暂无赛程数据导入官方赛程后会显示在这里</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getMatches } from '@/api/worldcup'
import { compactDateTime, scoreText, stageText, statusText, teamName, todayKey } from '@/utils/format'
const today = todayKey()
const tomorrow = todayKey(1)
const date = ref('')
const stage = ref('')
const matches = ref([])
const stages = [
{ label: '全部阶段', value: '' },
{ label: '小组赛', value: 'group' },
{ label: '32强', value: 'r32' },
{ label: '16强', value: 'r16' },
{ label: '1/4', value: 'qf' },
{ label: '半决赛', value: 'sf' },
{ label: '决赛', value: 'final' }
]
onLoad(loadMatches)
onPullDownRefresh(async () => {
await loadMatches()
uni.stopPullDownRefresh()
})
async function loadMatches() {
try {
const res = await getMatches({
date: date.value,
stage: stage.value,
page_size: 100
})
matches.value = res.data?.items || []
} catch (e) {}
}
function setDate(value) {
date.value = value
loadMatches()
}
function setStage(value) {
stage.value = value
loadMatches()
}
function openMatch(id) {
uni.navigateTo({ url: `/pages/match-detail/index?id=${id}` })
}
</script>
<style lang="scss" scoped>
.filter-row {
width: 100%;
margin-bottom: 16rpx;
white-space: nowrap;
}
.filter {
display: inline-flex;
align-items: center;
justify-content: center;
height: 64rpx;
padding: 0 22rpx;
margin-right: 12rpx;
border-radius: 8rpx;
color: #66736d;
background: #ffffff;
font-size: 24rpx;
}
.filter.active {
color: #ffffff;
background: #0b6b53;
}
.match-list {
margin-top: 22rpx;
}
.match-card {
padding: 24rpx;
margin-bottom: 18rpx;
}
.match-meta,
.match-foot {
display: flex;
justify-content: space-between;
color: #66736d;
font-size: 23rpx;
}
.match-main {
display: grid;
grid-template-columns: 1fr 112rpx 1fr;
gap: 16rpx;
align-items: center;
margin: 20rpx 0;
font-size: 30rpx;
font-weight: 800;
}
.right {
text-align: right;
}
.score {
color: #c8493a;
text-align: center;
font-weight: 900;
}
</style>
+214
View File
@@ -0,0 +1,214 @@
<template>
<view class="page safe-bottom">
<view class="team-hero card">
<text class="team-name">{{ team.name_zh || team.name_en || '球队详情' }}</text>
<text class="team-sub">{{ team.short_name }} · {{ team.group_code || '小组待定' }}</text>
<view class="tag-row">
<text class="tag" v-for="name in team.nicknames || []" :key="name">{{ name }}</text>
</view>
<text class="intro">{{ team.intro_short || '外号、历史成绩和赛程信息会在这里汇总。' }}</text>
<button class="btn-primary follow" @tap="followTeam"> 关注球队</button>
</view>
<view class="section">
<view class="section-head">
<text class="section-title">历史成绩</text>
</view>
<view class="history-grid">
<view class="history-card card">
<text>{{ team.world_cup_titles || 0 }}</text>
<text>冠军次数</text>
</view>
<view class="history-card card">
<text>{{ team.best_result || '待补充' }}</text>
<text>最佳成绩</text>
</view>
</view>
</view>
<view class="section" v-if="cards.length">
<view class="section-head">
<text class="section-title">梗图卡片</text>
</view>
<view class="content-card card" v-for="card in cards" :key="card.id">
<text class="tag">{{ card.type }}</text>
<text class="card-title">{{ card.title }}</text>
<text class="card-copy">{{ card.copywriting || card.subtitle }}</text>
</view>
</view>
<view class="section">
<view class="section-head">
<text class="section-title">球队赛程</text>
</view>
<view v-if="matches.length">
<view class="mini-match card" v-for="match in matches" :key="match.id" @tap="openMatch(match.id)">
<text>{{ compactDateTime(match.kickoff_beijing) }}</text>
<text>{{ teamName(match.home_team, match.home_placeholder) }} vs {{ teamName(match.away_team, match.away_placeholder) }}</text>
</view>
</view>
<view class="card empty" v-else>暂无该队赛程</view>
</view>
<view class="section">
<view class="section-head">
<text class="section-title">球员名单</text>
<text class="section-action" @tap="openPlayers">全部球员</text>
</view>
<view v-if="players.length">
<view class="player-row card" v-for="player in players.slice(0, 6)" :key="player.id">
<text>{{ player.shirt_number || '-' }}</text>
<text>{{ player.name_zh || player.name_en }}</text>
<text>{{ positionText(player.position) }}</text>
</view>
</view>
<view class="card empty" v-else>官方名单待确认</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { addFavorite, getTeamDetail } from '@/api/worldcup'
import { useUserStore } from '@/stores/user'
import { compactDateTime, teamName } from '@/utils/format'
const userStore = useUserStore()
const teamId = ref(0)
const team = ref({})
const matches = ref([])
const players = ref([])
const cards = ref([])
onLoad((query) => {
teamId.value = Number(query.id || 0)
loadDetail()
})
async function loadDetail() {
if (!teamId.value) return
try {
const res = await getTeamDetail(teamId.value)
const data = res.data || {}
team.value = data.team || {}
matches.value = data.matches || []
players.value = data.players || []
cards.value = data.cards || []
} catch (e) {}
}
async function followTeam() {
await userStore.silentLogin()
await addFavorite({ target_type: 'team', target_id: teamId.value })
uni.showToast({ title: '已关注球队', icon: 'success' })
}
function openMatch(id) {
uni.navigateTo({ url: `/pages/match-detail/index?id=${id}` })
}
function openPlayers() {
uni.navigateTo({ url: `/pages/players/index?team_id=${teamId.value}` })
}
function positionText(position) {
const map = { goalkeeper: '门将', defender: '后卫', midfielder: '中场', forward: '前锋' }
return map[position] || position || '位置待定'
}
</script>
<style lang="scss" scoped>
.team-hero {
margin-top: 72rpx;
padding: 30rpx 24rpx;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(242, 223, 173, 0.42));
}
.team-name {
display: block;
font-size: 46rpx;
font-weight: 900;
}
.team-sub,
.intro {
display: block;
margin-top: 12rpx;
color: #66736d;
font-size: 25rpx;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 22rpx;
}
.follow {
margin-top: 24rpx;
}
.history-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
}
.history-card {
padding: 24rpx;
}
.history-card text:first-child {
display: block;
color: #c8493a;
font-size: 42rpx;
font-weight: 900;
}
.history-card text:last-child {
display: block;
margin-top: 10rpx;
color: #66736d;
font-size: 24rpx;
}
.content-card {
padding: 24rpx;
margin-bottom: 16rpx;
}
.card-title {
display: block;
margin-top: 18rpx;
font-size: 34rpx;
font-weight: 900;
}
.card-copy {
display: block;
margin-top: 12rpx;
color: #66736d;
font-size: 25rpx;
line-height: 1.45;
}
.mini-match,
.player-row {
display: grid;
grid-template-columns: 170rpx 1fr;
gap: 16rpx;
align-items: center;
min-height: 80rpx;
padding: 0 22rpx;
margin-bottom: 12rpx;
font-size: 25rpx;
}
.player-row {
grid-template-columns: 60rpx 1fr 100rpx;
}
</style>
+148
View File
@@ -0,0 +1,148 @@
<template>
<view class="page safe-bottom">
<view class="nav-title">球队百科</view>
<view class="search-box card">
<input v-model="keyword" placeholder="搜索球队、外号或三字码" confirm-type="search" @confirm="loadTeams" />
<button class="search-btn" @tap="loadTeams">搜索</button>
</view>
<view class="team-list" v-if="teams.length">
<view class="team-card card" v-for="team in teams" :key="team.id" @tap="openTeam(team.id)">
<view class="team-top">
<view>
<text class="team-name">{{ team.name_zh || team.name_en }}</text>
<text class="team-code">{{ team.short_name }} · {{ team.group_code || '未分组' }}</text>
</view>
<text class="stars" v-if="team.world_cup_titles">{{ team.world_cup_titles }} </text>
</view>
<view class="nickname-row">
<text class="tag" v-for="name in (team.nicknames || []).slice(0, 2)" :key="name">{{ name }}</text>
</view>
<text class="intro">{{ team.intro_short || team.best_result || '查看外号、历史成绩和球队梗图' }}</text>
</view>
</view>
<view class="fallback" v-else>
<view class="team-card card" v-for="team in fallbackTeams" :key="team.name_zh">
<view class="team-top">
<view>
<text class="team-name">{{ team.name_zh }}</text>
<text class="team-code">{{ team.short_name }}</text>
</view>
<text class="stars">{{ team.stars }}</text>
</view>
<view class="nickname-row">
<text class="tag" v-for="name in team.nicknames" :key="name">{{ name }}</text>
</view>
<text class="intro">{{ team.copy }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getTeams } from '@/api/worldcup'
const keyword = ref('')
const teams = ref([])
const fallbackTeams = [
{ name_zh: '巴西', short_name: 'BRA', stars: '5 星', nicknames: ['五星巴西', '桑巴军团'], copy: '五次世界杯冠军,进攻足球的世界级记忆点。' },
{ name_zh: '阿根廷', short_name: 'ARG', stars: '3 星', nicknames: ['潘帕斯雄鹰', '蓝白军团'], copy: '技术、速度与自由,是中文球迷熟悉的南美想象。' },
{ name_zh: '德国', short_name: 'GER', stars: '4 星', nicknames: ['德国战车'], copy: '纪律、力量和效率,是德国队最经典的传播标签。' }
]
onLoad(loadTeams)
onPullDownRefresh(async () => {
await loadTeams()
uni.stopPullDownRefresh()
})
async function loadTeams() {
try {
const res = await getTeams({ keyword: keyword.value })
teams.value = res.data?.items || []
} catch (e) {}
}
function openTeam(id) {
uni.navigateTo({ url: `/pages/team-detail/index?id=${id}` })
}
</script>
<style lang="scss" scoped>
.search-box {
display: flex;
align-items: center;
height: 82rpx;
padding: 0 18rpx;
}
input {
flex: 1;
height: 82rpx;
font-size: 27rpx;
}
.search-btn {
width: 112rpx;
height: 56rpx;
border-radius: 8rpx;
color: #ffffff;
background: #0b6b53;
font-size: 24rpx;
}
.team-list,
.fallback {
margin-top: 24rpx;
}
.team-card {
padding: 24rpx;
margin-bottom: 18rpx;
}
.team-top {
display: flex;
justify-content: space-between;
gap: 24rpx;
}
.team-name {
display: block;
font-size: 34rpx;
font-weight: 900;
}
.team-code {
display: block;
margin-top: 8rpx;
color: #66736d;
font-size: 23rpx;
}
.stars {
color: #c8493a;
font-size: 28rpx;
font-weight: 900;
}
.nickname-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 18rpx;
}
.intro {
display: block;
margin-top: 18rpx;
color: #66736d;
font-size: 25rpx;
line-height: 1.45;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
import { createPinia } from 'pinia'
export default createPinia()
+65
View File
@@ -0,0 +1,65 @@
import { defineStore } from 'pinia'
import { MINI_PROGRAM_ID } from '@/config'
import { devLogin, login } from '@/api/worldcup'
import { storage, TOKEN_KEY, USER_KEY } from '@/utils/storage'
export const useUserStore = defineStore('user', {
state: () => ({
token: storage.get(TOKEN_KEY) || '',
user: storage.get(USER_KEY) || null,
loginPromise: null
}),
actions: {
async silentLogin(force = false) {
if (this.token && !force) return this.token
if (this.loginPromise) return this.loginPromise
this.loginPromise = new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
uni.login({
provider: 'weixin',
success: async (res) => {
try {
const payload = await login({
mini_program_id: MINI_PROGRAM_ID,
code: res.code
})
this.saveLogin(payload.data)
resolve(this.token)
} catch (e) {
reject(e)
} finally {
this.loginPromise = null
}
},
fail: (err) => {
this.loginPromise = null
reject(err)
}
})
// #endif
// #ifndef MP-WEIXIN
devLogin({ mini_program_id: MINI_PROGRAM_ID })
.then((payload) => {
this.saveLogin(payload.data)
resolve(this.token)
})
.catch(reject)
.finally(() => {
this.loginPromise = null
})
// #endif
})
return this.loginPromise
},
saveLogin(data) {
this.token = data?.token || ''
this.user = data?.user || null
storage.set(TOKEN_KEY, this.token)
storage.set(USER_KEY, this.user)
}
}
})
+120
View File
@@ -0,0 +1,120 @@
page {
min-height: 100%;
background: #f7f4ea;
color: #17211b;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
view,
text,
button,
input,
scroll-view {
box-sizing: border-box;
}
button {
margin: 0;
padding: 0;
border: 0;
line-height: 1;
background: transparent;
}
button::after {
border: 0;
}
.page {
min-height: 100vh;
padding: 28rpx;
background:
linear-gradient(180deg, #f7f4ea 0%, #edf3f2 48%, #f8f7f1 100%);
}
.nav-title {
padding-top: 64rpx;
margin-bottom: 28rpx;
font-size: 42rpx;
font-weight: 800;
line-height: 1.15;
}
.section {
margin-top: 30rpx;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 800;
}
.section-action {
color: #0b6b53;
font-size: 24rpx;
}
.card {
border: 1rpx solid rgba(23, 33, 27, 0.08);
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 12rpx 36rpx rgba(26, 41, 32, 0.08);
}
.btn-primary,
.btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 72rpx;
padding: 0 28rpx;
border-radius: 8rpx;
font-size: 26rpx;
font-weight: 700;
}
.btn-primary {
color: #ffffff;
background: #0b6b53;
}
.btn-ghost {
color: #17211b;
border: 1rpx solid rgba(23, 33, 27, 0.14);
background: #ffffff;
}
.tag {
display: inline-flex;
align-items: center;
min-height: 40rpx;
padding: 0 14rpx;
border-radius: 6rpx;
color: #5b4323;
background: #f2dfad;
font-size: 22rpx;
font-weight: 700;
}
.muted {
color: #66736d;
}
.empty {
padding: 36rpx 24rpx;
color: #66736d;
text-align: center;
font-size: 26rpx;
}
.safe-bottom {
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
+13
View File
@@ -0,0 +1,13 @@
uni.addInterceptor({
returnValue (res) {
if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
return res;
}
return new Promise((resolve, reject) => {
res.then((res) => {
if (!res) return resolve(res)
return res[0] ? reject(res[0]) : resolve(res[1])
});
});
},
});
+53
View File
@@ -0,0 +1,53 @@
export function stageText(stage) {
const map = {
group: '小组赛',
r32: '32 强',
r16: '16 强',
qf: '1/4 决赛',
sf: '半决赛',
third: '三四名决赛',
final: '决赛'
}
return map[stage] || stage || '待定'
}
export function statusText(status) {
const map = {
scheduled: '未开始',
live_first_half: '上半场',
halftime: '中场',
live_second_half: '下半场',
extra_time: '加时',
penalties: '点球大战',
finished: '完场',
postponed: '延期',
canceled: '取消'
}
return map[status] || status || '未开始'
}
export function teamName(team, placeholder = '待定') {
return team?.name_zh || team?.name_en || placeholder || '待定'
}
export function scoreText(match) {
const score = match?.score || {}
if (score.home === null || score.home === undefined) return 'vs'
return `${score.home} - ${score.away}`
}
export function compactDateTime(value) {
if (!value) return '时间待定'
const text = String(value).replace(/-/g, '/')
return text.slice(5, 16)
}
export function todayKey(offset = 0) {
const date = new Date()
date.setDate(date.getDate() + offset)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
+23
View File
@@ -0,0 +1,23 @@
export const TOKEN_KEY = 'worldcup_token'
export const USER_KEY = 'worldcup_user'
export const storage = {
get(key) {
try {
return uni.getStorageSync(key)
} catch (e) {
return ''
}
},
set(key, value) {
try {
uni.setStorageSync(key, value)
} catch (e) {}
},
remove(key) {
try {
uni.removeStorageSync(key)
} catch (e) {}
}
}