- 一张榜两个开关:本班 / 本年级 / 全服 × 这周 / 这学期 / 全部,默认本班 · 这学期。 原来最大那张全服 Top100 里,这学期在用的 25 级学生一个都进不去 - 做对一样多的不再按「提交少的在前」排,改成先做到的在前;名次旁边写比周一升降了几名 - 「你」那张卡:第几名、再做对几道上领奖台,要追的人早你几秒、追你的人晚你几秒 - 本年级 / 全服只列前面一段和你附近一段,中间折起来,点开再看 - 班级对抗按人均做对比,另有我和对手的名次走势、这周本班、每周冠军 - 老师不上榜,选班看;每行「⋯」能把怀疑抄代码的学生设成不计入排名(新增 user.rank_hidden_at, 首页周榜同样不算他),随时能恢复 - 去掉全服 Top10、日活两张图、在线人数和班级详情弹框里的统计词,以及它们背后的旧接口 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0cee4ef779
commit
1c6e2b9d82
31 files changed
+6633
-1392
No files matched your search
@@ -1,106 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Bar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
} from "chart.js"
|
||||
import { ChartType } from "utils/constants"
|
||||
import type { Rank } from "utils/types"
|
||||
|
||||
// 仅注册柱状图所需的 Chart.js 组件
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, Colors)
|
||||
|
||||
const props = defineProps<{ rankData: Rank[]; type: ChartType }>()
|
||||
|
||||
const data = computed(() => {
|
||||
const labels = props.rankData.map((rank) => rank.user.username)
|
||||
const datasets: any[] = [
|
||||
{
|
||||
label: props.type === ChartType.Rank ? "已解决" : "做题数",
|
||||
data: props.rankData.map((rank) => rank.acceptedNumber),
|
||||
backgroundColor: [
|
||||
"rgba(255, 99, 132, 0.2)",
|
||||
"rgba(255, 159, 64, 0.2)",
|
||||
"rgba(55, 66, 250, 0.2)",
|
||||
"rgba(75, 192, 192, 0.2)",
|
||||
"rgba(54, 162, 235, 0.2)",
|
||||
"rgba(153, 102, 255, 0.2)",
|
||||
"rgba(48, 51, 107, 0.2)",
|
||||
"rgba(249, 202, 36, 0.2)",
|
||||
"rgba(106, 176, 76, 0.2)",
|
||||
"rgba(119, 139, 235, 0.2)",
|
||||
],
|
||||
borderColor: [
|
||||
"rgba(255, 99, 132, 0.6)",
|
||||
"rgba(255, 159, 64, 0.6)",
|
||||
"rgba(55, 66, 250, 0.6)",
|
||||
"rgba(75, 192, 192, 0.6)",
|
||||
"rgba(54, 162, 235, 0.6)",
|
||||
"rgba(153, 102, 255, 0.6)",
|
||||
"rgba(48, 51, 107, 0.6)",
|
||||
"rgba(249, 202, 36, 0.6)",
|
||||
"rgba(106, 176, 76, 0.6)",
|
||||
"rgba(119, 139, 235, 0.6)",
|
||||
],
|
||||
hoverBackgroundColor: [
|
||||
"rgba(255, 99, 132, 0.8)",
|
||||
"rgba(255, 159, 64, 0.8)",
|
||||
"rgba(55, 66, 250, 0.8)",
|
||||
"rgba(75, 192, 192, 0.8)",
|
||||
"rgba(54, 162, 235, 0.8)",
|
||||
"rgba(153, 102, 255, 0.8)",
|
||||
"rgba(48, 51, 107, 0.8)",
|
||||
"rgba(249, 202, 36, 0.8)",
|
||||
"rgba(106, 176, 76, 0.8)",
|
||||
"rgba(119, 139, 235, 0.8)",
|
||||
],
|
||||
hoverBorderColor: [
|
||||
"rgba(255, 99, 132, 1)",
|
||||
"rgba(255, 159, 64, 1)",
|
||||
"rgba(55, 66, 250, 1)",
|
||||
"rgba(75, 192, 192, 1)",
|
||||
"rgba(54, 162, 235, 1)",
|
||||
"rgba(153, 102, 255, 1)",
|
||||
"rgba(48, 51, 107, 1)",
|
||||
"rgba(249, 202, 36, 1)",
|
||||
"rgba(106, 176, 76, 1)",
|
||||
"rgba(119, 139, 235, 1)",
|
||||
],
|
||||
borderWidth: 2,
|
||||
},
|
||||
]
|
||||
|
||||
if (props.type === ChartType.Rank) {
|
||||
datasets.push({
|
||||
label: "总提交数",
|
||||
data: props.rankData.map((rank) => rank.submissionNumber),
|
||||
hidden: true,
|
||||
})
|
||||
}
|
||||
return {
|
||||
labels,
|
||||
datasets,
|
||||
}
|
||||
})
|
||||
|
||||
const options = {
|
||||
maintainAspectRatio: false,
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="chart">
|
||||
<Bar :data="data" :options="options" />
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 500px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClassBattleItem } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { classLabel } from "oj/submission/utils"
|
||||
import { useRankPalette } from "../palette"
|
||||
|
||||
/**
|
||||
* 班级对抗:全服各班这学期人均做对,绿字是这周人均涨了多少。按人均比,因为班级人数从
|
||||
* 十来个到五十几个都有。列前 7 个班,自己的班不在里面就补在最后。
|
||||
*/
|
||||
const props = defineProps<{ items: ClassBattleItem[]; mine: string | null; teacher?: boolean }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const palette = useRankPalette()
|
||||
|
||||
const LIMIT = 7
|
||||
|
||||
const shown = computed(() => {
|
||||
const head = props.items.slice(0, LIMIT)
|
||||
const own = props.items.find((item) => item.className === props.mine)
|
||||
return own && own.rank > LIMIT ? [...head, own] : head
|
||||
})
|
||||
|
||||
const max = computed(() => Math.max(1, ...props.items.map((item) => item.perCapita)))
|
||||
|
||||
const summary = computed(() => {
|
||||
const own = props.items.find((item) => item.className === props.mine)
|
||||
if (!own) return ""
|
||||
const who = props.teacher ? "这个班" : "你们班"
|
||||
const faster = props.items.filter((item) => item.weekGain > own.weekGain).length + 1
|
||||
const speed = own.weekGain > 0 ? ` · 这周涨得第 ${faster} 快` : ""
|
||||
if (own.rank === 1) return `${who}第 1${speed}`
|
||||
const prev = props.items[own.rank - 2]!
|
||||
const need = Math.floor(prev.perCapita - own.perCapita) + 1
|
||||
return `${who}第 ${own.rank} · 每人再做对 ${need} 道就超过${shortLabel(prev.className)}${speed}`
|
||||
})
|
||||
|
||||
/** 同年级的班只说「3 班」,别的年级说全名 */
|
||||
function shortLabel(className: string) {
|
||||
if (props.mine && className.slice(0, 2) === props.mine.slice(0, 2))
|
||||
return ` ${className.slice(2)} 班`
|
||||
return ` ${classLabel(className)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="battle">
|
||||
<div class="title">
|
||||
<b>班级对抗</b><span class="muted">人均做对 · 全服 · 绿字是这周涨的</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in shown"
|
||||
:key="item.className"
|
||||
class="row"
|
||||
:class="{ mine: item.className === mine }"
|
||||
>
|
||||
<span class="rank">{{ item.rank }}</span>
|
||||
<span class="name">{{ classLabel(item.className) }}</span>
|
||||
<span class="bar-box">
|
||||
<span
|
||||
class="bar"
|
||||
:style="{
|
||||
width: `${(item.perCapita / max) * 100}%`,
|
||||
background: item.className === mine ? palette.me : palette.bar,
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
<span class="value">{{ item.perCapita }}</span>
|
||||
<span class="gain">{{ item.weekGain > 0 ? `+${item.weekGain}` : "" }}</span>
|
||||
</div>
|
||||
<span v-if="!items.length" class="muted">这学期还没有班做对题</span>
|
||||
<span v-if="summary" class="summary">{{ summary }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.battle {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border-radius: 6px;
|
||||
background: v-bind("theme.cardColor");
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.row {
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.row.mine {
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(24, 160, 88, 0.12);
|
||||
}
|
||||
|
||||
.rank {
|
||||
width: 14px;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 86px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mine .name {
|
||||
font-weight: 700;
|
||||
color: #18a058;
|
||||
}
|
||||
|
||||
.bar-box {
|
||||
flex-grow: 1;
|
||||
height: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-radius: 2px 5px 5px 2px;
|
||||
}
|
||||
|
||||
.value {
|
||||
width: 32px;
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.gain {
|
||||
width: 34px;
|
||||
font-size: 11px;
|
||||
color: #18a058;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #18a058;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { useDark } from "@vueuse/core"
|
||||
import { rgba } from "oj/submission/composables/tone"
|
||||
import { USERNAME_CLASS_RE } from "utils/constants"
|
||||
|
||||
/**
|
||||
* 排名页的头像。九成以上的学生没换过头像(1797 个号是 default.png),一排一模一样的
|
||||
* 默认图分不出人,所以默认头像画成「名字最后一个字 + 彩色圆」,颜色按用户名固定 ——
|
||||
* 同一个人在领奖台、赛道、对手卡里是同一个颜色。自己是实心绿。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
username: string
|
||||
avatar: string | null
|
||||
size: number
|
||||
me?: boolean
|
||||
}>()
|
||||
|
||||
const isDark = useDark()
|
||||
/** 头像文件丢了(备份恢复后没拷上传目录、被删)就退回名字那个字,别露出破图 */
|
||||
const broken = ref(false)
|
||||
watch(
|
||||
() => props.avatar,
|
||||
() => (broken.value = false),
|
||||
)
|
||||
|
||||
const PALETTE = ["#2f6fd0", "#c76a12", "#6a4bd8", "#0f7a6c", "#b4315f", "#4d5967", "#8a6a00"]
|
||||
|
||||
const letter = computed(() => {
|
||||
const name = props.username.replace(USERNAME_CLASS_RE, "") || props.username
|
||||
return Array.from(name).at(-1) ?? "?"
|
||||
})
|
||||
|
||||
const colors = computed(() => {
|
||||
if (props.me) return { background: "#18a058", color: "#ffffff" }
|
||||
let hash = 0
|
||||
for (const char of props.username) hash = (hash * 31 + char.codePointAt(0)!) >>> 0
|
||||
const base = PALETTE[hash % PALETTE.length]!
|
||||
return { background: rgba(base, isDark.value ? 0.28 : 0.13), color: base }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img
|
||||
v-if="avatar && !broken"
|
||||
class="avatar"
|
||||
:src="avatar"
|
||||
alt=""
|
||||
@error="broken = true"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="avatar letter"
|
||||
aria-hidden="true"
|
||||
:style="{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
fontSize: `${Math.round(size * 0.5)}px`,
|
||||
...colors,
|
||||
}"
|
||||
>{{ letter }}</span
|
||||
>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.letter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,273 @@
|
||||
<script setup lang="ts">
|
||||
import type { RankRow } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { gapText, toPass } from "../utils"
|
||||
import RankAvatar from "./RankAvatar.vue"
|
||||
|
||||
export interface MiniRank {
|
||||
label: string
|
||||
rank: number | null
|
||||
total: number | null
|
||||
go: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 「你」那张卡:大字第几名、比周一升了几名,一句最能推人一把的话(离领奖台几道 / 升了几名),
|
||||
* 再是两个对手 —— 前面那个要追的、后面那个在追你的,差几秒、差几道都写出来。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
me: RankRow
|
||||
ahead: RankRow | null
|
||||
behind: RankRow | null
|
||||
/** 领奖台第 3 名(我不在台上时用来算「再做对几道上领奖台」) */
|
||||
third: RankRow | null
|
||||
total: number
|
||||
label: string
|
||||
minis: MiniRank[]
|
||||
}>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
|
||||
const headline = computed(() => {
|
||||
const { me, ahead, third } = props
|
||||
if (me.rank === 1) return "你是第一名,后面的人在追你"
|
||||
if (me.rank <= 3 && ahead)
|
||||
return `你在领奖台上 · 再做对 ${toPass(me, ahead)} 道就是第 ${ahead.rank} 名`
|
||||
if (third) {
|
||||
const need = toPass(me, third)
|
||||
if (need <= 3) return `再做对 ${need} 道,你就上领奖台`
|
||||
}
|
||||
if ((me.change ?? 0) >= 3) return `比周一升了 ${me.change} 名`
|
||||
if (ahead) return `再做对 ${toPass(me, ahead)} 道就是第 ${ahead.rank} 名`
|
||||
return ""
|
||||
})
|
||||
|
||||
function chaseNote(me: RankRow, ahead: RankRow) {
|
||||
if (ahead.solved === me.solved && ahead.reachedAt && me.reachedAt)
|
||||
return `早你 ${gapText(ahead.reachedAt, me.reachedAt)}做到 ${me.solved} 道`
|
||||
return `比你多 ${ahead.solved - me.solved} 道`
|
||||
}
|
||||
|
||||
function threatNote(me: RankRow, behind: RankRow) {
|
||||
if (behind.solved === me.solved && behind.reachedAt && me.reachedAt)
|
||||
return `只比你晚 ${gapText(me.reachedAt, behind.reachedAt)},再做对 1 道就超过你`
|
||||
if (!behind.solved) return "还没做对"
|
||||
return `比你少 ${me.solved - behind.solved} 道`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="me-card">
|
||||
<div class="head">
|
||||
<RankAvatar :username="me.user.username" :avatar="me.avatar" :size="46" me />
|
||||
<div class="place">
|
||||
<span class="label">{{ label }}</span>
|
||||
<div class="big">
|
||||
<span>第</span><b>{{ me.rank }}</b
|
||||
><span>名</span>
|
||||
<span class="total">/ {{ total }}</span>
|
||||
<span v-if="(me.change ?? 0) > 0" class="up">↑{{ me.change }}</span>
|
||||
<span v-else-if="(me.change ?? 0) < 0" class="down">↓{{ -me.change! }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="headline" class="headline">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 19V5" />
|
||||
<path d="M5 12l7-7 7 7" />
|
||||
</svg>
|
||||
<b>{{ headline }}</b>
|
||||
</div>
|
||||
<div v-if="ahead" class="rival">
|
||||
<span class="tag chase">要追</span>
|
||||
<RankAvatar :username="ahead.user.username" :avatar="ahead.avatar" :size="20" />
|
||||
<UserName :username="ahead.user.username" class="rival-name" />
|
||||
<span class="note">{{ chaseNote(me, ahead) }}</span>
|
||||
</div>
|
||||
<div v-if="behind" class="rival">
|
||||
<span class="tag threat">追你</span>
|
||||
<RankAvatar :username="behind.user.username" :avatar="behind.avatar" :size="20" />
|
||||
<UserName :username="behind.user.username" class="rival-name" />
|
||||
<span class="note">{{ threatNote(me, behind) }}</span>
|
||||
</div>
|
||||
<div v-if="minis.length" class="minis">
|
||||
<button v-for="mini in minis" :key="mini.label" class="mini" @click="mini.go">
|
||||
<span class="mini-label">{{ mini.label }}</span>
|
||||
<b v-if="mini.rank"
|
||||
>第 {{ mini.rank }}<small v-if="mini.total"> / {{ mini.total }}</small></b
|
||||
>
|
||||
<b v-else class="none">没上榜</b>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.me-card {
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border: 1px solid rgba(24, 160, 88, 0.45);
|
||||
border-radius: 6px;
|
||||
background: rgba(24, 160, 88, 0.07);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.place {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor2");
|
||||
}
|
||||
|
||||
.big {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
color: #18a058;
|
||||
}
|
||||
|
||||
.big b {
|
||||
font-size: 44px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor3");
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.up,
|
||||
.down {
|
||||
margin-left: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.down {
|
||||
color: v-bind("theme.errorColor");
|
||||
}
|
||||
|
||||
.headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed rgba(24, 160, 88, 0.6);
|
||||
background: v-bind("theme.cardColor");
|
||||
color: #18a058;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rival {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rival-name {
|
||||
flex-shrink: 0;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 3px;
|
||||
padding: 0 5px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag.chase {
|
||||
color: #2f6fd0;
|
||||
background: rgba(47, 111, 208, 0.12);
|
||||
}
|
||||
|
||||
.tag.threat {
|
||||
color: #c76a12;
|
||||
background: rgba(199, 106, 18, 0.12);
|
||||
}
|
||||
|
||||
.minis {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mini {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: v-bind("theme.cardColor");
|
||||
color: v-bind("theme.textColor1");
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mini:hover {
|
||||
color: v-bind("theme.primaryColor");
|
||||
}
|
||||
|
||||
.mini-label {
|
||||
font-size: 11px;
|
||||
color: v-bind("theme.textColor3");
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mini b {
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mini small {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.mini .none {
|
||||
font-weight: 400;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
</style>
|
||||
@@ -1,31 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from "@iconify/vue"
|
||||
|
||||
interface Props {
|
||||
page: number
|
||||
limit: number
|
||||
index: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const index = computed(() => props.index + (props.page - 1) * props.limit + 1)
|
||||
const tooltip = computed(() => {
|
||||
if (index.value === 1) return "🏅 金牌"
|
||||
if (index.value === 2) return "🥈 银牌"
|
||||
if (index.value === 3) return "🥉 铜牌"
|
||||
return ""
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<span v-if="index > 3">{{ index }}</span>
|
||||
<n-tooltip v-else>
|
||||
<template #trigger>
|
||||
<n-icon :size="24">
|
||||
<Icon v-if="index === 1" icon="fluent-emoji:1st-place-medal"></Icon>
|
||||
<Icon v-if="index === 2" icon="fluent-emoji:2nd-place-medal"></Icon>
|
||||
<Icon v-if="index === 3" icon="fluent-emoji:3rd-place-medal"></Icon>
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ tooltip }}
|
||||
</n-tooltip>
|
||||
</template>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import type { RankRow } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { useRankPalette } from "../palette"
|
||||
import RankAvatar from "./RankAvatar.vue"
|
||||
|
||||
/**
|
||||
* 领奖台:前三名站在台阶上,第一名戴皇冠。赛道从第 4 名接着排,前三不再出现第二遍。
|
||||
* 你要追的人站在台上时台阶描蓝边、挂「你要追的」,和赛道里的蓝条是一回事。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
rows: RankRow[]
|
||||
meId?: number
|
||||
chaseId?: number
|
||||
threatId?: number
|
||||
}>()
|
||||
defineEmits<{ open: [username: string] }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const palette = useRankPalette()
|
||||
|
||||
/** 台阶从左到右是 2、1、3 */
|
||||
const steps = computed(() =>
|
||||
[2, 1, 3]
|
||||
.map((place) => ({ place, row: props.rows[place - 1] }))
|
||||
.filter((step): step is { place: number; row: RankRow } => !!step.row),
|
||||
)
|
||||
|
||||
const HEIGHTS: Record<number, number> = { 1: 50, 2: 38, 3: 30 }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="podium">
|
||||
<div v-for="{ place, row } in steps" :key="place" class="step" :class="`p${place}`">
|
||||
<div class="top">
|
||||
<svg
|
||||
v-if="place === 1"
|
||||
class="crown"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#f2c94c"
|
||||
stroke="#9a6700"
|
||||
stroke-width="1.6"
|
||||
stroke-linejoin="round"
|
||||
role="img"
|
||||
aria-label="第一名"
|
||||
>
|
||||
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
|
||||
</svg>
|
||||
<span v-if="row.user.id === chaseId" class="tag chase">你要追的</span>
|
||||
<span v-else-if="row.user.id === threatId" class="tag threat">在追你</span>
|
||||
<span v-else-if="row.user.id === meId" class="tag me">你</span>
|
||||
</div>
|
||||
<RankAvatar
|
||||
:username="row.user.username"
|
||||
:avatar="row.avatar"
|
||||
:size="place === 1 ? 34 : 28"
|
||||
:me="row.user.id === meId"
|
||||
/>
|
||||
<button class="name" @click="$emit('open', row.user.username)">
|
||||
<UserName :username="row.user.username" />
|
||||
</button>
|
||||
<div
|
||||
class="block"
|
||||
:style="{
|
||||
height: `${HEIGHTS[place]}px`,
|
||||
background: palette.medal[place - 1]!.background,
|
||||
boxShadow:
|
||||
row.user.id === chaseId
|
||||
? `inset 0 0 0 2px ${palette.chase}`
|
||||
: row.user.id === meId
|
||||
? `inset 0 0 0 2px ${palette.me}`
|
||||
: undefined,
|
||||
}"
|
||||
>
|
||||
<span class="place" :style="{ color: palette.medal[place - 1]!.color }">{{ place }}</span>
|
||||
<span class="solved"
|
||||
><b>{{ row.solved }}</b
|
||||
><small>道</small></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="!steps.length" class="empty">还没有人做对,现在做对 1 道就是第一名</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.podium {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin: 0 16px;
|
||||
border-bottom: 2px solid v-bind("theme.dividerColor");
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.step {
|
||||
width: 170px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.top {
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 3px;
|
||||
padding: 0 5px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag.chase {
|
||||
color: v-bind("palette.chase");
|
||||
background: rgba(47, 111, 208, 0.12);
|
||||
}
|
||||
|
||||
.tag.threat {
|
||||
color: v-bind("palette.threat");
|
||||
background: rgba(199, 106, 18, 0.12);
|
||||
}
|
||||
|
||||
.tag.me {
|
||||
color: v-bind("palette.me");
|
||||
background: rgba(24, 160, 88, 0.12);
|
||||
}
|
||||
|
||||
.name {
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.block {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: 6px 6px 0 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.place {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.solved {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.solved b {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.solved small {
|
||||
font-size: 11px;
|
||||
color: v-bind("theme.textColor3");
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 18px 0;
|
||||
color: v-bind("theme.textColor3");
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,323 @@
|
||||
<script setup lang="ts">
|
||||
import type { RankRow } from "@oj2/contract"
|
||||
import { useThemeVars, type DropdownOption } from "naive-ui"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useRankPalette } from "../palette"
|
||||
import RankAvatar from "./RankAvatar.vue"
|
||||
|
||||
/**
|
||||
* 赛道:每人一根条,条越长做对越多,一样长的先做到的排前面(设计稿 G1 / G2)。
|
||||
* 桌面两栏,机房 1366×768 一屏放得下一个班;本年级 / 全服只给前面一段 + 我附近一段,
|
||||
* 名次断开的地方折起来,点了再把中间那段要回来。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
rows: RankRow[]
|
||||
total: number
|
||||
complete: boolean
|
||||
/** 条的满格是几道(第 2 名的数,第 1 名常常一骑绝尘) */
|
||||
scale: number
|
||||
meId?: number
|
||||
chaseId?: number
|
||||
threatId?: number
|
||||
hotId?: number
|
||||
/** 本年级 / 全服里「你们班」的班号,这些人的条浅绿 */
|
||||
mateClass?: string | null
|
||||
teacher?: boolean
|
||||
expanding?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
open: [username: string]
|
||||
expand: []
|
||||
submissions: [username: string]
|
||||
hide: [row: RankRow]
|
||||
}>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const palette = useRankPalette()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
type Item =
|
||||
| { kind: "row"; row: RankRow }
|
||||
| { kind: "gap"; from: number; to: number; tail?: boolean; mine?: boolean }
|
||||
|
||||
const items = computed<Item[]>(() => {
|
||||
const list: Item[] = []
|
||||
let last = props.rows[0] ? props.rows[0].rank - 1 : 0
|
||||
for (const row of props.rows) {
|
||||
if (row.rank > last + 1) list.push({ kind: "gap", from: last + 1, to: row.rank - 1 })
|
||||
list.push({ kind: "row", row })
|
||||
last = row.rank
|
||||
}
|
||||
if (!props.complete && last < props.total)
|
||||
list.push({ kind: "gap", from: last + 1, to: props.total, tail: true })
|
||||
return list
|
||||
})
|
||||
|
||||
/** 两栏:前一半、后一半。手机一栏 */
|
||||
const columns = computed(() => {
|
||||
if (!isDesktop.value) return [items.value]
|
||||
const half = Math.ceil(items.value.length / 2)
|
||||
return [items.value.slice(0, half), items.value.slice(half)]
|
||||
})
|
||||
|
||||
function barColor(row: RankRow) {
|
||||
const id = row.user.id
|
||||
if (id === props.meId) return palette.value.me
|
||||
if (id === props.chaseId) return palette.value.chase
|
||||
if (id === props.threatId) return palette.value.threat
|
||||
if (props.mateClass && row.className === props.mateClass) return palette.value.mate
|
||||
return palette.value.bar
|
||||
}
|
||||
|
||||
function width(row: RankRow) {
|
||||
if (!row.solved) return "0"
|
||||
return `${Math.max(1.5, Math.min(100, (row.solved / Math.max(1, props.scale)) * 100))}%`
|
||||
}
|
||||
|
||||
const menu: DropdownOption[] = [
|
||||
{ label: "看他的提交", key: "submissions" },
|
||||
{ label: "不计入排名…", key: "hide" },
|
||||
]
|
||||
|
||||
function onMenu(key: string, row: RankRow) {
|
||||
if (key === "submissions") emit("submissions", row.user.username)
|
||||
else emit("hide", row)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="track" :class="{ single: !isDesktop }">
|
||||
<div v-for="(column, index) in columns" :key="index" class="column">
|
||||
<template
|
||||
v-for="item in column"
|
||||
:key="item.kind === 'row' ? item.row.user.id : `gap${item.from}`"
|
||||
>
|
||||
<button
|
||||
v-if="item.kind === 'gap'"
|
||||
class="gap"
|
||||
:disabled="expanding"
|
||||
@click="emit('expand')"
|
||||
>
|
||||
第 {{ item.from }}–{{ item.to }} 名 · {{ item.to - item.from + 1 }} 人 · 展开 ▾
|
||||
</button>
|
||||
<div v-else class="lane" :class="{ me: item.row.user.id === meId, zero: !item.row.solved }">
|
||||
<span class="rank">{{ item.row.solved ? item.row.rank : "—" }}</span>
|
||||
<RankAvatar
|
||||
:username="item.row.user.username"
|
||||
:avatar="item.row.avatar"
|
||||
:size="16"
|
||||
:me="item.row.user.id === meId"
|
||||
/>
|
||||
<button class="who" @click="emit('open', item.row.user.username)">
|
||||
<UserName :username="item.row.user.username" />
|
||||
</button>
|
||||
<span class="bar-box">
|
||||
<span class="bar" :style="{ width: width(item.row), background: barColor(item.row) }" />
|
||||
</span>
|
||||
<span class="solved">{{ item.row.solved }}</span>
|
||||
<span class="change">
|
||||
<span v-if="(item.row.change ?? 0) > 0" class="up">↑{{ item.row.change }}</span>
|
||||
<span v-else-if="(item.row.change ?? 0) < 0" class="down"
|
||||
>↓{{ -item.row.change! }}</span
|
||||
>
|
||||
<span v-else-if="item.row.change === 0" class="flat">—</span>
|
||||
</span>
|
||||
<span class="tag-box">
|
||||
<span v-if="item.row.user.id === chaseId" class="tag chase">要追</span>
|
||||
<span v-else-if="item.row.user.id === threatId" class="tag threat">追你</span>
|
||||
<span v-else-if="item.row.user.id === hotId" class="tag hot">冲得最猛</span>
|
||||
</span>
|
||||
<n-dropdown
|
||||
v-if="teacher"
|
||||
trigger="click"
|
||||
:options="menu"
|
||||
@select="(key: string) => onMenu(key, item.row)"
|
||||
>
|
||||
<button class="more" aria-label="更多">⋯</button>
|
||||
</n-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.track {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 36px;
|
||||
padding: 10px 24px 14px;
|
||||
}
|
||||
|
||||
.track.single {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 10px 12px 14px;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lane {
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.single .lane {
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.single .who {
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.single .tag-box {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.lane.me {
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
background: rgba(24, 160, 88, 0.12);
|
||||
}
|
||||
|
||||
.lane.zero {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.rank {
|
||||
width: 28px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.me .rank {
|
||||
color: #18a058;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.who {
|
||||
width: 108px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bar-box {
|
||||
flex-grow: 1;
|
||||
height: 11px;
|
||||
position: relative;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-radius: 2px 6px 6px 2px;
|
||||
}
|
||||
|
||||
.solved {
|
||||
width: 26px;
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.change {
|
||||
width: 34px;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.up {
|
||||
color: #18a058;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.down {
|
||||
color: v-bind("theme.errorColor");
|
||||
}
|
||||
|
||||
.flat {
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.tag-box {
|
||||
width: 58px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 3px;
|
||||
padding: 0 5px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag.chase {
|
||||
color: v-bind("palette.chase");
|
||||
background: rgba(47, 111, 208, 0.12);
|
||||
}
|
||||
|
||||
.tag.threat {
|
||||
color: v-bind("palette.threat");
|
||||
background: rgba(199, 106, 18, 0.12);
|
||||
}
|
||||
|
||||
.tag.hot {
|
||||
color: #ffffff;
|
||||
background: #c76a12;
|
||||
}
|
||||
|
||||
.more {
|
||||
width: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: v-bind("theme.textColor2");
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gap {
|
||||
height: 22px;
|
||||
margin: 4px 0;
|
||||
border: 0;
|
||||
border-top: 1px dashed v-bind("theme.borderColor");
|
||||
border-bottom: 1px dashed v-bind("theme.borderColor");
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gap:hover {
|
||||
color: v-bind("theme.primaryColor");
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import type { RankTrend } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
/**
|
||||
* 我和两个对手每周日晚上的名次。名次越小越好,所以纵轴倒过来(第 1 在最上面)。
|
||||
* 线的末端直接写名字,不要图例 —— 三条线,看名字比对颜色快。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
trend: RankTrend
|
||||
lines: { userId: number; label: string; color: string; strong?: boolean }[]
|
||||
}>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
|
||||
const W = 400
|
||||
const H = 190
|
||||
const PAD = { left: 40, right: 64, top: 12, bottom: 26 }
|
||||
|
||||
const series = computed(() =>
|
||||
props.lines
|
||||
.map((line) => ({
|
||||
...line,
|
||||
ranks: props.trend.series.find((s) => s.userId === line.userId)?.ranks,
|
||||
}))
|
||||
.filter((line): line is typeof line & { ranks: (number | null)[] } => !!line.ranks),
|
||||
)
|
||||
|
||||
const range = computed(() => {
|
||||
const values = series.value.flatMap((line) => line.ranks.filter((v): v is number => v !== null))
|
||||
const lo = Math.max(1, Math.min(...values, 1e9) - 1)
|
||||
const hi = Math.max(lo + 2, Math.max(...values, 0) + 1)
|
||||
return { lo, hi }
|
||||
})
|
||||
|
||||
const ticks = computed(() => {
|
||||
const { lo, hi } = range.value
|
||||
const step = Math.max(1, Math.ceil((hi - lo) / 5))
|
||||
const list: number[] = []
|
||||
for (let v = lo; v <= hi; v += step) list.push(v)
|
||||
return list
|
||||
})
|
||||
|
||||
const n = computed(() => props.trend.points.length)
|
||||
|
||||
function x(i: number) {
|
||||
return PAD.left + ((W - PAD.left - PAD.right) * i) / Math.max(1, n.value - 1)
|
||||
}
|
||||
|
||||
function y(rank: number) {
|
||||
const { lo, hi } = range.value
|
||||
return PAD.top + ((rank - lo) / (hi - lo)) * (H - PAD.top - PAD.bottom)
|
||||
}
|
||||
|
||||
/** 点是周一零点,说成前一天(周日晚上);最后一个点是现在 */
|
||||
const labels = computed(() =>
|
||||
props.trend.points.map((point, i) =>
|
||||
i === n.value - 1 ? "现在" : parseTime(new Date(Date.parse(point) - 1), "M月D日"),
|
||||
),
|
||||
)
|
||||
|
||||
function path(ranks: (number | null)[]) {
|
||||
let d = ""
|
||||
ranks.forEach((rank, i) => {
|
||||
if (rank === null) return
|
||||
d += `${d && ranks[i - 1] !== null ? "L" : "M"}${x(i).toFixed(1)},${y(rank).toFixed(1)} `
|
||||
})
|
||||
return d
|
||||
}
|
||||
|
||||
/** 线尾的名字按纵坐标排开,挨得太近往下错开 */
|
||||
const ends = computed(() => {
|
||||
const list = series.value
|
||||
.map((line) => {
|
||||
const last = line.ranks.at(-1)
|
||||
return last == null
|
||||
? null
|
||||
: { y: y(last), text: `${line.label} 第${last}`, color: line.color }
|
||||
})
|
||||
.filter((end): end is { y: number; text: string; color: string } => !!end)
|
||||
.sort((a, b) => a.y - b.y)
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
if (list[i]!.y - list[i - 1]!.y < 14) list[i]!.y = list[i - 1]!.y + 14
|
||||
}
|
||||
return list
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :viewBox="`0 0 ${W} ${H}`" class="chart" role="img" aria-label="我和对手每周的名次">
|
||||
<g v-for="tick in ticks" :key="tick">
|
||||
<line :x1="PAD.left" :x2="W - PAD.right" :y1="y(tick)" :y2="y(tick)" class="grid" />
|
||||
<text :x="PAD.left - 6" :y="y(tick) + 4" text-anchor="end" class="axis">第 {{ tick }}</text>
|
||||
</g>
|
||||
<text
|
||||
v-for="(label, i) in labels"
|
||||
:key="label"
|
||||
:x="x(i)"
|
||||
:y="H - 8"
|
||||
text-anchor="middle"
|
||||
class="axis"
|
||||
>
|
||||
{{ label }}
|
||||
</text>
|
||||
<g v-for="line in series" :key="line.userId">
|
||||
<path
|
||||
:d="path(line.ranks)"
|
||||
fill="none"
|
||||
:stroke="line.color"
|
||||
:stroke-width="line.strong ? 3 : 1.8"
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<template v-for="(rank, i) in line.ranks" :key="i">
|
||||
<circle
|
||||
v-if="rank !== null"
|
||||
:cx="x(i)"
|
||||
:cy="y(rank)"
|
||||
:r="line.strong ? 3.5 : 2.5"
|
||||
class="dot"
|
||||
:stroke="line.color"
|
||||
/>
|
||||
</template>
|
||||
</g>
|
||||
<text
|
||||
v-for="end in ends"
|
||||
:key="end.text"
|
||||
:x="W - PAD.right + 8"
|
||||
:y="end.y + 4"
|
||||
:fill="end.color"
|
||||
class="end"
|
||||
>
|
||||
{{ end.text }}
|
||||
</text>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.grid {
|
||||
stroke: v-bind("theme.dividerColor");
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.axis {
|
||||
font-size: 11px;
|
||||
fill: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.dot {
|
||||
fill: v-bind("theme.cardColor");
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.end {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
+795
-878
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
||||
import { useDark } from "@vueuse/core"
|
||||
|
||||
/**
|
||||
* 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、你要追的蓝、
|
||||
* 在追你的橙、本年级 / 全服里你们班的人浅绿,其余灰;前三名金银铜。
|
||||
* 暗色下灰条和浅绿换深一档,不然在暗底上发白。
|
||||
*/
|
||||
export function useRankPalette() {
|
||||
const isDark = useDark()
|
||||
return computed(() => ({
|
||||
me: "#18a058",
|
||||
chase: "#2f6fd0",
|
||||
threat: "#c76a12",
|
||||
mate: isDark.value ? "#2f6b47" : "#a8dcbd",
|
||||
bar: isDark.value ? "#4a525c" : "#c9d3dc",
|
||||
medal: [
|
||||
{
|
||||
color: isDark.value ? "#e2b33b" : "#9a6700",
|
||||
background: isDark.value ? "rgba(240,180,40,0.22)" : "#fbf1d9",
|
||||
},
|
||||
{
|
||||
color: isDark.value ? "#a7b1bd" : "#5f6b7a",
|
||||
background: isDark.value ? "rgba(120,135,155,0.22)" : "#eef1f4",
|
||||
},
|
||||
{
|
||||
color: isDark.value ? "#c58b62" : "#8c5a3c",
|
||||
background: isDark.value ? "rgba(170,110,70,0.22)" : "#f6ece5",
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
export type RankPalette = ReturnType<typeof useRankPalette>["value"]
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { RankPeriod, RankRow, RankScope } from "@oj2/contract"
|
||||
import { classLabel } from "oj/submission/utils"
|
||||
|
||||
export const SCOPE_OPTIONS: { value: RankScope; label: string }[] = [
|
||||
{ value: "class", label: "本班" },
|
||||
{ value: "grade", label: "本年级" },
|
||||
{ value: "all", label: "全服" },
|
||||
]
|
||||
|
||||
export const PERIOD_OPTIONS: { value: RankPeriod; label: string }[] = [
|
||||
{ value: "week", label: "这周" },
|
||||
{ value: "term", label: "这学期" },
|
||||
{ value: "all", label: "全部" },
|
||||
]
|
||||
|
||||
export function scopeLabel(scope: RankScope) {
|
||||
return SCOPE_OPTIONS.find((option) => option.value === scope)!.label
|
||||
}
|
||||
|
||||
export function periodLabel(period: RankPeriod) {
|
||||
return PERIOD_OPTIONS.find((option) => option.value === period)!.label
|
||||
}
|
||||
|
||||
/** 榜的标题:本班写班名,本年级写「25 级」,全服就是「全服」 */
|
||||
export function groupTitle(scope: RankScope, className: string | null) {
|
||||
if (scope === "class" && className) return classLabel(className)
|
||||
if (scope === "grade" && className) return `${className} 级`
|
||||
return "全服"
|
||||
}
|
||||
|
||||
/**
|
||||
* 两个人做到同样多道的时间差,说成学生的话:「6 秒」「2 分 40 秒」「3 小时」「2 天」。
|
||||
* 一节课的最后一道,全班常常差在几秒到几十秒 —— 这就是竞争感的来源,秒要写出来。
|
||||
*/
|
||||
export function gapText(earlier: string, later: string) {
|
||||
const seconds = Math.max(0, Math.round((Date.parse(later) - Date.parse(earlier)) / 1000))
|
||||
if (seconds === 0) return "同一秒"
|
||||
if (seconds < 60) return `${seconds} 秒`
|
||||
if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const rest = seconds % 60
|
||||
return rest ? `${minutes} 分 ${rest} 秒` : `${minutes} 分钟`
|
||||
}
|
||||
if (seconds < 86_400) return `${Math.floor(seconds / 3600)} 小时`
|
||||
return `${Math.floor(seconds / 86_400)} 天`
|
||||
}
|
||||
|
||||
/**
|
||||
* 再做对几道能超过 `target`:一样多时先做到的在前,后做到的人只能靠多做 1 道超过 ——
|
||||
* 所以永远是「差几道 + 1」。
|
||||
*/
|
||||
export function toPass(me: RankRow, target: RankRow) {
|
||||
return target.solved - me.solved + 1
|
||||
}
|
||||
|
||||
/** 这张榜上升得最猛的人(至少升 3 名才算),给他挂「冲得最猛」 */
|
||||
export function hottest(rows: RankRow[]) {
|
||||
let best: RankRow | null = null
|
||||
for (const row of rows) {
|
||||
if ((row.change ?? 0) >= 3 && (!best || row.change! > best.change!)) best = row
|
||||
}
|
||||
return best
|
||||
}
|
||||
Reference in new issue
Block a user