feat(登录): 登录框改成点名字:打开就是这个班的名单,点自己再输密码;老师和往届写用户名,这台电脑上次怎么登下次就怎么开;注册完直接登上
Deploy / deploy (push) Canceled after 0s
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:
1 parent
127bd6e5ce
commit
801d84c53b
6 files changed
+859
-505
No files matched your search
+1
-1
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 退登的两步(吊销服务端会话、清本地状态)绑在一起:只清本地会留一个还活着
|
||||
|
||||
@@ -157,6 +157,7 @@ export const STORAGE_KEY = {
|
||||
ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed",
|
||||
DEMO_MODE: "demoMode",
|
||||
LOGIN_CLASS: "loginClass",
|
||||
LOGIN_MODE: "loginMode",
|
||||
RANK_CLASS: "rankClass",
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user