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>
|
||||
Reference in new issue
Block a user