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
+822 -468

No files matched your search

+1 -1
View File
@@ -58,7 +58,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
跨端的(登录、资料、标签、验证码)在 `shared/api.ts`。 跨端的(登录、资料、标签、验证码)在 `shared/api.ts`。
Shared logic lives in `shared/`: 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` - `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 - `layout/` — `default.vue` and `admin.vue` layout wrappers
- `api.ts` — shared API calls (auth, profile, tags, captcha) - `api.ts` — shared API calls (auth, profile, tags, captcha)
+561 -258
View File
@@ -1,34 +1,83 @@
<script setup lang="ts"> <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 { classLabel, groupClassOptions } from "oj/submission/utils"
import { errorCode } from "utils/api" import { errorCode } from "utils/api"
import { STORAGE_KEY } from "utils/constants" import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage" import storage from "utils/storage"
import { getClassUsernames, login } from "../api" import { getClassUsernames, login } from "../api"
import { storeToRefs } from "pinia"
import { useAuthModalStore } from "../store/authModal" import { useAuthModalStore } from "../store/authModal"
import UserName from "./UserName.vue"
import { useConfigStore } from "../store/config" import { useConfigStore } from "../store/config"
import { useUserStore } from "../store/user" import { useUserStore } from "../store/user"
/**
* 登录框两种样子:
*
* - **点名字**(默认):班级记在这台电脑上,一打开就是这个班的名单,点自己的名字再输密码。
* 机房一台电脑对一个班,学生一个字都不用打。
* - **写用户名**:老师、往届(班级已经从后台配置里删掉的)、自己注册的。9 月以来约一成的
* 登录走这条,所以入口「名单里没有我」「老师登录」一直摆在名单底下。
*
* 这台电脑上次用哪种登上的,下次就先开哪种 —— 老师的电脑不用每次都切。
*/
type Mode = "pick" | "user"
const userStore = useUserStore() const userStore = useUserStore()
const configStore = useConfigStore() const configStore = useConfigStore()
const authStore = useAuthModalStore() const authStore = useAuthModalStore()
const { loginModalOpen } = storeToRefs(authStore)
const theme = useThemeVars()
const { // n-modal 把内容传送到 body 底下,CSS 的 v-bind() 挂不上去(变量挂在组件根上,
loginModalOpen, // 这个组件的根是 n-modal 自己)—— 颜色直接写到弹框根节点的 style 里
loginForm: form, const themeVars = computed(() => ({
loginLoading: isLoading, "--t-border-color": theme.value.borderColor,
loginError: msg, "--t-box-shadow-3": theme.value.boxShadow3,
} = storeToRefs(authStore) "--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 activeTab = ref<"student" | "admin">("student") const klass = ref<string | null>(null)
const studentRef = useTemplateRef("studentRef") const names = ref<string[]>([])
const adminRef = useTemplateRef("adminRef") const namesLoading = ref(false)
const studentPasswordRef = useTemplateRef("studentPasswordRef") const query = ref("")
const classUserOptions = ref<SelectOption[]>([]) const picked = ref<string | null>(null)
const classUserLoading = ref(false) const username = ref("")
// 姓名支持拼音和拼音缩写搜索(张三 ← zhangsan / zs)。pinyin-pro 带着一份几百 K 的 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 都能搜到)。 // 用 match 而不是自己拼拼音串:多音字它两个读音都认(单田芳 ← stf 和 dtf 都能搜到)。
type MatchFn = typeof import("pinyin-pro").match type MatchFn = typeof import("pinyin-pro").match
@@ -41,301 +90,555 @@ async function ensurePinyin() {
// 拉不下来就只剩中文匹配,不至于让人登不上 // 拉不下来就只剩中文匹配,不至于让人登不上
} }
} }
function filterClassUser(pattern: string, option: SelectOption) {
const keyword = pattern.trim() function matches(name: string, keyword: string) {
if (!keyword) return true if (name.includes(keyword)) return true
const label = String(option.label ?? "") return pinyinMatch.value ? pinyinMatch.value(name, keyword, { continuous: true }) !== null : false
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 resetForm() { // 按拼音排:zh 的排序规则在 Chrome 里就是拼音序,不用等 pinyin-pro
form.value.class = null const collator = new Intl.Collator("zh-CN")
form.value.username = null const shown = computed(() => {
form.value.password = "" const keyword = query.value.trim()
classUserOptions.value = [] return keyword ? names.value.filter((name) => matches(name, keyword)) : names.value
classUserLoading.value = false })
authStore.clearLoginError()
}
function onTabChange(tab: "student" | "admin") { async function loadNames(value: string) {
resetForm() namesLoading.value = true
studentRef.value?.restoreValidation() names.value = []
adminRef.value?.restoreValidation()
// 切回学生页签把记住的班级填回去,别因为误点了一下管理员就得重选
if (tab === "student") restoreLastClass()
}
function submit() {
const formRef = activeTab.value === "student" ? studentRef : adminRef
formRef.value!.validate(async (errors?: unknown) => {
if (errors) return
try { try {
authStore.clearLoginError() const list = await getClassUsernames(value.slice(2))
authStore.setLoginLoading(true) // 切班的请求比上一个先回来时,别让旧班的名单盖上来
const merged = { if (klass.value === value) names.value = [...list].sort(collator.compare)
username: form.value.username ?? "", } catch {
password: form.value.password, if (klass.value === value) names.value = []
}
// 选了班级的只填了姓名,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 { } finally {
authStore.setLoginLoading(false) if (klass.value === value) namesLoading.value = false
} }
if (!msg.value) { }
if (activeTab.value === "student") {
storage.set(STORAGE_KEY.LOGIN_CLASS, form.value.class) watch(klass, (value) => {
} picked.value = null
authStore.closeLoginModal() query.value = ""
// 「上次登录以来」不再弹框,拆进了首页的「上次来」卡(/me/last-visit) clearError()
await userStore.getMyProfile() if (value) {
} loadNames(value)
}) focusLater(() => searchRef.value)
} else names.value = []
})
function clearError() {
error.value = ""
locked.value = false
}
/** 要聚焦的框往往下一帧才渲染出来,所以传取值函数,到时候再取 */
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() { function goSignup() {
authStore.switchToSignup() authStore.switchToSignup()
} }
async function loadClassUsernames(selectedClass: string) { async function submit() {
classUserLoading.value = true if (loading.value || locked.value) return
try { const name =
const res = await getClassUsernames(selectedClass) mode.value === "pick" ? `${klass.value ?? ""}${picked.value ?? ""}` : username.value.trim()
classUserOptions.value = res.map((name: string) => ({ if (mode.value === "user" && !name) {
label: name, error.value = "写上用户名"
value: name, focusLater(() => usernameRef.value)
}))
} catch {
classUserOptions.value = []
} finally {
classUserLoading.value = false
}
}
watch(
() => form.value.class,
(selectedClass) => {
classUserOptions.value = []
form.value.username = null
if (!selectedClass) {
classUserLoading.value = false
return return
} }
loadClassUsernames(selectedClass.slice(2)) if (!password.value) {
}, error.value = "写上密码"
) focusLater(() => passwordRef.value)
return
// 选完姓名直接跳到密码框,学生的手不用离开这一列 }
function onUsernamePicked() { loading.value = true
nextTick(() => studentPasswordRef.value?.focus()) 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 restoreLastClass() { function restoreLast() {
if (form.value.class !== null) return const lastMode = storage.get(STORAGE_KEY.LOGIN_MODE)
mode.value = lastMode === "user" ? "user" : "pick"
const last = storage.get(STORAGE_KEY.LOGIN_CLASS) const last = storage.get(STORAGE_KEY.LOGIN_CLASS)
const known = (configStore.config?.classList ?? []).map((item) => `ks${item}`) if (typeof last === "string" && knownClasses.value.includes(last)) {
if (typeof last === "string" && known.includes(last)) { if (klass.value === last) {
form.value.class = last // 同一个班:名单留着,只把上一个人的痕迹清掉
picked.value = null
query.value = ""
} else klass.value = last
} }
} }
watch(classList, restoreLastClass) // 配置是异步到的,登录框可能先开了
watch(knownClasses, () => {
if (klass.value === null) restoreLast()
})
// 每次打开都从干净的表单开始,别把上一个人的姓名和密码留在框里 // 每次打开都从干净的表单开始,别把上一个人的名字和密码留在框里
watch(loginModalOpen, (open) => { watch(
loginModalOpen,
(open) => {
if (!open) return if (!open) return
ensurePinyin() ensurePinyin()
resetForm() picked.value = null
studentRef.value?.restoreValidation() username.value = ""
adminRef.value?.restoreValidation() password.value = ""
restoreLastClass() query.value = ""
}) clearError()
restoreLast()
onMounted(() => { focusLater(() => (mode.value === "user" ? usernameRef.value : searchRef.value))
authStore.clearLoginError() },
restoreLastClass() { immediate: true },
if (loginModalOpen.value) ensurePinyin() )
})
</script> </script>
<template> <template>
<n-modal <n-modal v-model:show="loginModalOpen" :mask-closable="false" :auto-focus="false">
:mask-closable="false" <div class="box" :style="themeVars" :class="mode" role="dialog" aria-label="登录">
v-model:show="loginModalOpen" <div class="head">
preset="card" <h2>登录</h2>
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 <n-select
v-model:value="form.class" v-if="mode === 'pick'"
:options="classList" v-model:value="klass"
class="klass"
:options="classOptions"
filterable filterable
name="class" placeholder="选班级"
id="login-class" :consistent-menu-width="false"
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"
/> />
<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 <n-input
v-else ref="passwordRef"
v-model:value="form.username" v-model:value="password"
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" type="password"
:name="isClassLogin ? 'class-password' : 'password'" show-password-on="click"
:id="isClassLogin ? 'login-class-password' : 'login-password'" size="large"
:autocomplete="isClassLogin ? 'new-password' : 'current-password'" placeholder="密码"
:input-props="{ name: 'class-password', autocomplete: 'new-password' }"
:status="error ? 'error' : undefined"
@update:value="locked = false"
@keyup.enter="submit" @keyup.enter="submit"
/> />
</n-form-item> <div v-if="error" class="err">{{ error }}</div>
<n-alert v-if="msg" type="error" :show-icon="false">{{ msg }}</n-alert> </div>
<n-form-item>
<n-flex style="width: 100%">
<n-button <n-button
class="narrow"
type="primary" type="primary"
:loading="isLoading" size="large"
:loading="loading"
:disabled="locked"
@click="submit" @click="submit"
:style="{
flex: configStore.config?.allowRegister ? '0 0 auto' : '1',
}"
> >
登录 登录
</n-button> </n-button>
<n-button v-if="configStore.config?.allowRegister" @click="goSignup"> <button class="link back" type="button" @click="unpick">
没有账号?立即注册 <Icon icon="ph:caret-left-bold" :width="14" />不是我,换一个名字
</n-button> </button>
</n-flex> </div>
</n-form-item>
<n-alert :show-icon="false" class="tip">
往届的班级、列表里找不到的班级、以及自己注册的账号,选【没有我所在的班级】,用户名要写完整,比如
23 计算机 1 班张三写 ks231张三(姓名可以用拼音或拼音缩写快速查找)。
</n-alert>
</n-form>
</n-tab-pane>
<n-tab-pane name="admin" tab="管理员登录"> <template v-else-if="klass">
<n-form ref="adminRef" :model="form" :rules="adminRules" show-require-mark>
<n-form-item label="用户名" path="username">
<n-input <n-input
v-model:value="form.username" ref="searchRef"
v-model:value="query"
class="search"
clearable clearable
name="username" placeholder="打名字或拼音缩写"
id="login-admin-username" :input-props="{ autocomplete: 'off' }"
autocomplete="username" @keyup.enter="onSearchEnter"
placeholder="请输入用户名" >
<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()"
/> />
</n-form-item> </label>
<n-form-item label="密码" path="password"> <label class="field">
<span class="lab">密码</span>
<n-input <n-input
v-model:value="form.password" ref="passwordRef"
clearable v-model:value="password"
type="password" type="password"
name="password" show-password-on="click"
id="login-admin-password" size="large"
autocomplete="current-password" placeholder="密码"
:input-props="{ name: 'password', autocomplete: 'current-password' }"
:status="error ? 'error' : undefined"
@update:value="locked = false"
@keyup.enter="submit" @keyup.enter="submit"
/> />
</n-form-item> <span v-if="error" class="err">{{ error }}</span>
<n-alert v-if="msg" type="error" :show-icon="false">{{ msg }}</n-alert> </label>
<n-form-item> <n-button type="primary" size="large" :loading="loading" :disabled="locked" @click="submit">
<n-button block type="primary" :loading="isLoading" @click="submit"> 登录 </n-button> 登录
</n-form-item> </n-button>
<n-alert :show-icon="false" class="tip"> <div class="links">
管理员和老师从这里登录,学生请走【学生登录】页签。 <button class="link back" type="button" @click="switchMode('pick')">
</n-alert> <Icon icon="ph:caret-left-bold" :width="14" />按班级选名字
</n-form> </button>
</n-tab-pane> <template v-if="configStore.config?.allowRegister">
</n-tabs> <i></i>
<button class="link" type="button" @click="goSignup">注册</button>
</template>
</div>
</div>
</div>
</n-modal> </n-modal>
</template> </template>
<style scoped> <style scoped>
.tip { .box {
margin-top: 4px; 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> </style>
+250 -93
View File
@@ -1,130 +1,287 @@
<script setup lang="ts"> <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 { errorCode } from "utils/api"
import { signup } from "../api" import { login, signup } from "../api"
import { storeToRefs } from "pinia"
import { useAuthModalStore } from "../store/authModal" import { useAuthModalStore } from "../store/authModal"
import { useUserStore } from "../store/user"
/**
* 注册框。错在哪个框就标在哪个框下面,不攒成最底下一条;注册完直接登上,
* 不用回登录框再输一遍。开关在后台设置里(allow_register),关着时顶栏没有入口。
*/
type Field = "username" | "email" | "password" | "again"
const authStore = useAuthModalStore() const authStore = useAuthModalStore()
const userStore = useUserStore()
const { signupModalOpen } = storeToRefs(authStore)
const theme = useThemeVars()
const { // n-modal 把内容传送到 body 底下,CSS 的 v-bind() 挂不上去(变量挂在组件根上,
signupModalOpen, // 这个组件的根是 n-modal 自己)—— 颜色直接写到弹框根节点的 style 里
signupForm: form, const themeVars = computed(() => ({
signupLoading: isLoading, "--t-box-shadow-3": theme.value.boxShadow3,
signupError: msg, "--t-error-color": theme.value.errorColor,
} = storeToRefs(authStore) "--t-hover-color": theme.value.hoverColor,
const signupRef = useTemplateRef("signupRef") "--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 = { const form = reactive({ username: "", email: "", password: "", again: "" })
username: [{ required: true, message: "用户名必填", trigger: "blur" }], const errors = reactive<Partial<Record<Field, string>>>({})
email: [{ required: true, message: "邮箱必填", trigger: "blur" }], /** 不归哪个框的错(注册太频繁、网络) */
password: [ const general = ref("")
{ required: true, message: "密码必填", trigger: "blur" }, const loading = ref(false)
{ min: 6, max: 20, message: "长度在 6 到 20 位之间", trigger: "input" },
], const usernameRef = useTemplateRef("usernameRef")
passwordAgain: [
{ required: true, message: "密码必填", trigger: "blur" }, function check() {
{ min: 6, max: 20, message: "长度在 6 到 20 位之间", trigger: "input" }, for (const key of Object.keys(errors) as Field[]) delete errors[key]
{ if (!form.username.trim()) errors.username = "写上用户名"
validator: (_: FormItemRule, value: string) => value === form.value.password, if (!form.email.trim()) errors.email = "写上邮箱"
message: "两次密码输入不一致", else if (!registerRequestSchema.shape.email.safeParse(form.email.trim()).success) {
trigger: "blur", 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() { function clearOne(field: Field) {
authStore.switchToLogin() delete errors[field]
general.value = ""
} }
function submit() { async function submit() {
signupRef.value!.validate(async (errors?: unknown) => { if (loading.value || !check()) return
if (!errors) { loading.value = true
general.value = ""
const username = form.username.trim()
try { try {
authStore.clearSignupError() await signup({ username, email: form.email.trim(), password: form.password })
authStore.setSignupLoading(true)
await signup({
username: form.value.username,
email: form.value.email,
password: form.value.password,
})
} catch (err) { } catch (err) {
if (errorCode(err) === "username-exists") { const code = errorCode(err)
authStore.setSignupError("用户名已存在") if (code === "username-exists") errors.username = "这个名字已经有人用了,换一个"
} else if (errorCode(err) === "email-exists") { else if (code === "email-exists") errors.email = "这个邮箱已经注册过了"
authStore.setSignupError("邮箱已存在") else if (code === "too-many-registrations") general.value = "注册太频繁了,等一会儿再试"
} else if (errorCode(err) === "too-many-registrations") { else if (code === "registration-disabled") general.value = "现在不开放注册"
authStore.setSignupError("注册太频繁,请稍后再试") else general.value = "注册不了,等一会儿再试"
} else { loading.value = false
authStore.setSignupError("无法注册") return
} }
} finally { try {
authStore.setSignupLoading(false) await login({ username, password: form.password })
}
if (!msg.value) {
authStore.closeSignupModal() 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> </script>
<template> <template>
<n-modal <n-modal v-model:show="signupModalOpen" :mask-closable="false" :auto-focus="false">
:mask-closable="false" <div class="box" :style="themeVars" role="dialog" aria-label="注册">
v-model:show="signupModalOpen" <div class="head">
preset="card" <h2>注册</h2>
title="注册" <span class="grow"></span>
style="width: 400px" <button class="close" type="button" aria-label="关闭" @click="authStore.closeSignupModal()">
:auto-focus="false" <Icon icon="ph:x" :width="18" />
> </button>
<n-form ref="signupRef" :model="form" :rules="rules" show-require-mark> </div>
<n-form-item label="用户名" path="username"> <div class="form">
<label class="field">
<span class="lab">用户名</span>
<n-input <n-input
ref="usernameRef"
v-model:value="form.username" v-model:value="form.username"
autofocus size="large"
clearable clearable
name="username" :maxlength="32"
id="signup-username" placeholder="以后登录就写它"
autocomplete="username" :input-props="{ name: 'username', autocomplete: 'username' }"
:status="errors.username ? 'error' : undefined"
@update:value="clearOne('username')"
/> />
</n-form-item> <span v-if="errors.username" class="err">{{ errors.username }}</span>
<n-form-item label="邮箱" path="email"> </label>
<label class="field">
<span class="lab">邮箱</span>
<n-input <n-input
v-model:value="form.email" v-model:value="form.email"
size="large"
clearable clearable
name="email" :maxlength="64"
id="signup-email" placeholder="常用的邮箱"
autocomplete="email" :input-props="{ name: 'email', autocomplete: 'email' }"
@change="submit" :status="errors.email ? 'error' : undefined"
@update:value="clearOne('email')"
/> />
</n-form-item> <span v-if="errors.email" class="err">{{ errors.email }}</span>
<n-form-item label="密码" path="password"> </label>
<label class="field">
<span class="lab">密码</span>
<n-input <n-input
v-model:value="form.password" v-model:value="form.password"
clearable
type="password" type="password"
name="password" show-password-on="click"
id="signup-password" size="large"
autocomplete="new-password" :maxlength="20"
placeholder="6 到 20 位"
:input-props="{ name: 'password', autocomplete: 'new-password' }"
:status="errors.password ? 'error' : undefined"
@update:value="clearOne('password')"
/> />
</n-form-item> <span v-if="errors.password" class="err">{{ errors.password }}</span>
<n-form-item label="确认密码" path="passwordAgain"> </label>
<label class="field">
<span class="lab">再输一遍密码</span>
<n-input <n-input
v-model:value="form.passwordAgain" v-model:value="form.again"
clearable
type="password" type="password"
name="passwordAgain" show-password-on="click"
id="signup-password-again" size="large"
autocomplete="new-password" :maxlength="20"
:input-props="{ name: 'password-again', autocomplete: 'new-password' }"
:status="errors.again ? 'error' : undefined"
@update:value="clearOne('again')"
@keyup.enter="submit"
/> />
</n-form-item> <span v-if="errors.again" class="err">{{ errors.again }}</span>
<n-alert v-if="msg" type="error" :show-icon="false"> {{ msg }}</n-alert> </label>
<n-form-item> <span v-if="general" class="err">{{ general }}</span>
<n-space> <n-button type="primary" size="large" :loading="loading" @click="submit">
<n-button type="primary" :loading="isLoading" @click="submit"> 注册 </n-button> 注册并登录
<n-button @click="goLogin">已经注册?现在登录</n-button> </n-button>
</n-space> <div class="links">
</n-form-item> <button class="link" type="button" @click="authStore.switchToLogin()">
</n-form> 已经有账号了,去登录
</button>
</div>
</div>
</div>
</n-modal> </n-modal>
</template> </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" import { defineStore } from "pinia"
/** /**
* 认证状态管理 Store * 登录框、注册框开没开。表单本身在两个组件里:每次打开都从干净的表单开始
* 统一管理登录、注册相关的模态框状态和表单状态 * (机房一台电脑轮流坐人,别把上一个人的名字和密码留在框里),不需要跨组件共享。
*/ */
export const useAuthModalStore = defineStore("authModal", () => { export const useAuthModalStore = defineStore("authModal", () => {
// ==================== 模态框状态 ====================
const loginModalOpen = ref(false) const loginModalOpen = ref(false)
const signupModalOpen = 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() { function openLoginModal() {
loginModalOpen.value = true loginModalOpen.value = true
} }
/**
* 关闭登录模态框
*/
function closeLoginModal() { function closeLoginModal() {
loginModalOpen.value = false loginModalOpen.value = false
} }
/**
* 打开注册模态框
*/
function openSignupModal() { function openSignupModal() {
signupModalOpen.value = true signupModalOpen.value = true
} }
/**
* 关闭注册模态框
*/
function closeSignupModal() { function closeSignupModal() {
signupModalOpen.value = false signupModalOpen.value = false
} }
/**
* 从登录切换到注册
*/
function switchToSignup() { function switchToSignup() {
closeLoginModal() closeLoginModal()
openSignupModal() openSignupModal()
} }
/**
* 从注册切换到登录
*/
function switchToLogin() { function switchToLogin() {
closeSignupModal() closeSignupModal()
openLoginModal() 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 { return {
// 模态框状态
loginModalOpen, loginModalOpen,
signupModalOpen, signupModalOpen,
// 登录表单状态
loginForm,
loginLoading,
loginError,
// 注册表单状态
signupForm,
signupLoading,
signupError,
// 模态框操作
openLoginModal, openLoginModal,
closeLoginModal, closeLoginModal,
openSignupModal, openSignupModal,
closeSignupModal, closeSignupModal,
switchToSignup, switchToSignup,
switchToLogin, switchToLogin,
// 登录表单操作
setLoginLoading,
setLoginError,
clearLoginError,
// 注册表单操作
setSignupLoading,
setSignupError,
clearSignupError,
} }
}) })
+7 -4
View File
@@ -95,11 +95,14 @@ export const useUserStore = defineStore("user", () => {
function clearProfile() { function clearProfile() {
profile.value = null profile.value = null
demoSwitch.value = false 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() 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", ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed",
DEMO_MODE: "demoMode", DEMO_MODE: "demoMode",
LOGIN_CLASS: "loginClass", LOGIN_CLASS: "loginClass",
LOGIN_MODE: "loginMode",
RANK_CLASS: "rankClass", RANK_CLASS: "rankClass",
} }