style(格式化): 改成单引号、一行最长 160,全仓重新格式化
Deploy / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 13:52:24 +08:00
1 parent 530e1a3d5d
commit 195069450f
480 files changed
+15507 -24650

No files matched your search

@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { RankRow, WeeklyChampion } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import UserName from "shared/components/UserName.vue"
import { parseTime } from "utils/functions"
import RankAvatar from "shared/components/RankAvatar.vue"
import type { RankRow, WeeklyChampion } from '@oj2/contract'
import { useThemeVars } from 'naive-ui'
import UserName from 'shared/components/UserName.vue'
import { parseTime } from 'utils/functions'
import RankAvatar from 'shared/components/RankAvatar.vue'
/** 本班最近几周的每周冠军,底下再挂这张榜上「进步最大」的那个人 */
defineProps<{
@@ -18,7 +18,7 @@ defineEmits<{ open: [username: string] }>()
const theme = useThemeVars()
function weekLabel(start: string) {
return `${parseTime(start, "M月D日")}那周`
return `${parseTime(start, 'M月D日')}那周`
}
</script>
@@ -26,24 +26,10 @@ function weekLabel(start: string) {
<div class="champions">
<div v-for="champion in champions" :key="champion.weekStart" class="row">
<span class="muted week">{{ weekLabel(champion.weekStart) }}</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="#f2c94c"
stroke="#9a6700"
stroke-width="1.6"
stroke-linejoin="round"
aria-hidden="true"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="#f2c94c" stroke="#9a6700" stroke-width="1.6" stroke-linejoin="round" aria-hidden="true">
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
</svg>
<RankAvatar
:username="champion.user.username"
:avatar="champion.avatar"
:size="20"
:me="champion.user.id === meId"
/>
<RankAvatar :username="champion.user.username" :avatar="champion.avatar" :size="20" :me="champion.user.id === meId" />
<button class="who" @click="$emit('open', champion.user.username)">
<UserName :username="champion.user.username" />
</button>
@@ -52,12 +38,7 @@ function weekLabel(start: string) {
<span v-if="!champions.length" class="muted">最近几周还没有冠军</span>
<div v-if="hot" class="row hot-line">
<span class="hot-tag">进步最大</span>
<RankAvatar
:username="hot.user.username"
:avatar="hot.avatar"
:size="20"
:me="hot.user.id === meId"
/>
<RankAvatar :username="hot.user.username" :avatar="hot.avatar" :size="20" :me="hot.user.id === meId" />
<button class="who" @click="$emit('open', hot.user.username)">
<UserName :username="hot.user.username" />
</button>
@@ -82,7 +63,7 @@ function weekLabel(start: string) {
.muted {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
}
.week {
@@ -109,7 +90,7 @@ function weekLabel(start: string) {
margin-top: 4px;
padding-top: 8px;
height: auto;
border-top: 1px solid v-bind("theme.dividerColor");
border-top: 1px solid v-bind('theme.dividerColor');
}
.hot-tag {
+17 -28
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { ClassBattleItem } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { classLabel } from "utils/classes"
import { useRankPalette } from "../palette"
import type { ClassBattleItem } from '@oj2/contract'
import { useThemeVars } from 'naive-ui'
import { classLabel } from 'utils/classes'
import { useRankPalette } from '../palette'
/**
* 班级对抗:全服各班这学期人均做对,绿字是这周人均涨了多少。按人均比,因为班级人数从
@@ -34,10 +34,10 @@ const max = computed(() => Math.max(1, ...props.items.map((item) => item.perCapi
const summary = computed(() => {
const own = props.items.find((item) => item.className === props.mine)
if (!own) return ""
const who = props.teacher ? "这个班" : "你们班"
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} 快` : ""
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
@@ -46,8 +46,7 @@ const summary = computed(() => {
/** 同年级的班只说「3 班」,别的年级说全名 */
function shortLabel(className: string) {
if (props.mine && className.slice(0, 2) === props.mine.slice(0, 2))
return ` ${className.slice(2)} 班`
if (props.mine && className.slice(0, 2) === props.mine.slice(0, 2)) return ` ${className.slice(2)} 班`
return ` ${classLabel(className)}`
}
</script>
@@ -56,19 +55,9 @@ function shortLabel(className: string) {
<div class="battle" :class="{ bare }">
<div class="title">
<b v-if="!bare">班级对抗</b><span class="muted">人均做对</span>
<router-link
v-if="pk"
:to="{ path: '/class', query: mine ? { classes: mine } : {} }"
class="pk"
>班级 PK ›</router-link
>
<router-link v-if="pk" :to="{ path: '/class', query: mine ? { classes: mine } : {} }" class="pk">班级 PK ›</router-link>
</div>
<div
v-for="item in shown"
:key="item.className"
class="row"
:class="{ mine: item.className === mine }"
>
<div v-for="item in shown" :key="item.className" class="row" :class="{ mine: item.className === mine }">
<span class="rank">{{ item.rank }}</span>
<button class="name" title="看班级详情" @click="emit('pick', item.className)">
{{ classLabel(item.className) }}
@@ -83,7 +72,7 @@ function shortLabel(className: string) {
/>
</span>
<span class="value">{{ item.perCapita }}</span>
<span class="gain">{{ item.weekGain > 0 ? `+${item.weekGain}` : "" }}</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>
@@ -96,9 +85,9 @@ function shortLabel(className: string) {
display: flex;
flex-direction: column;
gap: 2px;
border: 1px solid v-bind("theme.borderColor");
border: 1px solid v-bind('theme.borderColor');
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
background: v-bind('theme.cardColor');
}
.battle.bare {
@@ -120,7 +109,7 @@ function shortLabel(className: string) {
.muted {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
}
.row {
@@ -141,7 +130,7 @@ function shortLabel(className: string) {
.rank {
width: 16px;
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
font-variant-numeric: tabular-nums;
}
@@ -151,7 +140,7 @@ function shortLabel(className: string) {
border: 0;
background: none;
font: inherit;
color: v-bind("theme.primaryColor");
color: v-bind('theme.primaryColor');
text-align: left;
white-space: nowrap;
cursor: pointer;
@@ -164,7 +153,7 @@ function shortLabel(className: string) {
.pk {
margin-left: auto;
font-size: var(--oj-fs-meta);
color: v-bind("theme.primaryColor");
color: v-bind('theme.primaryColor');
text-decoration: none;
white-space: nowrap;
}
@@ -1,18 +1,18 @@
<script setup lang="ts">
import type { ClassDetail } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { getClassDetail } from "oj/api"
import { classLabel } from "utils/classes"
import UserName from "shared/components/UserName.vue"
import { useAIStream } from "shared/composables/aiStream"
import { useBackClose } from "shared/composables/backClose"
import { useBreakpoints } from "shared/composables/breakpoints"
import { parseTime } from "utils/functions"
import { useRankPalette } from "../palette"
import RankAvatar from "shared/components/RankAvatar.vue"
import type { ClassDetail } from '@oj2/contract'
import { useThemeVars } from 'naive-ui'
import { getClassDetail } from 'oj/api'
import { classLabel } from 'utils/classes'
import UserName from 'shared/components/UserName.vue'
import { useAIStream } from 'shared/composables/aiStream'
import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { parseTime } from 'utils/functions'
import { useRankPalette } from '../palette'
import RankAvatar from 'shared/components/RankAvatar.vue'
// Markdown 连着整套代码高亮(两百多 KB),这里要等点开 / AI 写完才用得上,别让它拖慢页面
const Markdown = defineAsyncComponent(() => import("shared/components/Markdown.vue"))
const Markdown = defineAsyncComponent(() => import('shared/components/Markdown.vue'))
/**
* 班级详情(设计稿「班级详情 · 老师 / 学生」):从右边滑出的抽屉,代替原来那个满是
@@ -20,7 +20,7 @@ const Markdown = defineAsyncComponent(() => import("shared/components/Markdown.v
* AI 报告直接在抽屉里出,不再关掉再弹第二个框。
*/
const props = defineProps<{ className: string | null; teacher: boolean; pk: boolean }>()
const show = defineModel<boolean>("show", { required: true })
const show = defineModel<boolean>('show', { required: true })
const router = useRouter()
const message = useMessage()
@@ -32,7 +32,7 @@ useBackClose(show, () => !isDesktop.value)
const detail = ref<ClassDetail | null>(null)
const loading = ref(false)
const report = ref("")
const report = ref('')
const ai = useAIStream()
watch(
@@ -40,7 +40,7 @@ watch(
async ([open, className]) => {
if (!open || !className) return
if (detail.value?.className !== className) {
report.value = ""
report.value = ''
ai.abort()
}
loading.value = true
@@ -65,22 +65,13 @@ const dot = computed(() => {
return Math.max(4, Math.min(10, Math.floor(172 / tallest) - 2))
})
const weekMax = computed(() =>
Math.max(
1,
...(detail.value?.weeks ?? []).flatMap((week) => [week.perCapita, week.gradeAvg ?? 0]),
),
)
const weekMax = computed(() => Math.max(1, ...(detail.value?.weeks ?? []).flatMap((week) => [week.perCapita, week.gradeAvg ?? 0])))
async function analyze() {
if (!props.className) return
report.value = ""
report.value = ''
try {
await ai.run(
"ai/class-analysis",
{ className: props.className },
{ onDelta: (content) => (report.value += content) },
)
await ai.run('ai/class-analysis', { className: props.className }, { onDelta: (content) => (report.value += content) })
} catch (error) {
message.error((error as Error).message)
}
@@ -97,9 +88,9 @@ function go(path: string, query: Record<string, string>) {
<n-drawer-content closable :native-scrollbar="false">
<template #header>
<div class="head">
<span class="title">{{ className ? classLabel(className) : "班级详情" }}</span>
<span class="title">{{ className ? classLabel(className) : '班级详情' }}</span>
<span v-if="detail" class="muted">
{{ detail.members }} 人 · 这学期({{ parseTime(detail.start, "M月D日") }}起)
{{ detail.members }} 人 · 这学期({{ parseTime(detail.start, 'M月D日') }}起)
<template v-if="detail.rank"> · 班级对抗第 {{ detail.rank }}</template>
</span>
</div>
@@ -120,11 +111,7 @@ function go(path: string, query: Record<string, string>) {
<b
>{{ detail.solvedMembers }} <small>/ {{ detail.members }} 人</small></b
>
<span>{{
detail.members - detail.solvedMembers
? `${detail.members - detail.solvedMembers} 人还没做对`
: "人人都做对过"
}}</span>
<span>{{ detail.members - detail.solvedMembers ? `${detail.members - detail.solvedMembers} 人还没做对` : '人人都做对过' }}</span>
</div>
</div>
@@ -148,11 +135,8 @@ function go(path: string, query: Record<string, string>) {
</div>
</div>
<span class="line">
前 10% 平均 <b>{{ detail.top10Avg }}</b> 道 · 后 10% 平均
<b>{{ detail.bottom10Avg }}</b> 道
<template v-if="detail.plateau">
· {{ detail.plateau.count }} 个人停在 {{ detail.plateau.solved }} 道</template
>
前 10% 平均 <b>{{ detail.top10Avg }}</b> 道 · 后 10% 平均 <b>{{ detail.bottom10Avg }}</b> 道
<template v-if="detail.plateau"> · {{ detail.plateau.count }} 个人停在 {{ detail.plateau.solved }} 道</template>
</span>
</section>
@@ -160,9 +144,7 @@ function go(path: string, query: Record<string, string>) {
<div class="sec-title">
每周人均新做对
<span class="key"><i :style="{ background: palette.me }" />本班</span>
<span v-if="detail.gradeAvg !== null" class="key"
><i :style="{ background: palette.bar }" />年级平均</span
>
<span v-if="detail.gradeAvg !== null" class="key"><i :style="{ background: palette.bar }" />年级平均</span>
</div>
<div class="weeks">
<div v-for="week in detail.weeks" :key="week.weekStart" class="week">
@@ -188,7 +170,7 @@ function go(path: string, query: Record<string, string>) {
/>
</div>
</div>
<span class="axis">{{ parseTime(week.weekStart, "M月D日") }}那周</span>
<span class="axis">{{ parseTime(week.weekStart, 'M月D日') }}那周</span>
</div>
</div>
</section>
@@ -200,16 +182,8 @@ function go(path: string, query: Record<string, string>) {
<UserName :username="row.user.username" />
<span class="muted">这学期 {{ row.solved }} 道 · 交过 {{ row.submissions }} 次</span>
<div class="spacer" />
<a
href="#"
@click.prevent="
go('/submission', { username: row.user.username, exactUsername: '1' })
"
>看提交 ›</a
>
<a href="#" @click.prevent="go('/ai-analysis', { username: row.user.username })"
>智能分析 ›</a
>
<a href="#" @click.prevent="go('/submission', { username: row.user.username, exactUsername: '1' })">看提交 ›</a>
<a href="#" @click.prevent="go('/ai-analysis', { username: row.user.username })">智能分析 ›</a>
</div>
<span v-if="!detail.care.length" class="muted">没有,这学期人人都做对 3 道以上</span>
</section>
@@ -231,22 +205,15 @@ function go(path: string, query: Record<string, string>) {
</svg>
<b>AI 分析这个班</b>
<div class="spacer" />
<n-button
size="small"
:type="report ? 'default' : 'info'"
:loading="ai.running.value"
@click="analyze"
>
{{ report ? "重新分析" : "开始分析" }}
<n-button size="small" :type="report ? 'default' : 'info'" :loading="ai.running.value" @click="analyze">
{{ report ? '重新分析' : '开始分析' }}
</n-button>
</div>
<span v-if="ai.waiting.value" class="muted">正在看这个班的数据…</span>
<Markdown v-if="report" :content="report" />
</section>
<a v-if="pk" href="#" class="pk" @click.prevent="go('/class', { classes: className! })"
>和别的班比 → 班级 PK ›</a
>
<a v-if="pk" href="#" class="pk" @click.prevent="go('/class', { classes: className! })">和别的班比 → 班级 PK ›</a>
</div>
<div v-else-if="!loading" class="muted">这个班的数据没取到</div>
</n-spin>
@@ -269,7 +236,7 @@ function go(path: string, query: Record<string, string>) {
.muted {
font-size: 12px;
font-weight: 400;
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
}
.body {
@@ -288,7 +255,7 @@ function go(path: string, query: Record<string, string>) {
min-width: 0;
padding: 10px 12px;
border-radius: 6px;
background: v-bind("theme.actionColor");
background: v-bind('theme.actionColor');
display: flex;
flex-direction: column;
gap: 2px;
@@ -302,12 +269,12 @@ function go(path: string, query: Record<string, string>) {
.big b small {
font-size: 13px;
font-weight: 400;
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
}
.big span {
font-size: 12px;
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
}
section {
@@ -323,7 +290,7 @@ section {
gap: 8px;
font-size: 13px;
font-weight: 600;
color: v-bind("theme.textColor2");
color: v-bind('theme.textColor2');
}
.dots {
@@ -348,7 +315,7 @@ section {
align-items: center;
gap: 2px;
padding-bottom: 4px;
border-bottom: 1px solid v-bind("theme.dividerColor");
border-bottom: 1px solid v-bind('theme.dividerColor');
width: 100%;
}
@@ -359,14 +326,14 @@ section {
.axis {
font-size: 11px;
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.line {
font-size: 13px;
color: v-bind("theme.textColor2");
color: v-bind('theme.textColor2');
}
.key {
@@ -418,7 +385,7 @@ section {
.v {
font-size: 11px;
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
font-variant-numeric: tabular-nums;
}
@@ -438,7 +405,7 @@ section {
.care a,
.pk {
font-size: 13px;
color: v-bind("theme.primaryColor");
color: v-bind('theme.primaryColor');
text-decoration: none;
white-space: nowrap;
}
@@ -463,6 +430,6 @@ section {
}
.ai-head b {
color: v-bind("theme.textColor1");
color: v-bind('theme.textColor1');
}
</style>
+10 -23
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { MOOD_MAX } from "@oj2/contract"
import { updateProfile } from "oj/api"
import { useUserStore } from "shared/store/user"
import { MOOD_MAX } from '@oj2/contract'
import { updateProfile } from 'oj/api'
import { useUserStore } from 'shared/store/user'
/**
* 就地改个性签名:「你」卡上的签名、赛道里自己那行的「写一句个性签名」点开的是同一个框。
@@ -15,11 +15,11 @@ const userStore = useUserStore()
const inputId = useId()
const editing = ref(false)
const draft = ref("")
const draft = ref('')
const saving = ref(false)
watch(editing, (open) => {
if (open) draft.value = props.mood ?? ""
if (open) draft.value = props.mood ?? ''
})
async function save() {
@@ -32,11 +32,11 @@ async function save() {
try {
await updateProfile({ mood })
if (userStore.profile) userStore.profile.mood = mood || null
emit("saved", mood || null)
emit('saved', mood || null)
editing.value = false
message.success(mood ? "签名改好了" : "签名清空了")
message.success(mood ? '签名改好了' : '签名清空了')
} catch {
message.error("没保存上,再试一次")
message.error('没保存上,再试一次')
} finally {
saving.value = false
}
@@ -44,26 +44,13 @@ async function save() {
</script>
<template>
<n-popover
v-model:show="editing"
trigger="click"
placement="bottom-start"
:style="{ width: '300px' }"
>
<n-popover v-model:show="editing" trigger="click" placement="bottom-start" :style="{ width: '300px' }">
<template #trigger>
<slot />
</template>
<form class="mood-form" @submit.prevent="save">
<label class="mood-label" :for="inputId">个性签名</label>
<n-input
v-model:value="draft"
:input-props="{ id: inputId }"
:maxlength="MOOD_MAX"
show-count
clearable
placeholder="写点什么,让大家认识你"
autofocus
/>
<n-input v-model:value="draft" :input-props="{ id: inputId }" :maxlength="MOOD_MAX" show-count clearable placeholder="写点什么,让大家认识你" autofocus />
<div class="mood-actions">
<n-button size="small" @click="editing = false">取消</n-button>
<n-button size="small" type="primary" attr-type="submit" :loading="saving"> 保存 </n-button>
+28 -31
View File
@@ -1,10 +1,10 @@
<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 MoodPopover from "./MoodPopover.vue"
import RankAvatar from "shared/components/RankAvatar.vue"
import type { RankRow } from '@oj2/contract'
import { useThemeVars } from 'naive-ui'
import UserName from 'shared/components/UserName.vue'
import { gapText, toPass } from '../utils'
import MoodPopover from './MoodPopover.vue'
import RankAvatar from 'shared/components/RankAvatar.vue'
export interface MiniRank {
label: string
@@ -49,9 +49,8 @@ const beyond = computed(() => !!props.cap && props.me.rank > props.cap)
const headline = computed(() => {
const { me, ahead, third } = props
if (beyond.value)
return props.lastListed ? `再做对 ${toPass(me, props.lastListed)} 道就进前 ${props.cap}` : ""
if (me.rank === 1) return "你是第一名"
if (beyond.value) return props.lastListed ? `再做对 ${toPass(me, props.lastListed)} 道就进前 ${props.cap}` : ''
if (me.rank === 1) return '你是第一名'
if (me.rank <= 3 && ahead)
// 「第 N 名」用不换行空格连住:手机上这一句正好折行,单个「名」会掉到下一行
return `你在领奖台上 · 再做对 ${toPass(me, ahead)} 道就是第\u00a0${ahead.rank}\u00a0名`
@@ -61,19 +60,17 @@ const headline = computed(() => {
}
if ((me.change ?? 0) >= 3) return `比周一升了 ${me.change} 名`
if (ahead) return `再做对 ${toPass(me, ahead)} 道就是第\u00a0${ahead.rank}\u00a0名`
return ""
return ''
})
function chaseNote(me: RankRow, ahead: RankRow) {
if (ahead.solved === me.solved && ahead.reachedAt && me.reachedAt)
return `比你早 ${gapText(ahead.reachedAt, me.reachedAt)}做到 ${me.solved} 道`
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)}做到 ${me.solved} 道`
if (!behind.solved) return "还没做对"
if (behind.solved === me.solved && behind.reachedAt && me.reachedAt) return `比你晚 ${gapText(me.reachedAt, behind.reachedAt)}做到 ${me.solved} 道`
if (!behind.solved) return '还没做对'
return `比你少 ${me.solved - behind.solved} 道`
}
</script>
@@ -176,7 +173,7 @@ function threatNote(me: RankRow, behind: RankRow) {
<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.replace(" · ", "\u00a0· ") }}&nbsp;›</span>
<span class="mini-label">{{ mini.label.replace(' · ', '\u00a0· ') }}&nbsp;›</span>
<b v-if="mini.rank && mini.cap && mini.rank > mini.cap">{{ mini.cap }} 名以外</b>
<b v-else-if="mini.rank"
>第 {{ mini.rank }}<small v-if="mini.total"> / {{ mini.total }}</small></b
@@ -227,7 +224,7 @@ function threatNote(me: RankRow, behind: RankRow) {
.label {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor2");
color: v-bind('theme.textColor2');
}
.big {
@@ -251,7 +248,7 @@ function threatNote(me: RankRow, behind: RankRow) {
.total {
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
margin-left: 2px;
}
@@ -263,7 +260,7 @@ function threatNote(me: RankRow, behind: RankRow) {
}
.down {
color: v-bind("theme.errorColor");
color: v-bind('theme.errorColor');
}
.headline {
@@ -273,7 +270,7 @@ function threatNote(me: RankRow, behind: RankRow) {
padding: 10px 12px;
border-radius: 6px;
border: 1px dashed rgba(24, 160, 88, 0.6);
background: v-bind("theme.cardColor");
background: v-bind('theme.cardColor');
color: #18a058;
font-size: var(--oj-fs-body);
}
@@ -317,7 +314,7 @@ function threatNote(me: RankRow, behind: RankRow) {
.rival-mood {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -331,8 +328,8 @@ function threatNote(me: RankRow, behind: RankRow) {
padding: 10px 12px;
border: 1px solid rgba(24, 160, 88, 0.3);
border-radius: 6px;
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor1");
background: v-bind('theme.cardColor');
color: v-bind('theme.textColor1');
font: inherit;
font-size: var(--oj-fs-sec);
line-height: 1.45;
@@ -380,7 +377,7 @@ function threatNote(me: RankRow, behind: RankRow) {
.note {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -413,7 +410,7 @@ function threatNote(me: RankRow, behind: RankRow) {
padding-top: 10px;
border-top: 1px solid rgba(24, 160, 88, 0.3);
font-size: var(--oj-fs-sec);
color: v-bind("theme.primaryColor");
color: v-bind('theme.primaryColor');
text-decoration: none;
}
@@ -436,19 +433,19 @@ function threatNote(me: RankRow, behind: RankRow) {
padding: 8px 12px;
border: 0;
border-radius: 6px;
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor1");
background: v-bind('theme.cardColor');
color: v-bind('theme.textColor1');
font: inherit;
cursor: pointer;
}
.mini:hover {
color: v-bind("theme.primaryColor");
color: v-bind('theme.primaryColor');
}
.mini-label {
font-size: 12px;
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
text-align: left;
word-break: keep-all;
}
@@ -463,11 +460,11 @@ function threatNote(me: RankRow, behind: RankRow) {
.mini small {
font-size: 12px;
font-weight: 400;
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
}
.mini .none {
font-weight: 400;
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
}
</style>
+28 -48
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { RankRow } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { useBreakpoints } from "shared/composables/breakpoints"
import UserName from "shared/components/UserName.vue"
import { useRankPalette } from "../palette"
import RankAvatar from "shared/components/RankAvatar.vue"
import type { RankRow } from '@oj2/contract'
import { useThemeVars } from 'naive-ui'
import { useBreakpoints } from 'shared/composables/breakpoints'
import UserName from 'shared/components/UserName.vue'
import { useRankPalette } from '../palette'
import RankAvatar from 'shared/components/RankAvatar.vue'
/**
* 领奖台:前三名站在台阶上,第一名戴皇冠。赛道从第 4 名接着排,前三不再出现第二遍。
@@ -28,9 +28,7 @@ const { isDesktop } = useBreakpoints()
/** 台阶从左到右是 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),
[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: 56, 2: 44, 3: 34 }
@@ -56,12 +54,7 @@ const HEIGHTS: Record<number, number> = { 1: 56, 2: 44, 3: 34 }
>
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
</svg>
<RankAvatar
:username="row.user.username"
:avatar="row.avatar"
:size="place === 1 ? 34 : 28"
:me="row.user.id === meId"
/>
<RankAvatar :username="row.user.username" :avatar="row.avatar" :size="place === 1 ? 34 : 28" :me="row.user.id === meId" />
</span>
<div class="words">
<div class="name-line">
@@ -80,15 +73,7 @@ const HEIGHTS: Record<number, number> = { 1: 56, 2: 44, 3: 34 }
>
{{ row.mood }}
</n-ellipsis>
<button
v-if="teacher"
class="clear"
aria-label="清空签名"
title="清空签名"
@click="$emit('clearMood', row)"
>
×
</button>
<button v-if="teacher" class="clear" aria-label="清空签名" title="清空签名" @click="$emit('clearMood', row)">×</button>
</div>
</div>
</div>
@@ -97,12 +82,7 @@ const HEIGHTS: Record<number, number> = { 1: 56, 2: 44, 3: 34 }
: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,
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>
@@ -123,7 +103,7 @@ const HEIGHTS: Record<number, number> = { 1: 56, 2: 44, 3: 34 }
justify-content: center;
gap: 8px;
margin: 4px 24px 0;
border-bottom: 2px solid v-bind("theme.dividerColor");
border-bottom: 2px solid v-bind('theme.dividerColor');
min-height: 60px;
}
@@ -175,12 +155,12 @@ const HEIGHTS: Record<number, number> = { 1: 56, 2: 44, 3: 34 }
.bubble {
position: relative;
padding: 4px 10px;
border: 1px solid v-bind("theme.dividerColor");
border: 1px solid v-bind('theme.dividerColor');
border-radius: 8px;
background: v-bind("theme.actionColor");
background: v-bind('theme.actionColor');
font-size: var(--oj-fs-meta);
line-height: 18px;
color: v-bind("theme.textColor2");
color: v-bind('theme.textColor2');
word-break: break-all;
}
@@ -191,10 +171,10 @@ const HEIGHTS: Record<number, number> = { 1: 56, 2: 44, 3: 34 }
width: 16px;
height: 16px;
padding: 0;
border: 1px solid v-bind("theme.borderColor");
border: 1px solid v-bind('theme.borderColor');
border-radius: 8px;
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor3");
background: v-bind('theme.cardColor');
color: v-bind('theme.textColor3');
font-size: 12px;
line-height: 13px;
cursor: pointer;
@@ -207,8 +187,8 @@ const HEIGHTS: Record<number, number> = { 1: 56, 2: 44, 3: 34 }
}
.clear:hover {
color: v-bind("theme.errorColor");
border-color: v-bind("theme.errorColor");
color: v-bind('theme.errorColor');
border-color: v-bind('theme.errorColor');
}
/* 手机:台阶窄,气泡挪到头像上面,下面依次头像、名字 */
@@ -257,16 +237,16 @@ const HEIGHTS: Record<number, number> = { 1: 56, 2: 44, 3: 34 }
}
.phone .bubble::after {
content: "";
content: '';
position: absolute;
left: 50%;
bottom: -5px;
width: 8px;
height: 8px;
margin-left: -4px;
background: v-bind("theme.actionColor");
border-right: 1px solid v-bind("theme.dividerColor");
border-bottom: 1px solid v-bind("theme.dividerColor");
background: v-bind('theme.actionColor');
border-right: 1px solid v-bind('theme.dividerColor');
border-bottom: 1px solid v-bind('theme.dividerColor');
transform: rotate(45deg);
}
@@ -280,17 +260,17 @@ const HEIGHTS: Record<number, number> = { 1: 56, 2: 44, 3: 34 }
}
.tag.chase {
color: v-bind("palette.chase");
color: v-bind('palette.chase');
background: rgba(47, 111, 208, 0.12);
}
.tag.threat {
color: v-bind("palette.threat");
color: v-bind('palette.threat');
background: rgba(199, 106, 18, 0.12);
}
.tag.me {
color: v-bind("palette.me");
color: v-bind('palette.me');
background: rgba(24, 160, 88, 0.12);
}
@@ -333,13 +313,13 @@ const HEIGHTS: Record<number, number> = { 1: 56, 2: 44, 3: 34 }
.solved small {
font-size: 12px;
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
margin-left: 1px;
}
.empty {
padding: 24px 0;
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
font-size: var(--oj-fs-sec);
}
</style>
+52 -106
View File
@@ -1,11 +1,11 @@
<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 MoodPopover from "./MoodPopover.vue"
import RankAvatar from "shared/components/RankAvatar.vue"
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 MoodPopover from './MoodPopover.vue'
import RankAvatar from 'shared/components/RankAvatar.vue'
/**
* 赛道:每人一根条,条越长做对越多,一样长的先做到的排前面(设计稿 G1 / G2)。
@@ -58,7 +58,7 @@ const theme = useThemeVars()
const palette = useRankPalette()
const { isDesktop } = useBreakpoints()
type Item = { kind: "row"; row: RankRow } | { kind: "gap"; from: number; to: number }
type Item = { kind: 'row'; row: RankRow } | { kind: 'gap'; from: number; to: number }
/** 手机默认列几行:领奖台下面的前 5 行,加你上下各 2 行;没有你就列前 10 行 */
const PHONE_HEAD = 5
@@ -72,50 +72,38 @@ const meRank = computed(() => props.rows.find((row) => row.user.id === props.meI
const visible = computed(() => {
if (isDesktop.value || !collapsed.value) return props.rows
const mine = meRank.value
return props.rows.filter((row, index) =>
mine
? index < PHONE_HEAD || Math.abs(row.rank - mine) <= PHONE_AROUND
: index < PHONE_HEAD_NO_ME,
)
return props.rows.filter((row, index) => (mine ? index < PHONE_HEAD || Math.abs(row.rank - mine) <= PHONE_AROUND : index < PHONE_HEAD_NO_ME))
})
const items = computed<Item[]>(() => {
const list: Item[] = []
let last = props.rows[0] ? props.rows[0].rank - 1 : 0
for (const row of visible.value) {
if (row.rank > last + 1) list.push({ kind: "gap", from: last + 1, to: row.rank - 1 })
list.push({ kind: "row", row })
if (row.rank > last + 1) list.push({ kind: 'gap', from: last + 1, to: row.rank - 1 })
list.push({ kind: 'row', row })
last = row.rank
}
const end = props.complete
? (props.rows.at(-1)?.rank ?? last)
: Math.min(props.total, props.cap ?? props.total)
if (last < end) list.push({ kind: "gap", from: last + 1, to: end })
const end = props.complete ? (props.rows.at(-1)?.rank ?? last) : Math.min(props.total, props.cap ?? props.total)
if (last < end) list.push({ kind: 'gap', from: last + 1, to: end })
return list
})
/** 手机上只有末尾一段折起来(本班的常见情况),按钮就说「看全班 N 人」 */
function gapText(item: { from: number; to: number }) {
const gaps = items.value.filter((entry) => entry.kind === "gap")
if (
!isDesktop.value &&
props.wholeLabel &&
gaps.length === 1 &&
item.to === props.rows.at(-1)?.rank
)
return `${props.wholeLabel} ▾`
const gaps = items.value.filter((entry) => entry.kind === 'gap')
if (!isDesktop.value && props.wholeLabel && gaps.length === 1 && item.to === props.rows.at(-1)?.rank) return `${props.wholeLabel} ▾`
return `第 ${item.from}–${item.to} 名 · ${item.to - item.from + 1} 人 · 展开 ▾`
}
/** 展开:手上的数据够就直接摊开,不够(本年级 / 全服中间那段)再找父组件要全量 */
function expandGap() {
collapsed.value = false
if (!props.complete) emit("expand")
if (!props.complete) emit('expand')
}
/** 桌面每一项大约多高:带签名(或「写一句」)的行多一行,两栏按高度对半分,不按人数 */
function heightOf(item: Item) {
if (item.kind === "gap") return 48
if (item.kind === 'gap') return 48
return hasSecondLine(item.row) ? 56 : 36
}
@@ -171,44 +159,32 @@ function width(row: RankRow) {
function menuOf(row: RankRow): DropdownOption[] {
return [
{ label: "看提交", key: "submissions" },
{ label: "看智能分析", key: "analysis" },
...(row.mood ? [{ label: "清空签名…", key: "mood" }] : []),
{ label: "不计入排名…", key: "hide" },
{ label: '看提交', key: 'submissions' },
{ label: '看智能分析', key: 'analysis' },
...(row.mood ? [{ label: '清空签名…', key: 'mood' }] : []),
{ label: '不计入排名…', key: 'hide' },
]
}
function onMenu(key: string, row: RankRow) {
if (key === "submissions") emit("submissions", row.user.username)
else if (key === "analysis") emit("analysis", row.user.username)
else if (key === "mood") emit("clearMood", row)
else emit("hide", row)
if (key === 'submissions') emit('submissions', row.user.username)
else if (key === 'analysis') emit('analysis', row.user.username)
else if (key === 'mood') emit('clearMood', row)
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}`"
>
<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="expandGap">
{{ gapText(item) }}
</button>
<div
v-else-if="!isDesktop"
class="prow"
:class="{ me: item.row.user.id === meId, zero: !item.row.solved, tall: !!item.row.mood }"
>
<div v-else-if="!isDesktop" class="prow" :class="{ me: item.row.user.id === meId, zero: !item.row.solved, tall: !!item.row.mood }">
<span class="fill" :style="{ width: width(item.row), background: fillColor(item.row) }" />
<span class="rank">{{ item.row.solved ? item.row.rank : "—" }}</span>
<RankAvatar
:username="item.row.user.username"
:avatar="item.row.avatar"
:size="20"
:me="item.row.user.id === meId"
/>
<span class="rank">{{ item.row.solved ? item.row.rank : '—' }}</span>
<RankAvatar :username="item.row.user.username" :avatar="item.row.avatar" :size="20" :me="item.row.user.id === meId" />
<div class="pmain">
<div class="pname">
<button class="who" @click="emit('open', item.row.user.username)">
@@ -223,66 +199,36 @@ function onMenu(key: string, row: RankRow) {
<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) < 0" class="down">↓{{ -item.row.change! }}</span>
<span v-else-if="item.row.change === 0" class="flat">—</span>
</span>
<n-dropdown
v-if="teacher"
trigger="click"
:options="menuOf(item.row)"
@select="(key: string) => onMenu(key, item.row)"
>
<n-dropdown v-if="teacher" trigger="click" :options="menuOf(item.row)" @select="(key: string) => onMenu(key, item.row)">
<button class="more" aria-label="更多">⋯</button>
</n-dropdown>
</div>
<div
v-else
class="entry"
:class="{ me: item.row.user.id === meId, zero: !item.row.solved }"
>
<div v-else class="entry" :class="{ me: item.row.user.id === meId, zero: !item.row.solved }">
<div class="lane">
<span class="rank">{{ item.row.solved ? item.row.rank : "—" }}</span>
<RankAvatar
:username="item.row.user.username"
:avatar="item.row.avatar"
:size="20"
:me="item.row.user.id === meId"
/>
<span class="rank">{{ item.row.solved ? item.row.rank : '—' }}</span>
<RankAvatar :username="item.row.user.username" :avatar="item.row.avatar" :size="20" :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" :style="{ background: palette.track }">
<span
class="bar"
:style="{ width: width(item.row), background: barColor(item.row) }"
/>
<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.user.id === hotId" class="up hot" title="进步最大"
>↑{{ item.row.change }}</span
>
<span v-if="item.row.user.id === hotId" class="up hot" title="进步最大">↑{{ item.row.change }}</span>
<span v-else-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) < 0" class="down">↓{{ -item.row.change! }}</span>
<span v-else-if="item.row.change === 0" class="flat">—</span>
</span>
<n-dropdown
v-if="teacher"
trigger="click"
:options="menuOf(item.row)"
@select="(key: string) => onMenu(key, item.row)"
>
<n-dropdown v-if="teacher" trigger="click" :options="menuOf(item.row)" @select="(key: string) => onMenu(key, item.row)">
<button class="more" aria-label="更多">⋯</button>
</n-dropdown>
</div>
<div v-if="item.row.mood" class="sig line">
<n-ellipsis :tooltip="{ contentStyle: { maxWidth: '300px' } }"
>“{{ item.row.mood }}”</n-ellipsis
>
<n-ellipsis :tooltip="{ contentStyle: { maxWidth: '300px' } }">“{{ item.row.mood }}”</n-ellipsis>
</div>
<div v-else-if="item.row.user.id === meId" class="line">
<MoodPopover :mood="null" @saved="emit('moodSaved', $event)">
@@ -404,7 +350,7 @@ function onMenu(key: string, row: RankRow) {
display: block;
font-size: var(--oj-fs-meta);
line-height: 18px;
color: v-bind("theme.textColor2");
color: v-bind('theme.textColor2');
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -427,7 +373,7 @@ function onMenu(key: string, row: RankRow) {
padding: 0 9px;
border: 1px dashed rgba(24, 160, 88, 0.6);
border-radius: 11px;
background: v-bind("theme.cardColor");
background: v-bind('theme.cardColor');
color: #18a058;
font: inherit;
font-size: var(--oj-fs-meta);
@@ -449,7 +395,7 @@ function onMenu(key: string, row: RankRow) {
.single .gap {
height: 38px;
margin: 2px 0 0;
border: 1px dashed v-bind("theme.borderColor");
border: 1px dashed v-bind('theme.borderColor');
border-radius: 6px;
font-size: var(--oj-fs-sec);
color: #0c7a43;
@@ -470,7 +416,7 @@ function onMenu(key: string, row: RankRow) {
width: 28px;
text-align: right;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
@@ -541,11 +487,11 @@ function onMenu(key: string, row: RankRow) {
}
.down {
color: v-bind("theme.errorColor");
color: v-bind('theme.errorColor');
}
.flat {
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
}
.tag {
@@ -558,12 +504,12 @@ function onMenu(key: string, row: RankRow) {
}
.tag.chase {
color: v-bind("palette.chase");
color: v-bind('palette.chase');
background: rgba(47, 111, 208, 0.12);
}
.tag.threat {
color: v-bind("palette.threat");
color: v-bind('palette.threat');
background: rgba(199, 106, 18, 0.12);
}
@@ -577,7 +523,7 @@ function onMenu(key: string, row: RankRow) {
padding: 0;
border: 0;
background: none;
color: v-bind("theme.textColor2");
color: v-bind('theme.textColor2');
font-weight: 700;
cursor: pointer;
}
@@ -586,16 +532,16 @@ function onMenu(key: string, row: RankRow) {
height: 36px;
margin: 6px 0;
border: 0;
border-top: 1px dashed v-bind("theme.borderColor");
border-bottom: 1px dashed v-bind("theme.borderColor");
border-top: 1px dashed v-bind('theme.borderColor');
border-bottom: 1px dashed v-bind('theme.borderColor');
background: none;
font: inherit;
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
cursor: pointer;
}
.gap:hover {
color: v-bind("theme.primaryColor");
color: v-bind('theme.primaryColor');
}
</style>
+14 -48
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { RankTrend } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { parseTime } from "utils/functions"
import type { RankTrend } from '@oj2/contract'
import { useThemeVars } from 'naive-ui'
import { parseTime } from 'utils/functions'
/**
* 我和前后一名每周日晚上的名次。名次越小越好,所以纵轴倒过来(第 1 在最上面)。
@@ -54,17 +54,13 @@ function y(rank: number) {
}
/** 点是周一零点,说成前一天(周日晚上);最后一个点是现在 */
const labels = computed(() =>
props.trend.points.map((point, i) =>
i === n.value - 1 ? "现在" : parseTime(new Date(Date.parse(point) - 1), "M月D日"),
),
)
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 = ""
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)} `
d += `${d && ranks[i - 1] !== null ? 'L' : 'M'}${x(i).toFixed(1)},${y(rank).toFixed(1)} `
})
return d
}
@@ -74,9 +70,7 @@ 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 }
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)
@@ -93,44 +87,16 @@ const ends = computed(() => {
<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"
>
<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"
/>
<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"
/>
<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"
>
<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>
@@ -144,17 +110,17 @@ const ends = computed(() => {
}
.grid {
stroke: v-bind("theme.dividerColor");
stroke: v-bind('theme.dividerColor');
stroke-width: 1;
}
.axis {
font-size: 11px;
fill: v-bind("theme.textColor3");
fill: v-bind('theme.textColor3');
}
.dot {
fill: v-bind("theme.cardColor");
fill: v-bind('theme.cardColor');
stroke-width: 2;
}
+9 -16
View File
@@ -1,9 +1,9 @@
<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 "shared/components/RankAvatar.vue"
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 'shared/components/RankAvatar.vue'
/** 这周本班前 5 名(做对过新题的),每周一清零 */
const props = defineProps<{ rows: RankRow[]; meId?: number; loaded: boolean }>()
@@ -31,12 +31,7 @@ const scale = computed(() => top.value[0]?.solved || 1)
"
>{{ row.rank }}</span
>
<RankAvatar
:username="row.user.username"
:avatar="row.avatar"
:size="18"
:me="row.user.id === meId"
/>
<RankAvatar :username="row.user.username" :avatar="row.avatar" :size="18" :me="row.user.id === meId" />
<button class="who" @click="$emit('open', row.user.username)">
<UserName :username="row.user.username" />
</button>
@@ -51,9 +46,7 @@ const scale = computed(() => top.value[0]?.solved || 1)
</span>
<b class="num">{{ row.solved }}</b>
</div>
<span v-if="loaded && !top.length" class="muted"
>这周还没人做对新题,现在做对 1 道就是第一</span
>
<span v-if="loaded && !top.length" class="muted">这周还没人做对新题,现在做对 1 道就是第一</span>
</div>
</template>
@@ -88,7 +81,7 @@ const scale = computed(() => top.value[0]?.solved || 1)
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
color: v-bind("theme.textColor2");
color: v-bind('theme.textColor2');
}
.who {
@@ -123,6 +116,6 @@ const scale = computed(() => top.value[0]?.solved || 1)
.muted {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
}
</style>
+97 -175
View File
@@ -1,39 +1,25 @@
<script setup lang="ts">
import type {
ClassBattleItem,
RankBoard,
RankPeriod,
RankRow,
RankScope,
WeeklyChampion,
} from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { clearMood, getClassBattle, getRankBoard, getWeeklyChampions, setRankHidden } from "oj/api"
import { classLabel, groupClassOptions } from "utils/classes"
import UserName from "shared/components/UserName.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useConfigStore } from "shared/store/config"
import { useUserStore } from "shared/store/user"
import { errorCode } from "utils/api"
import { STORAGE_KEY, USERNAME_CLASS_RE } from "utils/constants"
import storage from "utils/storage"
import ChampionList from "./components/ChampionList.vue"
import ClassBattle from "./components/ClassBattle.vue"
import ClassDetailDrawer from "./components/ClassDetailDrawer.vue"
import RankMe, { type MiniRank } from "./components/RankMe.vue"
import RankPodium from "./components/RankPodium.vue"
import RankTrack from "./components/RankTrack.vue"
import RankTrend from "./components/RankTrend.vue"
import WeekTop from "./components/WeekTop.vue"
import { useRankPalette } from "./palette"
import {
groupTitle,
hottest,
PERIOD_OPTIONS,
periodLabel,
SCOPE_OPTIONS,
scopeLabel,
} from "./utils"
import type { ClassBattleItem, RankBoard, RankPeriod, RankRow, RankScope, WeeklyChampion } from '@oj2/contract'
import { useThemeVars } from 'naive-ui'
import { clearMood, getClassBattle, getRankBoard, getWeeklyChampions, setRankHidden } from 'oj/api'
import { classLabel, groupClassOptions } from 'utils/classes'
import UserName from 'shared/components/UserName.vue'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useConfigStore } from 'shared/store/config'
import { useUserStore } from 'shared/store/user'
import { errorCode } from 'utils/api'
import { STORAGE_KEY, USERNAME_CLASS_RE } from 'utils/constants'
import storage from 'utils/storage'
import ChampionList from './components/ChampionList.vue'
import ClassBattle from './components/ClassBattle.vue'
import ClassDetailDrawer from './components/ClassDetailDrawer.vue'
import RankMe, { type MiniRank } from './components/RankMe.vue'
import RankPodium from './components/RankPodium.vue'
import RankTrack from './components/RankTrack.vue'
import RankTrend from './components/RankTrend.vue'
import WeekTop from './components/WeekTop.vue'
import { useRankPalette } from './palette'
import { groupTitle, hottest, PERIOD_OPTIONS, periodLabel, SCOPE_OPTIONS, scopeLabel } from './utils'
/**
* 排名页(设计稿「排名重设计」G1–G3,用户要「激发竞争」「图多」「一眼看出排名」)。
@@ -60,10 +46,10 @@ function pick<T extends string>(value: unknown, options: { value: T }[], fallbac
return options.some((option) => option.value === value) ? (value as T) : fallback
}
const scope = ref<RankScope>("all")
const period = ref<RankPeriod>(pick(route.query.period, PERIOD_OPTIONS, "term"))
const scope = ref<RankScope>('all')
const period = ref<RankPeriod>(pick(route.query.period, PERIOD_OPTIONS, 'term'))
/** 老师选的班;空 = 让后端挑最近布置过作业的班,挑完回填 */
const teacherClass = ref(typeof route.query.class === "string" ? route.query.class : "")
const teacherClass = ref(typeof route.query.class === 'string' ? route.query.class : '')
const board = ref<RankBoard | null>(null)
const loading = ref(false)
@@ -75,7 +61,7 @@ const champions = ref<WeeklyChampion[]>([])
const cache = reactive(new Map<string, RankBoard>())
function keyOf(s: RankScope, p: RankPeriod) {
return `${s}|${p}|${s === "all" ? "" : teacherClass.value}`
return `${s}|${p}|${s === 'all' ? '' : teacherClass.value}`
}
async function fetchBoard(s: RankScope, p: RankPeriod, full = false) {
@@ -89,20 +75,20 @@ async function load() {
try {
board.value = await fetchBoard(scope.value, period.value)
// 老师在「全部」时点了本班:后端挑了最近布置过作业的班,回填到下拉框(这一下不用再取一遍)
if (teacher.value && !teacherClass.value && board.value.scope === "class") {
if (teacher.value && !teacherClass.value && board.value.scope === 'class') {
backfilling = true
teacherClass.value = board.value.className ?? ""
teacherClass.value = board.value.className ?? ''
}
} catch (err) {
board.value = null
// 没布置过作业、后端猜不出:用上次在下拉框里选的班,没选过就回到全部(watch 会重取)
if (teacher.value && !teacherClass.value && errorCode(err) === "class-missing") {
if (teacher.value && !teacherClass.value && errorCode(err) === 'class-missing') {
loading.value = false
const saved = storage.get(STORAGE_KEY.RANK_CLASS)
if (typeof saved === "string" && saved) teacherClass.value = saved
if (typeof saved === 'string' && saved) teacherClass.value = saved
else {
scope.value = "all"
message.info("先选一个班")
scope.value = 'all'
message.info('先选一个班')
}
return
}
@@ -116,7 +102,7 @@ async function load() {
async function loadSide() {
const cls = classContext.value
if (cls) {
getRankBoard("class", "week", { className: teacherClass.value })
getRankBoard('class', 'week', { className: teacherClass.value })
.then((result) => (weekBoard.value = result))
.catch(() => (weekBoard.value = null))
getWeeklyChampions(teacherClass.value)
@@ -166,69 +152,57 @@ watch([scope, period, teacherClass], () => {
onMounted(async () => {
if (!userStore.user) await userStore.getMyProfile().catch(() => {})
// 老师默认看全部(用户定的),要看哪个班自己在下拉框里选;学生默认看本班
const fallback = teacher.value ? "all" : canScopeClass.value ? "class" : "all"
const fallback = teacher.value ? 'all' : canScopeClass.value ? 'class' : 'all'
scope.value = pick(route.query.scope, SCOPE_OPTIONS, fallback)
if (!canScopeClass.value && scope.value !== "all") scope.value = "all"
if (!canScopeClass.value && scope.value !== 'all') scope.value = 'all'
loadBattle()
// scope 从 "all" 改成别的会触发 watch 去取;没改就自己取
if (scope.value === "all") load()
if (scope.value === 'all') load()
})
/** 班级那几块(走势、这周本班、每周冠军、班级对抗高亮)对的是哪个班 */
const classContext = computed(
() => (teacher.value ? (scope.value === "all" ? "" : teacherClass.value) : myClass.value) || null,
)
const classContext = computed(() => (teacher.value ? (scope.value === 'all' ? '' : teacherClass.value) : myClass.value) || null)
/** 老师的班级下拉框:「全部」就是全服,选了班就切到本班(本年级时留在本年级) */
const pickedClass = computed({
get: () => (scope.value === "all" ? "" : teacherClass.value),
get: () => (scope.value === 'all' ? '' : teacherClass.value),
set: (value: string) => {
teacherClass.value = value
if (value) storage.set(STORAGE_KEY.RANK_CLASS, value)
if (!value) scope.value = "all"
else if (scope.value === "all") scope.value = "class"
if (!value) scope.value = 'all'
else if (scope.value === 'all') scope.value = 'class'
},
})
const rows = computed(() => board.value?.rows ?? [])
const podium = computed(() => rows.value.filter((row) => row.rank <= 3 && row.solved > 0))
const trackRows = computed(() =>
rows.value.filter((row) => row.rank > 3 || (row.rank <= 3 && !row.solved)),
)
const trackRows = computed(() => rows.value.filter((row) => row.rank > 3 || (row.rank <= 3 && !row.solved)))
/** 条的满格:第 2 名的数。第 1 名常常一骑绝尘,按它算别人的条全挤在左边 */
const scale = computed(() => rows.value[1]?.solved || rows.value[0]?.solved || 1)
const hot = computed(() => hottest(rows.value))
const me = computed(() => board.value?.me ?? null)
const meId = computed(() => (teacher.value ? undefined : userStore.user?.id))
const mateClass = computed(() => (board.value?.scope !== "class" ? classContext.value : null))
const mateClass = computed(() => (board.value?.scope !== 'class' ? classContext.value : null))
const title = computed(() =>
board.value
? `${groupTitle(board.value.scope, board.value.className)} · ${periodLabel(board.value.period)}`
: "",
)
const title = computed(() => (board.value ? `${groupTitle(board.value.scope, board.value.className)} · ${periodLabel(board.value.period)}` : ''))
/** 只报人数。同分怎么排、名单折在哪,赛道自己看得出来,不再写一遍(用户嫌解释太多) */
const subtitle = computed(() => {
const b = board.value
if (!b) return ""
return b.scope === "class" ? `${b.total} 人` : `${b.total} 人做对过`
if (!b) return ''
return b.scope === 'class' ? `${b.total} 人` : `${b.total} 人做对过`
})
const miniScopes = computed(() =>
SCOPE_OPTIONS.map((option) => option.value).filter(
(s) => s !== scope.value && (s === "all" || !!myClass.value),
),
)
const miniScopes = computed(() => SCOPE_OPTIONS.map((option) => option.value).filter((s) => s !== scope.value && (s === 'all' || !!myClass.value)))
const minis = computed<MiniRank[]>(() => {
const list: MiniRank[] = []
if (period.value !== "week" && myClass.value && weekBoard.value)
if (period.value !== 'week' && myClass.value && weekBoard.value)
list.push({
label: "这周 · 本班",
label: '这周 · 本班',
rank: weekBoard.value.me?.solved ? weekBoard.value.me.rank : null,
total: null,
go: () => {
scope.value = "class"
period.value = "week"
scope.value = 'class'
period.value = 'week'
},
})
for (const s of miniScopes.value) {
@@ -247,7 +221,7 @@ const minis = computed<MiniRank[]>(() => {
const teacherClassOptions = computed(() => {
const list = configStore.config?.classList ?? []
const all = teacherClass.value ? [teacherClass.value, ...list] : list
return [{ label: "全部", value: "" }, ...groupClassOptions(all)]
return [{ label: '全部', value: '' }, ...groupClassOptions(all)]
})
const myBattle = computed(() => battle.value.find((item) => item.className === classContext.value))
@@ -262,37 +236,30 @@ const daysLeft = computed(() => {
const trendLines = computed(() => {
const b = board.value
if (!b?.me) return []
const short = (row: RankRow) =>
row.user.username.replace(USERNAME_CLASS_RE, "") || row.user.username
const short = (row: RankRow) => row.user.username.replace(USERNAME_CLASS_RE, '') || row.user.username
return [
...(b.behind
? [{ userId: b.behind.user.id, label: short(b.behind), color: palette.value.threat }]
: []),
...(b.ahead
? [{ userId: b.ahead.user.id, label: short(b.ahead), color: palette.value.chase }]
: []),
{ userId: b.me.user.id, label: "你", color: palette.value.me, strong: true },
...(b.behind ? [{ userId: b.behind.user.id, label: short(b.behind), color: palette.value.threat }] : []),
...(b.ahead ? [{ userId: b.ahead.user.id, label: short(b.ahead), color: palette.value.chase }] : []),
{ userId: b.me.user.id, label: '你', color: palette.value.me, strong: true },
]
})
const weekTitle = computed(() =>
teacher.value && classContext.value ? `这周 · ${classLabel(classContext.value)}` : "这周本班",
)
const weekTitle = computed(() => (teacher.value && classContext.value ? `这周 · ${classLabel(classContext.value)}` : '这周本班'))
/** 手机上的页签:没有班就只剩班级对抗,没有走势(这周 / 老师)就不给走势那页 */
const phoneTabs = computed(() => [
{ key: "battle", label: "班级对抗" },
...(board.value?.trend && me.value ? [{ key: "trend", label: "名次走势" }] : []),
{ key: 'battle', label: '班级对抗' },
...(board.value?.trend && me.value ? [{ key: 'trend', label: '名次走势' }] : []),
...(classContext.value
? [
{ key: "week", label: teacher.value ? "这周" : "这周本班" },
{ key: "champions", label: "每周冠军" },
{ key: 'week', label: teacher.value ? '这周' : '这周本班' },
{ key: 'champions', label: '每周冠军' },
]
: []),
])
const phoneTab = ref("battle")
const phoneTab = ref('battle')
watch(phoneTabs, (tabs) => {
if (!tabs.some((tab) => tab.key === phoneTab.value)) phoneTab.value = "battle"
if (!tabs.some((tab) => tab.key === phoneTab.value)) phoneTab.value = 'battle'
})
/** 班级详情(+ 老师的 AI 分析):点班级对抗里的班名、或老师「这个班」卡上的按钮 */
@@ -304,27 +271,27 @@ function openDetail(className: string) {
}
function open(username: string) {
router.push({ path: "/user", query: { name: username } })
router.push({ path: '/user', query: { name: username } })
}
/** 老师看某个学生的智能分析(那一页本来就支持 ?username=) */
function openAnalysis(username: string) {
router.push({ path: "/ai-analysis", query: { username } })
router.push({ path: '/ai-analysis', query: { username } })
}
function openSubmissions(username: string) {
router.push({ path: "/submission", query: { username, exactUsername: "1" } })
router.push({ path: '/submission', query: { username, exactUsername: '1' } })
}
function hide(row: RankRow) {
dialog.warning({
title: "不计入排名",
title: '不计入排名',
content: `${row.user.username} 会从所有排名里消失(本班、本年级、全服、首页周榜),本人会看到「不计入排名」。比赛排名不受影响,随时能恢复。`,
positiveText: "不计入排名",
negativeText: "取消",
positiveText: '不计入排名',
negativeText: '取消',
onPositiveClick: async () => {
await setRankHidden(row.user.id, true)
message.success("已不计入排名")
message.success('已不计入排名')
cache.clear()
load()
loadBattle()
@@ -336,8 +303,7 @@ function hide(row: RankRow) {
function setMood(userId: number, mood: string | null) {
const b = board.value
if (!b) return
for (const row of [...b.rows, b.me, b.ahead, b.behind])
if (row?.user.id === userId) row.mood = mood
for (const row of [...b.rows, b.me, b.ahead, b.behind]) if (row?.user.id === userId) row.mood = mood
}
function onMoodSaved(mood: string | null) {
@@ -346,21 +312,21 @@ function onMoodSaved(mood: string | null) {
function confirmClearMood(row: RankRow) {
dialog.warning({
title: "清空个性签名",
title: '清空个性签名',
content: `${row.user.username} 的签名「${row.mood}」会被清空。只清这一句,之后还能重新写。`,
positiveText: "清空",
negativeText: "取消",
positiveText: '清空',
negativeText: '取消',
onPositiveClick: async () => {
await clearMood(row.user.id)
setMood(row.user.id, null)
message.success("签名已清空")
message.success('签名已清空')
},
})
}
async function restore(userId: number) {
await setRankHidden(userId, false)
message.success("已恢复")
message.success('已恢复')
cache.clear()
load()
loadBattle()
@@ -386,23 +352,11 @@ async function restore(userId: number) {
</button>
</div>
<div class="seg" role="group" aria-label="时间">
<button
v-for="option in PERIOD_OPTIONS"
:key="option.value"
:class="{ on: period === option.value }"
@click="period = option.value"
>
<button v-for="option in PERIOD_OPTIONS" :key="option.value" :class="{ on: period === option.value }" @click="period = option.value">
{{ option.label }}
</button>
</div>
<n-select
v-if="teacher"
v-model:value="pickedClass"
class="class-select"
filterable
placeholder="选班"
:options="teacherClassOptions"
/>
<n-select v-if="teacher" v-model:value="pickedClass" class="class-select" filterable placeholder="选班" :options="teacherClassOptions" />
</div>
<div class="main" :class="{ single: !isDesktop }">
@@ -469,7 +423,7 @@ async function restore(userId: number) {
<span class="muted">有疑问去问老师</span>
</div>
<div v-else-if="teacher" class="note-card">
<b>{{ classContext ? classLabel(classContext) : "全服" }}</b>
<b>{{ classContext ? classLabel(classContext) : '全服' }}</b>
<div v-if="myBattle" class="stats">
<div>
<span>人均做对</span><b>{{ myBattle.perCapita }}</b>
@@ -482,9 +436,7 @@ async function restore(userId: number) {
</div>
</div>
<div class="actions">
<n-button v-if="classContext" secondary type="info" @click="openDetail(classContext)">
班级详情 · AI 分析
</n-button>
<n-button v-if="classContext" secondary type="info" @click="openDetail(classContext)"> 班级详情 · AI 分析 </n-button>
<n-button
secondary
@click="
@@ -510,14 +462,7 @@ async function restore(userId: number) {
<span class="muted">做对 1 道新题就能上榜</span>
</div>
</template>
<ClassBattle
v-if="isDesktop"
:items="battle"
:mine="classContext"
:teacher="teacher"
:pk="userStore.isAuthed"
@pick="openDetail"
/>
<ClassBattle v-if="isDesktop" :items="battle" :mine="classContext" :teacher="teacher" :pk="userStore.isAuthed" @pick="openDetail" />
</aside>
</div>
<div v-if="isDesktop && classContext" class="below">
@@ -538,13 +483,7 @@ async function restore(userId: number) {
<div class="card-title">
<b>每周冠军</b>
</div>
<ChampionList
:champions="champions"
:hot="hot"
:since="period === 'week' ? '今天' : '这周'"
:me-id="meId"
@open="open"
/>
<ChampionList :champions="champions" :hot="hot" :since="period === 'week' ? '今天' : '这周'" :me-id="meId" @open="open" />
</div>
</div>
@@ -563,20 +502,8 @@ async function restore(userId: number) {
</button>
</div>
<div class="tab-body">
<ClassBattle
v-if="phoneTab === 'battle'"
bare
:items="battle"
:mine="classContext"
:teacher="teacher"
:pk="userStore.isAuthed"
@pick="openDetail"
/>
<RankTrend
v-else-if="phoneTab === 'trend' && board?.trend"
:trend="board.trend"
:lines="trendLines"
/>
<ClassBattle v-if="phoneTab === 'battle'" bare :items="battle" :mine="classContext" :teacher="teacher" :pk="userStore.isAuthed" @pick="openDetail" />
<RankTrend v-else-if="phoneTab === 'trend' && board?.trend" :trend="board.trend" :lines="trendLines" />
<template v-else-if="phoneTab === 'week'">
<span class="muted">{{ weekTitle }} · 还剩 {{ daysLeft }} 天</span>
<WeekTop :rows="weekBoard?.rows ?? []" :me-id="meId" :loaded="!!weekBoard" @open="open" />
@@ -591,12 +518,7 @@ async function restore(userId: number) {
/>
</div>
</div>
<ClassDetailDrawer
v-model:show="showDetail"
:class-name="detailClass"
:teacher="teacher"
:pk="userStore.isAuthed"
/>
<ClassDetailDrawer v-model:show="showDetail" :class-name="detailClass" :teacher="teacher" :pk="userStore.isAuthed" />
</div>
</template>
@@ -624,7 +546,7 @@ async function restore(userId: number) {
.seg {
display: inline-flex;
height: var(--oj-ctrl-h);
border: 1px solid v-bind("theme.borderColor");
border: 1px solid v-bind('theme.borderColor');
border-radius: 4px;
overflow: hidden;
}
@@ -632,9 +554,9 @@ async function restore(userId: number) {
.seg button {
padding: 0 14px;
border: 0;
border-left: 1px solid v-bind("theme.borderColor");
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor2");
border-left: 1px solid v-bind('theme.borderColor');
background: v-bind('theme.cardColor');
color: v-bind('theme.textColor2');
font: inherit;
font-size: var(--oj-fs-sec);
cursor: pointer;
@@ -652,7 +574,7 @@ async function restore(userId: number) {
}
.seg button:disabled {
color: v-bind("theme.textColorDisabled");
color: v-bind('theme.textColorDisabled');
cursor: not-allowed;
}
@@ -662,7 +584,7 @@ async function restore(userId: number) {
.muted {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
}
.main {
@@ -684,9 +606,9 @@ async function restore(userId: number) {
.track-card,
.card,
.note-card {
border: 1px solid v-bind("theme.borderColor");
border: 1px solid v-bind('theme.borderColor');
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
background: v-bind('theme.cardColor');
min-width: 0;
}
@@ -712,7 +634,7 @@ async function restore(userId: number) {
.empty {
padding: 60px 0;
text-align: center;
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
}
.side {
@@ -746,12 +668,12 @@ async function restore(userId: number) {
gap: 1px;
padding: 8px 10px;
border-radius: 4px;
background: v-bind("theme.actionColor");
background: v-bind('theme.actionColor');
}
.stats span {
font-size: 12px;
color: v-bind("theme.textColor3");
color: v-bind('theme.textColor3');
}
.stats b {
@@ -769,7 +691,7 @@ async function restore(userId: number) {
flex-direction: column;
gap: 6px;
padding-top: 10px;
border-top: 1px solid v-bind("theme.dividerColor");
border-top: 1px solid v-bind('theme.dividerColor');
}
.hidden-title {
@@ -819,7 +741,7 @@ async function restore(userId: number) {
.tabs {
display: flex;
border-bottom: 1px solid v-bind("theme.dividerColor");
border-bottom: 1px solid v-bind('theme.dividerColor');
}
.tabs button {
@@ -830,7 +752,7 @@ async function restore(userId: number) {
background: none;
font: inherit;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor2");
color: v-bind('theme.textColor2');
cursor: pointer;
}
+19 -19
View File
@@ -1,4 +1,4 @@
import { useDark } from "@vueuse/core"
import { useDark } from '@vueuse/core'
/**
* 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、前一名蓝、
@@ -8,39 +8,39 @@ import { useDark } from "@vueuse/core"
export function useRankPalette() {
const isDark = useDark()
return computed(() => ({
me: "#18a058",
chase: "#2f6fd0",
threat: "#c76a12",
mate: isDark.value ? "#2f6b47" : "#a8dcbd",
bar: isDark.value ? "#4a525c" : "#c9d3dc",
me: '#18a058',
chase: '#2f6fd0',
threat: '#c76a12',
mate: isDark.value ? '#2f6b47' : '#a8dcbd',
bar: isDark.value ? '#4a525c' : '#c9d3dc',
/**
* 桌面上条后面那道满宽的底槽。没有它的话,大家题数一样时一列等长的灰色短条
* 像页面没加载完的占位块(2026-10 用户截图问「这是什么」)
*/
track: isDark.value ? "rgba(255,255,255,0.05)" : "#f1f4f7",
track: isDark.value ? 'rgba(255,255,255,0.05)' : '#f1f4f7',
/** 手机上条是整行底色,要淡到字压在上面看得清 */
soft: {
me: isDark.value ? "rgba(24,160,88,0.32)" : "#cdebd9",
chase: isDark.value ? "rgba(47,111,208,0.32)" : "#d6e3f7",
threat: isDark.value ? "rgba(199,106,18,0.32)" : "#f7e0cb",
mate: isDark.value ? "rgba(24,160,88,0.16)" : "#e2f3e9",
bar: isDark.value ? "rgba(255,255,255,0.08)" : "#eef1f4",
me: isDark.value ? 'rgba(24,160,88,0.32)' : '#cdebd9',
chase: isDark.value ? 'rgba(47,111,208,0.32)' : '#d6e3f7',
threat: isDark.value ? 'rgba(199,106,18,0.32)' : '#f7e0cb',
mate: isDark.value ? 'rgba(24,160,88,0.16)' : '#e2f3e9',
bar: isDark.value ? 'rgba(255,255,255,0.08)' : '#eef1f4',
},
medal: [
{
color: isDark.value ? "#e2b33b" : "#9a6700",
background: isDark.value ? "rgba(240,180,40,0.22)" : "#fbf1d9",
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 ? '#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",
color: isDark.value ? '#c58b62' : '#8c5a3c',
background: isDark.value ? 'rgba(170,110,70,0.22)' : '#f6ece5',
},
],
}))
}
export type RankPalette = ReturnType<typeof useRankPalette>["value"]
export type RankPalette = ReturnType<typeof useRankPalette>['value']
+12 -12
View File
@@ -1,16 +1,16 @@
import type { RankPeriod, RankRow, RankScope } from "@oj2/contract"
import { classLabel } from "utils/classes"
import type { RankPeriod, RankRow, RankScope } from '@oj2/contract'
import { classLabel } from 'utils/classes'
export const SCOPE_OPTIONS: { value: RankScope; label: string }[] = [
{ value: "class", label: "本班" },
{ value: "grade", label: "本年级" },
{ value: "all", label: "全服" },
{ 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: "全部" },
{ value: 'week', label: '这周' },
{ value: 'term', label: '这学期' },
{ value: 'all', label: '全部' },
]
export function scopeLabel(scope: RankScope) {
@@ -23,9 +23,9 @@ export function periodLabel(period: RankPeriod) {
/** 榜的标题:本班写班名,本年级写「25 级」,全服就是「全服」 */
export function groupTitle(scope: RankScope, className: string | null) {
if (scope === "class" && className) return classLabel(className)
if (scope === "grade" && className) return `${className} 级`
return "全服"
if (scope === 'class' && className) return classLabel(className)
if (scope === 'grade' && className) return `${className} 级`
return '全服'
}
/**
@@ -34,7 +34,7 @@ export function groupTitle(scope: RankScope, className: string | null) {
*/
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 === 0) return '同一秒'
if (seconds < 60) return `${seconds} 秒`
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60)