Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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· ") }} ›</span>
|
||||
<span class="mini-label">{{ mini.label.replace(' · ', '\u00a0· ') }} ›</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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']
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user