Files
BYW-VoiceHub/app/components/Auth/RegisterForm.vue
T
Henry_Du 3eff710d5d feat(微信订阅): 接入歌曲排期模板并加固部署流程
订阅消息(小程序侧唯一能主动触达用户的通道):
- 新增 server/utils/wechatSubscribeFields.ts:按微信字段类型裁剪值(thing≤20、
  phrase 必须 5 个汉字、time/date/character_string 各自上限),空白压成一行,
  北京时间 time 由 playTime 的开始时间生成,解析不出退化为当天 00:00。
- wechatSubscribeService 内置 SONG_SELECTED 模板(站长新给的那个模板 ID),
  并转发上述纯函数;notificationService.createSongSelectedNotification 现在会带上
  playDate/playTime/sequence,三个排期接口(schedule / publish / bulk-publish)补传 sequence。
- 新增 test/wechatSubscribeFields.test.mjs(10 例,node --test 全绿)。
- test-subscribe 管理接口支持 vars 覆盖并返回真实发送值,方便站长核对字段名。

部署流程(Gitea Actions 容器里没有 buildx 插件,旧流程在此硬失败):
- 每个 job 各自安装/探测 docker-buildx(v0.19.3,走 gh-proxy 再退直连),
  不把 `buildx version` 当唯一依据;装不上时降级为普通 `docker build` 继续发布,
  不再因为缺插件整条流水线失败。

注意:SONG_SELECTED 的字段名(thing2/date4/time1/character_string3/phrase5)是按常见
命名推断的,需在公众平台后台核对;不一致时用环境变量覆盖,模板 ID 变更后老用户需重新授权。
2026-10-11 00:04:14 +08:00

721 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="login-form">
<div class="form-header">
<h2>注册账号</h2>
<p>创建您的宝一外校园点歌系统账户</p>
</div>
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleRegister">
<div class="form-group">
<label for="username">真实姓名(大名)</label>
<div class="input-wrapper">
<svg
class="input-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<input
id="username"
v-model="username"
:class="{ 'input-error': error }"
placeholder="请输入真实姓名(大名),仅限汉字"
required
type="text"
@input="error = ''"
>
</div>
</div>
<div class="form-group">
<label for="password">密码</label>
<div class="input-wrapper">
<svg
class="input-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<rect height="11" rx="2" ry="2" width="18" x="3" y="11" />
<circle cx="12" cy="16" r="1" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<input
id="password"
v-model="password"
:class="{ 'input-error': error }"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
required
@input="error = ''"
>
<button class="password-toggle" type="button" @click="showPassword = !showPassword">
<svg
v-if="showPassword"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
/>
<line x1="1" x2="23" y1="1" y2="23" />
</svg>
<svg v-else fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
</div>
<div class="form-row" style="display: flex; gap: 12px;">
<div class="form-group" style="flex: 1;">
<label for="grade">年级</label>
<div class="input-wrapper">
<CustomSelect
v-model="grade"
:options="gradeOptions"
placeholder="请选择年级"
class-name="w-full custom-select-override"
@update:model-value="error = ''"
/>
</div>
</div>
<div class="form-group" style="flex: 1;">
<label for="class">班级</label>
<div class="input-wrapper">
<CustomSelect
v-model="studentClass"
:options="classOptions"
placeholder="请选择班级"
class-name="w-full custom-select-override"
@update:model-value="error = ''"
/>
</div>
</div>
</div>
<div class="form-group">
<label for="studentId">学号 (选填)</label>
<div class="input-wrapper">
<svg class="input-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
<input
id="studentId"
v-model="studentId"
:class="{ 'input-error': error }"
placeholder="请输入学号"
type="text"
@input="error = ''"
>
</div>
</div>
<div class="form-group">
<label for="email">邮箱 (选填)</label>
<div class="input-wrapper">
<svg
class="input-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path d="M4 4h16v16H4z" />
<path d="m4 6 8 7 8-7" />
</svg>
<input
id="email"
v-model="email"
:class="{ 'input-error': error }"
placeholder="例如:name@example.com"
type="email"
@input="error = ''"
>
</div>
</div>
<div v-if="email" class="form-group">
<label for="emailCode">邮箱验证码</label>
<div class="flex gap-2" style="display: flex; gap: 8px;">
<div class="input-wrapper flex-1" style="flex: 1;">
<svg
class="input-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<input
id="emailCode"
v-model="emailCode"
type="text"
maxlength="6"
placeholder="6位数字验证码"
class="form-input"
:disabled="loading"
>
</div>
<button
type="button"
:disabled="!email || emailActionLoading"
class="px-4 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap border border-zinc-700"
@click="sendRegisterEmailCode"
>
{{ emailActionLoading ? '发送中...' : '发送验证码' }}
</button>
</div>
</div>
<div v-if="error" class="error-container">
<svg
class="error-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>
<span class="error-message">{{ error }}</span>
</div>
<button :disabled="loading" class="submit-btn register-btn" type="submit">
<svg v-if="loading" class="loading-spinner" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
fill="none"
r="10"
stroke="currentColor"
stroke-dasharray="31.416"
stroke-dashoffset="31.416"
stroke-linecap="round"
stroke-width="2"
>
<animate
attributeName="stroke-dasharray"
dur="2s"
repeatCount="indefinite"
values="0 31.416;15.708 15.708;0 31.416"
/>
<animate
attributeName="stroke-dashoffset"
dur="2s"
repeatCount="indefinite"
values="0;-15.708;-31.416"
/>
</circle>
</svg>
<span v-if="loading">注册中...</span>
<span v-else>注 册</span>
</button>
</form>
<div class="form-footer" style="margin-top: 12px; margin-bottom: 12px;">
<p class="help-text">
已有账号? <NuxtLink to="/login" style="color: var(--primary);">去登录</NuxtLink>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAuth } from '~/composables/useAuth'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
const username = ref('')
const password = ref('')
const email = ref('')
const emailCode = ref('')
const grade = ref('')
const studentClass = ref('')
const studentId = ref('')
const error = ref('')
const loading = ref(false)
const showPassword = ref(false)
const emailActionLoading = ref(false)
const pendingRedirectPath = ref('/')
const auth = useAuth()
const gradeOptions = [
{ label: '高一', value: '高一' },
{ label: '高二', value: '高二' },
{ label: '高三', value: '高三' }
]
const classOptions = Array.from({ length: 16 }, (_, i) => ({
label: `${i + 1}班`,
value: (i + 1).toString()
}))
const sendRegisterEmailCode = async () => {
if (!email.value || !email.value.trim()) {
error.value = '请先输入邮箱'
return
}
emailActionLoading.value = true
try {
await $fetch('/api/auth/email/send-code', {
method: 'POST',
body: { email: email.value.trim() }
})
if ((window as any).$showNotification) {
;(window as any).$showNotification('验证码已发送,请查收邮箱', 'success')
}
} catch (err: any) {
if ((window as any).$showNotification) {
;(window as any).$showNotification(err.data?.message || err.message || '验证码发送失败', 'error')
}
} finally {
emailActionLoading.value = false
}
}
const handleRegister = async () => {
if (!username.value || !password.value || !grade.value || !studentClass.value) {
error.value = '请填写完整的注册信息'
return
}
const chineseRegex = /^[\u4e00-\u9fa5]+$/
if (!chineseRegex.test(username.value)) {
error.value = '真实姓名(大名)只能包含汉字'
return
}
if (email.value && !emailCode.value) {
error.value = '请填写邮箱验证码'
return
}
error.value = ''
loading.value = true
try {
const response = await auth.register({
username: username.value,
password: password.value,
email: email.value,
emailCode: emailCode.value,
grade: grade.value,
class: studentClass.value,
studentId: studentId.value
})
if (response?.success) {
pendingRedirectPath.value = auth.isAdmin.value ? '/dashboard' : '/'
await navigateTo(pendingRedirectPath.value)
}
} catch (err: any) {
error.value = err.data?.message || err.message || '注册失败'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-form {
width: 100%;
max-width: 400px;
animation: fadeInUp 0.4s ease both;
}
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.form-header {
text-align: center;
margin-bottom: 32px;
}
.form-header h2 {
font-size: 28px;
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0 0 8px 0;
}
.form-header p {
font-size: 16px;
color: var(--text-tertiary);
margin: 0;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: var(--font-medium);
color: var(--text-secondary);
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.input-wrapper::after {
content: '';
position: absolute;
left: 12px;
right: 12px;
bottom: 8px;
height: 2px;
background: var(--primary);
border-radius: 2px;
opacity: 0;
transform: scaleX(0.2);
transition:
transform var(--transition-normal),
opacity var(--transition-normal);
}
.input-wrapper:focus-within::after {
opacity: 0.35;
transform: scaleX(1);
}
.input-icon {
position: absolute;
left: 16px;
width: 20px;
height: 20px;
color: var(--text-quaternary);
z-index: 1;
}
.custom-select-override {
width: 100%;
}
.custom-select-override :deep(> .flex) {
padding: 14px 16px;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: var(--radius-lg);
color: var(--input-text);
height: 54px;
}
.custom-select-override :deep(> .flex:hover) {
filter: brightness(1.03);
}
.custom-select-override :deep(> .border-blue-500\/50) {
border-color: var(--input-border-focus);
box-shadow: var(--input-shadow-focus);
}
.custom-select-override :deep(.text-\[11px\]) {
font-size: 16px;
font-weight: normal;
}
.custom-select-override :deep(.text-\[8px\]) {
display: none;
}
.input-wrapper input {
width: 100%;
padding: 16px 16px 16px 48px;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: var(--radius-lg);
color: var(--input-text);
font-size: 16px;
transition:
border-color var(--transition-normal),
box-shadow var(--transition-normal);
}
.input-wrapper input::placeholder {
color: var(--input-placeholder);
}
.input-wrapper input:focus {
outline: none;
border-color: var(--input-border-focus);
box-shadow: var(--input-shadow-focus);
}
.input-wrapper input:focus + .input-icon,
.input-wrapper input:not(:placeholder-shown) + .input-icon {
color: var(--primary);
}
.input-wrapper input:hover {
filter: brightness(1.03);
}
.input-wrapper input.input-error {
border-color: var(--error);
box-shadow: 0 0 0 3px var(--error-light);
}
.password-toggle {
position: absolute;
right: 16px;
width: 20px;
height: 20px;
background: none;
border: none;
color: var(--text-quaternary);
cursor: pointer;
transition:
color 0.2s ease,
transform var(--transition-fast);
z-index: 1;
}
.password-toggle:hover {
color: var(--text-primary);
}
.password-toggle:active {
transform: scale(0.95);
}
.password-toggle svg {
width: 100%;
height: 100%;
}
.error-container {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--error-light);
border: 1px solid var(--error-border);
border-radius: var(--radius-lg);
color: var(--error);
}
.auth-form.has-error {
animation: shake 0.4s ease;
}
@keyframes shake {
0% {
transform: translateX(0);
}
15% {
transform: translateX(-6px);
}
30% {
transform: translateX(6px);
}
45% {
transform: translateX(-4px);
}
60% {
transform: translateX(4px);
}
75% {
transform: translateX(-2px);
}
90% {
transform: translateX(2px);
}
100% {
transform: translateX(0);
}
}
.error-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.error-message {
font-size: 14px;
font-weight: var(--font-medium);
}
.submit-btn {
width: 100%;
padding: 16px;
background: var(--primary);
color: var(--text-primary, #ffffff);
border: none;
border-radius: var(--radius-lg);
font-size: 16px;
font-weight: var(--font-semibold);
cursor: pointer;
transition:
background var(--transition-normal),
box-shadow var(--transition-normal),
transform var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
position: relative;
overflow: hidden;
}
.submit-btn:hover:not(:disabled) {
background: var(--primary-dark);
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.submit-btn:active:not(:disabled) {
transform: translateY(0);
}
.register-btn {
padding: 18px;
font-size: 18px;
margin-top: 8px;
}
.loading-spinner {
width: 20px;
height: 20px;
}
.form-footer {
margin-top: 24px;
text-align: center;
}
.help-text {
font-size: 12px;
color: var(--text-quaternary);
margin: 0;
line-height: 1.5;
}
.help-text code {
background: var(--input-bg);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
color: var(--primary);
font-size: 11px;
}
.webauthn-section {
width: 100%;
}
.divider {
display: flex;
align-items: center;
text-align: center;
margin: 20px 0;
color: var(--text-quaternary);
font-size: 12px;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--input-border);
}
.divider span {
padding: 0 10px;
}
.webauthn-btn {
width: 100%;
padding: 14px;
background: var(--surface-secondary);
color: var(--text-primary);
border: 1px solid var(--input-border);
border-radius: var(--radius-lg);
font-size: 15px;
font-weight: var(--font-medium);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.webauthn-btn:hover:not(:disabled) {
background: var(--surface-tertiary);
border-color: var(--input-border-focus);
}
.webauthn-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.webauthn-icon {
width: 20px;
height: 20px;
}
/* 响应式设计 */
@media (max-width: 480px) {
.form-header h2 {
font-size: 24px;
}
.form-header p {
font-size: 14px;
}
.input-wrapper input {
padding: 14px 14px 14px 44px;
font-size: 16px; /* 防止iOS缩放 */
}
.submit-btn {
padding: 14px;
font-size: 16px;
}
}
</style>