feat(登录): 学生登录改成班级+姓名两个下拉,一个字都不用打
Some checks failed
Deploy / deploy (push) Has been cancelled

登录框拆成「学生登录 / 管理员登录」两个页签。学生页签选完班级选姓名,
ks 前缀提交时自动拼,选完姓名焦点直接跳到密码框,回车登录;上次选的班级
记在 localStorage 下次自动填好(姓名不记 —— 同一台机器换个人坐就是别人)。
管理员页签只有用户名+密码,不拼前缀。

「没有我所在的班级」保留在班级列表末尾,往届的班级从后台 class_list 里删掉后
学生仍能从这里写完整用户名登录。为了把「还没选」和「没有我所在的班级」分开,
loginForm.class 的初值从空串改成 null —— 原来两者都是空串,一打开就默认落在
「没有我所在的班级」那一档、显示文本框,正好和「方便学生」相反。username 同样
改成 nullable:空串在 n-select 里是「选了一个空值」,占位符不显示。

clearProfile 的 storage.clear() 给 LOGIN_CLASS 开了个口子:机房一台机器对一个班,
下课登出、下节课再来还是同一个班,清掉的话每个人都得重新选一遍。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQCLjAmz6pPXMNGoZmvAmC
This commit is contained in:
2026-09-01 06:57:24 -06:00
parent ec35400249
commit 047e6dd7d3
4 changed files with 258 additions and 112 deletions

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import { getClassUsernames, login } from "../api" import { getClassUsernames, login } from "../api"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { useAuthModalStore } from "../store/authModal" import { useAuthModalStore } from "../store/authModal"
@@ -15,59 +17,107 @@ const {
loginLoading: isLoading, loginLoading: isLoading,
loginError: msg, loginError: msg,
} = storeToRefs(authStore) } = storeToRefs(authStore)
const loginRef = useTemplateRef("loginRef")
// 学生页签走「班级 + 姓名」两个下拉,一个字都不用打;
// 管理员页签只有用户名 + 密码,不拼班级前缀。
const activeTab = ref<"student" | "admin">("student")
const studentRef = useTemplateRef("studentRef")
const adminRef = useTemplateRef("adminRef")
const studentPasswordRef = useTemplateRef("studentPasswordRef")
const classUserOptions = ref<SelectOption[]>([]) const classUserOptions = ref<SelectOption[]>([])
const classUserLoading = ref(false) const classUserLoading = ref(false)
const isClassLogin = computed(() => Boolean(form.value.class)) // 空串是「没有我所在的班级」null 才是没选 —— 往届的班级毕业后会从后台配置里删掉,
// 这一项是他们唯一的入口,写完整用户名登录
const OTHER_CLASS = ""
const classList = computed<SelectOption[]>(() => { const classList = computed<SelectOption[]>(() => {
const defaults = [{ label: "没有我所在的班级", value: "" }]
const configs = const configs =
configStore.config?.classList.map((item) => ({ configStore.config?.classList.map((item) => ({
label: `${item.slice(0, 2)}计算机${item.slice(2)}`, label: `${item.slice(0, 2)}计算机${item.slice(2)}`,
value: `ks${item}`, value: `ks${item}`,
})) ?? [] })) ?? []
return [...defaults, ...configs] return [...configs, { label: "没有我所在的班级", value: OTHER_CLASS }]
}) })
const rules: FormRules = { // 选了具体班级就是「选姓名」,选了「没有我所在的班级」就是「写用户名」
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: [ username: [
{ required: true, message: "用户名必填", trigger: ["blur", "change"] }, { required: true, message: "用户名必填", trigger: ["blur", "change"] },
], ],
password: [ password: passwordRule,
{ required: true, message: "密码必填", trigger: "blur" },
{ min: 6, max: 20, message: "长度在 6 到 20 位之间", trigger: "input" },
],
} }
async function submit() { function resetForm() {
loginRef.value!.validate(async (errors?: unknown) => { form.value.class = null
if (!errors) { form.value.username = null
try { form.value.password = ""
authStore.clearLoginError() classUserOptions.value = []
authStore.setLoginLoading(true) classUserLoading.value = false
const merged = { authStore.clearLoginError()
username: form.value.username, }
password: form.value.password,
} function onTabChange(tab: "student" | "admin") {
if (form.value.class) { resetForm()
merged.username = form.value.class + form.value.username studentRef.value?.restoreValidation()
} adminRef.value?.restoreValidation()
await login(merged) // 切回学生页签把记住的班级填回去,别因为误点了一下管理员就得重选
} catch (err: any) { if (tab === "student") restoreLastClass()
// 判错误码而不是错误文案:文案在后端,改一个字这里就静默掉进「无法登录」 }
if (err.error === "account-disabled") {
authStore.setLoginError("此账号已被封禁") function submit() {
} else if (err.error === "invalid-credentials") { const formRef = activeTab.value === "student" ? studentRef : adminRef
authStore.setLoginError("用户名或密码不正确") formRef.value!.validate(async (errors?: unknown) => {
} else { if (errors) return
authStore.setLoginError("无法登录") try {
} authStore.clearLoginError()
} finally { authStore.setLoginLoading(true)
authStore.setLoginLoading(false) const merged = {
username: form.value.username ?? "",
password: form.value.password,
} }
if (!msg.value) { // 选了班级的只填了姓名ks + 班级号的前缀这里补
authStore.closeLoginModal() if (activeTab.value === "student" && form.value.class) {
await userStore.getMyProfile() merged.username = form.value.class + merged.username
} }
await login(merged)
} catch (err: any) {
// 判错误码而不是错误文案:文案在后端,改一个字这里就静默掉进「无法登录」
if (err.error === "account-disabled") {
authStore.setLoginError("此账号已被封禁")
} else if (err.error === "invalid-credentials") {
authStore.setLoginError("用户名或密码不正确")
} 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()
await userStore.getMyProfile()
} }
}) })
} }
@@ -95,7 +145,7 @@ watch(
() => form.value.class, () => form.value.class,
(selectedClass) => { (selectedClass) => {
classUserOptions.value = [] classUserOptions.value = []
form.value.username = "" form.value.username = null
if (!selectedClass) { if (!selectedClass) {
classUserLoading.value = false classUserLoading.value = false
return return
@@ -104,8 +154,40 @@ watch(
}, },
) )
// 选完姓名直接跳到密码框,学生的手不用离开这一列
function onUsernamePicked() {
nextTick(() => studentPasswordRef.value?.focus())
}
/**
* 机房电脑一台对一个班,班级记在本地下次自动填上;
* 姓名不记 —— 同一台机器换个人坐就是别人的名字了。
*/
function restoreLastClass() {
if (form.value.class !== null) return
const last = storage.get(STORAGE_KEY.LOGIN_CLASS)
if (
typeof last === "string" &&
classList.value.some((item) => item.value === last)
) {
form.value.class = last
}
}
watch(classList, restoreLastClass)
// 每次打开都从干净的表单开始,别把上一个人的姓名和密码留在框里
watch(loginModalOpen, (open) => {
if (!open) return
resetForm()
studentRef.value?.restoreValidation()
adminRef.value?.restoreValidation()
restoreLastClass()
})
onMounted(() => { onMounted(() => {
authStore.clearLoginError() authStore.clearLoginError()
restoreLastClass()
}) })
</script> </script>
@@ -115,85 +197,142 @@ onMounted(() => {
v-model:show="loginModalOpen" v-model:show="loginModalOpen"
preset="card" preset="card"
title="登录" title="登录"
style="width: 400px" style="width: 420px"
:auto-focus="false" :auto-focus="false"
> >
<n-form ref="loginRef" :model="form" :rules="rules" show-require-mark> <n-tabs v-model:value="activeTab" @update:value="onTabChange">
<n-alert :show-icon="false" class="tip"> <n-tab-pane name="student" tab="学生登录">
关于选择班级的提醒<br /> <n-form
1. 如果是上课统一生成的账号选择相应班级用户名直接写自己的名字 ref="studentRef"
<br /> :model="form"
2. :rules="studentRules"
同样是上课用的号但是没有你的班级选择没有我所在的班级用户名要写ks班级+姓名比如23计算机1班张三就写ks231张三 show-require-mark
<br /> >
3. 如果是自己注册的号选择没有我所在的班级 <br /> <n-form-item label="班级" path="class">
</n-alert> <n-select
<n-form-item label="选择班级" path="class" :show-require-mark="false"> v-model:value="form.class"
<n-select :options="classList"
v-model:value="form.class" filterable
:options="classList" name="class"
clearable id="login-class"
name="class" placeholder="选择班级"
id="login-class" />
/> </n-form-item>
</n-form-item> <n-form-item
<n-form-item label="用户名" path="username"> :label="isClassLogin ? '姓名' : '用户名'"
<n-select path="username"
v-if="form.class"
v-model:value="form.username"
:options="classUserOptions"
:loading="classUserLoading"
clearable
filterable
:name="isClassLogin ? 'class-username' : 'username'"
:id="isClassLogin ? 'login-class-username' : 'login-username'"
placeholder="请选择姓名"
/>
<n-input
v-else
v-model:value="form.username"
autofocus
clearable
:name="isClassLogin ? 'class-username' : 'username'"
:id="isClassLogin ? 'login-class-username' : 'login-username'"
:autocomplete="isClassLogin ? 'off' : 'username'"
/>
</n-form-item>
<n-form-item label="密码" path="password">
<n-input
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-select
</n-button> v-if="isClassLogin"
<n-button v-if="configStore.config?.allowRegister" @click="goSignup"> v-model:value="form.username"
没有账号立即注册 :options="classUserOptions"
</n-button> :loading="classUserLoading"
</n-flex> filterable
</n-form-item> name="class-username"
</n-form> 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-tab-pane name="admin" tab="管理员登录">
<n-form
ref="adminRef"
:model="form"
:rules="adminRules"
show-require-mark
>
<n-form-item label="用户名" path="username">
<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
type="password"
name="password"
id="login-admin-password"
autocomplete="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-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>
</n-modal> </n-modal>
</template> </template>
<style scoped> <style scoped>
.tip { .tip {
margin-bottom: 20px; margin-top: 4px;
} }
</style> </style>

View File

@@ -10,9 +10,11 @@ export const useAuthModalStore = defineStore("authModal", () => {
const signupModalOpen = ref(false) const signupModalOpen = ref(false)
// ==================== 登录表单状态 ==================== // ==================== 登录表单状态 ====================
// class 为 null 表示还没选,别用空串 —— 空串是「没有我所在的班级」那一项的值。
// username 同理用 null空串在 n-select 里是「选了一个空值」,占位符不显示。
const loginForm = reactive({ const loginForm = reactive({
class: "", class: null as string | null,
username: "", username: null as string | null,
password: "", password: "",
}) })

View File

@@ -82,7 +82,11 @@ export const useUserStore = defineStore("user", () => {
function clearProfile() { function clearProfile() {
profile.value = null profile.value = null
demoMode.value = false demoMode.value = false
// 登录框记的班级要跨退登活下来:机房一台机器对一个班,下课登出、下节课再来
// 还是同一个班,清掉的话每个人都得重新选一遍。它是机器的属性,不是谁的隐私。
const loginClass = storage.get(STORAGE_KEY.LOGIN_CLASS)
storage.clear() storage.clear()
if (loginClass !== null) storage.set(STORAGE_KEY.LOGIN_CLASS, loginClass)
} }
// 退登的两步(吊销服务端会话、清本地状态)绑在一起:只清本地会留一个还活着 // 退登的两步(吊销服务端会话、清本地状态)绑在一起:只清本地会留一个还活着

View File

@@ -152,6 +152,7 @@ export const STORAGE_KEY = {
ADMIN_PROBLEM: "adminProblem", ADMIN_PROBLEM: "adminProblem",
ADMIN_PROBLEM_TAGS: "adminProblemTags", ADMIN_PROBLEM_TAGS: "adminProblemTags",
DEMO_MODE: "demoMode", DEMO_MODE: "demoMode",
LOGIN_CLASS: "loginClass",
} }
export const DIFFICULTY = { export const DIFFICULTY = {