feat: 初始化世界杯观赛助手小程序
- uni-app (Vue3) + Vite 框架 - 7个页面:首页、赛程、比赛详情、球队、球队详情、球员、个人中心 - API 接口配置 (开发/生产环境) - 状态管理 (Pinia)
This commit is contained in:
+15
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export default createPinia()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user