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
+824
-470
No files matched your search
+1
-1
@@ -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)
|
||||||
|
|||||||
@@ -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") {
|
|
||||||
resetForm()
|
|
||||||
studentRef.value?.restoreValidation()
|
|
||||||
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 {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadNames(value: string) {
|
||||||
|
namesLoading.value = true
|
||||||
|
names.value = []
|
||||||
|
try {
|
||||||
|
const list = await getClassUsernames(value.slice(2))
|
||||||
|
// 切班的请求比上一个先回来时,别让旧班的名单盖上来
|
||||||
|
if (klass.value === value) names.value = [...list].sort(collator.compare)
|
||||||
|
} catch {
|
||||||
|
if (klass.value === value) names.value = []
|
||||||
|
} finally {
|
||||||
|
if (klass.value === value) namesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(klass, (value) => {
|
||||||
|
picked.value = null
|
||||||
|
query.value = ""
|
||||||
|
clearError()
|
||||||
|
if (value) {
|
||||||
|
loadNames(value)
|
||||||
|
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>
|
||||||
@@ -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>
|
||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 退登的两步(吊销服务端会话、清本地状态)绑在一起:只清本地会留一个还活着
|
// 退登的两步(吊销服务端会话、清本地状态)绑在一起:只清本地会留一个还活着
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user