Files
OJ2/apps/web/src/shared/components/Login.vue
T
xuyueandClaude Opus 5.5 a5d5c39411 fix(手机): 收起键盘后「提交」按钮不回来;比赛领奖台第一名被挤出屏幕;iPhone 点输入框整页放大
- 题目页:安卓按返回键收键盘时编辑器不失焦,符号键一直贴底、「运行例子 / 提交」不回来。
  改成看键盘真弹着没有(可视视口变矮),不只看焦点
- 比赛排名:结束后的领奖台三块共 646px,手机上第一名被切掉、第三名看不见;改成平分一行
- iPhone:点 14/15px 的输入框和代码编辑器,Safari 会整页放大且不缩回;只给 iOS 加 maximum-scale=1
- 登录:手机上选完班不再自动聚焦搜索框(键盘弹出来盖住半张名单)
- 题单:窄屏上题目拆成两张没标题的卡片,像分了两组;放不下两栏时合成一张
- 「中等」在题目页是蓝色、在题目列表是橙色,统一成橙色
- 零碎:自学四列表格在 360 宽上收窄格子;统计「错得最多的题」表头不再挤成竖排;
  「第 N 名」「14 道」不再把最后一个字甩到下一行

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:36:33 +08:00

656 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import RankAvatar from "oj/rank/components/RankAvatar.vue"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { errorCode } from "utils/api"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import { getClassUsernames, login } from "../api"
import { useAuthModalStore } from "../store/authModal"
import UserName from "./UserName.vue"
import { useConfigStore } from "../store/config"
import { useUserStore } from "../store/user"
/**
* 登录框两种样子:
*
* - **点名字**(默认):班级记在这台电脑上,一打开就是这个班的名单,点自己的名字再输密码。
* 机房一台电脑对一个班,学生一个字都不用打。
* - **写用户名**:老师、往届(班级已经从后台配置里删掉的)、自己注册的。9 月以来约一成的
* 登录走这条,所以入口「名单里没有我」「老师登录」一直摆在名单底下。
*
* 这台电脑上次用哪种登上的,下次就先开哪种 —— 老师的电脑不用每次都切。
*/
type Mode = "pick" | "user"
const userStore = useUserStore()
const configStore = useConfigStore()
const authStore = useAuthModalStore()
const { loginModalOpen } = storeToRefs(authStore)
const theme = useThemeVars()
// n-modal 把内容传送到 body 底下,CSS 的 v-bind() 挂不上去(变量挂在组件根上,
// 这个组件的根是 n-modal 自己)—— 颜色直接写到弹框根节点的 style 里
const themeVars = computed(() => ({
"--t-border-color": theme.value.borderColor,
"--t-box-shadow-3": theme.value.boxShadow3,
"--t-error-color": theme.value.errorColor,
"--t-hover-color": theme.value.hoverColor,
"--t-modal-color": theme.value.modalColor,
"--t-primary-color": theme.value.primaryColor,
"--t-primary-color-hover": theme.value.primaryColorHover,
"--t-text-color-1": theme.value.textColor1,
"--t-text-color-2": theme.value.textColor2,
"--t-text-color-3": theme.value.textColor3,
}))
const mode = ref<Mode>("pick")
/** 选中的班,带 ks 前缀(`ks257`),和用户名的前缀同一个写法 */
const klass = ref<string | null>(null)
const names = ref<string[]>([])
const namesLoading = ref(false)
const query = ref("")
const picked = ref<string | null>(null)
const username = ref("")
const password = ref("")
const loading = ref(false)
const error = ref("")
/** 错太多次被锁了:按钮灰掉,换个名字 / 换种登法才放开 */
const locked = ref(false)
const searchRef = useTemplateRef("searchRef")
const passwordRef = useTemplateRef("passwordRef")
const usernameRef = useTemplateRef("usernameRef")
const knownClasses = computed(() => (configStore.config?.classList ?? []).map((c) => `ks${c}`))
const classOptions = computed(() =>
groupClassOptions(configStore.config?.classList ?? [], (item) => ({
label: classLabel(item),
value: `ks${item}`,
})),
)
/** 还没选过班的电脑:名单的位置先摆一排排班级 */
const classGroups = computed(() =>
classOptions.value.flatMap((option) =>
"children" in option ? [{ label: option.label, items: option.children }] : [],
),
)
// 名字支持拼音和拼音缩写搜索(张三 ← zhangsan / zs)。pinyin-pro 带着一份几百 K 的
// 词典,静态引入会压进首屏 —— 改成打开登录框才拉,已登录的人一次都不会下载。
// 用 match 而不是自己拼拼音串:多音字它两个读音都认(单田芳 ← stf 和 dtf 都能搜到)。
type MatchFn = typeof import("pinyin-pro").match
const pinyinMatch = shallowRef<MatchFn | null>(null)
async function ensurePinyin() {
if (pinyinMatch.value) return
try {
pinyinMatch.value = (await import("pinyin-pro")).match
} catch {
// 拉不下来就只剩中文匹配,不至于让人登不上
}
}
function matches(name: string, keyword: string) {
if (name.includes(keyword)) return true
return pinyinMatch.value ? pinyinMatch.value(name, keyword, { continuous: true }) !== null : false
}
// 按拼音排:zh 的排序规则在 Chrome 里就是拼音序,不用等 pinyin-pro
const collator = new Intl.Collator("zh-CN")
const shown = computed(() => {
const keyword = query.value.trim()
return keyword ? names.value.filter((name) => matches(name, keyword)) : names.value
})
async function loadNames(value: string) {
namesLoading.value = true
names.value = []
try {
const list = await getClassUsernames(value.slice(2))
// 切班的请求比上一个先回来时,别让旧班的名单盖上来
if (klass.value === value) names.value = [...list].sort(collator.compare)
} catch {
if (klass.value === value) names.value = []
} finally {
if (klass.value === value) namesLoading.value = false
}
}
watch(klass, (value) => {
picked.value = null
query.value = ""
clearError()
if (value) {
loadNames(value)
focusSearch()
} else names.value = []
})
function clearError() {
error.value = ""
locked.value = false
}
/** 要聚焦的框往往下一帧才渲染出来,所以传取值函数,到时候再取 */
function focusLater(target: () => { focus: () => void } | null | undefined) {
nextTick(() => target()?.focus())
}
/**
* 手机上不替学生点搜索框:一聚焦键盘就弹出来,把下面的名单盖掉一半,
* 而手机上本来就是直接点名字更快。电脑上照旧聚焦,进来就能打拼音缩写。
*/
const touchScreen = useMediaQuery("(pointer: coarse)")
function focusSearch() {
if (!touchScreen.value) focusLater(() => searchRef.value)
}
function pick(name: string) {
picked.value = name
password.value = ""
clearError()
focusLater(() => passwordRef.value)
}
function unpick() {
picked.value = null
password.value = ""
clearError()
focusSearch()
}
/** 搜到只剩一个人,回车就是点他 */
function onSearchEnter() {
if (shown.value.length === 1) pick(shown.value[0]!)
}
function switchMode(next: Mode) {
mode.value = next
picked.value = null
username.value = ""
password.value = ""
query.value = ""
clearError()
if (next === "user") focusLater(() => usernameRef.value)
else focusSearch()
}
function goSignup() {
authStore.switchToSignup()
}
async function submit() {
if (loading.value || locked.value) return
const name =
mode.value === "pick" ? `${klass.value ?? ""}${picked.value ?? ""}` : username.value.trim()
if (mode.value === "user" && !name) {
error.value = "写上用户名"
focusLater(() => usernameRef.value)
return
}
if (!password.value) {
error.value = "写上密码"
focusLater(() => passwordRef.value)
return
}
loading.value = true
clearError()
try {
await login({ username: name, password: password.value })
} catch (err) {
// 判错误码而不是错误文案:文案在后端,改一个字这里就静默掉进「登录不上」
const code = errorCode(err)
if (code === "invalid-credentials") {
// 名字是点出来的,错只会错在密码
error.value = mode.value === "pick" ? "密码不对,再试一次" : "用户名或密码不对"
} else if (code === "too-many-login-attempts") {
error.value = "密码错太多次了,15 分钟后再试"
locked.value = true
} else if (code === "account-disabled") {
error.value = "这个账号停用了,去找老师"
} else {
error.value = "登录不上,等一会儿再试"
}
password.value = ""
focusLater(() => passwordRef.value)
return
} finally {
loading.value = false
}
storage.set(STORAGE_KEY.LOGIN_MODE, mode.value)
if (mode.value === "pick") storage.set(STORAGE_KEY.LOGIN_CLASS, klass.value)
authStore.closeLoginModal()
// 「上次登录以来」不再弹框,拆进了首页的「上次来」卡(/me/last-visit)
await userStore.getMyProfile()
}
/**
* 机房电脑一台对一个班,班级记在本地下次自动填上;
* 名字不记 —— 同一台机器换个人坐就是别人的名字了。
*/
function restoreLast() {
const lastMode = storage.get(STORAGE_KEY.LOGIN_MODE)
mode.value = lastMode === "user" ? "user" : "pick"
const last = storage.get(STORAGE_KEY.LOGIN_CLASS)
if (typeof last === "string" && knownClasses.value.includes(last)) {
if (klass.value === last) {
// 同一个班:名单留着,只把上一个人的痕迹清掉
picked.value = null
query.value = ""
} else klass.value = last
}
}
// 配置是异步到的,登录框可能先开了
watch(knownClasses, () => {
if (klass.value === null) restoreLast()
})
// 每次打开都从干净的表单开始,别把上一个人的名字和密码留在框里
watch(
loginModalOpen,
(open) => {
if (!open) return
ensurePinyin()
picked.value = null
username.value = ""
password.value = ""
query.value = ""
clearError()
restoreLast()
if (mode.value === "user") focusLater(() => usernameRef.value)
else focusSearch()
},
{ immediate: true },
)
</script>
<template>
<n-modal v-model:show="loginModalOpen" :mask-closable="false" :auto-focus="false">
<div class="box" :style="themeVars" :class="mode" role="dialog" aria-label="登录">
<div class="head">
<h2>登录</h2>
<n-select
v-if="mode === 'pick'"
v-model:value="klass"
class="klass"
:options="classOptions"
filterable
placeholder="选班级"
:consistent-menu-width="false"
/>
<span class="grow"></span>
<button class="close" type="button" aria-label="关闭" @click="authStore.closeLoginModal()">
<Icon icon="ph:x" :width="18" />
</button>
</div>
<template v-if="mode === 'pick'">
<!-- 点了名字:名单换成这个人,光标直接在密码框 -->
<div v-if="picked" class="me">
<RankAvatar :username="`${klass}${picked}`" :avatar="null" :size="64" />
<div class="me-name"><UserName :username="`${klass}${picked}`" /></div>
<div class="field narrow">
<n-input
ref="passwordRef"
v-model:value="password"
type="password"
show-password-on="click"
size="large"
placeholder="密码"
:input-props="{ name: 'class-password', autocomplete: 'new-password' }"
:status="error ? 'error' : undefined"
@update:value="locked = false"
@keyup.enter="submit"
/>
<div v-if="error" class="err">{{ error }}</div>
</div>
<n-button
class="narrow"
type="primary"
size="large"
:loading="loading"
:disabled="locked"
@click="submit"
>
登录
</n-button>
<button class="link back" type="button" @click="unpick">
<Icon icon="ph:caret-left-bold" :width="14" />不是我,换一个名字
</button>
</div>
<template v-else-if="klass">
<n-input
ref="searchRef"
v-model:value="query"
class="search"
clearable
placeholder="打名字或拼音缩写"
:input-props="{ autocomplete: 'off' }"
@keyup.enter="onSearchEnter"
>
<template #prefix><Icon icon="ph:magnifying-glass" :width="16" /></template>
</n-input>
<div class="scroll">
<div v-if="namesLoading" class="empty"><n-spin size="small" /></div>
<div v-else-if="!names.length" class="empty">这个班还没有名单</div>
<div v-else-if="!shown.length" class="empty">没有叫这个的</div>
<div v-else class="grid">
<button
v-for="name in shown"
:key="name"
class="cell"
type="button"
@click="pick(name)"
>
{{ name }}
</button>
</div>
</div>
</template>
<!-- 这台电脑还没登过:先选班 -->
<div v-else class="scroll classes">
<div v-for="group in classGroups" :key="group.label" class="grade">
<span class="grade-label">{{ group.label }}</span>
<div class="grid">
<button
v-for="item in group.items"
:key="item.value"
class="cell"
type="button"
@click="klass = item.value"
>
{{ item.label }}
</button>
</div>
</div>
</div>
<div class="links">
<button class="link" type="button" @click="switchMode('user')">名单里没有我</button>
<i></i>
<button class="link" type="button" @click="switchMode('user')">老师登录</button>
<template v-if="configStore.config?.allowRegister">
<i></i>
<button class="link" type="button" @click="goSignup">注册</button>
</template>
</div>
</template>
<!-- 写用户名:老师、往届、自己注册的 -->
<div v-else class="form">
<label class="field">
<span class="lab">用户名</span>
<n-input
ref="usernameRef"
v-model:value="username"
size="large"
clearable
placeholder="完整用户名,如 ks231张三"
:input-props="{ name: 'username', autocomplete: 'username' }"
@keyup.enter="passwordRef?.focus()"
/>
</label>
<label class="field">
<span class="lab">密码</span>
<n-input
ref="passwordRef"
v-model:value="password"
type="password"
show-password-on="click"
size="large"
placeholder="密码"
:input-props="{ name: 'password', autocomplete: 'current-password' }"
:status="error ? 'error' : undefined"
@update:value="locked = false"
@keyup.enter="submit"
/>
<span v-if="error" class="err">{{ error }}</span>
</label>
<n-button type="primary" size="large" :loading="loading" :disabled="locked" @click="submit">
登录
</n-button>
<div class="links">
<button class="link back" type="button" @click="switchMode('pick')">
<Icon icon="ph:caret-left-bold" :width="14" />按班级选名字
</button>
<template v-if="configStore.config?.allowRegister">
<i></i>
<button class="link" type="button" @click="goSignup">注册</button>
</template>
</div>
</div>
</div>
</n-modal>
</template>
<style scoped>
.box {
display: flex;
flex-direction: column;
box-sizing: border-box;
max-height: calc(100vh - 32px);
padding: 24px 28px;
border-radius: var(--oj-radius);
background: var(--t-modal-color);
color: var(--t-text-color-1);
box-shadow: var(--t-box-shadow-3);
}
.box.pick {
width: min(800px, calc(100vw - 32px));
}
.box.user {
width: min(420px, calc(100vw - 32px));
}
.head {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.head h2 {
margin: 0;
font-size: 20px;
line-height: 28px;
}
.klass {
width: 170px;
}
.grow {
flex: 1;
}
.close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--t-text-color-2);
cursor: pointer;
}
.close:hover {
background: var(--t-hover-color);
}
.search {
margin-top: 18px;
flex-shrink: 0;
}
.scroll {
margin-top: 14px;
min-height: 0;
overflow-y: auto;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 8px;
}
.cell {
height: 40px;
padding: 0 6px;
border: 1px solid var(--t-border-color);
border-radius: 6px;
background: transparent;
font: inherit;
font-size: var(--oj-fs-body);
color: var(--t-text-color-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.cell:hover,
.cell:focus-visible {
border-color: var(--t-primary-color);
color: var(--t-primary-color);
outline: none;
}
.classes {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 20px;
}
.classes .grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
}
.grade {
display: flex;
flex-direction: column;
gap: 8px;
}
.grade-label {
font-size: var(--oj-fs-sec);
color: var(--t-text-color-3);
}
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 160px;
color: var(--t-text-color-3);
}
.me {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
padding: 36px 0 30px;
}
.me-name {
font-size: 22px;
}
.narrow {
width: min(300px, 100%);
}
.field {
display: flex;
flex-direction: column;
}
.lab {
margin-bottom: 6px;
font-size: var(--oj-fs-sec);
color: var(--t-text-color-2);
}
.err {
margin-top: 6px;
font-size: var(--oj-fs-sec);
line-height: 20px;
color: var(--t-error-color);
}
.form {
display: flex;
flex-direction: column;
gap: 18px;
margin-top: 20px;
}
.links {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 18px;
flex-shrink: 0;
}
.form .links {
margin-top: 0;
}
.links i {
width: 1px;
height: 12px;
background: var(--t-border-color);
}
.link {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: var(--oj-fs-sec);
color: var(--t-primary-color);
cursor: pointer;
}
.link:hover {
color: var(--t-primary-color-hover);
}
/* 手机:铺满一屏,名单往下滑,底下两条入口一直在 */
@media (max-width: 640px) {
.box.pick,
.box.user {
width: 100vw;
height: 100vh;
max-height: none;
border-radius: 0;
padding: 12px 16px 16px;
}
.klass {
width: 150px;
}
.scroll {
flex: 1;
}
}
</style>