first commit

This commit is contained in:
2026-10-12 11:22:54 +08:00
parent 0a4e3efc37
commit 1c3a297ce5
38 changed files with 13343 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
# 需要连接本地或测试 API 时再配置;当前 mock 演示不使用代理。
VITE_API_PROXY_TARGET=

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.hbuilderx/
.DS_Store
.env.local
.env.*.local

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>拍立方 PLAYFUN</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8132
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "playfun-miniprogram",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev:h5": "uni",
"dev:mp-weixin": "uni -p mp-weixin",
"build:h5": "uni build",
"build:mp-weixin": "uni build -p mp-weixin"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-5020620260917001",
"@dcloudio/uni-components": "3.0.0-5020620260917001",
"@dcloudio/uni-h5": "3.0.0-5020620260917001",
"@dcloudio/uni-mp-weixin": "3.0.0-5020620260917001",
"@dcloudio/uni-ui": "^1.5.12",
"vue": "^3.4.21"
},
"devDependencies": {
"@dcloudio/uni-cli-shared": "3.0.0-5020620260917001",
"@dcloudio/vite-plugin-uni": "3.0.0-5020620260917001",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"sass": "^1.105.1",
"vite": "5.2.8"
}
}

38
project.config.json Normal file
View File

@@ -0,0 +1,38 @@
{
"description": "拍立方 PLAYFUN 微信小程序 MVP",
"appid": "wxe5850e60fc982491",
"projectname": "playfun",
"setting": {
"urlCheck": false,
"es6": true,
"enhance": true,
"postcss": true,
"minified": true,
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"compileType": "miniprogram",
"libVersion": "3.6.0",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"editorSetting": {}
}

View File

@@ -0,0 +1,21 @@
{
"libVersion": "3.15.3",
"projectname": "playfun",
"setting": {
"urlCheck": false,
"coverView": false,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"useApiHook": true,
"showShadowRootInWxmlPanel": false,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"compileHotReLoad": true,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false
}
}

View File

@@ -0,0 +1,76 @@
Add-Type -AssemblyName System.Drawing
# Draw 27px line icons at 3x resolution for the native tab bar.
function Draw-TabIcon {
param(
[System.Drawing.Graphics] $Graphics,
[System.Drawing.Pen] $Pen,
[string] $Name
)
switch ($Name) {
'home' {
$Graphics.DrawLine($Pen, 3, 11, 12, 4)
$Graphics.DrawLine($Pen, 12, 4, 21, 11)
$Graphics.DrawLine($Pen, 5, 10, 5, 21)
$Graphics.DrawLine($Pen, 19, 10, 19, 21)
$Graphics.DrawLine($Pen, 5, 21, 19, 21)
$Graphics.DrawLine($Pen, 10, 21, 10, 15)
$Graphics.DrawLine($Pen, 10, 15, 14, 15)
$Graphics.DrawLine($Pen, 14, 15, 14, 21)
}
'match' {
$Graphics.DrawEllipse($Pen, 4, 4, 6, 9)
$Graphics.DrawEllipse($Pen, 14, 4, 6, 9)
$Graphics.DrawLine($Pen, 7, 13, 3, 21)
$Graphics.DrawLine($Pen, 17, 13, 21, 21)
$Graphics.DrawLine($Pen, 10, 17, 14, 17)
$Graphics.DrawEllipse($Pen, 10.5, 3, 3, 3)
}
'rank' {
$Graphics.DrawRectangle($Pen, 3, 15, 6, 6)
$Graphics.DrawRectangle($Pen, 9, 11, 6, 10)
$Graphics.DrawRectangle($Pen, 15, 17, 6, 4)
$Graphics.DrawLine($Pen, 3, 21, 21, 21)
$Graphics.DrawEllipse($Pen, 10, 3, 4, 4)
}
'shop' {
$Graphics.DrawRectangle($Pen, 4, 8, 16, 13)
$Graphics.DrawArc($Pen, 8, 3, 8, 10, 180, 180)
$Graphics.DrawLine($Pen, 8, 8, 8, 9)
$Graphics.DrawLine($Pen, 16, 8, 16, 9)
}
'me' {
$Graphics.DrawEllipse($Pen, 8, 3, 8, 8)
$Graphics.DrawBezier($Pen, 4, 21, 4, 10, 20, 10, 20, 21)
$Graphics.DrawLine($Pen, 4, 21, 20, 21)
}
}
}
# Match the native tab bar text colors in pages.json.
$colors = @{ normal = '#9ba59e'; selected = '#4c9b53' }
$names = @('home', 'match', 'rank', 'shop', 'me')
$outputDirectory = Join-Path $PSScriptRoot '..\src\static\tabbar'
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
foreach ($name in $names) {
foreach ($state in $colors.Keys) {
# Keep the background transparent and antialias the strokes.
$bitmap = [System.Drawing.Bitmap]::new(81, 81)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$graphics.ScaleTransform(3, 3)
$pen = [System.Drawing.Pen]::new([System.Drawing.ColorTranslator]::FromHtml($colors[$state]), 1.8)
$pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
$pen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
$pen.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
Draw-TabIcon -Graphics $graphics -Pen $pen -Name $name
$bitmap.Save((Join-Path $outputDirectory "$name-$state.png"), [System.Drawing.Imaging.ImageFormat]::Png)
$pen.Dispose()
$graphics.Dispose()
$bitmap.Dispose()
}
}

151
src/App.vue Normal file
View File

@@ -0,0 +1,151 @@
<script>
// 全局样式与页面生命周期的根组件。
export default {};
</script>
<style>
page {
background: #f3f6f3;
color: #202820;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 28rpx;
}
button {
font: inherit;
}
button::after {
border: 0;
}
.page {
min-height: 100vh;
padding: 28rpx 28rpx 48rpx;
box-sizing: border-box;
}
.page-title {
font-size: 44rpx;
line-height: 1.2;
font-weight: 750;
color: #193d26;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 42rpx 0 22rpx;
}
.section-title {
font-size: 34rpx;
font-weight: 700;
}
.muted {
color: #89928b;
}
.small {
font-size: 23rpx;
}
.card {
background: #fff;
border: 1rpx solid #e9eee9;
border-radius: 16rpx;
box-shadow: 0 10rpx 28rpx rgba(25, 65, 35, .045);
}
.chip-row {
display: flex;
gap: 14rpx;
overflow-x: auto;
white-space: nowrap;
padding: 4rpx 0 12rpx;
}
.chip {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
min-height: 62rpx;
padding: 0 25rpx;
border-radius: 32rpx;
background: #fff;
color: #667068;
font-size: 24rpx;
border: 1rpx solid #e9eee9;
box-sizing: border-box;
}
.chip.active {
background: #4c9b53;
color: #fff;
border-color: #4c9b53;
font-weight: 650;
}
.primary-button {
background: #4c9b53;
color: #fff;
border-radius: 16rpx;
font-size: 28rpx;
font-weight: 650;
}
.outline-button {
color: #3e8c48;
background: #f4faf4;
border: 1rpx solid #d6e8d6;
border-radius: 14rpx;
}
.empty {
padding: 88rpx 24rpx;
text-align: center;
color: #8b958d;
}
.modal-mask {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(15, 31, 21, .48);
display: flex;
align-items: flex-end;
}
.sheet {
width: 100%;
max-height: 88vh;
overflow-y: auto;
box-sizing: border-box;
padding: 28rpx 30rpx calc(36rpx + env(safe-area-inset-bottom));
border-radius: 28rpx 28rpx 0 0;
background: #fff;
}
.sheet-grip {
width: 72rpx;
height: 7rpx;
margin: 0 auto 27rpx;
border-radius: 9rpx;
background: #dfe5df;
}
.sheet-title {
font-size: 34rpx;
font-weight: 720;
margin-bottom: 26rpx;
}
.field-label {
font-size: 24rpx;
color: #6a746c;
margin: 28rpx 0 14rpx;
}
</style>

12
src/main.js Normal file
View File

@@ -0,0 +1,12 @@
import { createSSRApp } from 'vue';
import App from './App.vue';
import UniTransition from '@dcloudio/uni-ui/lib/uni-transition/uni-transition.vue';
// 创建 uni-app 所需的 Vue 应用实例。
export function createApp() {
// 应用根实例由 uni-app 接管生命周期。
const app = createSSRApp(App);
// uni-popup 内部通过名称引用 uni-transition,需要全局注册。
app.component('uni-transition', UniTransition);
return { app };
}

15
src/manifest.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "拍立方 PLAYFUN",
"appid": "",
"description": "匹克球场地与拼场小程序",
"versionName": "0.1.0",
"versionCode": "100",
"transformPx": false,
"mp-weixin": {
"appid": "wxe5850e60fc982491",
"setting": { "urlCheck": false },
"usingComponents": true
},
"uniStatistics": { "enable": false },
"vueVersion": "3"
}

73
src/mock/data.js Normal file
View File

@@ -0,0 +1,73 @@
// 南京场地示例仅供交互演示,不代表实时价格或可订状态。
export const venues = [
{ id: 'lining', name: '李宁迪拓运动馆', district: '雨花台', address: '雨花台区示意路 1 号', distance: '1.2km', surface: '室内木地板', price: 80, rating: '4.9', courts: 6, band: 'main', note: '室内木地板 · 配套更衣室', color: 'green', mapX: 55, mapY: 55, features: ['室内木地板', '更衣室', '停车场', '饮水区'] },
{ id: 'olympic', name: '奥体全民健身园', district: '建邺', address: '建邺区示意路 2 号', distance: '3.8km', surface: '户外塑胶', price: 30, rating: '4.7', courts: 4, band: 'budget', note: '户外塑胶 · 配套夜间照明', color: 'mint', mapX: 23, mapY: 39, features: ['户外塑胶', '夜间照明', '公共交通'] },
{ id: 'honey', name: '蜜糖体育中心', district: '雨花台', address: '雨花台区示意路 3 号', distance: '2.5km', surface: '4 人包场', price: 75, rating: '4.8', courts: 4, band: 'main', note: '室内场地 · 配套休息区', color: 'deep', mapX: 72, mapY: 76, features: ['室内场地', '更衣室', '休息区'] },
{ id: 'sky', name: '金鹰 51FLOOR 空中馆', district: '建邺', address: '建邺区示意路 4 号', distance: '4.2km', surface: '云端景观', price: 129, rating: '5.0', courts: 11, band: 'premium', note: '室内场地 · 配套淋浴', color: 'gold', mapX: 38, mapY: 21, features: ['室内场地', '淋浴', '休息区', '停车场'] }
];
// 根据指定的演示时间生成客户端当地日期。
export function getDateAfter(days, referenceTime = Date.now()) {
const today = new Date(referenceTime);
const date = new Date(today.getFullYear(), today.getMonth(), today.getDate() + days);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${month}-${day}`;
}
// 返回演示时间之后三天的可选时段,并扣除未取消拼场占用的示例片数。
export function getVenueSlots(venueId, matches = [], referenceTime = Date.now()) {
const venue = venues.find((item) => item.id === venueId);
if (!venue) return [];
const definitions = [
{ offset: 1, label: '明天', time: '09:00', duration: 1.5 },
{ offset: 1, label: '明天', time: '19:30', duration: 2 },
{ offset: 2, label: '后天', time: '14:00', duration: 2 },
{ offset: 3, label: '3 天后', time: '19:30', duration: 2 }
];
return definitions.map((definition) => {
const date = getDateAfter(definition.offset, referenceTime);
const id = `${venueId}-${date}-${definition.time.replace(':', '')}`;
const booked = matches.filter((match) => match.slotId === id && match.status !== 'canceled').length;
return { ...definition, id, date, totalPrice: Math.ceil(venue.price * definition.duration), remaining: Math.max(0, Math.min(venue.courts, 3) - booked) };
});
}
// 根据演示时间生成种子拼场,种子人数代表其他演示用户。
export function createInitialMatches(referenceTime = Date.now()) {
const seeds = [
{ id: 'm1', venueId: 'lining', offset: 1, time: '19:30', duration: 2, level: '中级', players: 3, capacity: 4 },
{ id: 'm2', venueId: 'olympic', offset: 2, time: '09:00', duration: 1.5, level: '入门', players: 1, capacity: 4 },
{ id: 'm3', venueId: 'honey', offset: 2, time: '14:00', duration: 2, level: '初级', players: 4, capacity: 4 },
{ id: 'm4', venueId: 'sky', offset: 3, time: '19:30', duration: 2, level: '高级', players: 2, capacity: 4 }
];
return seeds.map((seed) => {
const venue = venues.find((item) => item.id === seed.venueId);
const date = getDateAfter(seed.offset, referenceTime);
const totalCents = Math.round(venue.price * seed.duration * 100);
return {
id: seed.id, venueId: seed.venueId, venue: venue.name, distance: venue.distance,
slotId: `${seed.venueId}-${date}-${seed.time.replace(':', '')}`,
date, day: `${date.slice(5).replace('-', '月')}日`, time: seed.time,
startAt: new Date(`${date}T${seed.time}:00`).getTime(), duration: `${seed.duration} 小时`, durationHours: seed.duration,
level: seed.level, players: seed.players, capacity: seed.capacity,
totalCents, fee: Math.ceil(totalCents / seed.capacity / 100),
status: seed.players === seed.capacity ? 'formed' : 'recruiting', hostId: 'seed', venueStatus: 'pending', courtNumber: '', result: null
};
});
}
// 六段位的基础积分门槛。
export const levels = [
{ name: '入门', points: 0 }, { name: '初级', points: 400 }, { name: '中级', points: 800 },
{ name: '高级', points: 1600 }, { name: '专业', points: 2400 }, { name: '精锐', points: 3200 }
];
// 自评直接用于拼场匹配段位,不产生段位积分。
export const questions = [
'我了解匹克球的基本计分方式', '我能稳定发球到有效区域', '我能连续完成 5 次正手回球',
'我能控制反手回球的方向', '我知道双弹规则', '我能在非截击区进行轻吊球',
'我能主动选择站位并与搭档沟通', '我能接住对手的深球', '我能在比赛中控制失误',
'我能根据对手站位调整落点', '我能稳定完成第三拍处理', '我能应对快速网前对抗',
'我参加过至少 5 场正式对局', '我能在不同节奏下保持稳定发挥', '我了解并使用基础战术配合'
];

383
src/mock/engine.js Normal file
View File

@@ -0,0 +1,383 @@
import { createInitialMatches, getVenueSlots, levels, venues } from './data.js';
// 演示规则以毫秒和人民币分为单位,避免倒计时与费用的浮点误差。
export const HOLD_MS = 10 * 60 * 1000;
export const CUTOFF_MS = 12 * 60 * 60 * 1000;
export const VENUE_CONFIRM_MS = 2 * 60 * 60 * 1000;
export const CHECKIN_OPEN_MS = 30 * 60 * 1000;
export const CHECKIN_CLOSE_MS = 15 * 60 * 1000;
// 段位顺序用于本人及相邻段位的报名校验。
const rankOrder = ['入门', '初级', '中级', '高级', '专业', '精锐'];
// 创建一份可重置的初始演示数据。
export function createInitialState() {
return { version: 5, matches: createInitialMatches(), orders: [], assessment: null, profileName: '球搭子阿豪', pendingCreate: null, nowOffsetMs: 0, nextId: 1, rankPoints: 0, rankHistory: [], weeklyClaims: [] };
}
// 为旧版本地记录补充履约字段,并清除无比赛依据的一键赛事积分。
export function upgradeState(state) {
if (state.version === 5) return false;
if (state.version === 2) {
for (const match of state.matches) {
match.durationHours = match.durationHours || parseFloat(match.duration);
match.venueStatus = match.venueStatus || (match.status === 'completed' ? 'confirmed' : 'pending');
match.courtNumber = match.courtNumber || '';
match.result = match.result || null;
}
for (const order of state.orders) {
order.attendance = order.attendance || null;
order.cancellationRequest = order.cancellationRequest || null;
}
}
state.weeklyClaims = (state.weeklyClaims || []).filter((claim) => claim.taskId !== 'competition');
const claims = state.weeklyClaims.map((claim) => ({ eventId: `weekly-${claim.weekStart}-${claim.taskId}`, title: `本周任务 · ${claim.taskId === 'matches' ? '完成 2 场约球' : claim.taskId === 'reviews' ? '完成 3 次赛后评价' : '赢得 1 场对局'}`, points: claim.points, settledAt: claim.claimedAt }));
const results = state.matches.filter((match) => match.result?.status === 'confirmed' && state.orders.some((order) => order.matchId === match.id && order.role !== 'guest' && order.status === 'paid' && order.attendance === 'checked_in')).map((match) => ({ eventId: `match-${match.id}`, title: `拼场赛果 · ${match.venue}`, points: match.result.homeScore > match.result.awayScore ? 160 : 80, settledAt: match.result.confirmedAt || match.startAt }));
let total = 0;
state.rankHistory = [...claims, ...results].sort((first, second) => first.settledAt - second.settledAt).map((entry) => ({ ...entry, total: total += entry.points })).reverse();
state.rankPoints = total;
delete state.nextRankEventId;
state.version = 5;
return true;
}
// 积分只决定赛事段位,报名段位仍由自评单独决定。
export function getCompetitionProgress(points) {
const currentIndex = levels.reduce((index, level, candidate) => points >= level.points ? candidate : index, 0);
const current = levels[currentIndex];
const next = levels[currentIndex + 1] || null;
const percent = next ? Math.round((points - current.points) / (next.points - current.points) * 100) : 100;
return { current, next, percent, remaining: next ? next.points - points : 0 };
}
// 本周任务使用演示时钟的周一零点归档,跨周后进度重新计算。
export function getWeeklyTasks(state) {
const now = new Date(mockNow(state));
const weekStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - (now.getDay() + 6) % 7).getTime();
const weekEnd = weekStart + 7 * 24 * 60 * 60 * 1000;
const myOrders = state.orders.filter((order) => order.role !== 'guest' && order.status === 'paid' && order.attendance === 'checked_in' && state.matches.some((match) => match.id === order.matchId && match.status === 'completed'));
const completed = myOrders.filter((order) => state.matches.some((match) => match.id === order.matchId && match.startAt >= weekStart && match.startAt < weekEnd)).length;
const wins = myOrders.filter((order) => state.matches.some((match) => match.id === order.matchId && match.result?.status === 'confirmed' && match.result.homeScore > match.result.awayScore && match.result.confirmedAt >= weekStart && match.result.confirmedAt < weekEnd)).length;
const reviews = myOrders.filter((order) => state.matches.some((match) => match.id === order.matchId && match.result?.status === 'confirmed' && match.result.confirmedAt >= weekStart && match.result.confirmedAt < weekEnd)).length;
const progress = { matches: completed, wins, reviews };
const definitions = [{ id: 'matches', title: '完成 2 场约球', target: 2 }, { id: 'wins', title: '赢得 1 场对局', target: 1 }, { id: 'reviews', title: '完成 3 次赛后评价', target: 3 }];
return definitions.map((task) => ({ ...task, progress: Math.min(progress[task.id], task.target), reward: 60, claimed: (state.weeklyClaims || []).some((claim) => claim.weekStart === weekStart && claim.taskId === task.id), weekStart }));
}
// 达标后由用户主动领取每周任务奖励,同一周同一项仅可领取一次。
export function claimWeeklyTask(state, taskId) {
const task = getWeeklyTasks(state).find((item) => item.id === taskId);
if (!task || task.progress < task.target) throw new Error('任务尚未完成');
if (task.claimed) throw new Error('本周奖励已领取');
state.weeklyClaims ||= [];
state.weeklyClaims.push({ taskId, weekStart: task.weekStart, points: task.reward, claimedAt: mockNow(state) });
state.rankPoints += task.reward;
state.rankHistory.unshift({ eventId: `weekly-${task.weekStart}-${taskId}`, title: `本周任务 · ${task.title}`, points: task.reward, total: state.rankPoints, settledAt: mockNow(state) });
return task.reward;
}
// 读取演示时钟;推进时间只改变本地演示数据。
export function mockNow(state) { return Date.now() + (state.nowOffsetMs || 0); }
// 调整演示时间;保留既有记录,缺少未来可报名种子场时补一组新场次。
export function setDemoTime(state, timestamp) {
if (!Number.isFinite(timestamp)) throw new Error('请选择有效的演示时间');
const previous = mockNow(state);
state.nowOffsetMs = timestamp - Date.now();
if (timestamp > previous) reconcileState(state);
const hasUpcoming = state.matches.some((match) => match.hostId === 'seed' && match.status === 'recruiting' && match.players < match.capacity && match.startAt - timestamp > CUTOFF_MS);
if (!hasUpcoming) {
const nextSeeds = createInitialMatches(timestamp);
for (const match of nextSeeds) {
match.id = `seed-${state.nextId++}`;
state.matches.push(match);
}
}
}
// 为单打或双打按分分摊费用,末位承担无法整除的余数。
export function seatCents(match, seatIndex) {
const base = Math.floor(match.totalCents / match.capacity);
return base + (seatIndex === match.capacity - 1 ? match.totalCents % match.capacity : 0);
}
// 待支付订单同样占住一个名额。
export function heldCount(state, matchId) {
return state.orders.filter((order) => order.matchId === matchId && order.status === 'pending').length;
}
// 用户在一个拼场上最多保有一张待支付或已支付订单。
export function activeOrder(state, matchId) {
return state.orders.find((order) => order.matchId === matchId && order.role !== 'guest' && ['pending', 'paid'].includes(order.status));
}
// 种子拼场中没有独立订单的球友人数,由已确认人数扣除本地已付款订单得到。
export function seedParticipantCount(state, match) {
const paidCount = state.orders.filter((order) => order.matchId === match.id && order.status === 'paid').length;
return Math.max(0, match.players - paidCount);
}
// 取消整场时统一关闭待支付订单,并向已付款订单全额退款。
function cancelMatch(state, match, reason, now) {
match.status = 'canceled';
match.cancellationReason = reason;
for (const order of state.orders) {
if (order.matchId !== match.id) continue;
if (order.status === 'pending') order.status = 'canceled';
if (order.status === 'paid') { order.status = 'refunded'; order.refundedAt = now; }
if (order.cancellationRequest?.status === 'pending') {
order.cancellationRequest.status = 'approved';
order.cancellationRequest.reviewedAt = now;
}
}
}
// 签到人数不足时取消整场;已签到用户退款,未签到用户保留已支付状态且不退款。
function cancelForAttendanceFailure(state, match, now) {
match.status = 'canceled';
match.cancellationReason = '签到人数不足,拼场取消';
for (const order of state.orders) {
if (order.matchId !== match.id) continue;
if (order.status === 'pending') {
order.status = 'canceled';
continue;
}
if (order.status === 'paid' && order.attendance === 'checked_in') {
order.status = 'refunded';
order.refundedAt = now;
}
if (order.status === 'paid' && order.attendance === 'no_show') order.forfeitedAt = now;
}
}
// 按当前演示时间统一结算支付超时、场地确认、签到和完赛。
export function reconcileState(state) {
const now = mockNow(state);
let changed = false;
for (const order of state.orders) {
if (order.status !== 'pending') continue;
const match = state.matches.find((item) => item.id === order.matchId);
if (now < order.expiresAt && match && now < match.startAt - CUTOFF_MS) continue;
order.status = 'expired';
changed = true;
if (order.role === 'host' && match) cancelMatch(state, match, '发起人支付超时', now);
}
for (const match of state.matches) {
if (['canceled', 'draft', 'completed'].includes(match.status)) continue;
if (match.status === 'in_progress' && now < match.startAt) continue;
if (now >= match.startAt - CUTOFF_MS && match.players < match.capacity) {
cancelMatch(state, match, '报名截止未满员', now);
changed = true;
continue;
}
if (match.players === match.capacity && now >= match.startAt - VENUE_CONFIRM_MS && match.venueStatus !== 'confirmed') {
cancelMatch(state, match, '场地未在开场前 2 小时确认', now);
changed = true;
continue;
}
const endAt = match.startAt + (match.durationHours || parseFloat(match.duration)) * 60 * 60 * 1000;
const nextStatus = now >= endAt ? 'completed' : now >= match.startAt ? 'in_progress' : match.players === match.capacity ? 'formed' : 'recruiting';
if (match.status !== nextStatus) { match.status = nextStatus; changed = true; }
if (now >= match.startAt + CHECKIN_CLOSE_MS) {
for (const order of state.orders) {
if (order.matchId === match.id && order.status === 'paid' && !order.attendance) {
order.attendance = 'no_show';
changed = true;
}
}
for (let index = 0; index < seedParticipantCount(state, match); index += 1) {
match.demoAttendance ||= {};
if (!match.demoAttendance[index]) {
match.demoAttendance[index] = 'no_show';
changed = true;
}
}
const checkedInOrders = state.orders.filter((order) => order.matchId === match.id && order.status === 'paid' && order.attendance === 'checked_in').length;
const checkedInSeeds = Object.values(match.demoAttendance || {}).filter((attendance) => attendance === 'checked_in').length;
if (checkedInOrders + checkedInSeeds < match.capacity) {
cancelForAttendanceFailure(state, match, now);
changed = true;
continue;
}
}
}
return changed;
}
// 建立发起人的待支付订单,并暂占一片演示场地。
export function createHostOrder(state, input) {
reconcileState(state);
const now = mockNow(state);
const venue = venues.find((item) => item.id === input.venueId);
const slot = getVenueSlots(input.venueId, state.matches, now).find((item) => item.id === input.slotId);
if (!venue || !slot || slot.remaining < 1) throw new Error('该时段已无可用场地');
const startAt = new Date(`${slot.date}T${slot.time}:00`).getTime();
if (startAt - now <= CUTOFF_MS) throw new Error('距开场不足 12 小时,无法发起');
if (![2, 4].includes(input.capacity)) throw new Error('请选择 2 人或 4 人拼场');
const matchId = `local-${state.nextId++}`;
const totalCents = Math.round(slot.totalPrice * 100);
const match = {
id: matchId, venueId: venue.id, venue: venue.name, distance: venue.distance,
slotId: slot.id, date: slot.date, day: slot.label, time: slot.time, startAt,
duration: `${slot.duration} 小时`, durationHours: slot.duration, level: input.level, players: 0,
capacity: input.capacity, totalCents, fee: Math.ceil(totalCents / input.capacity / 100),
status: 'draft', hostId: 'self', venueStatus: 'pending', courtNumber: '', result: null
};
const order = { id: `order-${state.nextId++}`, matchId, role: 'host', seatIndex: 0, amountCents: seatCents(match, 0), status: 'pending', createdAt: now, expiresAt: now + HOLD_MS };
state.matches.unshift(match);
state.orders.unshift(order);
return order;
}
// 检查段位和实际可用名额后,建立参与者待支付订单。
export function createJoinOrder(state, matchId) {
reconcileState(state);
const now = mockNow(state);
const match = state.matches.find((item) => item.id === matchId);
if (!match || !['recruiting', 'formed'].includes(match.status) || now >= match.startAt - CUTOFF_MS) throw new Error('该拼场已停止报名');
if (activeOrder(state, matchId)) throw new Error('你已有该场拼场订单');
const level = state.assessment?.level || '中级';
if (Math.abs(rankOrder.indexOf(match.level) - rankOrder.indexOf(level)) > 1) throw new Error(`你的匹配段位是${level},可加入本人及相邻段位的拼场`);
const seatIndex = match.players + heldCount(state, matchId);
if (seatIndex >= match.capacity) throw new Error('该拼场已满员');
const order = { id: `order-${state.nextId++}`, matchId, role: 'member', seatIndex, amountCents: seatCents(match, seatIndex), status: 'pending', createdAt: now, expiresAt: now + HOLD_MS };
state.orders.unshift(order);
return order;
}
// 模拟付款只确认本地占位,不请求支付服务。
export function payOrder(state, orderId) {
reconcileState(state);
const order = state.orders.find((item) => item.id === orderId);
const match = state.matches.find((item) => item.id === order?.matchId);
if (!order || order.status !== 'pending' || !match || match.status === 'canceled') throw new Error('订单已失效,请重新选择');
order.status = 'paid';
order.paidAt = mockNow(state);
match.players += 1;
match.status = match.players === match.capacity ? 'formed' : 'recruiting';
return order;
}
// 截止前取消订单;发起人取消时整个拼场和已付款订单一起退款。
export function cancelOrder(state, orderId) {
reconcileState(state);
const order = state.orders.find((item) => item.id === orderId);
const match = state.matches.find((item) => item.id === order?.matchId);
if (!order || !match || !['pending', 'paid'].includes(order.status)) throw new Error('该订单无法取消');
const now = mockNow(state);
if (now >= match.startAt - CUTOFF_MS) throw new Error('已过报名截止时间,无法主动取消');
if (order.role === 'host') {
cancelMatch(state, match, '发起人取消', now);
} else {
order.status = order.status === 'paid' ? 'refunded' : 'canceled';
if (order.status === 'refunded') {
order.refundedAt = now;
match.players -= 1;
match.status = 'recruiting';
}
}
return order;
}
// 为本机发起的拼场补入一位演示球友,以便验证满员和成局。
export function addDemoGuest(state, matchId) {
reconcileState(state);
const now = mockNow(state);
const match = state.matches.find((item) => item.id === matchId);
if (!match || match.hostId !== 'self' || !['recruiting', 'formed'].includes(match.status) || now >= match.startAt - CUTOFF_MS) throw new Error('当前拼场无法补位');
const seatIndex = match.players + heldCount(state, matchId);
if (seatIndex >= match.capacity) throw new Error('该拼场已满员');
state.orders.push({ id: `guest-${state.nextId++}`, matchId, role: 'guest', seatIndex, amountCents: seatCents(match, seatIndex), status: 'paid', createdAt: now, paidAt: now });
match.players += 1;
match.status = match.players === match.capacity ? 'formed' : 'recruiting';
}
// 模拟平台在开场前确认具体场地和球场号。
export function confirmVenue(state, matchId, courtNumber = '1 号场') {
reconcileState(state);
const match = state.matches.find((item) => item.id === matchId);
if (!match || match.status !== 'formed' || match.venueStatus !== 'pending' || mockNow(state) >= match.startAt - VENUE_CONFIRM_MS) throw new Error('当前无法确认场地');
match.venueStatus = 'confirmed';
match.courtNumber = courtNumber;
match.venueConfirmedAt = mockNow(state);
}
// 模拟平台判定场地不可用,并对整场已支付订单退款。
export function markVenueUnavailable(state, matchId) {
reconcileState(state);
const match = state.matches.find((item) => item.id === matchId);
if (!match || match.status !== 'formed' || mockNow(state) >= match.startAt) throw new Error('当前无法取消场地');
match.venueStatus = 'unavailable';
cancelMatch(state, match, '场地方无法提供场地', mockNow(state));
}
// 已付款用户仅能在开场前 30 分钟至开场后 15 分钟签到。
export function checkIn(state, orderId) {
reconcileState(state);
const order = state.orders.find((item) => item.id === orderId);
const match = state.matches.find((item) => item.id === order?.matchId);
const now = mockNow(state);
if (!order || order.status !== 'paid' || order.attendance || !match || match.venueStatus !== 'confirmed' || !['formed', 'in_progress'].includes(match.status) || now < match.startAt - CHECKIN_OPEN_MS || now >= match.startAt + CHECKIN_CLOSE_MS) throw new Error('当前不在可签到时段');
order.attendance = 'checked_in';
order.checkedInAt = now;
}
// 为种子球友或后加入的演示球友分别记录签到,不能替当前用户操作。
export function checkInDemoParticipant(state, matchId, participantId) {
reconcileState(state);
const match = state.matches.find((item) => item.id === matchId);
const now = mockNow(state);
if (!match || match.venueStatus !== 'confirmed' || !['formed', 'in_progress'].includes(match.status) || now < match.startAt - CHECKIN_OPEN_MS || now >= match.startAt + CHECKIN_CLOSE_MS) throw new Error('当前不在可签到时段');
const guest = state.orders.find((order) => order.id === participantId && order.matchId === matchId && order.role === 'guest');
if (guest) { checkIn(state, guest.id); return; }
const seedIndex = /^seed-(\d+)$/.exec(participantId)?.[1];
if (seedIndex === undefined || Number(seedIndex) >= seedParticipantCount(state, match)) throw new Error('没有找到该演示球友');
match.demoAttendance ||= {};
if (match.demoAttendance[seedIndex]) throw new Error('该球友已完成签到记录');
match.demoAttendance[seedIndex] = 'checked_in';
}
// 报名截止后只接收申请,不直接改变已付款订单或整场状态。
export function requestCancellation(state, orderId, reason) {
reconcileState(state);
const order = state.orders.find((item) => item.id === orderId);
const match = state.matches.find((item) => item.id === order?.matchId);
const now = mockNow(state);
if (!['个人原因', '场地方原因'].includes(reason)) throw new Error('请选择申请原因');
if (!order || order.status !== 'paid' || !match || match.status !== 'formed' || now < match.startAt - CUTOFF_MS || now >= match.startAt || order.cancellationRequest?.status === 'pending') throw new Error('当前无法提交取消申请');
order.cancellationRequest = { reason, status: 'pending', createdAt: now };
}
// 演示运营审核;通过后取消整场并全额退款,驳回则保留订单。
export function reviewCancellationRequest(state, orderId, approved) {
reconcileState(state);
const order = state.orders.find((item) => item.id === orderId);
const match = state.matches.find((item) => item.id === order?.matchId);
if (!order || !match || order.cancellationRequest?.status !== 'pending' || match.status !== 'formed' || mockNow(state) >= match.startAt) throw new Error('该申请无法审核');
order.cancellationRequest.status = approved ? 'approved' : 'rejected';
order.cancellationRequest.reviewedAt = mockNow(state);
if (approved) cancelMatch(state, match, '运营审核同意取消', mockNow(state));
}
// 完赛且本人已签到后记录一局比分和场地评价,等待对手确认。
export function submitResult(state, orderId, homeScore, awayScore, rating) {
reconcileState(state);
const order = state.orders.find((item) => item.id === orderId);
const match = state.matches.find((item) => item.id === order?.matchId);
if (!order || order.status !== 'paid' || order.attendance !== 'checked_in' || !match || match.status !== 'completed' || match.result) throw new Error('当前无法提交赛果');
if (![homeScore, awayScore].every((score) => Number.isInteger(score) && score >= 0 && score <= 99) || Math.max(homeScore, awayScore) < 11 || Math.abs(homeScore - awayScore) < 2 || !Number.isInteger(rating) || rating < 1 || rating > 5) throw new Error('请填写有效比分和 1 至 5 星评价');
match.result = { homeScore, awayScore, rating, status: 'submitted', submittedAt: mockNow(state) };
}
// 模拟对手确认赛果;仅为已签到的本人订单结算一次段位积分。
export function confirmResult(state, matchId) {
reconcileState(state);
const match = state.matches.find((item) => item.id === matchId);
if (!match || match.result?.status !== 'submitted') throw new Error('当前没有待确认的赛果');
match.result.status = 'confirmed';
match.result.confirmedAt = mockNow(state);
const myOrder = state.orders.find((order) => order.matchId === matchId && order.role !== 'guest' && order.status === 'paid' && order.attendance === 'checked_in');
if (!myOrder || state.rankHistory.some((entry) => entry.eventId === `match-${matchId}`)) return;
const points = match.result.homeScore > match.result.awayScore ? 160 : 80;
state.rankPoints += points;
state.rankHistory.unshift({ eventId: `match-${matchId}`, title: `拼场赛果 · ${match.venue}`, points, total: state.rankPoints, settledAt: match.result.confirmedAt });
}

122
src/mock/shop.js Normal file
View File

@@ -0,0 +1,122 @@
// 商品数据来自演示配置,真实项目可替换为小程序商城或一件代发服务的接口。
export const shopProducts = [
{ id: 'starter-kit', name: '新球友入门礼包', category: 'all', tag: '官方补贴', price: 9.9, originalPrice: 29.9, stock: 99, sales: '已售 2,480', color: 'teal', icon: 'P', desc: '木拍 ×1 · 匹克球 ×2 · 护腕 · 入门手册' },
{ id: 'indoor-balls', name: '户外专用匹克球 · 40孔 × 3 只装', category: 'ball', tag: '耗材回购', price: 19.9, originalPrice: 35, stock: 35, sales: '已售 1,246', color: 'green', icon: 'O', desc: '耐打耐磨 · 适合日常训练与户外对局' },
{ id: 'paddle', name: '玻纤入门拍 · 蓝白 235g', category: 'paddle', tag: '官方补贴', price: 49, originalPrice: 89, stock: 18, sales: '已售 860', color: 'blue', icon: '◐', desc: '轻量玻纤面 · 新手好上手 · 含拍套' },
{ id: 'grip', name: '吸汗手胶 3 条装', category: 'accessory', tag: '新 人 专享', price: 12.9, originalPrice: 19.9, stock: 50, sales: '已售 738', color: 'amber', icon: '≈', desc: '防滑吸汗 · 适配主流球拍手柄' },
{ id: 'bag', name: '轻量球拍双肩包', category: 'accessory', tag: '热卖', price: 39.9, originalPrice: 69, stock: 12, sales: '已售 326', color: 'purple', icon: '□', desc: '可装 2 支球拍 · 独立球仓 · 防泼水' },
{ id: 'training-ball', name: '训练耐打球 12 只装', category: 'ball', tag: '训练必备', price: 29.9, originalPrice: 49, stock: 24, sales: '已售 512', color: 'orange', icon: 'O', desc: '标准尺寸 · 适合发球与多球训练' }
];
// 商城分类筛选项,和商品 category 保持一一对应。
export const shopCategories = [
{ id: 'all', label: '全部' }, { id: 'ball', label: '球类' }, { id: 'paddle', label: '球拍' }, { id: 'accessory', label: '护具周边' }
];
const shopStorageKey = 'playfun-shop-v1';
// 读取商城购物车和商城订单,首次进入时创建空快照。
export function getShopState() {
try {
const saved = uni.getStorageSync(shopStorageKey);
if (saved?.version === 1) return saved;
const initial = { version: 1, cart: [], orders: [], points: 1280, nextId: 1 };
uni.setStorageSync(shopStorageKey, initial);
return initial;
} catch (error) {
return { version: 1, cart: [], orders: [], points: 1280, nextId: 1 };
}
}
// 写入商城状态快照,保证购物车和下单动作可跨页面保留。
export function updateShopState(updater) {
const next = updater(getShopState());
uni.setStorageSync(shopStorageKey, next);
return next;
}
// 返回购物车内商品的可展示明细和合计金额。
export function getCartSummary(state) {
const items = state.cart.map((line) => ({ ...line, product: shopProducts.find((product) => product.id === line.productId) })).filter((line) => line.product);
const total = items.reduce((sum, line) => sum + line.product.price * line.quantity, 0);
return { items, total: Number(total.toFixed(2)), count: items.reduce((sum, line) => sum + line.quantity, 0) };
}
// 按 100 积分抵 1 元计算本单最多可使用的积分,抵扣上限为商品金额的 20%。
export function getPointsDiscount(state, total) {
const maxDiscount = Math.floor(total * 20) / 100;
const pointsUsed = Math.min(state.points || 0, Math.floor(maxDiscount * 100));
return { pointsUsed, discount: Number((pointsUsed / 100).toFixed(2)), payable: Number((total - pointsUsed / 100).toFixed(2)) };
}
// 添加商品或增加已有商品数量,并限制单品数量不超过 9 件。
export function addToCart(state, productId) {
const product = shopProducts.find((item) => item.id === productId);
if (!product || product.stock < 1) throw new Error('该商品暂时缺货');
const line = state.cart.find((item) => item.productId === productId);
if (line) line.quantity = Math.min(product.stock, 9, line.quantity + 1);
else state.cart.push({ productId, quantity: 1 });
return state;
}
// 调整购物车中某个商品的数量,减到 0 时移除商品。
export function changeCartQuantity(state, productId, delta) {
const line = state.cart.find((item) => item.productId === productId);
if (!line) return state;
line.quantity += delta;
state.cart = state.cart.filter((item) => item.quantity > 0);
return state;
}
// 创建一笔本地商城订单并清空已结算商品,模拟第三方商城下单回调。
export function checkoutShop(state, selectedIds, pointsUsed = 0) {
const summary = getCartSummary(state);
const selected = summary.items.filter((item) => selectedIds.includes(item.productId));
if (!selected.length) throw new Error('请选择要结算的商品');
const amount = Number(selected.reduce((sum, item) => sum + item.product.price * item.quantity, 0).toFixed(2));
selected.forEach((item) => { if (item.quantity > item.product.stock) throw new Error(`${item.product.name}库存不足`); });
const discount = getPointsDiscount(state, amount);
const actualPoints = Math.min(pointsUsed, discount.pointsUsed);
const order = { id: `shop-${state.nextId++}`, items: selected, amount, pointsUsed: actualPoints, pointsDiscount: Number((actualPoints / 100).toFixed(2)), payableAmount: Number((amount - actualPoints / 100).toFixed(2)), status: 'pending_pay', createdAt: Date.now(), fulfillment: 'one-drop', logistics: null };
state.points = Math.max(0, (state.points || 0) - actualPoints);
state.orders.unshift(order);
state.cart = state.cart.filter((line) => !selectedIds.includes(line.productId));
return order;
}
// 模拟微信支付成功,并推进一件代发订单进入待发货状态。
export function payShopOrder(state, orderId) {
const order = state.orders.find((item) => item.id === orderId);
if (!order || order.status !== 'pending_pay') throw new Error('订单当前不可支付');
order.status = 'to_ship';
order.paidAt = Date.now();
return order;
}
// 模拟供应商发货和物流节点更新。
export function advanceShopLogistics(state, orderId) {
const order = state.orders.find((item) => item.id === orderId);
if (!order || !['to_ship', 'shipped'].includes(order.status)) throw new Error('当前订单不能更新物流');
if (order.status === 'to_ship') { order.status = 'shipped'; order.logistics = { company: '顺丰速运', trackingNo: 'SF1234567890', updatedAt: Date.now(), step: '供应商已发货' }; }
else { order.status = 'delivered'; order.logistics.step = '已签收'; order.logistics.updatedAt = Date.now(); }
return order;
}
// 模拟用户发起售后退款,待平台审核。
export function requestShopRefund(state, orderId) {
const order = state.orders.find((item) => item.id === orderId);
if (!order || !['to_ship', 'shipped', 'delivered'].includes(order.status)) throw new Error('当前订单不能申请退款');
order.status = 'refund_pending';
order.refundReason = '用户申请退款';
return order;
}
// 模拟平台同意退款并关闭订单。
export function approveShopRefund(state, orderId) {
const order = state.orders.find((item) => item.id === orderId);
if (!order || order.status !== 'refund_pending') throw new Error('没有待审核的退款');
order.status = 'refunded';
state.points = (state.points || 0) + (order.pointsUsed || 0);
order.refundedAt = Date.now();
return order;
}

41
src/mock/store.js Normal file
View File

@@ -0,0 +1,41 @@
import { createInitialState, reconcileState, upgradeState } from './engine.js';
// 存储版本升级时保留上一阶段的拼场、订单和用户档案。
const storageKey = 'playfun-mock-v2';
const previousStorageKey = 'playfun-mock-v1';
// 读取演示数据,并在页面切换时结算过期订单及拼场。
export function getMockState() {
try {
const saved = uni.getStorageSync(storageKey);
if ([2, 3, 4, 5].includes(saved?.version)) {
const upgraded = upgradeState(saved);
if (reconcileState(saved) || upgraded) uni.setStorageSync(storageKey, saved);
return saved;
}
const previous = uni.getStorageSync(previousStorageKey);
const initial = createInitialState();
if (previous) {
initial.profileName = previous.profileName || initial.profileName;
initial.assessment = previous.assessment || null;
}
uni.setStorageSync(storageKey, initial);
return initial;
} catch (error) {
return createInitialState();
}
}
// 统一写入演示状态,所有订单动作通过同一个数据快照执行。
export function updateMockState(updater) {
const nextState = updater(getMockState());
uni.setStorageSync(storageKey, nextState);
return nextState;
}
// 清除本机演示记录并恢复动态日期的初始拼场。
export function resetMockState() {
const initial = createInitialState();
uni.setStorageSync(storageKey, initial);
return initial;
}

50
src/pages.json Normal file
View File

@@ -0,0 +1,50 @@
{
"pages": [
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "拍立方" } },
{ "path": "pages/match/match", "style": { "navigationBarTitleText": "约球拼场" } },
{ "path": "pages/rank/rank", "style": { "navigationBarTitleText": "段位进阶" } },
{ "path": "pages/shop/shop", "style": { "navigationBarTitleText": "装备严选" } },
{ "path": "pages/shop/orders", "style": { "navigationBarTitleText": "商城订单" } },
{ "path": "pages/me/me", "style": { "navigationBarTitleText": "我的" } }
],
"subPackages": [
{
"root": "subpackages/venue",
"pages": [
{ "path": "detail", "style": { "navigationBarTitleText": "场地详情" } },
{ "path": "map", "style": { "navigationBarTitleText": "场地地图" } }
]
},
{
"root": "subpackages/match",
"pages": [
{ "path": "detail", "style": { "navigationBarTitleText": "拼场详情" } }
]
},
{
"root": "subpackages/order",
"pages": [
{ "path": "orders", "style": { "navigationBarTitleText": "我的订单" } }
]
}
],
"globalStyle": {
"navigationStyle": "default",
"backgroundColor": "#f3f6f3",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
},
"tabBar": {
"color": "#9ba59e",
"selectedColor": "#4c9b53",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"list": [
{ "pagePath": "pages/index/index", "text": "首页", "iconPath": "static/tabbar/home-normal.png", "selectedIconPath": "static/tabbar/home-selected.png" },
{ "pagePath": "pages/match/match", "text": "约球", "iconPath": "static/tabbar/match-normal.png", "selectedIconPath": "static/tabbar/match-selected.png" },
{ "pagePath": "pages/rank/rank", "text": "段位", "iconPath": "static/tabbar/rank-normal.png", "selectedIconPath": "static/tabbar/rank-selected.png" },
{ "pagePath": "pages/shop/shop", "text": "商城", "iconPath": "static/tabbar/shop-normal.png", "selectedIconPath": "static/tabbar/shop-selected.png" },
{ "pagePath": "pages/me/me", "text": "我的", "iconPath": "static/tabbar/me-normal.png", "selectedIconPath": "static/tabbar/me-selected.png" }
]
}
}

336
src/pages/index/index.vue Normal file
View File

@@ -0,0 +1,336 @@
<script setup>
import { computed, ref } from 'vue';
import { venues } from '../../mock/data';
// 首页价格带筛选项。
const bands = [
{ value: 'all', label: '全部' }, { value: 'budget', label: '普惠 9.9-50' },
{ value: 'main', label: '主流 60-90' }, { value: 'premium', label: '高端 99+' }
];
// 当前关键词和价格带。
const keyword = ref('');
const activeBand = ref('all');
// 按用户输入过滤本地场地样例。
const visibleVenues = computed(() => venues.filter((venue) =>
(activeBand.value === 'all' || venue.band === activeBand.value) &&
(!keyword.value.trim() || `${venue.name}${venue.district}${venue.surface}`.includes(keyword.value.trim()))
));
// 进入所选场地详情。
function showVenue(venue) { uni.navigateTo({ url: `/subpackages/venue/detail?id=${venue.id}` }); }
// 地图入口展示四家场地的区域位置示意。
function openMap() { uni.navigateTo({ url: '/subpackages/venue/map' }); }
// 首页快捷入口跳转到对应主页面。
function openTab(url) { uni.switchTab({ url }); }
</script>
<template>
<view class="page home-page">
<view class="location">⌖ 南京 · 雨花台 <text class="demo-tag">演示数据</text></view>
<view class="search-box">
<text class="search-icon">⌕</text>
<input v-model="keyword" type="text" placeholder="搜索场地 / 区域 / 设施" confirm-type="search" />
<text v-if="keyword" class="clear-search" @tap="keyword = ''">×</text>
</view>
<view class="event-banner">
<view class="event-top">赛事报名中 <text>南京站</text></view>
<view class="event-title">拍立方杯 CPC 积分赛</view>
<view class="event-sub">分级别公开赛 · 冠军直通年度总决赛</view>
<view class="event-mark">P</view>
</view>
<view class="quick-grid">
<view class="quick-item" @tap="openTab('/pages/match/match')">
<view class="quick-icon green">+</view><text>约球拼场</text>
</view>
<view class="quick-item" @tap="openTab('/pages/rank/rank')">
<view class="quick-icon amber">◎</view><text>段位进阶</text>
</view>
<view class="quick-item" @tap="openTab('/pages/rank/rank')">
<view class="quick-icon mint">✓</view><text>自评定级</text>
</view>
</view>
<view class="section-head"><text class="section-title">附近场地</text><text class="map-link" @tap="openMap">⌖ 地图模式
›</text></view>
<scroll-view class="chip-row" scroll-x show-scrollbar="false">
<view v-for="band in bands" :key="band.value" class="chip" :class="{ active: activeBand === band.value }"
@tap="activeBand = band.value">{{ band.label }}</view>
</scroll-view>
<view v-if="!visibleVenues.length" class="empty">没有找到符合条件的场地</view>
<view v-for="venue in visibleVenues" :key="venue.id" class="venue-card card" @tap="showVenue(venue)">
<view class="venue-art" :class="venue.color"><text class="court-count">{{ venue.courts }} 片</text><text
class="court-symbol">⊕</text></view>
<view class="venue-main">
<view class="venue-title">{{ venue.name }} <text class="district">{{ venue.district }}</text></view>
<view class="venue-meta">⌖ {{ venue.distance }} · {{ venue.surface }}</view>
<view class="venue-price"><text>{{ venue.price }}</text> 元/小时 <text class="rating">★ {{ venue.rating }}</text>
</view>
<view class="venue-note">{{ venue.note }}</view>
</view>
</view>
</view>
</template>
<style scoped>
.home-page {
padding-bottom: 52rpx;
}
.location {
color: #3a6140;
font-size: 24rpx;
margin-bottom: 18rpx;
}
.demo-tag {
color: #9a9f98;
font-size: 20rpx;
margin-left: 12rpx;
}
.search-box {
display: flex;
align-items: center;
height: 82rpx;
border-radius: 18rpx;
background: #fff;
padding: 0 24rpx;
box-shadow: 0 8rpx 25rpx rgba(28, 57, 31, .04);
}
.search-icon {
font-size: 40rpx;
color: #90a093;
margin-right: 14rpx;
}
.search-box input {
flex: 1;
height: 100%;
font-size: 26rpx;
}
.clear-search {
color: #8b968d;
padding: 12rpx;
font-size: 32rpx;
}
.event-banner {
position: relative;
overflow: hidden;
height: 238rpx;
box-sizing: border-box;
margin-top: 26rpx;
padding: 29rpx;
border-radius: 18rpx;
color: #fff;
background: linear-gradient(120deg, #216c37, #4cae5d);
}
.event-top {
position: relative;
z-index: 1;
color: #d8f3db;
font-size: 22rpx;
}
.event-top text {
margin-left: 12rpx;
padding: 4rpx 13rpx;
border: 1rpx solid #b8e5be;
border-radius: 20rpx;
}
.event-title {
position: relative;
z-index: 1;
font-weight: 760;
font-size: 37rpx;
margin-top: 20rpx;
}
.event-sub {
position: relative;
z-index: 1;
color: #e4f4e6;
font-size: 22rpx;
margin-top: 12rpx;
}
.event-mark {
position: absolute;
right: -18rpx;
bottom: -70rpx;
font-weight: 900;
font-size: 300rpx;
line-height: 1;
color: rgba(255, 255, 255, .12);
}
.quick-grid {
display: flex;
justify-content: space-around;
margin: 34rpx 0 8rpx;
}
.quick-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
font-size: 23rpx;
color: #475449;
}
.quick-icon {
width: 82rpx;
height: 82rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 42rpx;
font-weight: 700;
}
.quick-icon.green {
color: #358d44;
background: #e0f1e2;
}
.quick-icon.amber {
color: #c78a24;
background: #fff0d7;
}
.quick-icon.mint {
color: #2f9f8d;
background: #e0f3ef;
}
.venue-card {
display: flex;
gap: 22rpx;
padding: 20rpx;
margin: 16rpx 0;
min-height: 188rpx;
box-sizing: border-box;
}
.venue-art {
position: relative;
width: 152rpx;
flex: none;
border-radius: 13rpx;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.venue-art.green {
background: #55aa5d;
}
.venue-art.mint {
background: #6cc68c;
}
.venue-art.deep {
background: #328544;
}
.venue-art.gold {
background: #f0b247;
}
.court-count {
position: absolute;
top: 10rpx;
left: 10rpx;
padding: 2rpx 8rpx;
border-radius: 5rpx;
background: rgba(24, 74, 32, .48);
font-size: 19rpx;
}
.court-symbol {
font-size: 69rpx;
font-weight: 200;
}
.venue-main {
flex: 1;
min-width: 0;
}
.venue-title {
font-size: 27rpx;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.district {
font-size: 20rpx;
color: #56a361;
background: #e9f5e9;
padding: 3rpx 7rpx;
border-radius: 5rpx;
font-weight: 500;
}
.venue-meta {
font-size: 21rpx;
color: #a0aaa2;
margin-top: 12rpx;
}
.venue-price {
color: #a8b1a9;
font-size: 20rpx;
margin-top: 15rpx;
}
.venue-price>text:first-child {
color: #3a9348;
font-size: 34rpx;
font-weight: 750;
}
.rating {
float: right;
color: #c89434;
font-size: 22rpx;
padding-top: 9rpx;
}
.venue-note {
display: inline-block;
color: #629468;
background: #ecf7ed;
border-radius: 12rpx;
font-size: 19rpx;
padding: 4rpx 10rpx;
margin-top: 8rpx;
max-width: 100%;
box-sizing: border-box;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.map-link {
color: #4d9856;
font-size: 23rpx;
padding: 10rpx 0 10rpx 16rpx;
}
</style>

345
src/pages/match/match.vue Normal file
View File

@@ -0,0 +1,345 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { getMockState, updateMockState } from '../../mock/store';
import { activeOrder, createHostOrder, createJoinOrder, heldCount, mockNow, CUTOFF_MS } from '../../mock/engine';
import { getVenueSlots, venues } from '../../mock/data';
// 拼场段位选项。
const levels = ['全部段位', '入门', '初级', '中级', '高级'];
// 当前筛选、表单与本地数据。
const activeLevel = ref('全部段位');
const showCreate = ref(false);
const state = ref(getMockState());
const form = ref({ venueId: 'lining', slotId: getVenueSlots('lining')[0].id, level: '中级', capacity: 4 });
// 当前用户的匹配段位来自最近一次自评,演示档案默认为中级。
// 当前筛选结果与表单费用均由数据计算。
const visibleMatches = computed(() => state.value.matches.filter((match) =>
['recruiting', 'formed'].includes(match.status) &&
(activeLevel.value === '全部段位' || match.level === activeLevel.value)
));
const venueSlots = computed(() => getVenueSlots(form.value.venueId, state.value.matches, mockNow(state.value)));
const selectedSlot = computed(() => venueSlots.value.find((slot) => slot.id === form.value.slotId));
const feeEach = computed(() => Math.ceil((selectedSlot.value?.totalPrice || 0) / form.value.capacity));
// 每次切回约球页时重新读取本地演示状态。
onShow(() => {
state.value = getMockState();
if (state.value.pendingCreate) {
form.value.venueId = state.value.pendingCreate.venueId;
form.value.slotId = state.value.pendingCreate.slotId;
showCreate.value = true;
state.value = updateMockState((current) => ({ ...current, pendingCreate: null }));
}
if (!venueSlots.value.some((slot) => slot.id === form.value.slotId && canCreateSlot(slot))) {
form.value.slotId = venueSlots.value.find(canCreateSlot)?.id || '';
}
});
// 弹层出现时隐藏系统 TabBar,确保底部发布按钮可以点击。
watch(showCreate, (visible) => {
if (visible) uni.hideTabBar({ animation: false });
else uni.showTabBar({ animation: false });
});
// 场地变化时改选该场地第一段可用时段。
function selectVenue(venueId) {
form.value.venueId = venueId;
form.value.slotId = getVenueSlots(venueId, state.value.matches, mockNow(state.value)).find(canCreateSlot)?.id || '';
}
// 场地发起必须留出至少 12 小时报名时间。
function canCreateSlot(slot) {
return slot.remaining > 0 && new Date(`${slot.date}T${slot.time}:00`).getTime() - mockNow(state.value) > CUTOFF_MS;
}
// 发起拼场先创建待支付订单,再到详情页完成演示付款。
function publish() {
try {
let order;
state.value = updateMockState((current) => {
order = createHostOrder(current, { ...form.value });
return current;
});
showCreate.value = false;
uni.navigateTo({ url: `/subpackages/match/detail?id=${order.matchId}` });
} catch (error) {
uni.showToast({ title: error.message, icon: 'none' });
}
}
// 判断可报名人数时把十分钟内的待支付名额一起计算。
function remainingSeats(match) { return match.capacity - match.players - heldCount(state.value, match.id); }
// 查看拼场详情与本人的订单状态。
function openDetail(id) { uni.navigateTo({ url: `/subpackages/match/detail?id=${id}` }); }
// 报名先生成待支付订单,段位和截止时间由规则层复核。
function join(match) {
try {
state.value = updateMockState((current) => { createJoinOrder(current, match.id); return current; });
openDetail(match.id);
} catch (error) {
uni.showModal({ title: '暂无法报名', content: error.message, showCancel: false });
}
}
</script>
<template>
<view class="page match-page">
<view class="match-summary"><text class="muted small">找对球友,马上开打</text><text class="top-count">{{ visibleMatches.length }} 场进行中</text></view>
<button class="primary-button create-button" @tap="showCreate = true">+ 发起拼场</button>
<scroll-view class="chip-row level-row" scroll-x show-scrollbar="false">
<view v-for="level in levels" :key="level" class="chip" :class="{ active: activeLevel === level }"
@tap="activeLevel = level">{{ level }}</view>
</scroll-view>
<view v-if="!visibleMatches.length" class="empty">当前段位暂无拼场</view>
<view v-for="match in visibleMatches" :key="match.id" class="match-card card" @tap="openDetail(match.id)">
<view class="time-col"><text class="day">{{ match.day }}</text><text class="time">{{ match.time }}</text></view>
<view class="match-body">
<view class="match-title">{{ match.venue }} · {{ match.level }}局</view>
<view class="match-meta">⌖ {{ match.distance }} ◷ {{ match.duration }} <text class="level-badge">{{ match.level
}}</text></view>
<view class="match-bottom">
<view class="slots">
<view v-for="slot in match.capacity" :key="slot" class="slot" :class="{ filled: slot <= match.players }">
</view>
</view>
<text class="match-fee">{{ match.players }}/{{ match.capacity }} 人 · {{ match.fee ? `人均约 ${match.fee} 元` :
'免费' }}</text>
</view>
</view>
<button class="join-button"
:class="{ disabled: !activeOrder(state, match.id) && (remainingSeats(match) <= 0 || mockNow(state) >= match.startAt - 12 * 60 * 60 * 1000) }"
:disabled="!activeOrder(state, match.id) && (remainingSeats(match) <= 0 || mockNow(state) >= match.startAt - 12 * 60 * 60 * 1000)"
@tap.stop="activeOrder(state, match.id) ? openDetail(match.id) : join(match)">{{ activeOrder(state, match.id) ?
'查看订单' : mockNow(state) >= match.startAt - 12 * 60 * 60 * 1000 ? '已截止' : remainingSeats(match) <= 0 ? '名额已满'
: '加入' }}</button>
</view>
<view v-if="showCreate" class="modal-mask" @tap="showCreate = false">
<view class="sheet" @tap.stop>
<view class="sheet-grip"></view>
<view class="sheet-title">发起拼场</view>
<view class="field-label">选择场地</view>
<view class="option-row">
<view v-for="venue in venues" :key="venue.id" class="option" :class="{ selected: form.venueId === venue.id }"
@tap="selectVenue(venue.id)">{{ venue.name }}</view>
</view>
<view class="field-label">约球时段</view>
<view class="option-row">
<view v-for="slot in venueSlots" :key="slot.id" class="option"
:class="{ selected: form.slotId === slot.id, unavailable: !canCreateSlot(slot) }"
@tap="canCreateSlot(slot) && (form.slotId = slot.id)">{{ slot.date }} {{ slot.time }}<text
class="option-remaining">{{ canCreateSlot(slot) ? `余 ${slot.remaining} 片` : slot.remaining ? '报名时间不足' :
'已满' }}</text></view>
</view>
<view class="field-label">目标段位</view>
<view class="option-row">
<view v-for="level in levels.slice(1)" :key="level" class="option" :class="{ selected: form.level === level }"
@tap="form.level = level">{{ level }}</view>
</view>
<view class="field-label">人数(含你)</view>
<view class="option-row">
<view v-for="capacity in [2, 4]" :key="capacity" class="option"
:class="{ selected: form.capacity === capacity }" @tap="form.capacity = capacity">{{ capacity }} 人 · {{
capacity === 2 ? '单打' : '双打' }}</view>
</view>
<view class="fee-bar">
<view><text class="muted small">参考场地费 · 演示数据</text>
<view>人均 <text class="fee-amount">¥{{ feeEach }}</text></view>
</view><text class="muted small">{{ selectedSlot?.totalPrice || 0 }} 元 / {{ form.capacity }} 人</text>
</view>
<button class="primary-button publish-button" :disabled="!selectedSlot" @tap="publish">创建待支付订单</button>
</view>
</view>
</view>
</template>
<style scoped>
.match-summary {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16rpx;
}
.top-count {
color: #66956d;
font-size: 23rpx;
}
.create-button {
margin: 12rpx 0 27rpx;
height: 88rpx;
line-height: 88rpx;
}
.level-row {
margin-bottom: 6rpx;
}
.match-card {
display: flex;
align-items: center;
gap: 18rpx;
padding: 25rpx 20rpx;
margin: 16rpx 0;
min-height: 160rpx;
box-sizing: border-box;
}
.time-col {
display: flex;
flex-direction: column;
width: 92rpx;
flex: none;
align-items: center;
}
.day {
color: #a0aaa2;
font-size: 21rpx;
}
.time {
color: #3d984a;
font-weight: 750;
font-size: 28rpx;
margin-top: 8rpx;
}
.match-body {
flex: 1;
min-width: 0;
}
.match-title {
font-weight: 680;
font-size: 25rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.match-meta {
color: #a3aca4;
font-size: 19rpx;
margin-top: 12rpx;
white-space: nowrap;
}
.level-badge {
color: #ae7829;
background: #fff3e0;
border-radius: 7rpx;
padding: 4rpx 8rpx;
}
.match-bottom {
display: flex;
align-items: center;
gap: 9rpx;
margin-top: 19rpx;
}
.slots {
display: flex;
flex: none;
}
.slot {
width: 23rpx;
height: 23rpx;
box-sizing: border-box;
border: 1rpx solid #d7e5d9;
border-radius: 50%;
margin-right: 3rpx;
}
.slot.filled {
border-color: #61a76a;
background: #d7edda;
}
.match-fee {
color: #7c867e;
font-size: 19rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.join-button {
flex: none;
margin: 0;
padding: 0 4rpx;
width: 120rpx;
height: 61rpx;
line-height: 61rpx;
box-sizing: border-box;
white-space: nowrap;
border-radius: 13rpx;
color: #fff;
font-size: 20rpx;
background: #4c9b53;
box-shadow: 0 5rpx 13rpx rgba(69, 152, 78, .17);
}
.join-button.disabled {
background: #edf0ed;
color: #77877a;
box-shadow: none;
}
.option-row {
display: flex;
flex-wrap: wrap;
gap: 13rpx;
}
.option {
background: #f5f7f5;
color: #6f7971;
font-size: 23rpx;
border: 1rpx solid #edf0ed;
border-radius: 24rpx;
padding: 13rpx 22rpx;
}
.option.selected {
color: #fff;
background: #4c9b53;
border-color: #4c9b53;
font-weight: 650;
}
.option.unavailable {
opacity: .42;
}
.option-remaining {
display: block;
font-size: 18rpx;
margin-top: 4rpx;
opacity: .8;
}
.fee-bar {
display: flex;
align-items: end;
justify-content: space-between;
margin: 36rpx 0 22rpx;
}
.fee-amount {
color: #3d9549;
font-size: 37rpx;
font-weight: 750;
}
.publish-button {
height: 84rpx;
line-height: 84rpx;
}
</style>

344
src/pages/me/me.vue Normal file
View File

@@ -0,0 +1,344 @@
<script setup>
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { getMockState, resetMockState, updateMockState } from '../../mock/store';
import { mockNow, setDemoTime } from '../../mock/engine';
import { getDateAfter } from '../../mock/data';
// 我的页面只展示本地演示记录。
const state = ref(getMockState());
// 日期和时分由用户单独选择,确认后才改变本地演示时钟。
const selectedDate = ref(getDateAfter(0, mockNow(state.value)));
const selectedTime = ref(new Date(mockNow(state.value)).toTimeString().slice(0, 5));
// 根据本地订单计算个人统计。
const gameCount = computed(() => state.value.orders.filter((order) => order.role === 'member' && order.status === 'paid').length);
const hostedCount = computed(() => state.value.matches.filter((match) => match.hostId === 'self' && match.status !== 'draft').length);
const matchLevel = computed(() => state.value.assessment?.level || '中级');
const myMatches = computed(() => state.value.matches.filter((match) => state.value.orders.some((order) => order.matchId === match.id && order.role !== 'guest')));
// 把当前演示时钟同步到日期和时间选择器。
function syncDemoTimeFields() {
selectedDate.value = getDateAfter(0, mockNow(state.value));
selectedTime.value = new Date(mockNow(state.value)).toTimeString().slice(0, 5);
}
// 切换页面后同步最新报名、自评与个人资料。
onShow(() => { state.value = getMockState(); syncDemoTimeFields(); });
// 记录日期选择器返回的本地日期。
function selectDate(event) { selectedDate.value = event.detail.value; }
// 记录时间选择器返回的本地时分。
function selectTime(event) { selectedTime.value = event.detail.value; }
// 跳转演示时钟后刷新过期状态,并补充可报名的未来场次。
function applyDemoTime() {
const target = new Date(`${selectedDate.value}T${selectedTime.value}:00`).getTime();
try {
state.value = updateMockState((current) => { setDemoTime(current, target); return current; });
syncDemoTimeFields();
uni.showToast({ title: '演示时间已更新', icon: 'none' });
} catch (error) {
uni.showToast({ title: error.message, icon: 'none' });
}
}
// 编辑本地演示昵称。
function editName() {
uni.showModal({
title: '编辑昵称', editable: true, placeholderText: '输入你的昵称', content: state.value.profileName,
success: (result) => {
if (!result.confirm || !result.content?.trim()) return;
state.value = updateMockState((current) => ({ ...current, profileName: result.content.trim().slice(0, 16) }));
}
});
}
// 打开本机演示订单列表。
function showOrders() {
uni.navigateTo({ url: '/subpackages/order/orders' });
}
// 打开我的拼场详情。
function showMatch(match) { uni.navigateTo({ url: `/subpackages/match/detail?id=${match.id}` }); }
// 推进演示时钟,用于立即验证十分钟支付超时。
function expirePending() {
state.value = updateMockState((current) => {
setDemoTime(current, mockNow(current) + 11 * 60 * 1000);
return current;
});
syncDemoTimeFields();
uni.showToast({ title: '演示时间已推进 11 分钟', icon: 'none' });
}
// 将演示时钟推进至最近一场拼场的报名截止之后。
function reachCutoff() {
const now = mockNow(state.value);
const next = state.value.matches.filter((match) => ['recruiting', 'formed', 'draft'].includes(match.status) && match.startAt - 12 * 60 * 60 * 1000 > now)
.sort((a, b) => a.startAt - b.startAt)[0];
if (!next) { uni.showToast({ title: '没有待截止的拼场', icon: 'none' }); return; }
state.value = updateMockState((current) => {
setDemoTime(current, next.startAt - 12 * 60 * 60 * 1000 + 1000);
return current;
});
syncDemoTimeFields();
uni.showToast({ title: '已推进到最近报名截止', icon: 'none' });
}
// 确认后清除本机报名、自评和昵称,方便重新体验完整流程。
function resetDemo() {
uni.showModal({
title: '重置演示数据', content: '将恢复初始拼场、段位和昵称。此操作只影响本机演示数据。',
confirmText: '确认重置', success: (result) => {
if (!result.confirm) return;
state.value = resetMockState();
syncDemoTimeFields();
uni.showToast({ title: '已重置', icon: 'success' });
}
});
}
</script>
<template>
<view class="page me-page">
<view class="me-profile card">
<view class="me-avatar">{{ state.profileName.slice(-1) }}</view>
<view class="me-name-block">
<view class="me-name">{{ state.profileName }}</view>
<view class="me-level">{{ matchLevel }} · 匹配段位 南京 · 雨花台</view>
</view><text class="edit-link" @tap="editName">编辑 ›</text>
</view>
<view class="me-stats card">
<view><strong>{{ gameCount }}</strong><text>已报名</text></view>
<view><strong>{{ hostedCount }}</strong><text>发起拼场</text></view>
<view><strong>{{state.orders.filter((order) => order.role !== 'guest' && order.status === 'pending').length
}}</strong><text>待支付</text></view>
<view><strong>{{state.orders.filter((order) => order.role !== 'guest' && order.status === 'refunded').length
}}</strong><text>已退款</text></view>
</view>
<view class="section-head"><text class="section-title">我的拼场</text><text class="muted small">本机演示记录</text></view>
<view v-if="!myMatches.length" class="empty card">还没有加入或发起拼场</view>
<view v-for="match in myMatches" :key="match.id" class="my-match card" @tap="showMatch(match)">
<view>
<view class="my-match-name">{{ match.venue }} · {{ match.level }}局</view>
<view class="muted small">{{ match.date }} {{ match.time }} · {{ match.players }}/{{ match.capacity }} 人 · {{ ({
canceled: '已取消', draft: '待支付', formed: '已成局', in_progress: '进行中', completed: '已完赛', recruiting: '招募中'
})[match.status] }}</view>
</view><text class="my-match-tag">{{ match.hostId === 'self' ? '我发起的' : '我加入的' }}</text>
</view>
<view class="section-head"><text class="section-title">常用服务</text></view>
<view class="menu-list card">
<view class="menu-item" @tap="showOrders"><text class="menu-icon">▤</text><text>我的订单</text><text
class="arrow">›</text></view>
<view class="menu-item" @tap="uni.switchTab({ url: '/pages/rank/rank' })"><text
class="menu-icon">◎</text><text>我的段位</text><text class="arrow">›</text></view>
<view class="menu-item" @tap="uni.switchTab({ url: '/pages/match/match' })"><text
class="menu-icon">⊕</text><text>发现拼场</text><text class="arrow">›</text></view>
<view class="menu-item" @tap="resetDemo"><text class="menu-icon">↺</text><text>重置演示数据</text><text
class="arrow">›</text></view>
</view>
<view class="section-head"><text class="section-title">演示时间</text></view>
<view class="demo-time"><text>当前演示时间:{{ new Date(mockNow(state)).toLocaleString('zh-CN') }}</text>
<view class="time-picker-row">
<picker mode="date" :value="selectedDate" @change="selectDate">
<view class="time-picker">{{ selectedDate }}</view>
</picker>
<picker mode="time" :value="selectedTime" @change="selectTime">
<view class="time-picker">{{ selectedTime }}</view>
</picker><button class="primary-button apply-time" @tap="applyDemoTime">设为演示时间</button>
</view>
<view class="quick-time-row"><button class="outline-button" @tap="expirePending">推进 11 分钟</button><button
class="outline-button" @tap="reachCutoff">推进到最近截止</button></view><text
class="time-note">回拨时间会保留已完成的订单和赛果</text>
</view>
<view class="privacy-note">当前内容为本机演示数据,不代表真实场地库存、订单或积分。</view>
</view>
</template>
<style scoped>
.me-profile {
display: flex;
align-items: center;
gap: 19rpx;
padding: 30rpx 24rpx;
}
.me-avatar {
width: 94rpx;
height: 94rpx;
flex: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #3a944a;
background: #dcefe0;
font-size: 41rpx;
font-weight: 760;
}
.me-name-block {
flex: 1;
min-width: 0;
}
.me-name {
font-size: 31rpx;
font-weight: 730;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.me-level {
color: #849289;
font-size: 20rpx;
margin-top: 13rpx;
}
.edit-link {
color: #4f9b58;
font-size: 22rpx;
white-space: nowrap;
}
.me-stats {
display: flex;
padding: 27rpx 12rpx;
margin-top: 18rpx;
}
.me-stats view {
flex: 1;
text-align: center;
}
.me-stats strong {
display: block;
color: #327c3e;
font-size: 34rpx;
}
.me-stats text {
display: block;
color: #9da79e;
font-size: 20rpx;
margin-top: 8rpx;
}
.my-match {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10rpx;
padding: 25rpx;
margin: 13rpx 0;
}
.my-match-name {
font-size: 25rpx;
font-weight: 660;
margin-bottom: 10rpx;
}
.my-match-tag {
color: #4f9957;
background: #eaf6eb;
border-radius: 9rpx;
padding: 7rpx 12rpx;
font-size: 20rpx;
white-space: nowrap;
}
.menu-list {
padding: 0 24rpx;
}
.menu-item {
display: flex;
align-items: center;
min-height: 103rpx;
border-bottom: 1rpx solid #f1f3f1;
font-size: 25rpx;
}
.menu-item:last-child {
border: 0;
}
.menu-icon {
width: 42rpx;
color: #5ca465;
font-size: 31rpx;
}
.arrow {
margin-left: auto;
color: #a6afa7;
font-size: 36rpx;
}
.privacy-note {
text-align: center;
color: #a5afa7;
font-size: 20rpx;
margin: 40rpx 0 16rpx;
line-height: 1.5;
}
.demo-time {
color: #738076;
font-size: 22rpx;
}
.demo-time>view {
display: flex;
gap: 12rpx;
margin-top: 14rpx;
}
.demo-time button {
margin: 0;
flex: 1;
padding: 13rpx 10rpx;
font-size: 21rpx;
line-height: 1.4;
}
.time-picker-row {
align-items: stretch;
}
.time-picker-row picker {
flex: 1;
min-width: 0;
}
.time-picker {
min-height: 61rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
padding: 8rpx;
border: 1rpx solid #d6e8d6;
border-radius: 9rpx;
background: #fff;
color: #2d6037;
}
.demo-time .apply-time {
flex: 1.3;
color: #fff;
}
.time-note {
display: block;
margin-top: 12rpx;
font-size: 19rpx;
color: #9ca69e;
}
</style>

599
src/pages/rank/rank.vue Normal file
View File

@@ -0,0 +1,599 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { levels, questions } from '../../mock/data';
import { claimWeeklyTask, getCompetitionProgress, getWeeklyTasks } from '../../mock/engine';
import { getMockState, updateMockState } from '../../mock/store';
// 本地自评结果和问卷步骤。
const assessment = ref(null);
// 段位积分依据已确认的拼场赛果和本周任务奖励计算。
const rankState = ref(getMockState());
const competition = computed(() => getCompetitionProgress(rankState.value.rankPoints));
const resultCount = computed(() => rankState.value.rankHistory.filter((entry) => entry.eventId.startsWith('match-')).length);
const weeklyTasks = computed(() => getWeeklyTasks(rankState.value));
// 展示的昵称来自本地演示档案。
const profileName = ref('球搭子阿豪');
const showQuiz = ref(false);
const questionIndex = ref(0);
const answers = ref([]);
// 匹配段位与下一次可重评时间。
const matchLevel = computed(() => assessment.value?.level || '中级');
const canRetake = computed(() => !assessment.value || Date.now() - assessment.value.completedAt >= 30 * 24 * 60 * 60 * 1000);
const retakeDate = computed(() => assessment.value ? new Date(assessment.value.completedAt + 30 * 24 * 60 * 60 * 1000).toLocaleDateString('zh-CN') : '');
// 切回页面时恢复本地自评结果。
onShow(() => {
const state = getMockState();
assessment.value = state.assessment;
profileName.value = state.profileName;
rankState.value = state;
});
// 自评弹层打开时隐藏系统 TabBar,避免选项被遮挡。
watch(showQuiz, (visible) => {
if (visible) uni.hideTabBar({ animation: false });
else uni.showTabBar({ animation: false });
});
// 从第一题开始一次完整的十五题自评。
function startQuiz() {
if (!canRetake.value) {
uni.showToast({ title: `${retakeDate.value} 后可重评`, icon: 'none' });
return;
}
questionIndex.value = 0;
answers.value = [];
showQuiz.value = true;
}
// 保存单题分值,完成时计算可用于报名的匹配段位。
function answer(score) {
answers.value.push(score);
if (questionIndex.value < questions.length - 1) {
questionIndex.value += 1;
return;
}
const total = answers.value.reduce((sum, value) => sum + value, 0);
const level = total >= 25 ? '高级' : total >= 17 ? '中级' : total >= 9 ? '初级' : '入门';
const result = { level, total, completedAt: Date.now() };
updateMockState((state) => ({ ...state, assessment: result }));
assessment.value = result;
showQuiz.value = false;
uni.showModal({ title: '自评完成', content: `当前匹配段位:${level}\n现在可用该段位加入相符的拼场。`, showCancel: false });
}
// 本周进度达标后领取积分,并将领取动作写入同一份本地流水。
function claimTask(taskId) {
try {
let points;
rankState.value = updateMockState((state) => {
points = claimWeeklyTask(state, taskId);
return state;
});
uni.showToast({ title: `已领取 ${points} 积分`, icon: 'none' });
} catch (error) {
uni.showToast({ title: error.message, icon: 'none' });
}
}
// 积分记录使用本地演示时钟对应的结算日期。
function formatDate(timestamp) {
return new Date(timestamp).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
// 从段位页进入可报名的拼场列表。
function findMatch() {
uni.switchTab({ url: '/pages/match/match' });
}
</script>
<template>
<view class="page rank-page">
<view class="profile-card card">
<view class="profile-top">
<view class="profile-avatar">{{ profileName.slice(-1) }}</view>
<view>
<view class="profile-name">{{ profileName }}</view>
<view class="profile-area">南京 · 雨花台</view>
</view>
<view class="rank-tag">{{ matchLevel }} · 匹配段位</view>
</view>
<view class="stat-grid">
<view><strong>{{ rankState.rankPoints.toLocaleString() }}</strong><text>段位积分</text></view>
<view><strong>{{ competition.current.name }}</strong><text>积分段位</text></view>
<view><strong>{{ resultCount }}</strong><text>已确认赛果</text></view>
</view>
</view>
<view class="progress-card card">
<view class="progress-ring"><text>{{ competition.percent }}%</text><small>晋级进度</small></view>
<view class="progress-copy">
<view class="progress-title">{{ competition.current.name }}<template v-if="competition.next"> → {{
competition.next.name }}</template><template v-else> · 已达最高段位</template></view>
<view class="progress-info">{{ competition.next ? `还需 ${competition.remaining} 分 · 下一档
${competition.next.points} 分` :
'继续约球可累积积分' }}</view>
<view class="progress-info">确认赛果和周任务计分 · 自评用于匹配</view>
<view class="progress-track">
<view :style="{ width: `${competition.percent}%` }"></view>
</view>
</view>
</view>
<view class="section-head"><text class="section-title">自评定级</text><text class="muted small">15 题 · 约 3 分钟</text>
</view>
<view class="assessment card">
<view>
<view class="assessment-title">当前匹配段位:{{ matchLevel }}</view>
<view class="assessment-sub">{{ assessment ? `自评得分 ${assessment.total}/30 · ${retakeDate} 后可重评` :
'完成自评后,结果直接用于拼场匹配'
}}</view>
</view><button class="outline-button assess-button" :disabled="!canRetake" @tap="startQuiz">{{ assessment ? '重新自评'
:
'开始自评' }}</button>
</view>
<view class="section-head"><text class="section-title">赛果计分</text><text class="muted small">双方确认后入账</text></view>
<view class="event-panel card">
<view class="event-meta">已签到并完赛的拼场,我方获胜 +160 分,落败 +80 分</view><button class="outline-button find-button"
@tap="findMatch">发现拼场</button>
</view>
<view class="section-head"><text class="section-title">本周晋级任务</text><text class="muted small">达标后领取积分</text></view>
<view class="task-list card">
<view v-for="task in weeklyTasks" :key="task.id" class="task"><text class="task-check"
:class="{ done: task.claimed }">{{ task.claimed ? '✓' : '' }}</text>
<view class="task-copy">
<view>{{ task.title }}</view><text class="muted small">本周 {{ task.progress }}/{{ task.target }} · 奖励 +{{
task.reward }} 分</text>
</view><button v-if="task.progress >= task.target" class="claim-button" :disabled="task.claimed"
@tap="claimTask(task.id)">{{ task.claimed ? '已领取' : '领取' }}</button>
</view>
</view>
<view class="section-head"><text class="section-title">积分记录</text><text class="muted small">共 {{
rankState.rankHistory.length }} 条</text></view>
<view class="history-list card">
<view v-if="!rankState.rankHistory.length" class="history-empty">暂无积分记录</view>
<view v-for="entry in rankState.rankHistory" :key="entry.eventId" class="history-item">
<view>
<view class="history-title">{{ entry.title }}</view><text class="muted small">{{ formatDate(entry.settledAt)
}} ·
结算后 {{ entry.total }} 分</text>
</view><text class="history-points">+{{ entry.points }}</text>
</view>
</view>
<view class="section-head"><text class="section-title">六段位晋级体系</text></view>
<view class="level-list card">
<view v-for="(level, index) in levels" :key="level.name" class="level-item"
:class="{ current: level.name === competition.current.name }"><text class="level-dot"
:class="`dot-${index}`"></text><text>{{ level.name }}</text><text class="level-points">{{ level.points }}
分起<template v-if="level.name === competition.current.name"> · 当前</template></text></view>
</view>
<view v-if="showQuiz" class="modal-mask">
<view class="sheet quiz-sheet">
<view class="sheet-grip"></view>
<view class="quiz-progress">自评定级 <text>{{ questionIndex + 1 }} / {{ questions.length }}</text></view>
<view class="quiz-track">
<view :style="{ width: `${(questionIndex + 1) / questions.length * 100}%` }"></view>
</view>
<view class="quiz-question">{{ questions[questionIndex] }}</view>
<view class="quiz-hint">请选择最符合你实际表现的选项</view><button class="quiz-option" @tap="answer(0)">还做不到</button><button
class="quiz-option" @tap="answer(1)">偶尔可以</button><button class="quiz-option"
@tap="answer(2)">能稳定做到</button><text class="quiz-exit" @tap="showQuiz = false">退出自评</text>
</view>
</view>
</view>
</template>
<style scoped>
.profile-card {
padding: 28rpx;
}
.profile-top {
display: flex;
align-items: center;
gap: 17rpx;
}
.profile-avatar {
width: 78rpx;
height: 78rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #d6ebd8;
color: #438c4b;
font-size: 36rpx;
font-weight: 750;
}
.profile-name {
font-weight: 700;
font-size: 29rpx;
}
.profile-area {
margin-top: 7rpx;
color: #98a39a;
font-size: 21rpx;
}
.rank-tag {
margin-left: auto;
color: #906925;
background: #fff0d4;
border-radius: 12rpx;
padding: 9rpx 12rpx;
font-size: 19rpx;
white-space: nowrap;
}
.stat-grid {
display: flex;
margin-top: 32rpx;
border-top: 1rpx solid #eef1ee;
padding-top: 24rpx;
}
.stat-grid view {
flex: 1;
text-align: center;
}
.stat-grid strong {
display: block;
color: #2c7539;
font-size: 35rpx;
}
.stat-grid text {
display: block;
color: #a0aaa1;
font-size: 20rpx;
margin-top: 5rpx;
}
.progress-card {
display: flex;
align-items: center;
gap: 25rpx;
padding: 27rpx;
margin-top: 20rpx;
}
.progress-ring {
width: 145rpx;
height: 145rpx;
flex: none;
border-radius: 50%;
border: 13rpx solid #f1ead8;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #b58227;
}
.progress-ring text {
font-weight: 800;
font-size: 34rpx;
}
.progress-ring small {
font-size: 17rpx;
}
.progress-copy {
flex: 1;
min-width: 0;
}
.progress-title {
font-size: 26rpx;
font-weight: 700;
}
.progress-info {
color: #8d968e;
font-size: 19rpx;
margin-top: 10rpx;
}
.progress-track {
height: 9rpx;
background: #f6f0e5;
border-radius: 9rpx;
margin-top: 17rpx;
}
.progress-track view {
width: 80%;
height: 100%;
background: #dba54a;
border-radius: inherit;
}
.assessment {
display: flex;
align-items: center;
gap: 15rpx;
padding: 24rpx;
}
.assessment>view {
flex: 1;
min-width: 0;
}
.assessment-title {
font-size: 25rpx;
font-weight: 650;
}
.assessment-sub {
color: #98a39a;
font-size: 20rpx;
margin-top: 9rpx;
line-height: 1.5;
}
.assess-button {
flex: none;
margin: 0;
padding: 0 17rpx;
height: 60rpx;
line-height: 60rpx;
font-size: 21rpx;
}
.assess-button[disabled] {
opacity: .55;
}
.event-panel {
padding: 24rpx;
display: flex;
align-items: center;
gap: 18rpx;
}
.event-meta {
flex: 1;
min-width: 0;
color: #7f8b81;
font-size: 23rpx;
line-height: 1.5;
}
.find-button {
flex: none;
margin: 0;
padding: 0 20rpx;
height: 60rpx;
line-height: 60rpx;
font-size: 21rpx;
}
.task-list {
padding: 0 24rpx;
}
.task {
min-height: 108rpx;
display: flex;
align-items: center;
gap: 17rpx;
border-bottom: 1rpx solid #f2f4f2;
}
.task:last-child {
border: 0;
}
.task-copy {
flex: 1;
min-width: 0;
font-size: 24rpx;
}
.task-copy .muted {
display: block;
margin-top: 8rpx;
}
.task-check {
flex: none;
width: 34rpx;
height: 34rpx;
border-radius: 50%;
background: #eef0ed;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
}
.task-check.done {
background: #e2ac48;
}
.claim-button {
flex: none;
margin: 0;
padding: 0 18rpx;
min-width: 96rpx;
height: 54rpx;
line-height: 54rpx;
border-radius: 9rpx;
background: #e8f4e9;
color: #398546;
font-size: 20rpx;
}
.claim-button[disabled] {
background: #f2f3f2;
color: #a1aaa1;
}
.history-list {
padding: 0 24rpx;
}
.history-item {
min-height: 100rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
border-bottom: 1rpx solid #f2f4f2;
}
.history-item:last-child {
border: 0;
}
.history-title {
font-size: 24rpx;
margin-bottom: 6rpx;
}
.history-points {
flex: none;
color: #3b8d4a;
font-weight: 700;
font-size: 27rpx;
}
.history-empty {
padding: 32rpx 0;
color: #98a39a;
text-align: center;
font-size: 23rpx;
}
.level-list {
padding: 10rpx 28rpx;
}
.level-item {
display: flex;
align-items: center;
gap: 17rpx;
height: 76rpx;
font-size: 24rpx;
border-bottom: 1rpx solid #f3f4f3;
}
.level-item:last-child {
border: 0;
}
.level-item.current {
color: #378c42;
font-weight: 700;
}
.level-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #bac8b8;
}
.dot-1 {
background: #7dbb8c;
}
.dot-2 {
background: #95acc8;
}
.dot-3 {
background: #dfab52;
}
.dot-4 {
background: #bd87a8;
}
.dot-5 {
background: #755fa4;
}
.level-points {
margin-left: auto;
color: #a1aaa1;
font-size: 21rpx;
}
.quiz-sheet {
min-height: 520rpx;
}
.quiz-progress {
display: flex;
justify-content: space-between;
font-weight: 700;
font-size: 30rpx;
}
.quiz-progress text {
color: #6aa270;
font-size: 24rpx;
}
.quiz-track {
height: 8rpx;
border-radius: 8rpx;
background: #eaf1e9;
margin-top: 22rpx;
}
.quiz-track view {
height: 100%;
border-radius: inherit;
background: #51a05a;
}
.quiz-question {
font-size: 31rpx;
font-weight: 650;
line-height: 1.5;
margin: 53rpx 0 12rpx;
}
.quiz-hint {
color: #9da79f;
font-size: 22rpx;
margin-bottom: 30rpx;
}
.quiz-option {
width: 100%;
background: #f5f8f5;
color: #3a5d40;
border-radius: 14rpx;
margin: 13rpx 0;
font-size: 25rpx;
}
.quiz-option:active {
background: #dceedd;
}
.quiz-exit {
display: block;
text-align: center;
color: #9ca49d;
font-size: 22rpx;
padding: 20rpx;
}
</style>

136
src/pages/shop/orders.vue Normal file
View File

@@ -0,0 +1,136 @@
<script setup>
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { advanceShopLogistics, approveShopRefund, getShopState, payShopOrder, requestShopRefund, updateShopState } from '../../mock/shop';
// 当前商城订单快照,所有状态均为本机演示数据。
const state = ref(getShopState());
const active = ref('all');
const tabs = [{ id: 'all', label: '全部' }, { id: 'pending_pay', label: '待支付' }, { id: 'to_ship', label: '待发货' }, { id: 'shipped', label: '物流中' }, { id: 'refunded', label: '售后' }];
const orders = computed(() => state.value.orders.filter((order) => active.value === 'all' || order.status === active.value || (active.value === 'refunded' && ['refund_pending', 'refunded'].includes(order.status))));
onShow(() => { state.value = getShopState(); });
// 把订单状态转换成用户可读文案。
function statusText(status) { return ({ pending_pay: '待支付', to_ship: '待发货', shipped: '运输中', delivered: '已签收', refund_pending: '退款审核中', refunded: '已退款' })[status] || status; }
// 模拟用户支付、物流推进和退款申请。
function action(order) {
try {
state.value = updateShopState((current) => { if (order.status === 'pending_pay') payShopOrder(current, order.id); else if (order.status === 'to_ship' || order.status === 'shipped') advanceShopLogistics(current, order.id); else if (order.status === 'delivered') requestShopRefund(current, order.id); else if (order.status === 'refund_pending') approveShopRefund(current, order.id); return current; });
uni.showToast({ title: '状态已更新', icon: 'success' });
} catch (error) { uni.showToast({ title: error.message, icon: 'none' }); }
}
function actionText(status) { return ({ pending_pay: '模拟支付', to_ship: '模拟发货', shipped: '模拟签收', delivered: '申请退款', refund_pending: '模拟退款通过' })[status] || ''; }
</script>
<template>
<view class="page shop-orders">
<scroll-view class="chip-row" scroll-x show-scrollbar="false">
<view v-for="tab in tabs" :key="tab.id" class="chip" :class="{ active: active === tab.id }"
@tap="active = tab.id">{{ tab.label }}</view>
</scroll-view>
<view v-if="!orders.length" class="empty card">还没有商城订单</view>
<view v-for="order in orders" :key="order.id" class="shop-order card">
<view class="order-head"><text>{{ order.id }}</text><text class="order-status">{{ statusText(order.status)
}}</text></view>
<view v-for="line in order.items" :key="line.productId" class="order-product">
<view class="order-thumb" :class="line.product.color">{{ line.product.icon }}</view>
<view class="order-product-main">
<view>{{ line.product.name }}</view><text>¥{{ line.product.price }} × {{ line.quantity }}</text>
</view>
</view>
<view class="order-foot">
<view>实付 ¥{{ order.payableAmount ?? order.amount }}<text v-if="order.pointsUsed" class="order-points">已抵 {{
order.pointsDiscount }} 元</text></view><button v-if="actionText(order.status)" class="outline-button"
@tap="action(order)">{{ actionText(order.status) }}</button>
</view>
<view v-if="order.logistics" class="logistics">{{ order.logistics.company }} {{ order.logistics.trackingNo }} · {{
order.logistics.step }}</view>
</view>
<view class="shop-note">商城订单为本机演示;真实接入后由供应商同步库存、发货和售后状态。</view>
</view>
</template>
<style scoped>
.shop-orders {
padding-bottom: 60rpx;
}
.shop-order {
padding: 24rpx;
margin: 16rpx 0;
}
.order-head,
.order-foot {
display: flex;
align-items: center;
justify-content: space-between;
color: #9aa49c;
font-size: 21rpx;
}
.order-status {
color: #378b49;
}
.order-product {
display: flex;
align-items: center;
gap: 16rpx;
margin-top: 22rpx;
}
.order-thumb {
width: 84rpx;
height: 84rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12rpx;
color: #fff;
font-size: 42rpx;
}
.order-product-main {
flex: 1;
min-width: 0;
font-size: 24rpx;
}
.order-product-main text {
display: block;
margin-top: 9rpx;
color: #8b968d;
font-size: 21rpx;
}
.order-foot {
border-top: 1rpx solid #edf1ed;
margin-top: 20rpx;
padding-top: 17rpx;
color: #397d45;
}
.order-foot button {
margin: 0;
padding: 9rpx 18rpx;
font-size: 21rpx;
}
.logistics {
margin-top: 15rpx;
padding: 13rpx;
border-radius: 10rpx;
background: #f2f8f2;
color: #66856c;
font-size: 20rpx;
}
.shop-note {
text-align: center;
color: #a5afa7;
font-size: 20rpx;
margin-top: 34rpx;
line-height: 1.5;
}
</style>

692
src/pages/shop/shop.vue Normal file
View File

@@ -0,0 +1,692 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { onHide, onShow } from '@dcloudio/uni-app';
import UniPopup from '@dcloudio/uni-ui/lib/uni-popup/uni-popup.vue';
import UniIcons from '@dcloudio/uni-ui/lib/uni-icons/uni-icons.vue';
import { addToCart, changeCartQuantity, checkoutShop, getCartSummary, getPointsDiscount, getShopState, shopCategories, shopProducts, updateShopState } from '../../mock/shop';
// 当前商城分类和搜索关键词。
const activeCategory = ref('all');
const keyword = ref('');
const state = ref(getShopState());
const showCart = ref(false);
const selectedProduct = ref(null);
const usePoints = ref(true);
// 两个弹出层分别管理购物车和商品详情。
const cartPopup = ref(null);
const detailPopup = ref(null);
// 按分类和关键词过滤商品。
const visibleProducts = computed(() => shopProducts.filter((product) => (activeCategory.value === 'all' || product.category === activeCategory.value) && (!keyword.value.trim() || `${product.name}${product.desc}`.includes(keyword.value.trim()))));
// 计算购物车商品、数量和金额。
const cart = computed(() => getCartSummary(state.value));
// 展示本单可抵扣积分和抵扣后的实付金额。
const pointsOffer = computed(() => getPointsDiscount(state.value, cart.value.total));
// 购物车最多显示四行商品,更多商品在弹出层内滚动。
const cartListHeight = computed(() => `${Math.min(Math.max(cart.value.items.length, 1), 4) * 112}rpx`);
// 页面重新显示时同步购物车和商城订单。
onShow(() => { state.value = getShopState(); });
// 微信原生 Tab 栏会盖住自定义弹层,弹层显示时暂时隐藏它。
watch(() => showCart.value || Boolean(selectedProduct.value), (visible) => {
if (visible) uni.hideTabBar({ animation: false });
else uni.showTabBar({ animation: false });
});
// 离开商城时恢复 Tab 栏,避免影响其他页面。
onHide(() => { cartPopup.value?.close(); detailPopup.value?.close(); showCart.value = false; selectedProduct.value = null; uni.showTabBar({ animation: false }); });
// 打开购物车底部弹出层。
function openCart() { showCart.value = true; cartPopup.value?.open(); }
// 关闭购物车底部弹出层。
function closeCart() { cartPopup.value?.close(); }
// 遮罩关闭时同步购物车状态。
function onCartPopupChange(event) { showCart.value = event.show; }
// 打开商品详情底部弹出层。
function openProduct(product) { selectedProduct.value = product; detailPopup.value?.open(); }
// 关闭商品详情底部弹出层。
function closeProduct() { detailPopup.value?.close(); }
// 遮罩关闭时清除当前商品。
function onDetailPopupChange(event) { if (!event.show) selectedProduct.value = null; }
// 立即把商品加入购物车并给出轻提示。
function addProduct(product) {
try { state.value = updateShopState((current) => addToCart(current, product.id)); } catch (error) { uni.showToast({ title: error.message, icon: 'none' }); return; }
uni.showToast({ title: '已加入购物车', icon: 'none' });
}
// 调整购物车中的商品件数。
function updateQuantity(productId, delta) {
state.value = updateShopState((current) => changeCartQuantity(current, productId, delta));
}
// 提交购物车中的全部商品,模拟一件代发订单创建。
function checkout() {
try {
state.value = updateShopState((current) => { checkoutShop(current, current.cart.map((item) => item.productId), usePoints.value ? pointsOffer.value.pointsUsed : 0); return current; });
closeCart();
usePoints.value = true;
uni.showToast({ title: '订单已创建,待发货', icon: 'success' });
} catch (error) { uni.showToast({ title: error.message, icon: 'none' }); }
}
// 打开商城订单列表,复用我的页面里的入口提示当前能力边界。
function openOrders() { uni.navigateTo({ url: '/pages/shop/orders' }); }
</script>
<template>
<view class="page shop-page">
<view class="starter-banner">
<view class="banner-copy"><text class="banner-kicker">平台补贴 · 每人一份</text><text
class="banner-title">新球友礼包</text><text class="banner-desc">木拍 ×1 · 匹克球 ×2 · 护腕 · 入门手册</text></view>
<view class="banner-price">¥9.9 <text>起 / 包邮</text></view><button @tap="addProduct(shopProducts[0])">领取</button>
</view>
<view class="points-card">
<view class="points-icon">☆</view>
<view class="points-copy"><strong>你的 {{ state.points.toLocaleString() }} 积分可抵 ¥12.8</strong><text>拼场赢积分 · 商城直接花 ·
越玩越便宜</text></view><button @tap="activeCategory = 'all'">去兑换</button>
</view>
<view class="section-head"><text class="section-title">球友价好物</text><view class="shop-tools"><text class="order-link" @tap="openOrders">订单 ›</text><view class="cart-entry" role="button" aria-label="购物车" @tap="openCart"><uni-icons type="cart" size="22" color="#4c8352" /><text v-if="cart.count" class="cart-badge">{{ cart.count }}</text></view></view></view>
<view class="search-box"><text>⌕</text><input v-model="keyword" placeholder="搜索球拍、球、护具" /><text v-if="keyword"
@tap="keyword = ''">×</text></view>
<scroll-view class="chip-row" scroll-x show-scrollbar="false">
<view v-for="category in shopCategories" :key="category.id" class="chip"
:class="{ active: activeCategory === category.id }" @tap="activeCategory = category.id">{{ category.label }}
</view>
</scroll-view>
<view class="product-grid">
<view v-for="product in visibleProducts" :key="product.id" class="product-card card"
@tap="openProduct(product)">
<view class="product-art" :class="product.color"><text class="product-tag">{{ product.tag }}</text><text
class="product-icon">{{ product.icon }}</text></view>
<view class="product-info">
<view class="product-name">{{ product.name }}</view>
<view class="product-desc">{{ product.desc }}</view>
<view class="product-bottom">
<view><text class="product-price">¥{{ product.price }}</text><text class="old-price">¥{{
product.originalPrice }}</text></view><button class="add-button"
@tap.stop="addProduct(product)">+</button>
</view>
<view class="product-sales">库存 {{ product.stock }} · {{ product.sales }}</view>
</view>
</view>
</view>
<view v-if="!visibleProducts.length" class="empty">没有找到相关商品</view>
<uni-popup ref="detailPopup" type="bottom" background-color="#fff" border-radius="24rpx 24rpx 0 0" @change="onDetailPopupChange">
<view v-if="selectedProduct" class="detail-sheet">
<view class="popup-heading"><text>商品详情</text><text class="popup-close" @tap="closeProduct">×</text></view>
<view class="detail-art" :class="selectedProduct.color"><text>{{ selectedProduct.icon }}</text></view>
<view class="detail-title">{{ selectedProduct.name }}</view>
<view class="detail-price">¥{{ selectedProduct.price }} <text>¥{{ selectedProduct.originalPrice }}</text></view>
<view class="detail-stock">库存 {{ selectedProduct.stock }} · {{ selectedProduct.tag }}</view>
<view class="detail-desc">{{ selectedProduct.desc }}</view><button class="primary-button detail-add"
@tap="addProduct(selectedProduct); closeProduct()">加入购物车</button>
</view>
</uni-popup>
<uni-popup ref="cartPopup" type="bottom" background-color="#fff" border-radius="24rpx 24rpx 0 0" @change="onCartPopupChange">
<view class="cart-sheet">
<view class="popup-heading"><view>购物车 <text class="cart-count">{{ cart.count }} 件</text></view><text class="popup-close" @tap="closeCart">×</text></view>
<scroll-view class="cart-items" scroll-y :style="{ height: cartListHeight }">
<view v-if="!cart.items.length" class="cart-empty">购物车还是空的</view>
<view v-for="line in cart.items" :key="line.productId" class="cart-line">
<view class="cart-thumb" :class="line.product.color">{{ line.product.icon }}</view>
<view class="cart-main">
<view class="cart-name">{{ line.product.name }}</view>
<view class="cart-price">¥{{ line.product.price }}</view>
</view>
<view class="quantity"><text @tap="updateQuantity(line.productId, -1)">−</text><text>{{ line.quantity
}}</text><text @tap="updateQuantity(line.productId, 1)">+</text></view>
</view>
</scroll-view>
<view v-if="cart.items.length" class="points-use">
<view><strong>积分抵扣</strong><text>可用 {{ state.points }} 积分,最多抵 ¥{{ pointsOffer.discount }}</text></view>
<switch :checked="usePoints" color="#4c9b53" @change="usePoints = $event.detail.value" />
</view>
<view v-if="cart.items.length" class="checkout-row">
<view class="checkout-total"><text class="checkout-original">¥{{ cart.total }}</text><strong>¥{{ usePoints ? pointsOffer.payable :
cart.total }}</strong><text class="checkout-saving">已省 ¥{{ usePoints ? pointsOffer.discount : 0 }}</text>
</view><button class="primary-button" @tap="checkout">提交订单</button>
</view>
</view>
</uni-popup>
</view>
</template>
<style scoped>
.shop-page {
padding-bottom: 48rpx;
}
.shop-tools {
display: flex;
align-items: center;
gap: 24rpx;
}
.cart-entry {
position: relative;
flex: none;
width: 68rpx;
height: 68rpx;
display: flex;
align-items: center;
justify-content: center;
border: 1rpx solid #e4eae4;
border-radius: 35rpx;
color: #4c8352;
background: #fff;
}
.cart-badge {
position: absolute;
right: -3rpx;
top: -4rpx;
min-width: 28rpx;
height: 28rpx;
padding: 0 6rpx;
border-radius: 20rpx;
background: #e46055;
color: #fff;
font-size: 17rpx;
text-align: center;
line-height: 28rpx;
box-sizing: border-box;
}
.starter-banner {
position: relative;
height: 244rpx;
padding: 26rpx;
box-sizing: border-box;
overflow: hidden;
border-radius: 24rpx;
color: #fff;
background: linear-gradient(120deg, #248c83, #63c5bb);
}
.starter-banner::after {
content: '';
position: absolute;
width: 190rpx;
height: 190rpx;
right: -30rpx;
top: -68rpx;
border-radius: 50%;
background: rgba(255, 255, 255, .12);
}
.banner-copy {
position: relative;
z-index: 1;
}
.banner-kicker {
display: inline-block;
padding: 5rpx 12rpx;
border: 1rpx solid rgba(255, 255, 255, .65);
border-radius: 18rpx;
font-size: 20rpx;
}
.banner-title {
display: block;
margin-top: 20rpx;
font-size: 37rpx;
font-weight: 760;
}
.banner-desc {
display: block;
margin-top: 14rpx;
color: #dcfaf7;
font-size: 21rpx;
}
.banner-price {
position: absolute;
left: 27rpx;
bottom: 20rpx;
font-size: 35rpx;
font-weight: 760;
z-index: 1;
}
.banner-price text {
font-size: 20rpx;
font-weight: 500;
}
.starter-banner button {
position: absolute;
right: 29rpx;
top: 102rpx;
z-index: 2;
width: 106rpx;
height: 68rpx;
line-height: 68rpx;
padding: 0;
border-radius: 36rpx;
color: #378d84;
background: #fff;
font-size: 24rpx;
font-weight: 700;
}
.points-card {
display: flex;
align-items: center;
gap: 18rpx;
margin-top: 18rpx;
padding: 20rpx 19rpx;
background: #fff;
border-radius: 20rpx;
border: 1rpx solid #e9eee9;
}
.points-icon {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #d08d22;
background: #fff0d4;
font-size: 38rpx;
}
.points-copy {
flex: 1;
min-width: 0;
}
.points-copy strong {
display: block;
font-size: 24rpx;
}
.points-copy text {
display: block;
margin-top: 7rpx;
color: #a0a9a1;
font-size: 19rpx;
}
.points-card button {
padding: 10rpx 17rpx;
border-radius: 24rpx;
background: #edf8ef;
color: #388846;
font-size: 21rpx;
white-space: nowrap;
}
.order-link {
color: #4d9856;
font-size: 22rpx;
}
.search-box {
display: flex;
align-items: center;
height: 73rpx;
margin-bottom: 16rpx;
padding: 0 19rpx;
background: #fff;
border: 1rpx solid #e6ece6;
border-radius: 14rpx;
color: #94a098;
}
.search-box text {
font-size: 31rpx;
margin-right: 11rpx;
}
.search-box input {
flex: 1;
height: 100%;
font-size: 23rpx;
}
.product-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
}
.product-card {
overflow: hidden;
border-radius: 18rpx;
}
.product-art {
position: relative;
height: 190rpx;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, .9);
}
.product-art.teal, .cart-thumb.teal, .detail-art.teal {
background: #49b3ad;
}
.product-art.green, .cart-thumb.green, .detail-art.green {
background: #7bc373;
}
.product-art.blue, .cart-thumb.blue, .detail-art.blue {
background: #5594d2;
}
.product-art.amber, .cart-thumb.amber, .detail-art.amber {
background: #efb452;
}
.product-art.purple, .cart-thumb.purple, .detail-art.purple {
background: #8b83c5;
}
.product-art.orange, .cart-thumb.orange, .detail-art.orange {
background: #e88a4e;
}
.product-tag {
position: absolute;
top: 13rpx;
left: 13rpx;
padding: 4rpx 8rpx;
border-radius: 10rpx;
background: rgba(255, 255, 255, .86);
color: #53705a;
font-size: 17rpx;
}
.product-icon {
font-size: 96rpx;
font-weight: 200;
}
.product-info {
padding: 17rpx 15rpx 15rpx;
}
.product-name {
min-height: 66rpx;
font-size: 24rpx;
font-weight: 700;
line-height: 1.4;
}
.product-desc {
height: 34rpx;
color: #94a097;
font-size: 18rpx;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.product-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 13rpx;
}
.product-price {
color: #21886b;
font-size: 31rpx;
font-weight: 760;
}
.old-price {
margin-left: 7rpx;
color: #b1b9b2;
font-size: 17rpx;
text-decoration: line-through;
}
.add-button {
width: 50rpx;
height: 50rpx;
margin: 0;
padding: 0;
border-radius: 50%;
color: #fff;
background: #4aa156;
font-size: 37rpx;
line-height: 47rpx;
}
.product-sales {
margin-top: 11rpx;
color: #a1aaa2;
font-size: 17rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cart-sheet, .detail-sheet {
padding: 0 28rpx 18rpx;
box-sizing: border-box;
background: #fff;
}
.popup-heading {
min-height: 90rpx;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 31rpx;
font-weight: 720;
}
.cart-count {
margin-left: 10rpx;
color: #919b93;
font-size: 21rpx;
font-weight: 400;
}
.popup-close {
width: 52rpx;
height: 52rpx;
color: #7e8a80;
font-size: 42rpx;
font-weight: 400;
line-height: 48rpx;
text-align: center;
}
.cart-empty {
padding: 28rpx 0;
color: #9aa49d;
text-align: center;
font-size: 23rpx;
}
.cart-line {
display: flex;
align-items: center;
gap: 16rpx;
height: 112rpx;
box-sizing: border-box;
padding: 12rpx 0;
border-bottom: 1rpx solid #edf1ed;
}
.cart-thumb {
width: 82rpx;
height: 82rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12rpx;
color: #fff;
font-size: 43rpx;
}
.cart-main {
flex: 1;
min-width: 0;
}
.cart-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 23rpx;
}
.cart-price {
color: #288e53;
margin-top: 8rpx;
font-size: 22rpx;
}
.quantity {
display: flex;
align-items: center;
gap: 17rpx;
color: #4e8b55;
font-size: 28rpx;
}
.quantity text:first-child,
.quantity text:last-child {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: #edf7ee;
text-align: center;
line-height: 38rpx;
}
.checkout-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding-top: 18rpx;
color: #66736a;
font-size: 23rpx;
}
.checkout-row strong {
margin-left: 7rpx;
color: #23844a;
font-size: 33rpx;
}
.checkout-row button {
width: 200rpx;
height: 72rpx;
line-height: 72rpx;
margin: 0;
padding: 0;
}
.small {
margin-left: 7rpx;
}
.checkout-total { min-width: 0; }
.detail-art {
height: 260rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 18rpx;
color: rgba(255, 255, 255, .9);
}
.detail-art text {
font-size: 145rpx;
}
.detail-title {
margin-top: 26rpx;
font-size: 34rpx;
font-weight: 730;
}
.detail-price {
margin-top: 15rpx;
color: #23884f;
font-size: 39rpx;
font-weight: 760;
}
.detail-price text {
margin-left: 10rpx;
color: #adb5ae;
font-size: 21rpx;
text-decoration: line-through;
font-weight: 400;
}
.detail-stock {
margin-top: 12rpx;
color: #6d9574;
font-size: 21rpx;
}
.detail-desc {
margin-top: 24rpx;
color: #6e786f;
font-size: 24rpx;
line-height: 1.6;
}
.detail-add {
width: 100%;
height: 78rpx;
line-height: 78rpx;
margin-top: 30rpx;
}
.points-use {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 18rpx;
padding: 18rpx 0;
border-top: 1rpx solid #edf1ed;
border-bottom: 1rpx solid #edf1ed;
}
.points-use strong {
display: block;
font-size: 23rpx;
}
.points-use text {
display: block;
margin-top: 7rpx;
color: #77917b;
font-size: 19rpx;
}
.checkout-original {
margin-right: 9rpx;
color: #a7b0a8;
font-size: 20rpx;
text-decoration: line-through;
}
.checkout-saving {
display: block;
margin-top: 5rpx;
color: #d18a2b;
font-size: 18rpx;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 908 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 B

View File

@@ -0,0 +1,615 @@
<script setup>
import { computed, ref } from 'vue';
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app';
import { getMockState, updateMockState } from '../../mock/store';
import { venues } from '../../mock/data';
import { activeOrder, addDemoGuest, cancelOrder, checkIn, checkInDemoParticipant, confirmResult, confirmVenue, createJoinOrder, heldCount, markVenueUnavailable, mockNow, payOrder, requestCancellation, reviewCancellationRequest, seedParticipantCount, setDemoTime, submitResult, CHECKIN_CLOSE_MS, CHECKIN_OPEN_MS, CUTOFF_MS, VENUE_CONFIRM_MS } from '../../mock/engine';
// 当前拼场和每秒更新的演示状态。
const matchId = ref('');
const state = ref(getMockState());
const now = ref(Date.now());
let timer = null;
// 完赛后的比分和评价输入。
const homeScore = ref('');
const awayScore = ref('');
const rating = ref(5);
// 当前拼场、本人的有效订单及历史订单。
const match = computed(() => state.value.matches.find((item) => item.id === matchId.value));
const venue = computed(() => venues.find((item) => item.id === match.value?.venueId));
const order = computed(() => activeOrder(state.value, matchId.value) || state.value.orders.find((item) => item.matchId === matchId.value && item.role !== 'guest'));
const available = computed(() => match.value ? match.value.capacity - match.value.players - heldCount(state.value, matchId.value) : 0);
const cutoff = computed(() => match.value?.startAt - CUTOFF_MS);
const canJoin = computed(() => match.value && match.value.hostId !== 'self' && ['recruiting', 'formed'].includes(match.value.status) && now.value < cutoff.value && available.value > 0 && !activeOrder(state.value, matchId.value));
const canCancel = computed(() => order.value && ['pending', 'paid'].includes(order.value.status) && now.value < cutoff.value);
const canRequest = computed(() => order.value?.status === 'paid' && match.value?.status === 'formed' && now.value >= cutoff.value && now.value < match.value.startAt && order.value.cancellationRequest?.status !== 'pending');
const canCheckIn = computed(() => order.value?.status === 'paid' && !order.value.attendance && match.value?.venueStatus === 'confirmed' && ['formed', 'in_progress'].includes(match.value.status) && now.value >= match.value.startAt - CHECKIN_OPEN_MS && now.value < match.value.startAt + CHECKIN_CLOSE_MS);
const demoCheckinOpen = computed(() => match.value?.venueStatus === 'confirmed' && ['formed', 'in_progress'].includes(match.value.status) && now.value >= match.value.startAt - CHECKIN_OPEN_MS && now.value < match.value.startAt + CHECKIN_CLOSE_MS);
const canSubmitResult = computed(() => order.value?.status === 'paid' && order.value.attendance === 'checked_in' && match.value?.status === 'completed' && !match.value.result);
// 仅展示本人签到、完赛且已确认赛果后获得的段位积分。
const rankCredit = computed(() => state.value.rankHistory?.find((entry) => entry.eventId === `match-${matchId.value}`));
const statusText = computed(() => ({ draft: '待发起人支付', recruiting: '招募中', formed: '已成局', in_progress: '进行中', canceled: '已取消', completed: '已完赛' })[match.value?.status] || '未知状态');
const orderText = computed(() => ({ pending: '待支付', paid: '已支付', expired: '支付超时', canceled: '已取消', refunded: '已全额退款' })[order.value?.status] || '');
const minutesLeft = computed(() => Math.max(0, Math.ceil(((order.value?.expiresAt || 0) - now.value) / 60000)));
const participants = computed(() => {
if (!match.value) return [];
const paid = state.value.orders.filter((item) => item.matchId === matchId.value && item.status === 'paid');
const others = Array.from({ length: seedParticipantCount(state.value, match.value) }, (_, index) => ({ id: `seed-${index}`, name: `球友 ${index + 1}`, attendance: match.value.demoAttendance?.[index] || null, isDemo: true }));
return [...paid.map((item) => ({ id: item.id, name: item.role === 'guest' ? `演示球友 ${item.seatIndex}` : state.value.profileName, attendance: item.attendance, isDemo: item.role === 'guest' })), ...others];
});
// 从约球列表、我的拼场或订单列表进入详情。
onLoad((query) => { matchId.value = query.id || ''; });
// 页面停留时持续刷新十分钟占位倒计时。
onShow(() => {
refresh();
timer = setInterval(refresh, 1000);
});
// 离开页面后停止计时器。
onHide(() => { clearInterval(timer); timer = null; });
onUnload(() => { clearInterval(timer); timer = null; });
// 同步本地状态和演示时钟。
function refresh() {
state.value = getMockState();
now.value = mockNow(state.value);
}
// 将金额分转换为订单展示用的元。
function yuan(cents) { return (cents / 100).toFixed(2); }
// 按客户端本地时区展示关键时间。
function formatTime(timestamp) {
const date = new Date(timestamp);
return `${date.getMonth() + 1}月${date.getDate()}日 ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
// 执行本地演示动作,并把规则错误展示给用户。
function runAction(action, successText = '') {
try {
updateMockState((current) => { action(current); return current; });
refresh();
if (successText) uni.showToast({ title: successText, icon: 'none' });
} catch (error) { uni.showToast({ title: error.message, icon: 'none' }); refresh(); }
}
// 模拟支付成功后确认名额。
function pay() {
try {
updateMockState((current) => { payOrder(current, order.value.id); return current; });
refresh();
uni.showToast({ title: '模拟支付成功', icon: 'success' });
} catch (error) { uni.showToast({ title: error.message, icon: 'none' }); refresh(); }
}
// 确认后取消待支付或已支付订单,已支付款项演示为全额退款。
function cancel() {
uni.showModal({
title: order.value.role === 'host' ? '取消整个拼场?' : '取消报名?',
content: order.value.status === 'paid' ? '已支付费用将显示为全额退款。当前只是本地演示,不发生真实资金流转。' : '待支付名额将立即释放。',
confirmText: '确认取消',
success: (result) => {
if (!result.confirm) return;
try {
updateMockState((current) => { cancelOrder(current, order.value.id); return current; });
refresh();
} catch (error) { uni.showToast({ title: error.message, icon: 'none' }); refresh(); }
}
});
}
// 从拼场详情创建参与者的待支付订单。
function join() {
try {
updateMockState((current) => { createJoinOrder(current, matchId.value); return current; });
refresh();
} catch (error) { uni.showModal({ title: '暂无法报名', content: error.message, showCancel: false }); refresh(); }
}
// 本机发起人可补入演示球友,直接验证满员与成局。
function addGuest() {
try {
updateMockState((current) => { addDemoGuest(current, matchId.value); return current; });
refresh();
} catch (error) { uni.showToast({ title: error.message, icon: 'none' }); refresh(); }
}
// 用户在签到时段内确认到场。
function signIn() { runAction((current) => checkIn(current, order.value.id), '签到成功'); }
// 在演示签到窗口内为选中的其他球友记录到场。
function signInDemo(participant) { runAction((current) => checkInDemoParticipant(current, matchId.value, participant.id), `${participant.name}已签到`); }
// 截止后提交取消申请,等待演示运营审核。
function applyCancel() {
uni.showActionSheet({
itemList: ['个人原因', '场地方原因'],
success: ({ tapIndex }) => runAction((current) => requestCancellation(current, order.value.id, ['个人原因', '场地方原因'][tapIndex]), '申请已提交')
});
}
// 模拟平台确认具体球场或判定场地无法履约。
function venueAction(unavailable = false) {
runAction((current) => unavailable ? markVenueUnavailable(current, matchId.value) : confirmVenue(current, matchId.value), unavailable ? '已取消并模拟退款' : '场地已确认');
}
// 模拟运营审核截止后的取消申请。
function reviewRequest(approved) {
runAction((current) => reviewCancellationRequest(current, order.value.id, approved), approved ? '申请已通过' : '申请已驳回');
}
// 推进到本场签到、未签到或完赛节点,供本地演示验证。
function advanceTo(stage) {
const target = stage === 'checkin' ? match.value.startAt - CHECKIN_OPEN_MS + 1000 : stage === 'late' ? match.value.startAt + CHECKIN_CLOSE_MS + 1000 : match.value.startAt + (match.value.durationHours || parseFloat(match.value.duration)) * 60 * 60 * 1000 + 1000;
if (target <= now.value) { uni.showToast({ title: '已超过该时间节点', icon: 'none' }); return; }
runAction((current) => setDemoTime(current, target), '演示时间已推进');
}
// 提交一局比分和场地评价,等待模拟对手确认。
function saveResult() {
if (homeScore.value === '' || awayScore.value === '') { uni.showToast({ title: '请填写双方比分', icon: 'none' }); return; }
runAction((current) => submitResult(current, order.value.id, Number(homeScore.value), Number(awayScore.value), rating.value), '赛果已提交');
}
// 模拟对手确认本场赛果。
function acceptResult() { runAction((current) => confirmResult(current, matchId.value), '赛果已确认'); }
</script>
<template>
<view class="page detail-page">
<view v-if="match">
<view class="status-line"><text class="status-tag" :class="match.status">{{ statusText }}</text><text
class="muted small">{{ match.capacity === 2 ? '单打' : '双打' }} · {{ match.level }}段位</text></view>
<view class="venue-name">{{ match.venue }}</view>
<view class="date-line">{{ match.date }} {{ match.time }} · {{ match.duration }}</view>
<view class="info-grid">
<view><text>{{ match.status === 'canceled' ? '取消前报名' : '已确认' }}</text><strong>{{ match.players }}/{{
match.capacity }} 人</strong></view>
<view><text>待支付暂占</text><strong>{{ heldCount(state, match.id) }} 人</strong></view>
<view><text>报名截止</text><strong>{{ formatTime(cutoff) }}</strong></view>
</view>
<view v-if="match.status === 'canceled'" class="cancel-reason">取消原因:{{ match.cancellationReason || '拼场已取消' }}<text
v-if="match.cancellationReason === '签到人数不足,拼场取消'">。已签到用户退款,未签到用户不退款。</text></view>
<view class="section-head"><text class="section-title">场地安排</text><text class="venue-state">{{ match.venueStatus
=== 'confirmed' ? '已确认' : match.venueStatus === 'unavailable' ? '无法履约' : match.status === 'canceled' ? '拼场已取消'
: match.status === 'formed' ? '平台确认中' : '成局后确认' }}</text></view>
<view class="fulfillment-panel">
<view><text>场馆</text><strong>{{ match.venue }}</strong></view>
<view><text>地址</text><strong>{{ match.venueStatus === 'confirmed' ? `南京市${venue?.address || '场馆地址待确认'}(演示)` :
'待平台确认' }}</strong></view>
<view><text>球场</text><strong>{{ match.venueStatus === 'confirmed' ? match.courtNumber : '待分配' }}</strong></view>
<view><text>签到时间</text><strong>{{ formatTime(match.startAt - CHECKIN_OPEN_MS) }} 至 {{ formatTime(match.startAt +
CHECKIN_CLOSE_MS) }}</strong></view>
</view>
<view v-if="['formed', 'in_progress', 'completed'].includes(match.status)" class="section-head"><text
class="section-title">球友与签到</text><text class="muted small">{{ participants.length }} 人</text></view>
<view v-if="['formed', 'in_progress', 'completed'].includes(match.status)" class="participant-list">
<view v-for="participant in participants" :key="participant.id" class="participant"><text
class="participant-avatar">{{ participant.name.slice(-1) }}</text><text class="participant-name">{{
participant.name }}</text><button
v-if="participant.isDemo && !participant.attendance && ['formed', 'in_progress'].includes(match.status)"
class="outline-button demo-checkin" :disabled="!demoCheckinOpen" @tap="signInDemo(participant)">{{
demoCheckinOpen ? '模拟签到' : '签到未开放' }}</button><text v-else class="participant-state">{{
participant.attendance === 'checked_in' ? '已签到' : participant.attendance === 'no_show' ? '未签到' : '待签到'
}}</text></view>
</view>
<view class="section-head"><text class="section-title">费用明细</text></view>
<view class="fee-list">
<view><text>场地总费用</text><text>¥{{ yuan(match.totalCents) }}</text></view>
<view><text>费用收取方</text><text>拍立方平台(演示)</text></view>
<view><text>平台服务费</text><text>¥0.00</text></view>
<view><text>人均参考</text><text>约 ¥{{ match.fee }}</text></view>
<view v-if="order" class="amount"><text>我的订单实付</text><strong>¥{{ yuan(order.amountCents) }}</strong></view>
</view>
<view v-if="order" class="order-panel">
<view class="order-head"><text>我的订单</text><text class="order-status">{{ orderText }}</text></view>
<view class="muted small">{{ order.id }} · {{ order.role === 'host' ? '发起人' : '参与者' }}</view>
<view v-if="order.status === 'pending'" class="notice">名额保留约 {{ minutesLeft }} 分钟。点击下方按钮只模拟支付,不会扣款。</view>
<view v-else-if="order.status === 'refunded'" class="notice">演示退款 ¥{{ yuan(order.amountCents) }},没有真实退款交易。
</view>
<view v-else-if="order.attendance === 'checked_in'" class="notice">你已签到。本场结束后可提交比分和评价。</view>
<view v-else-if="order.attendance === 'no_show'" class="notice">签到窗口已关闭,本次记录为未签到。</view>
<view v-if="order.cancellationRequest" class="request-note">取消申请:{{ order.cancellationRequest.reason }} · {{
order.cancellationRequest.status === 'pending' ? '待审核' : order.cancellationRequest.status === 'approved' ?
'已通过' : '已驳回' }}</view>
</view>
<view v-if="match.status === 'completed'" class="section-head"><text class="section-title">完赛记录</text><text
class="muted small">双方确认后计入段位积分</text></view>
<view v-if="match.status === 'completed'" class="result-panel">
<view v-if="match.result">
<view class="result-score">{{ match.result.homeScore }} : {{ match.result.awayScore }}</view>
<view class="muted small">场地 {{ match.result.rating }} 星 · {{ match.result.status === 'confirmed' ? '双方已确认' :
'待对手确认' }}<template v-if="rankCredit"> · 段位积分 +{{ rankCredit.points }}</template>
</view>
</view>
<view v-else-if="canSubmitResult">
<view class="score-row"><input v-model="homeScore" type="number" placeholder="我方" /><text>:</text><input
v-model="awayScore" type="number" placeholder="对方" /></view>
<view class="rating-row"><text>场地评价</text>
<view v-for="star in 5" :key="star" class="rating-option" :class="{ selected: rating === star }"
@tap="rating = star">{{ star }} ★</view>
</view><button class="primary-button result-button" @tap="saveResult">提交赛果</button>
</view>
<view v-else class="muted small">已签到的球友可提交本场比分和评价。</view>
</view>
<view class="section-head"><text class="section-title">演示操作</text><text class="muted small">仅影响本机</text></view>
<view class="demo-tools">
<button v-if="match.hostId === 'self' && match.status === 'recruiting'" class="outline-button"
@tap="addGuest">模拟球友加入</button>
<button
v-if="match.status === 'formed' && match.venueStatus === 'pending' && now < match.startAt - VENUE_CONFIRM_MS"
class="outline-button" @tap="venueAction(false)">平台确认场地</button>
<button v-if="match.status === 'formed' && now < match.startAt" class="outline-button"
@tap="venueAction(true)">场地无法履约</button>
<button v-if="order?.cancellationRequest?.status === 'pending' && match.status === 'formed'"
class="outline-button" @tap="reviewRequest(true)">审核通过</button>
<button v-if="order?.cancellationRequest?.status === 'pending' && match.status === 'formed'"
class="outline-button" @tap="reviewRequest(false)">审核驳回</button>
<button
v-if="match.venueStatus === 'confirmed' && ['formed', 'in_progress'].includes(match.status) && now < match.startAt - CHECKIN_OPEN_MS"
class="outline-button" @tap="advanceTo('checkin')">至签到开放</button>
<button
v-if="match.venueStatus === 'confirmed' && ['formed', 'in_progress'].includes(match.status) && now < match.startAt + CHECKIN_CLOSE_MS"
class="outline-button" @tap="advanceTo('late')">至签到截止</button>
<button v-if="match.venueStatus === 'confirmed' && ['formed', 'in_progress'].includes(match.status)"
class="outline-button" @tap="advanceTo('end')">至比赛结束</button>
<button v-if="match.result?.status === 'submitted'" class="outline-button" @tap="acceptResult">模拟对手确认</button>
</view>
<view class="rules">满员即成局,距开场 12 小时停止报名。成局后平台需在开场前 2 小时确认场地;未确认或场地无法履约则整场取消并模拟全额退款。截止后的取消申请由演示运营审核。</view>
<view class="action-bar">
<button v-if="order?.status === 'pending'" class="primary-button action-button" @tap="pay">模拟支付 ¥{{
yuan(order.amountCents) }}</button>
<button v-else-if="canJoin" class="primary-button action-button" @tap="join">报名并保留名额</button>
<button v-else-if="canCheckIn" class="primary-button action-button" @tap="signIn">确认签到</button>
<button v-else class="outline-button action-button" disabled>{{ order?.attendance === 'checked_in' ? '已签到' :
order?.attendance === 'no_show' ? '未签到' : order?.status === 'paid' ? '已确认名额' : statusText }}</button>
<button v-if="canCancel" class="cancel-button" @tap="cancel">取消{{ order.role === 'host' ? '拼场' : '订单'
}}</button>
<button v-else-if="canRequest" class="cancel-button" @tap="applyCancel">申请取消</button>
</view>
</view>
<view v-else class="empty">拼场不存在或已被重置</view>
</view>
</template>
<style scoped>
.detail-page {
padding-bottom: 220rpx;
}
.status-line {
display: flex;
gap: 18rpx;
align-items: center;
margin: 12rpx 0 20rpx;
}
.status-tag {
color: #267b3c;
background: #def1e1;
border-radius: 7rpx;
padding: 7rpx 13rpx;
font-size: 22rpx;
}
.status-tag.canceled {
color: #8a8e8a;
background: #e9ece9;
}
.status-tag.formed {
color: #956c20;
background: #fff0d1;
}
.venue-name {
font-size: 39rpx;
font-weight: 750;
line-height: 1.35;
}
.date-line {
color: #69776c;
font-size: 26rpx;
margin-top: 15rpx;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24rpx;
margin-top: 30rpx;
padding: 26rpx;
background: #fff;
border-radius: 8rpx;
}
.info-grid view {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.info-grid text {
color: #8b978d;
font-size: 22rpx;
}
.info-grid strong {
font-size: 26rpx;
color: #2b5934;
}
.cancel-reason {
color: #9d654a;
font-size: 23rpx;
margin-top: 17rpx;
}
.venue-state {
color: #488d53;
font-size: 22rpx;
}
.fulfillment-panel {
background: #fff;
padding: 12rpx 26rpx;
border-radius: 8rpx;
}
.fulfillment-panel view {
display: flex;
justify-content: space-between;
gap: 18rpx;
padding: 17rpx 0;
border-bottom: 1rpx solid #edf1ed;
font-size: 23rpx;
}
.fulfillment-panel view:last-child {
border: 0;
}
.fulfillment-panel text {
color: #8a958b;
flex: none;
}
.fulfillment-panel strong {
text-align: right;
font-weight: 620;
line-height: 1.4;
overflow-wrap: anywhere;
}
.participant-list {
background: #fff;
padding: 0 25rpx;
border-radius: 8rpx;
}
.participant {
display: flex;
align-items: center;
gap: 15rpx;
min-height: 82rpx;
border-bottom: 1rpx solid #edf1ed;
font-size: 24rpx;
}
.participant:last-child {
border: 0;
}
.participant-avatar {
width: 43rpx;
height: 43rpx;
flex: none;
border-radius: 50%;
background: #e3f0e5;
color: #4b8f55;
display: flex;
align-items: center;
justify-content: center;
font-size: 20rpx;
}
.participant-name {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.participant-state {
margin-left: auto;
flex: none;
color: #7f9984;
font-size: 21rpx;
}
.demo-checkin {
margin: 0 0 0 auto;
flex: none;
padding: 7rpx 12rpx;
font-size: 20rpx;
line-height: 1.4;
}
.demo-checkin[disabled] {
color: #9ca79e;
background: #f0f3f0;
border-color: #e8ede8;
}
.fee-list {
background: #fff;
padding: 12rpx 26rpx;
border-radius: 8rpx;
}
.fee-list view {
display: flex;
justify-content: space-between;
gap: 16rpx;
padding: 17rpx 0;
color: #667268;
font-size: 25rpx;
}
.fee-list view text:last-child {
text-align: right;
}
.fee-list .amount {
border-top: 1rpx solid #e8eee8;
color: #243c29;
}
.amount strong {
color: #328b41;
font-size: 30rpx;
}
.order-panel {
background: #fff;
padding: 26rpx;
border-radius: 8rpx;
margin-top: 24rpx;
}
.order-head {
display: flex;
justify-content: space-between;
font-weight: 700;
margin-bottom: 12rpx;
}
.order-status {
color: #398d47;
}
.notice {
font-size: 22rpx;
color: #6b7e6d;
background: #edf7ee;
padding: 18rpx;
margin-top: 20rpx;
line-height: 1.5;
}
.request-note {
color: #a76c3f;
font-size: 22rpx;
margin-top: 15rpx;
}
.result-panel {
background: #fff;
padding: 26rpx;
border-radius: 8rpx;
}
.result-score {
color: #327f40;
font-size: 42rpx;
font-weight: 750;
margin-bottom: 10rpx;
}
.score-row {
display: flex;
align-items: center;
justify-content: center;
gap: 15rpx;
}
.score-row input {
width: 126rpx;
height: 70rpx;
text-align: center;
border: 1rpx solid #d8e8da;
border-radius: 7rpx;
}
.rating-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 9rpx;
margin: 22rpx 0;
font-size: 21rpx;
}
.rating-row>text {
width: 100%;
color: #6c766e;
}
.rating-option {
color: #8c948d;
border: 1rpx solid #e1e8e1;
border-radius: 6rpx;
padding: 9rpx;
}
.rating-option.selected {
color: #976822;
border-color: #e7c78d;
background: #fff4df;
}
.result-button {
width: 100%;
height: 74rpx;
line-height: 74rpx;
}
.demo-tools {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.demo-tools button {
margin: 0;
padding: 8rpx 16rpx;
font-size: 21rpx;
line-height: 1.5;
}
.rules {
color: #879188;
line-height: 1.6;
font-size: 21rpx;
margin-top: 28rpx;
}
.action-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 20rpx 28rpx calc(20rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -8rpx 24rpx rgba(20, 50, 24, .08);
}
.action-button {
width: 100%;
height: 82rpx;
line-height: 82rpx;
}
.cancel-button {
background: transparent;
color: #a65143;
font-size: 23rpx;
margin-top: 8rpx;
}
.cancel-button::after {
border: 0;
}
</style>

View File

@@ -0,0 +1,128 @@
<script setup>
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { getMockState } from '../../mock/store';
// 订单列表按状态筛选本机用户的演示订单。
const state = ref(getMockState());
const active = ref('all');
const tabs = [
{ id: 'all', label: '全部' }, { id: 'pending', label: '待支付' },
{ id: 'paid', label: '已支付' }, { id: 'closed', label: '已关闭' }
];
const orders = computed(() => state.value.orders.filter((order) =>
order.role !== 'guest' && (active.value === 'all' || order.status === active.value || (active.value === 'closed' && ['expired', 'canceled', 'refunded'].includes(order.status)))
));
// 返回页面时结算演示时间带来的订单状态变化。
onShow(() => { state.value = getMockState(); });
// 查找订单对应的拼场摘要。
function getMatch(order) { return state.value.matches.find((match) => match.id === order.matchId); }
// 打开拼场详情处理支付或取消。
function openOrder(order) { uni.navigateTo({ url: `/subpackages/match/detail?id=${order.matchId}` }); }
// 转换人民币分为两位小数。
function yuan(cents) { return (cents / 100).toFixed(2); }
// 将本地订单状态显示为中文。
function statusText(status) { return ({ pending: '待支付', paid: '已支付', expired: '支付超时', canceled: '已取消', refunded: '已全额退款' })[status]; }
</script>
<template>
<view class="page orders-page">
<scroll-view class="chip-row" scroll-x show-scrollbar="false">
<view v-for="tab in tabs" :key="tab.id" class="chip" :class="{ active: active === tab.id }"
@tap="active = tab.id">{{ tab.label }}</view>
</scroll-view>
<view v-if="!orders.length" class="empty">暂无{{active === 'all' ? '' : tabs.find((tab) => tab.id === active).label
}}订单</view>
<view v-for="order in orders" :key="order.id" class="order-item" @tap="openOrder(order)">
<view class="order-top"><text class="order-id">{{ order.id }}</text><text class="order-status"
:class="order.status">{{ statusText(order.status) }}</text></view>
<view class="order-venue">{{ getMatch(order)?.venue || '拼场已重置' }}</view>
<view class="order-date">{{ getMatch(order)?.date }} {{ getMatch(order)?.time }} · {{ order.role === 'host' ?
'我发起的' : '我加入的' }}{{ order.attendance === 'checked_in' ? ' · 已签到' : order.attendance === 'no_show' ? ' · 未签到' :
'' }}</view>
<view class="order-bottom"><text>{{ order.status === 'refunded' ? '演示退款' : '演示费用' }}</text><strong>¥{{
yuan(order.amountCents) }}</strong><text class="arrow">›</text></view>
</view>
<view class="note">所有订单和退款均为本机模拟,不会产生真实交易。</view>
</view>
</template>
<style scoped>
.chip-row {
margin: 8rpx 0 12rpx;
}
.order-item {
background: #fff;
border: 1rpx solid #e9eee9;
border-radius: 8rpx;
padding: 24rpx;
margin: 16rpx 0;
}
.order-top,
.order-bottom {
display: flex;
align-items: center;
justify-content: space-between;
}
.order-id {
color: #98a19a;
font-size: 20rpx;
}
.order-status {
font-size: 22rpx;
color: #3f914d;
}
.order-status.expired,
.order-status.canceled {
color: #8d958e;
}
.order-status.refunded {
color: #aa7441;
}
.order-venue {
font-weight: 720;
font-size: 29rpx;
margin-top: 22rpx;
}
.order-date {
color: #879188;
font-size: 22rpx;
margin-top: 11rpx;
}
.order-bottom {
border-top: 1rpx solid #edf0ed;
margin-top: 23rpx;
padding-top: 17rpx;
color: #879188;
font-size: 23rpx;
}
.order-bottom strong {
margin-left: auto;
color: #2c763c;
font-size: 29rpx;
}
.arrow {
margin-left: 12rpx;
color: #a7aea7;
font-size: 35rpx;
}
.note {
text-align: center;
color: #a5aea6;
font-size: 20rpx;
padding: 30rpx 0;
}
</style>

View File

@@ -0,0 +1,344 @@
<script setup>
import { computed, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { getVenueSlots, venues } from '../../mock/data';
import { getMockState, updateMockState } from '../../mock/store';
import { CUTOFF_MS, mockNow } from '../../mock/engine';
// 当前场地标识与用户选择的演示时段。
const venueId = ref('');
const selectedSlotId = ref('');
const matches = ref([]);
const demoTime = ref(Date.now());
// 场地与剩余时段从同一份 mock 数据生成。
const venue = computed(() => venues.find((item) => item.id === venueId.value));
const slots = computed(() => getVenueSlots(venueId.value, matches.value, demoTime.value));
const selectedSlot = computed(() => slots.value.find((slot) => slot.id === selectedSlotId.value));
// 根据首页或地图传入的 ID 定位场地。
onLoad((query) => { venueId.value = query.id || ''; });
// 返回详情页时同步本地占用片数。
onShow(() => {
const state = getMockState();
matches.value = state.matches;
demoTime.value = mockNow(state);
if (!slots.value.some((slot) => slot.id === selectedSlotId.value && canCreateSlot(slot))) {
selectedSlotId.value = slots.value.find(canCreateSlot)?.id || '';
}
});
// 距开场不足 12 小时的时段不再用于发起拼场。
function canCreateSlot(slot) {
return slot.remaining > 0 && new Date(`${slot.date}T${slot.time}:00`).getTime() - demoTime.value > CUTOFF_MS;
}
// 跳转至地图并选中当前场地。
function showMap() { uni.navigateTo({ url: `/subpackages/venue/map?id=${venueId.value}` }); }
// 将选中的场地时段交给约球页表单。
function startMatch() {
if (!selectedSlot.value || !canCreateSlot(selectedSlot.value)) return;
updateMockState((state) => ({ ...state, pendingCreate: { venueId: venueId.value, slotId: selectedSlotId.value } }));
uni.switchTab({ url: '/pages/match/match' });
}
</script>
<template>
<view v-if="venue" class="page detail-page">
<view class="court-art" :class="venue.color">
<view class="court-lines">
<view class="court-net"></view>
</view>
<view class="art-label">场地示意 · {{ venue.courts }} 片球场</view>
</view>
<view class="venue-heading">
<view>
<view class="venue-name">{{ venue.name }}</view>
<view class="venue-area">⌖ 南京 · {{ venue.district }} · 距你 {{ venue.distance }}</view>
</view>
<view class="venue-rating">★ {{ venue.rating }}</view>
</view>
<view class="price-strip">
<view><text class="price-number">¥{{ venue.price }}</text><text class="muted small"> / 小时起</text></view><text
class="price-note">{{ venue.note }}</text>
</view>
<view class="section-head"><text class="section-title">场地信息</text><text class="map-link" @tap="showMap">位置示意
›</text></view>
<view class="info-grid card">
<view><text class="info-label">场地类型</text><text>{{ venue.surface }}</text></view>
<view><text class="info-label">球场数量</text><text>{{ venue.courts }} 片</text></view>
<view><text class="info-label">所在区域</text><text>南京 · {{ venue.district }}</text></view>
<view><text class="info-label">参考距离</text><text>{{ venue.distance }}</text></view>
</view>
<view class="feature-row">
<view v-for="feature in venue.features" :key="feature" class="feature">✓ {{ feature }}</view>
</view>
<view class="section-head"><text class="section-title">选择时段</text><text class="muted small">未来 3 天 · 演示库存</text>
</view>
<view class="slot-list">
<view v-for="slot in slots" :key="slot.id" class="slot-card card"
:class="{ selected: selectedSlotId === slot.id, unavailable: !canCreateSlot(slot) }"
@tap="canCreateSlot(slot) && (selectedSlotId = slot.id)">
<view>
<view class="slot-time">{{ slot.label }} {{ slot.time }}</view>
<view class="slot-date">{{ slot.date }} · {{ slot.duration }} 小时 · {{ canCreateSlot(slot) ? `余
${slot.remaining} 片` : slot.remaining ? '报名时间不足' : '已满' }}</view>
</view>
<view class="slot-price">¥{{ slot.totalPrice }}<text> / 场</text></view>
</view>
</view>
<view class="booking-bar">
<view><text class="muted small">所选时段参考场地费</text>
<view class="booking-price">¥{{ selectedSlot?.totalPrice || 0 }}</view>
</view><button class="primary-button booking-button" :disabled="!selectedSlot" @tap="startMatch">以此时段发起拼场</button>
</view>
</view>
<view v-else class="page">
<view class="empty">没有找到这家场地</view>
</view>
</template>
<style scoped>
.detail-page {
padding-bottom: 170rpx;
}
.court-art {
height: 340rpx;
position: relative;
overflow: hidden;
border-radius: 18rpx;
display: flex;
justify-content: center;
align-items: center;
}
.court-art.green {
background: #448d52;
}
.court-art.mint {
background: #59a878;
}
.court-art.deep {
background: #327746;
}
.court-art.gold {
background: #d6a345;
}
.court-lines {
position: relative;
width: 59%;
height: 64%;
border: 5rpx solid rgba(255, 255, 255, .82);
box-sizing: border-box;
}
.court-lines::before {
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 0;
border-left: 4rpx solid rgba(255, 255, 255, .82);
}
.court-lines::after {
content: '';
position: absolute;
left: 0;
right: 0;
top: 29%;
border-top: 4rpx solid rgba(255, 255, 255, .82);
}
.court-net {
position: absolute;
left: -9%;
right: -9%;
top: 50%;
border-top: 6rpx dashed rgba(255, 255, 255, .95);
z-index: 1;
}
.art-label {
position: absolute;
left: 22rpx;
bottom: 18rpx;
background: rgba(12, 61, 31, .42);
color: #fff;
border-radius: 8rpx;
padding: 7rpx 12rpx;
font-size: 20rpx;
}
.venue-heading {
display: flex;
justify-content: space-between;
gap: 16rpx;
align-items: start;
margin-top: 29rpx;
}
.venue-name {
font-size: 37rpx;
font-weight: 740;
}
.venue-area {
color: #89968c;
font-size: 23rpx;
margin-top: 13rpx;
}
.venue-rating {
color: #bd8a2a;
font-size: 24rpx;
white-space: nowrap;
padding-top: 9rpx;
}
.price-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 13rpx;
margin-top: 27rpx;
padding: 21rpx 25rpx;
background: #e8f4e9;
border-radius: 13rpx;
}
.price-number {
font-size: 37rpx;
font-weight: 750;
color: #328843;
}
.price-note {
color: #618c67;
font-size: 20rpx;
text-align: right;
max-width: 51%;
}
.map-link {
color: #4e9a58;
font-size: 23rpx;
padding: 9rpx 0;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 23rpx 16rpx;
padding: 28rpx;
font-size: 24rpx;
}
.info-grid>view {
display: flex;
flex-direction: column;
gap: 9rpx;
}
.info-label {
color: #9ba69d;
font-size: 20rpx;
}
.feature-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 18rpx;
}
.feature {
color: #548c5c;
background: #e7f2e9;
border-radius: 9rpx;
padding: 9rpx 14rpx;
font-size: 21rpx;
}
.slot-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 22rpx 25rpx;
margin: 13rpx 0;
border: 2rpx solid transparent;
}
.slot-card.selected {
border-color: #58a661;
background: #f8fcf8;
}
.slot-card.unavailable {
opacity: .48;
}
.slot-time {
font-weight: 690;
font-size: 27rpx;
}
.slot-date {
color: #a0aaa2;
font-size: 20rpx;
margin-top: 9rpx;
}
.slot-price {
color: #368d44;
font-size: 30rpx;
font-weight: 730;
white-space: nowrap;
}
.slot-price text {
color: #a1aaa2;
font-size: 20rpx;
font-weight: 400;
}
.booking-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 5;
padding: 20rpx 28rpx calc(20rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -8rpx 25rpx rgba(25, 65, 35, .07);
display: flex;
justify-content: space-between;
align-items: center;
}
.booking-price {
color: #368d44;
font-size: 36rpx;
font-weight: 750;
}
.booking-button {
margin: 0;
width: 350rpx;
height: 76rpx;
line-height: 76rpx;
font-size: 25rpx;
}
.booking-button[disabled] {
opacity: .5;
}
</style>

View File

@@ -0,0 +1,250 @@
<script setup>
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { venues } from '../../mock/data';
// 地图页价格带筛选项。
const bands = [
{ value: 'all', label: '全部' }, { value: 'budget', label: '普惠' },
{ value: 'main', label: '主流' }, { value: 'premium', label: '高端' }
];
// 当前筛选和已选场地。
const activeBand = ref('all');
const selectedId = ref(venues[0].id);
// 当前应显示的场地标记和底部场地摘要。
const filteredVenues = computed(() => venues.filter((venue) => activeBand.value === 'all' || venue.band === activeBand.value));
const selectedVenue = computed(() => venues.find((venue) => venue.id === selectedId.value));
// 从场地详情打开地图时保留所选场地。
onLoad((query) => { if (venues.some((venue) => venue.id === query.id)) selectedId.value = query.id; });
// 切换价格带后选择结果中的第一家场地。
function selectBand(value) {
activeBand.value = value;
if (!filteredVenues.value.some((venue) => venue.id === selectedId.value)) selectedId.value = filteredVenues.value[0].id;
}
// 打开地图中所选场地的详情。
function openDetail() { uni.navigateTo({ url: `/subpackages/venue/detail?id=${selectedId.value}` }); }
</script>
<template>
<view class="page map-page">
<view class="map-location muted small">南京 · 区域位置示意</view>
<scroll-view class="chip-row" scroll-x show-scrollbar="false">
<view v-for="band in bands" :key="band.value" class="chip" :class="{ active: activeBand === band.value }"
@tap="selectBand(band.value)">{{ band.label }}</view>
</scroll-view>
<view class="mock-map">
<view class="district-name jiangye">建邺区</view>
<view class="district-name yuhuatai">雨花台区</view>
<view class="road road-a"></view>
<view class="road road-b"></view>
<view class="road road-c"></view>
<view class="road road-d"></view>
<view class="map-river"></view>
<view v-for="venue in filteredVenues" :key="venue.id" class="map-pin"
:class="{ selected: selectedId === venue.id }" :style="{ left: `${venue.mapX}%`, top: `${venue.mapY}%` }"
@tap="selectedId = venue.id"><text>¥{{ venue.price }}</text></view>
<view class="map-self">你在附近</view>
</view>
<view class="map-caption">场地位置为交互示意,非导航坐标</view>
<view v-if="selectedVenue" class="selected-venue card">
<view class="selected-head">
<view>
<view class="selected-name">{{ selectedVenue.name }}</view>
<view class="muted small">{{ selectedVenue.district }} · {{ selectedVenue.distance }} · {{
selectedVenue.surface }}</view>
</view><text class="rating">★ {{ selectedVenue.rating }}</text>
</view>
<view class="selected-bottom">
<view><text class="selected-price">¥{{ selectedVenue.price }}</text><text class="muted small"> / 小时起</text>
</view><button class="primary-button detail-button" @tap="openDetail">查看场地</button>
</view>
</view>
<view class="section-head"><text class="section-title">地图中的场地</text><text class="muted small">{{
filteredVenues.length }} 家</text></view>
<view v-for="venue in filteredVenues" :key="venue.id" class="map-list-row" @tap="selectedId = venue.id"><text>{{
venue.name }}</text><text class="muted small">{{ venue.distance }} ›</text></view>
</view>
</template>
<style scoped>
.map-page {
padding-bottom: 50rpx;
}
.map-location {
margin-bottom: 18rpx;
}
.mock-map {
position: relative;
height: 650rpx;
margin-top: 20rpx;
border: 1rpx solid #dfe9df;
border-radius: 15rpx;
overflow: hidden;
background: #e9f0e9;
}
.road {
position: absolute;
background: #fff;
box-shadow: 0 0 0 1rpx #e4e9e3;
}
.road-a {
width: 120%;
height: 25rpx;
left: -10%;
top: 27%;
transform: rotate(-12deg);
}
.road-b {
width: 125%;
height: 36rpx;
left: -10%;
top: 61%;
transform: rotate(16deg);
}
.road-c {
width: 23rpx;
height: 120%;
top: -10%;
left: 31%;
transform: rotate(17deg);
}
.road-d {
width: 26rpx;
height: 120%;
top: -10%;
right: 28%;
transform: rotate(-19deg);
}
.map-river {
position: absolute;
width: 53rpx;
height: 120%;
left: 45%;
top: -10%;
transform: rotate(20deg);
background: #c9e5e5;
opacity: .8;
}
.district-name {
position: absolute;
z-index: 1;
color: #a1aea4;
font-size: 25rpx;
font-weight: 650;
}
.jiangye {
top: 11%;
left: 12%;
}
.yuhuatai {
bottom: 13%;
right: 11%;
}
.map-pin {
position: absolute;
z-index: 2;
transform: translate(-50%, -50%);
padding: 10rpx 16rpx;
border-radius: 25rpx;
background: #fff;
box-shadow: 0 5rpx 18rpx rgba(27, 70, 36, .2);
color: #347b40;
font-size: 22rpx;
font-weight: 700;
white-space: nowrap;
}
.map-pin.selected {
color: #fff;
background: #3e9550;
transform: translate(-50%, -50%) scale(1.12);
}
.map-self {
position: absolute;
z-index: 2;
left: 49%;
top: 72%;
color: #687c9a;
background: #e2eaf5;
border: 2rpx solid #fff;
border-radius: 20rpx;
padding: 5rpx 12rpx;
font-size: 18rpx;
}
.map-caption {
color: #a0aaa2;
font-size: 20rpx;
margin-top: 11rpx;
}
.selected-venue {
padding: 25rpx;
margin-top: 26rpx;
}
.selected-head {
display: flex;
justify-content: space-between;
gap: 14rpx;
}
.selected-name {
font-size: 28rpx;
font-weight: 710;
margin-bottom: 10rpx;
}
.rating {
color: #bd8b31;
font-size: 22rpx;
white-space: nowrap;
}
.selected-bottom {
display: flex;
justify-content: space-between;
align-items: end;
margin-top: 22rpx;
}
.selected-price {
color: #398d47;
font-size: 34rpx;
font-weight: 760;
}
.detail-button {
margin: 0;
width: 177rpx;
height: 65rpx;
line-height: 65rpx;
font-size: 22rpx;
}
.map-list-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 5rpx;
border-bottom: 1rpx solid #e5eae5;
font-size: 24rpx;
}
</style>

364
tests/mock-engine.test.js Normal file
View File

@@ -0,0 +1,364 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { claimWeeklyTask, createInitialState, createHostOrder, createJoinOrder, payOrder, cancelOrder, addDemoGuest, checkIn, checkInDemoParticipant, confirmResult, confirmVenue, getCompetitionProgress, getWeeklyTasks, markVenueUnavailable, reconcileState, requestCancellation, reviewCancellationRequest, seedParticipantCount, setDemoTime, submitResult, upgradeState, mockNow, CUTOFF_MS, CHECKIN_OPEN_MS, CHECKIN_CLOSE_MS, VENUE_CONFIRM_MS } from '../src/mock/engine.js';
import { getDateAfter, getVenueSlots } from '../src/mock/data.js';
// 发起人未支付时只暂占场地,支付后才公开招募。
test('host payment and cancellation control venue inventory', () => {
const state = createInitialState();
const slot = getVenueSlots('lining', state.matches).find((item) => item.remaining > 0);
const before = slot.remaining;
const order = createHostOrder(state, { venueId: 'lining', slotId: slot.id, level: '中级', capacity: 2 });
const match = state.matches.find((item) => item.id === order.matchId);
assert.equal(match.status, 'draft');
assert.equal(match.players, 0);
assert.equal(getVenueSlots('lining', state.matches).find((item) => item.id === slot.id).remaining, before - 1);
payOrder(state, order.id);
assert.equal(match.status, 'recruiting');
assert.equal(match.players, 1);
addDemoGuest(state, match.id);
assert.equal(match.status, 'formed');
cancelOrder(state, order.id);
assert.equal(match.status, 'canceled');
assert.equal(order.status, 'refunded');
assert.equal(getVenueSlots('lining', state.matches).find((item) => item.id === slot.id).remaining, before);
});
// 参与者暂占名额不可重复下单,付款后取消会全额退款并释放座位。
test('join hold, payment, and refund', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm1');
const order = createJoinOrder(state, match.id);
assert.throws(() => createJoinOrder(state, match.id), /已有/);
assert.equal(match.players, 3);
payOrder(state, order.id);
assert.equal(match.players, 4);
assert.equal(match.status, 'formed');
cancelOrder(state, order.id);
assert.equal(order.status, 'refunded');
assert.equal(match.players, 3);
assert.equal(match.status, 'recruiting');
});
// 支付超时和报名截止都由统一的演示时钟结算。
test('expiry and cutoff settle orders', () => {
const state = createInitialState();
const expired = createJoinOrder(state, 'm1');
state.nowOffsetMs += 11 * 60 * 1000;
reconcileState(state);
assert.equal(expired.status, 'expired');
assert.equal(state.matches.find((item) => item.id === 'm1').players, 3);
const paid = createJoinOrder(state, 'm4');
payOrder(state, paid.id);
const match = state.matches.find((item) => item.id === 'm4');
state.nowOffsetMs += match.startAt - CUTOFF_MS - mockNow(state) + 1000;
reconcileState(state);
assert.equal(match.status, 'canceled');
assert.equal(paid.status, 'refunded');
assert.throws(() => createJoinOrder(state, match.id), /停止报名/);
});
// 已满员拼场跨过报名截止仍保持成局,待支付的发起人超时则释放场地。
test('formed match survives cutoff while expired host releases venue', () => {
const state = createInitialState();
const formed = state.matches.find((item) => item.id === 'm3');
const slot = getVenueSlots('lining', state.matches).find((item) => item.remaining > 0);
const before = slot.remaining;
const hostOrder = createHostOrder(state, { venueId: 'lining', slotId: slot.id, level: '中级', capacity: 2 });
state.nowOffsetMs += 11 * 60 * 1000;
reconcileState(state);
assert.equal(hostOrder.status, 'expired');
assert.equal(getVenueSlots('lining', state.matches).find((item) => item.id === slot.id).remaining, before);
state.nowOffsetMs += formed.startAt - CUTOFF_MS - mockNow(state) + 1000;
reconcileState(state);
assert.equal(formed.status, 'formed');
});
// 场地未按时确认或场地方无法履约时,整场取消并全额退款。
test('venue confirmation deadline and unavailability refund paid orders', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm1');
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
state.nowOffsetMs += match.startAt - VENUE_CONFIRM_MS - mockNow(state) + 1000;
reconcileState(state);
assert.equal(match.status, 'canceled');
assert.equal(order.status, 'refunded');
const second = createInitialState();
const formed = second.matches.find((item) => item.id === 'm3');
confirmVenue(second, formed.id);
assert.equal(formed.venueStatus, 'confirmed');
markVenueUnavailable(second, formed.id);
assert.equal(formed.status, 'canceled');
assert.equal(formed.venueStatus, 'unavailable');
});
// 签到仅在窗口内有效;赛果确认后只为本人结算一次段位积分。
test('check-in, completion, and result confirmation', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm1');
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
confirmVenue(state, match.id);
assert.throws(() => checkIn(state, order.id), /签到时段/);
state.nowOffsetMs += match.startAt - CHECKIN_OPEN_MS - mockNow(state) + 1000;
checkIn(state, order.id);
match.demoAttendance = { 0: 'checked_in', 1: 'checked_in', 2: 'checked_in' };
assert.equal(order.attendance, 'checked_in');
state.nowOffsetMs += match.startAt + 2 * 60 * 60 * 1000 - mockNow(state) + 1000;
reconcileState(state);
assert.equal(match.status, 'completed');
submitResult(state, order.id, 11, 9, 5);
assert.equal(match.result.status, 'submitted');
confirmResult(state, match.id);
assert.equal(match.result.status, 'confirmed');
assert.equal(state.rankPoints, 160);
assert.equal(state.rankHistory[0].eventId, `match-${match.id}`);
assert.throws(() => confirmResult(state, match.id), /没有待确认/);
assert.equal(state.rankPoints, 160);
assert.equal(state.assessment, null);
});
// 本人签到后的落败赛果只结算 80 分完赛积分。
test('loss earns participation points only for a checked-in player', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm1');
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
confirmVenue(state, match.id);
state.nowOffsetMs += match.startAt - CHECKIN_OPEN_MS - mockNow(state) + 1000;
checkIn(state, order.id);
match.demoAttendance = { 0: 'checked_in', 1: 'checked_in', 2: 'checked_in' };
state.nowOffsetMs += match.startAt + 2 * 60 * 60 * 1000 - mockNow(state) + 1000;
submitResult(state, order.id, 9, 11, 4);
confirmResult(state, match.id);
assert.equal(state.rankPoints, 80);
assert.equal(state.rankHistory.length, 1);
});
// 签到截止后未操作的已付款订单标记未签到,不能补签。
test('missed check-in becomes no-show', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm1');
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
confirmVenue(state, match.id);
state.nowOffsetMs += match.startAt + CHECKIN_CLOSE_MS - mockNow(state) + 1000;
reconcileState(state);
assert.equal(order.attendance, 'no_show');
assert.throws(() => checkIn(state, order.id), /签到时段/);
});
// 签到人数不足时整场取消;已签到用户退款,未签到用户不退款且不能提交赛果。
test('insufficient attendance cancels match with selective refunds', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm1');
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
confirmVenue(state, match.id);
state.nowOffsetMs += match.startAt - CHECKIN_OPEN_MS - mockNow(state) + 1000;
checkIn(state, order.id);
state.nowOffsetMs += match.startAt + CHECKIN_CLOSE_MS - mockNow(state) + 1000;
reconcileState(state);
assert.equal(match.status, 'canceled');
assert.equal(match.cancellationReason, '签到人数不足,拼场取消');
assert.equal(order.status, 'refunded');
const noShowState = createInitialState();
const noShowMatch = noShowState.matches.find((item) => item.id === 'm1');
const noShowOrder = createJoinOrder(noShowState, noShowMatch.id);
payOrder(noShowState, noShowOrder.id);
confirmVenue(noShowState, noShowMatch.id);
noShowState.nowOffsetMs += noShowMatch.startAt + CHECKIN_CLOSE_MS - mockNow(noShowState) + 1000;
reconcileState(noShowState);
assert.equal(noShowOrder.attendance, 'no_show');
assert.equal(noShowOrder.status, 'paid');
assert.equal(noShowOrder.forfeitedAt > 0, true);
});
// 截止后的取消申请由运营审核,驳回保留名额,通过取消整场并退款。
test('late cancellation request needs review', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm1');
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
confirmVenue(state, match.id);
state.nowOffsetMs += match.startAt - CUTOFF_MS - mockNow(state) + 1000;
assert.throws(() => cancelOrder(state, order.id), /无法主动取消/);
requestCancellation(state, order.id, '个人原因');
reviewCancellationRequest(state, order.id, false);
assert.equal(order.status, 'paid');
assert.equal(order.cancellationRequest.status, 'rejected');
requestCancellation(state, order.id, '场地方原因');
reviewCancellationRequest(state, order.id, true);
assert.equal(match.status, 'canceled');
assert.equal(order.status, 'refunded');
});
// 上一阶段的本地订单、人数和用户资料在升级后保持不变。
test('v2 data upgrades without losing orders or profile', () => {
const state = createInitialState();
const order = createJoinOrder(state, 'm1');
payOrder(state, order.id);
state.profileName = '测试球友';
state.version = 2;
delete state.matches[0].venueStatus;
delete state.matches[0].durationHours;
delete order.attendance;
assert.equal(upgradeState(state), true);
assert.equal(state.version, 5);
assert.equal(state.profileName, '测试球友');
assert.equal(order.status, 'paid');
assert.equal(state.matches[0].players, 4);
assert.equal(state.matches[0].venueStatus, 'pending');
assert.equal(state.rankPoints, 0);
assert.equal(upgradeState(state), false);
});
// 积分门槛与自评匹配段位分别计算,最高档显示满进度。
test('competition rank follows points independently from matching rank', () => {
const state = createInitialState();
state.assessment = { level: '入门', total: 5, completedAt: Date.now() };
assert.deepEqual([getCompetitionProgress(0).current.name, getCompetitionProgress(400).current.name, getCompetitionProgress(800).current.name], ['入门', '初级', '中级']);
assert.equal(getCompetitionProgress(1280).percent, 60);
assert.equal(state.assessment.level, '入门');
state.rankPoints = 3200;
assert.equal(getCompetitionProgress(state.rankPoints).current.name, '精锐');
assert.equal(getCompetitionProgress(state.rankPoints).percent, 100);
assert.equal(getCompetitionProgress(state.rankPoints).next, null);
});
// 旧版本地状态升级时保留档案和订单,清除旧的虚拟初始积分。
test('v3 data upgrades without fabricated rank points', () => {
const state = createInitialState();
state.version = 3;
state.profileName = '旧用户';
state.assessment = { level: '高级', total: 25, completedAt: 123 };
delete state.rankPoints;
delete state.rankHistory;
assert.equal(upgradeState(state), true);
assert.equal(state.version, 5);
assert.equal(state.profileName, '旧用户');
assert.equal(state.assessment.level, '高级');
assert.equal(state.rankHistory.length, 0);
assert.equal(upgradeState(state), false);
assert.equal(state.rankHistory.length, 0);
});
// 已确认的胜场解锁本周任务,奖励领取后不能重复加分。
test('weekly win task can be claimed once', () => {
const state = createInitialState();
const match = state.matches[0];
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
order.attendance = 'checked_in';
match.status = 'completed';
match.startAt = Date.now();
match.result = { homeScore: 11, awayScore: 9, rating: 5, status: 'confirmed', confirmedAt: Date.now() };
assert.equal(getWeeklyTasks(state).find((task) => task.id === 'wins').progress, 1);
assert.equal(claimWeeklyTask(state, 'wins'), 60);
assert.equal(state.rankPoints, 60);
assert.equal(getWeeklyTasks(state).find((task) => task.id === 'wins').claimed, true);
assert.throws(() => claimWeeklyTask(state, 'wins'), /已领取/);
assert.equal(state.rankHistory.length, 1);
});
// 升级清除旧的一键赛事和相关奖励,同时补记此前已确认的真实赛果。
test('v4 migration keeps confirmed results but removes one-click event points', () => {
const state = createInitialState();
const match = state.matches[0];
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
order.attendance = 'checked_in';
match.status = 'completed';
match.result = { homeScore: 9, awayScore: 11, status: 'confirmed', confirmedAt: Date.now() };
state.version = 4;
state.rankPoints = 1420;
state.rankHistory = [{ eventId: 'rank-event-1', points: 80, total: 1360, settledAt: Date.now() }];
state.weeklyClaims = [{ taskId: 'competition', weekStart: 0, points: 60, claimedAt: Date.now() }];
assert.equal(upgradeState(state), true);
assert.equal(state.rankPoints, 80);
assert.equal(state.rankHistory[0].eventId, `match-${match.id}`);
assert.equal(state.weeklyClaims.length, 0);
assert.equal(order.status, 'paid');
});
// 时间选择后的时段按演示日期生成;回拨不清空订单或重复生成种子场。
test('selected demo time drives venue slots and preserves existing orders', () => {
const state = createInitialState();
state.assessment = { level: '中级', total: 20, completedAt: Date.now() };
const target = Date.now() + 15 * 24 * 60 * 60 * 1000;
setDemoTime(state, target);
const slots = getVenueSlots('lining', state.matches, mockNow(state));
assert.equal(slots[0].date, getDateAfter(1, target));
const order = createHostOrder(state, { venueId: 'lining', slotId: slots[0].id, level: '中级', capacity: 2 });
const seedCount = state.matches.filter((match) => match.hostId === 'seed').length;
setDemoTime(state, target - 7 * 24 * 60 * 60 * 1000);
assert.equal(state.orders.find((item) => item.id === order.id), order);
assert.equal(state.assessment.level, '中级');
assert.equal(state.matches.filter((match) => match.hostId === 'seed').length, seedCount);
assert.equal(getVenueSlots('lining', state.matches, mockNow(state))[0].date, getDateAfter(1, target - 7 * 24 * 60 * 60 * 1000));
});
// 指定演示周内的场次完赛可完成周任务,跨周与回拨时按所选周重新统计。
test('weekly task follows the selected demo week', () => {
const state = createInitialState();
const today = new Date();
const daysUntilMonday = (8 - today.getDay()) % 7 || 7;
const monday = new Date(today.getFullYear(), today.getMonth(), today.getDate() + daysUntilMonday, 6);
setDemoTime(state, monday.getTime());
const slot = getVenueSlots('lining', state.matches, mockNow(state))[0];
const order = createHostOrder(state, { venueId: 'lining', slotId: slot.id, level: '中级', capacity: 2 });
payOrder(state, order.id);
addDemoGuest(state, order.matchId);
const guest = state.orders.find((item) => item.matchId === order.matchId && item.role === 'guest');
confirmVenue(state, order.matchId);
const match = state.matches.find((item) => item.id === order.matchId);
setDemoTime(state, match.startAt - CHECKIN_OPEN_MS + 1000);
checkIn(state, order.id);
checkInDemoParticipant(state, order.matchId, guest.id);
setDemoTime(state, match.startAt + 2 * 60 * 60 * 1000);
assert.equal(getWeeklyTasks(state).find((task) => task.id === 'matches').progress, 1);
setDemoTime(state, monday.getTime() + 7 * 24 * 60 * 60 * 1000);
assert.equal(getWeeklyTasks(state).find((task) => task.id === 'matches').progress, 0);
setDemoTime(state, match.startAt + 3 * 60 * 60 * 1000);
assert.equal(match.status, 'completed');
assert.equal(getWeeklyTasks(state).find((task) => task.id === 'matches').progress, 1);
});
// 种子球友可逐个模拟签到,未操作的人在窗口关闭后记录未签到。
test('seed participants keep individual attendance', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm3');
confirmVenue(state, match.id);
assert.equal(seedParticipantCount(state, match), 4);
assert.throws(() => checkInDemoParticipant(state, match.id, 'seed-0'), /签到时段/);
state.nowOffsetMs += match.startAt - CHECKIN_OPEN_MS - mockNow(state) + 1000;
checkInDemoParticipant(state, match.id, 'seed-0');
assert.equal(match.demoAttendance[0], 'checked_in');
assert.throws(() => checkInDemoParticipant(state, match.id, 'seed-0'), /已完成/);
assert.throws(() => checkInDemoParticipant(state, match.id, 'seed-4'), /没有找到/);
state.nowOffsetMs += match.startAt + CHECKIN_CLOSE_MS - mockNow(state) + 1000;
reconcileState(state);
assert.equal(match.demoAttendance[0], 'checked_in');
assert.equal(match.demoAttendance[1], 'no_show');
assert.throws(() => checkInDemoParticipant(state, match.id, 'seed-1'), /签到时段/);
});
// 后加入的演示球友有独立订单,可签到但不能借此给当前用户签到。
test('added demo guest can check in separately', () => {
const state = createInitialState();
const slot = getVenueSlots('lining', state.matches).find((item) => item.remaining > 0);
const host = createHostOrder(state, { venueId: 'lining', slotId: slot.id, level: '中级', capacity: 2 });
payOrder(state, host.id);
addDemoGuest(state, host.matchId);
const match = state.matches.find((item) => item.id === host.matchId);
const guest = state.orders.find((item) => item.matchId === match.id && item.role === 'guest');
confirmVenue(state, match.id);
state.nowOffsetMs += match.startAt - CHECKIN_OPEN_MS - mockNow(state) + 1000;
checkInDemoParticipant(state, match.id, guest.id);
assert.equal(guest.attendance, 'checked_in');
assert.equal(host.attendance, undefined);
assert.throws(() => checkInDemoParticipant(state, match.id, host.id), /没有找到/);
});

29
vite.config.js Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig, loadEnv } from 'vite';
import vueJsx from '@vitejs/plugin-vue-jsx';
import uniPackage from '@dcloudio/vite-plugin-uni';
// 当前 uni-app 编译包在 ESM 环境下会包一层默认导出。
const uni = uniPackage.default || uniPackage;
// H5 和微信小程序共用构建配置,开发代理仅在配置目标时启用。
export default defineConfig(({ mode }) => {
// 代理目标由本地环境指定,避免把 mock 项目连接到示例中的服务。
const env = loadEnv(mode, process.cwd(), 'VITE_');
const proxyTarget = env.VITE_API_PROXY_TARGET;
return {
base: './',
plugins: [uni(), vueJsx()],
server: {
host: '0.0.0.0',
port: 9876,
proxy: proxyTarget ? {
'/api': {
target: proxyTarget,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api(?=\/|$)/, '')
}
} : undefined
}
};
});