feat(登录): 登录框改成点名字:打开就是这个班的名单,点自己再输密码;老师和往届写用户名,这台电脑上次怎么登下次就怎么开;注册完直接登上
Deploy / deploy (push) Canceled after 0s

- 学生登录 / 管理员登录两个页签和「没有我所在的班级」合成一条「写用户名」,入口是名单底下的「名单里没有我」「老师登录」
- 名单按拼音排、可打拼音缩写筛,只剩一个人时回车就选中;这台电脑没登过时先列出全部班级
- 密码错就地标在密码框下面(「密码不对,再试一次」),错太多次按钮灰掉写清 15 分钟
- 注册保留邮箱,错在哪个框标在哪个框下面,按钮改「注册并登录」
- name / autocomplete 原来写在 n-input 上、落在外层 div,没到真正的 input;改走 input-props,点名字时的密码框才真的不会让浏览器填上一个人的密码
- 登法和班级一样跨退出登录保留;authModal store 只管开关,表单回到组件里

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 21:48:30 +08:00
1 parent 127bd6e5ce
commit 801d84c53b
6 files changed
+859 -505

No files matched your search

+1 -1
View File
@@ -58,7 +58,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
跨端的(登录、资料、标签、验证码)在 `shared/api.ts`。
Shared logic lives in `shared/`:
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码)
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (登录框、注册框开没开), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码)
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂看板的「请假」), `problemJump` (按题号直达), `teacherHelp` (老师接单 / 打开学生编辑器), `chartTheme`, `breakpoints`, `rarity`
- `layout/` — `default.vue` and `admin.vue` layout wrappers
- `api.ts` — shared API calls (auth, profile, tags, captcha)
+578 -275
View File
@@ -1,34 +1,83 @@
<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 { storeToRefs } from "pinia"
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()
const {
loginModalOpen,
loginForm: form,
loginLoading: isLoading,
loginError: msg,
} = storeToRefs(authStore)
// 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 activeTab = ref<"student" | "admin">("student")
const studentRef = useTemplateRef("studentRef")
const adminRef = useTemplateRef("adminRef")
const studentPasswordRef = useTemplateRef("studentPasswordRef")
const classUserOptions = ref<SelectOption[]>([])
const classUserLoading = ref(false)
// 姓名支持拼音和拼音缩写搜索(张三 ← zhangsan / zs)。pinyin-pro 带着一份几百 K 的
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
@@ -41,301 +90,555 @@ async function ensurePinyin() {
// 拉不下来就只剩中文匹配,不至于让人登不上
}
}
function filterClassUser(pattern: string, option: SelectOption) {
const keyword = pattern.trim()
if (!keyword) return true
const label = String(option.label ?? "")
if (label.includes(keyword)) return true
return pinyinMatch.value
? pinyinMatch.value(label, keyword, { continuous: true }) !== null
: false
}
// 空串是「没有我所在的班级」,null 才是没选 —— 往届的班级毕业后会从后台配置里删掉,
// 这一项是他们唯一的入口,写完整用户名登录。放在最前:班级一多它沉在底下,要翻到底才找得到
const OTHER_CLASS = ""
const classList = computed<SelectOption[]>(() => [
{ label: "没有我所在的班级", value: OTHER_CLASS },
...groupClassOptions(configStore.config?.classList ?? [], (item) => ({
label: classLabel(item),
value: `ks${item}`,
})),
])
// 选了具体班级就是「选姓名」,选了「没有我所在的班级」就是「写用户名」
const isClassLogin = computed(() => Boolean(form.value.class))
const passwordRule: FormItemRule[] = [
{ required: true, message: "密码必填", trigger: "blur" },
{ min: 6, max: 20, message: "长度在 6 到 20 位之间", trigger: "input" },
]
const studentRules = computed<FormRules>(() => ({
class: [
{
validator: () => form.value.class !== null,
message: "班级必选",
trigger: ["blur", "change"],
},
],
username: [
{
required: true,
message: isClassLogin.value ? "姓名必选" : "用户名必填",
trigger: ["blur", "change"],
},
],
password: passwordRule,
}))
const adminRules: FormRules = {
username: [{ required: true, message: "用户名必填", trigger: ["blur", "change"] }],
password: passwordRule,
function matches(name: string, keyword: string) {
if (name.includes(keyword)) return true
return pinyinMatch.value ? pinyinMatch.value(name, keyword, { continuous: true }) !== null : false
}
function resetForm() {
form.value.class = null
form.value.username = null
form.value.password = ""
classUserOptions.value = []
classUserLoading.value = false
authStore.clearLoginError()
// 按拼音排: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
}
}
function onTabChange(tab: "student" | "admin") {
resetForm()
studentRef.value?.restoreValidation()
adminRef.value?.restoreValidation()
// 切回学生页签把记住的班级填回去,别因为误点了一下管理员就得重选
if (tab === "student") restoreLastClass()
watch(klass, (value) => {
picked.value = null
query.value = ""
clearError()
if (value) {
loadNames(value)
focusLater(() => searchRef.value)
} else names.value = []
})
function clearError() {
error.value = ""
locked.value = false
}
function submit() {
const formRef = activeTab.value === "student" ? studentRef : adminRef
formRef.value!.validate(async (errors?: unknown) => {
if (errors) return
try {
authStore.clearLoginError()
authStore.setLoginLoading(true)
const merged = {
username: form.value.username ?? "",
password: form.value.password,
}
// 选了班级的只填了姓名,ks + 班级号的前缀这里补
if (activeTab.value === "student" && form.value.class) {
merged.username = form.value.class + merged.username
}
await login(merged)
} catch (err) {
// 判错误码而不是错误文案:文案在后端,改一个字这里就静默掉进「无法登录」
if (errorCode(err) === "account-disabled") {
authStore.setLoginError("此账号已被封禁")
} else if (errorCode(err) === "invalid-credentials") {
authStore.setLoginError("用户名或密码不正确")
} else if (errorCode(err) === "too-many-login-attempts") {
authStore.setLoginError("密码错误次数太多,请 15 分钟后再试")
} else {
authStore.setLoginError("无法登录")
}
} finally {
authStore.setLoginLoading(false)
}
if (!msg.value) {
if (activeTab.value === "student") {
storage.set(STORAGE_KEY.LOGIN_CLASS, form.value.class)
}
authStore.closeLoginModal()
// 「上次登录以来」不再弹框,拆进了首页的「上次来」卡(/me/last-visit)
await userStore.getMyProfile()
}
})
/** 要聚焦的框往往下一帧才渲染出来,所以传取值函数,到时候再取 */
function focusLater(target: () => { focus: () => void } | null | undefined) {
nextTick(() => target()?.focus())
}
function pick(name: string) {
picked.value = name
password.value = ""
clearError()
focusLater(() => passwordRef.value)
}
function unpick() {
picked.value = null
password.value = ""
clearError()
focusLater(() => searchRef.value)
}
/** 搜到只剩一个人,回车就是点他 */
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()
focusLater(() => (next === "user" ? usernameRef.value : searchRef.value))
}
function goSignup() {
authStore.switchToSignup()
}
async function loadClassUsernames(selectedClass: string) {
classUserLoading.value = true
try {
const res = await getClassUsernames(selectedClass)
classUserOptions.value = res.map((name: string) => ({
label: name,
value: name,
}))
} catch {
classUserOptions.value = []
} finally {
classUserLoading.value = false
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
}
}
watch(
() => form.value.class,
(selectedClass) => {
classUserOptions.value = []
form.value.username = null
if (!selectedClass) {
classUserLoading.value = false
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 = "登录不上,等一会儿再试"
}
loadClassUsernames(selectedClass.slice(2))
},
)
// 选完姓名直接跳到密码框,学生的手不用离开这一列
function onUsernamePicked() {
nextTick(() => studentPasswordRef.value?.focus())
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 restoreLastClass() {
if (form.value.class !== null) return
function restoreLast() {
const lastMode = storage.get(STORAGE_KEY.LOGIN_MODE)
mode.value = lastMode === "user" ? "user" : "pick"
const last = storage.get(STORAGE_KEY.LOGIN_CLASS)
const known = (configStore.config?.classList ?? []).map((item) => `ks${item}`)
if (typeof last === "string" && known.includes(last)) {
form.value.class = last
if (typeof last === "string" && knownClasses.value.includes(last)) {
if (klass.value === last) {
// 同一个班:名单留着,只把上一个人的痕迹清掉
picked.value = null
query.value = ""
} else klass.value = last
}
}
watch(classList, restoreLastClass)
// 每次打开都从干净的表单开始,别把上一个人的姓名和密码留在框里
watch(loginModalOpen, (open) => {
if (!open) return
ensurePinyin()
resetForm()
studentRef.value?.restoreValidation()
adminRef.value?.restoreValidation()
restoreLastClass()
// 配置是异步到的,登录框可能先开了
watch(knownClasses, () => {
if (klass.value === null) restoreLast()
})
onMounted(() => {
authStore.clearLoginError()
restoreLastClass()
if (loginModalOpen.value) ensurePinyin()
})
// 每次打开都从干净的表单开始,别把上一个人的名字和密码留在框里
watch(
loginModalOpen,
(open) => {
if (!open) return
ensurePinyin()
picked.value = null
username.value = ""
password.value = ""
query.value = ""
clearError()
restoreLast()
focusLater(() => (mode.value === "user" ? usernameRef.value : searchRef.value))
},
{ immediate: true },
)
</script>
<template>
<n-modal
:mask-closable="false"
v-model:show="loginModalOpen"
preset="card"
title="登录"
style="width: 420px"
:auto-focus="false"
>
<n-tabs v-model:value="activeTab" @update:value="onTabChange">
<n-tab-pane name="student" tab="学生登录">
<n-form ref="studentRef" :model="form" :rules="studentRules" show-require-mark>
<n-form-item label="班级" path="class">
<n-select
v-model:value="form.class"
:options="classList"
filterable
name="class"
id="login-class"
placeholder="选择班级"
/>
</n-form-item>
<n-form-item :label="isClassLogin ? '姓名' : '用户名'" path="username">
<n-select
v-if="isClassLogin"
v-model:value="form.username"
:options="classUserOptions"
:loading="classUserLoading"
filterable
:filter="filterClassUser"
name="class-username"
id="login-class-username"
placeholder="选择姓名,可打拼音或缩写搜索"
@update:value="onUsernamePicked"
/>
<n-input
v-else
v-model:value="form.username"
clearable
:disabled="form.class === null"
name="username"
id="login-username"
autocomplete="username"
placeholder="完整用户名,如 ks231张三"
/>
</n-form-item>
<n-form-item label="密码" path="password">
<n-input
ref="studentPasswordRef"
v-model:value="form.password"
clearable
type="password"
:name="isClassLogin ? 'class-password' : 'password'"
:id="isClassLogin ? 'login-class-password' : 'login-password'"
:autocomplete="isClassLogin ? 'new-password' : 'current-password'"
@keyup.enter="submit"
/>
</n-form-item>
<n-alert v-if="msg" type="error" :show-icon="false">{{ msg }}</n-alert>
<n-form-item>
<n-flex style="width: 100%">
<n-button
type="primary"
:loading="isLoading"
@click="submit"
:style="{
flex: configStore.config?.allowRegister ? '0 0 auto' : '1',
}"
>
登录
</n-button>
<n-button v-if="configStore.config?.allowRegister" @click="goSignup">
没有账号?立即注册
</n-button>
</n-flex>
</n-form-item>
<n-alert :show-icon="false" class="tip">
往届的班级、列表里找不到的班级、以及自己注册的账号,选【没有我所在的班级】,用户名要写完整,比如
23 计算机 1 班张三写 ks231张三(姓名可以用拼音或拼音缩写快速查找)。
</n-alert>
</n-form>
</n-tab-pane>
<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>
<n-tab-pane name="admin" tab="管理员登录">
<n-form ref="adminRef" :model="form" :rules="adminRules" show-require-mark>
<n-form-item label="用户名" path="username">
<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
v-model:value="form.username"
clearable
name="username"
id="login-admin-username"
autocomplete="username"
placeholder="请输入用户名"
/>
</n-form-item>
<n-form-item label="密码" path="password">
<n-input
v-model:value="form.password"
clearable
ref="passwordRef"
v-model:value="password"
type="password"
name="password"
id="login-admin-password"
autocomplete="current-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"
/>
</n-form-item>
<n-alert v-if="msg" type="error" :show-icon="false">{{ msg }}</n-alert>
<n-form-item>
<n-button block type="primary" :loading="isLoading" @click="submit"> 登录 </n-button>
</n-form-item>
<n-alert :show-icon="false" class="tip">
管理员和老师从这里登录,学生请走【学生登录】页签。
</n-alert>
</n-form>
</n-tab-pane>
</n-tabs>
<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>
.tip {
margin-top: 4px;
.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>
+270 -113
View File
@@ -1,130 +1,287 @@
<script setup lang="ts">
import { registerRequestSchema } from "@oj2/contract"
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { errorCode } from "utils/api"
import { signup } from "../api"
import { storeToRefs } from "pinia"
import { login, signup } from "../api"
import { useAuthModalStore } from "../store/authModal"
import { useUserStore } from "../store/user"
/**
* 注册框。错在哪个框就标在哪个框下面,不攒成最底下一条;注册完直接登上,
* 不用回登录框再输一遍。开关在后台设置里(allow_register),关着时顶栏没有入口。
*/
type Field = "username" | "email" | "password" | "again"
const authStore = useAuthModalStore()
const userStore = useUserStore()
const { signupModalOpen } = storeToRefs(authStore)
const theme = useThemeVars()
const {
signupModalOpen,
signupForm: form,
signupLoading: isLoading,
signupError: msg,
} = storeToRefs(authStore)
const signupRef = useTemplateRef("signupRef")
// n-modal 把内容传送到 body 底下,CSS 的 v-bind() 挂不上去(变量挂在组件根上,
// 这个组件的根是 n-modal 自己)—— 颜色直接写到弹框根节点的 style 里
const themeVars = computed(() => ({
"--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,
}))
const rules: FormRules = {
username: [{ required: true, message: "用户名必填", trigger: "blur" }],
email: [{ required: true, message: "邮箱必填", trigger: "blur" }],
password: [
{ required: true, message: "密码必填", trigger: "blur" },
{ min: 6, max: 20, message: "长度在 6 到 20 位之间", trigger: "input" },
],
passwordAgain: [
{ required: true, message: "密码必填", trigger: "blur" },
{ min: 6, max: 20, message: "长度在 6 到 20 位之间", trigger: "input" },
{
validator: (_: FormItemRule, value: string) => value === form.value.password,
message: "两次密码输入不一致",
trigger: "blur",
},
],
const form = reactive({ username: "", email: "", password: "", again: "" })
const errors = reactive<Partial<Record<Field, string>>>({})
/** 不归哪个框的错(注册太频繁、网络) */
const general = ref("")
const loading = ref(false)
const usernameRef = useTemplateRef("usernameRef")
function check() {
for (const key of Object.keys(errors) as Field[]) delete errors[key]
if (!form.username.trim()) errors.username = "写上用户名"
if (!form.email.trim()) errors.email = "写上邮箱"
else if (!registerRequestSchema.shape.email.safeParse(form.email.trim()).success) {
errors.email = "邮箱的样子不对,比如 zhangsan@qq.com"
}
if (form.password.length < 6 || form.password.length > 20) errors.password = "密码要 6 到 20 位"
if (!errors.password && form.again !== form.password) errors.again = "和上面的不一样"
return Object.keys(errors).length === 0
}
function goLogin() {
authStore.switchToLogin()
function clearOne(field: Field) {
delete errors[field]
general.value = ""
}
function submit() {
signupRef.value!.validate(async (errors?: unknown) => {
if (!errors) {
try {
authStore.clearSignupError()
authStore.setSignupLoading(true)
await signup({
username: form.value.username,
email: form.value.email,
password: form.value.password,
})
} catch (err) {
if (errorCode(err) === "username-exists") {
authStore.setSignupError("用户名已存在")
} else if (errorCode(err) === "email-exists") {
authStore.setSignupError("邮箱已存在")
} else if (errorCode(err) === "too-many-registrations") {
authStore.setSignupError("注册太频繁,请稍后再试")
} else {
authStore.setSignupError("无法注册")
}
} finally {
authStore.setSignupLoading(false)
}
if (!msg.value) {
authStore.closeSignupModal()
}
}
})
async function submit() {
if (loading.value || !check()) return
loading.value = true
general.value = ""
const username = form.username.trim()
try {
await signup({ username, email: form.email.trim(), password: form.password })
} catch (err) {
const code = errorCode(err)
if (code === "username-exists") errors.username = "这个名字已经有人用了,换一个"
else if (code === "email-exists") errors.email = "这个邮箱已经注册过了"
else if (code === "too-many-registrations") general.value = "注册太频繁了,等一会儿再试"
else if (code === "registration-disabled") general.value = "现在不开放注册"
else general.value = "注册不了,等一会儿再试"
loading.value = false
return
}
try {
await login({ username, password: form.password })
authStore.closeSignupModal()
await userStore.getMyProfile()
} catch {
// 号已经建好了,只是这一下没登上:回登录框自己登
authStore.switchToLogin()
} finally {
loading.value = false
}
}
watch(signupModalOpen, (open) => {
if (!open) return
Object.assign(form, { username: "", email: "", password: "", again: "" })
for (const key of Object.keys(errors) as Field[]) delete errors[key]
general.value = ""
nextTick(() => usernameRef.value?.focus())
})
</script>
<template>
<n-modal
:mask-closable="false"
v-model:show="signupModalOpen"
preset="card"
title="注册"
style="width: 400px"
:auto-focus="false"
>
<n-form ref="signupRef" :model="form" :rules="rules" show-require-mark>
<n-form-item label="用户名" path="username">
<n-input
v-model:value="form.username"
autofocus
clearable
name="username"
id="signup-username"
autocomplete="username"
/>
</n-form-item>
<n-form-item label="邮箱" path="email">
<n-input
v-model:value="form.email"
clearable
name="email"
id="signup-email"
autocomplete="email"
@change="submit"
/>
</n-form-item>
<n-form-item label="密码" path="password">
<n-input
v-model:value="form.password"
clearable
type="password"
name="password"
id="signup-password"
autocomplete="new-password"
/>
</n-form-item>
<n-form-item label="确认密码" path="passwordAgain">
<n-input
v-model:value="form.passwordAgain"
clearable
type="password"
name="passwordAgain"
id="signup-password-again"
autocomplete="new-password"
/>
</n-form-item>
<n-alert v-if="msg" type="error" :show-icon="false"> {{ msg }}</n-alert>
<n-form-item>
<n-space>
<n-button type="primary" :loading="isLoading" @click="submit"> 注册 </n-button>
<n-button @click="goLogin">已经注册?现在登录</n-button>
</n-space>
</n-form-item>
</n-form>
<n-modal v-model:show="signupModalOpen" :mask-closable="false" :auto-focus="false">
<div class="box" :style="themeVars" role="dialog" aria-label="注册">
<div class="head">
<h2>注册</h2>
<span class="grow"></span>
<button class="close" type="button" aria-label="关闭" @click="authStore.closeSignupModal()">
<Icon icon="ph:x" :width="18" />
</button>
</div>
<div class="form">
<label class="field">
<span class="lab">用户名</span>
<n-input
ref="usernameRef"
v-model:value="form.username"
size="large"
clearable
:maxlength="32"
placeholder="以后登录就写它"
:input-props="{ name: 'username', autocomplete: 'username' }"
:status="errors.username ? 'error' : undefined"
@update:value="clearOne('username')"
/>
<span v-if="errors.username" class="err">{{ errors.username }}</span>
</label>
<label class="field">
<span class="lab">邮箱</span>
<n-input
v-model:value="form.email"
size="large"
clearable
:maxlength="64"
placeholder="常用的邮箱"
:input-props="{ name: 'email', autocomplete: 'email' }"
:status="errors.email ? 'error' : undefined"
@update:value="clearOne('email')"
/>
<span v-if="errors.email" class="err">{{ errors.email }}</span>
</label>
<label class="field">
<span class="lab">密码</span>
<n-input
v-model:value="form.password"
type="password"
show-password-on="click"
size="large"
:maxlength="20"
placeholder="6 到 20 位"
:input-props="{ name: 'password', autocomplete: 'new-password' }"
:status="errors.password ? 'error' : undefined"
@update:value="clearOne('password')"
/>
<span v-if="errors.password" class="err">{{ errors.password }}</span>
</label>
<label class="field">
<span class="lab">再输一遍密码</span>
<n-input
v-model:value="form.again"
type="password"
show-password-on="click"
size="large"
:maxlength="20"
:input-props="{ name: 'password-again', autocomplete: 'new-password' }"
:status="errors.again ? 'error' : undefined"
@update:value="clearOne('again')"
@keyup.enter="submit"
/>
<span v-if="errors.again" class="err">{{ errors.again }}</span>
</label>
<span v-if="general" class="err">{{ general }}</span>
<n-button type="primary" size="large" :loading="loading" @click="submit">
注册并登录
</n-button>
<div class="links">
<button class="link" type="button" @click="authStore.switchToLogin()">
已经有账号了,去登录
</button>
</div>
</div>
</div>
</n-modal>
</template>
<style scoped>
.box {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: min(420px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow-y: auto;
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);
}
.head {
display: flex;
align-items: center;
gap: 12px;
}
.head h2 {
margin: 0;
font-size: 20px;
line-height: 28px;
}
.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);
}
.form {
display: flex;
flex-direction: column;
gap: 18px;
margin-top: 20px;
}
.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 > .err {
margin-top: -8px;
}
.links {
display: flex;
justify-content: center;
}
.link {
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 {
width: 100vw;
height: 100vh;
max-height: none;
border-radius: 0;
padding: 12px 16px 16px;
}
}
</style>
+2 -112
View File
@@ -1,157 +1,47 @@
import { defineStore } from "pinia"
/**
* 认证状态管理 Store
* 统一管理登录、注册相关的模态框状态和表单状态
* 登录框、注册框开没开。表单本身在两个组件里:每次打开都从干净的表单开始
* (机房一台电脑轮流坐人,别把上一个人的名字和密码留在框里),不需要跨组件共享。
*/
export const useAuthModalStore = defineStore("authModal", () => {
// ==================== 模态框状态 ====================
const loginModalOpen = ref(false)
const signupModalOpen = ref(false)
// ==================== 登录表单状态 ====================
// class 为 null 表示还没选,别用空串 —— 空串是「没有我所在的班级」那一项的值。
// username 同理用 null:空串在 n-select 里是「选了一个空值」,占位符不显示。
const loginForm = reactive({
class: null as string | null,
username: null as string | null,
password: "",
})
const loginLoading = ref(false)
const loginError = ref("")
// ==================== 注册表单状态 ====================
const signupForm = reactive({
username: "",
email: "",
password: "",
passwordAgain: "",
})
const signupLoading = ref(false)
const signupError = ref("")
// ==================== 模态框操作 ====================
/**
* 打开登录模态框
*/
function openLoginModal() {
loginModalOpen.value = true
}
/**
* 关闭登录模态框
*/
function closeLoginModal() {
loginModalOpen.value = false
}
/**
* 打开注册模态框
*/
function openSignupModal() {
signupModalOpen.value = true
}
/**
* 关闭注册模态框
*/
function closeSignupModal() {
signupModalOpen.value = false
}
/**
* 从登录切换到注册
*/
function switchToSignup() {
closeLoginModal()
openSignupModal()
}
/**
* 从注册切换到登录
*/
function switchToLogin() {
closeSignupModal()
openLoginModal()
}
// ==================== 登录表单操作 ====================
/**
* 设置登录加载状态
*/
function setLoginLoading(loading: boolean) {
loginLoading.value = loading
}
/**
* 设置登录错误信息
*/
function setLoginError(error: string) {
loginError.value = error
}
/**
* 清空登录错误
*/
function clearLoginError() {
loginError.value = ""
}
// ==================== 注册表单操作 ====================
/**
* 设置注册加载状态
*/
function setSignupLoading(loading: boolean) {
signupLoading.value = loading
}
/**
* 设置注册错误信息
*/
function setSignupError(error: string) {
signupError.value = error
}
/**
* 清空注册错误
*/
function clearSignupError() {
signupError.value = ""
}
return {
// 模态框状态
loginModalOpen,
signupModalOpen,
// 登录表单状态
loginForm,
loginLoading,
loginError,
// 注册表单状态
signupForm,
signupLoading,
signupError,
// 模态框操作
openLoginModal,
closeLoginModal,
openSignupModal,
closeSignupModal,
switchToSignup,
switchToLogin,
// 登录表单操作
setLoginLoading,
setLoginError,
clearLoginError,
// 注册表单操作
setSignupLoading,
setSignupError,
clearSignupError,
}
})
+7 -4
View File
@@ -95,11 +95,14 @@ export const useUserStore = defineStore("user", () => {
function clearProfile() {
profile.value = null
demoSwitch.value = false
// 登录框记的班级要跨退登活下来:机房一台机器对一个班,下课登出、下节课再来
// 还是同一个班,清掉的话每个人都得重新选一遍。它是机器的属性,不是谁的隐私。
const loginClass = storage.get(STORAGE_KEY.LOGIN_CLASS)
// 登录框记的班级和登法要跨退登活下来:机房一台机器对一个班,下课登出、下节课再来
// 还是同一个班,清掉的话每个人都得重新选一遍;老师的电脑同理,下次还是写用户名。
// 它们是机器的属性,不是谁的隐私。
const keep = [STORAGE_KEY.LOGIN_CLASS, STORAGE_KEY.LOGIN_MODE].map(
(key) => [key, storage.get(key)] as const,
)
storage.clear()
if (loginClass !== null) storage.set(STORAGE_KEY.LOGIN_CLASS, loginClass)
for (const [key, value] of keep) if (value !== null) storage.set(key, value)
}
// 退登的两步(吊销服务端会话、清本地状态)绑在一起:只清本地会留一个还活着
+1
View File
@@ -157,6 +157,7 @@ export const STORAGE_KEY = {
ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed",
DEMO_MODE: "demoMode",
LOGIN_CLASS: "loginClass",
LOGIN_MODE: "loginMode",
RANK_CLASS: "rankClass",
}