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

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>