管理面板功能完善,注册需要手机号
This commit is contained in:
17
AGENTS.md
17
AGENTS.md
@@ -82,6 +82,23 @@ Important:
|
||||
6. Use `reviewer` before final delivery, especially for cross-layer changes or auth/files/storage flows.
|
||||
7. Use `deployer` only after code is committed or otherwise ready to ship.
|
||||
|
||||
## Project-level hard rules
|
||||
|
||||
### First-principles thinking
|
||||
|
||||
- Start from the original requirement and problem, not from assumptions about the user's preferred implementation path.
|
||||
- Do not assume the user already knows exactly what they want or how to get there.
|
||||
- Stay cautious about motive, goal, and scope. If the underlying objective or business target is materially unclear, pause and discuss it with the user before implementation.
|
||||
|
||||
|
||||
### Solution and refactor rule
|
||||
|
||||
- Do not propose compatibility-style or patch-style solutions.
|
||||
- Do not over-design. Use the shortest correct implementation path.
|
||||
- Do not add fallback, downgrade, or extra solution branches that the user did not ask for.
|
||||
- Do not propose any solution beyond the user's stated requirement if it could shift business logic.
|
||||
- Every proposed modification or refactor plan must be logically correct and validated across the full request path before it is presented.
|
||||
|
||||
## Repo-specific guardrails
|
||||
|
||||
- Do not run `npm` commands at the repository root. This repo has a root `package-lock.json` but no root `package.json`.
|
||||
|
||||
@@ -124,6 +124,7 @@ public class AdminService {
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getEmail(),
|
||||
user.getPhoneNumber(),
|
||||
user.getCreatedAt(),
|
||||
user.getLastSchoolStudentId(),
|
||||
user.getLastSchoolSemester(),
|
||||
|
||||
@@ -8,6 +8,7 @@ public record AdminUserResponse(
|
||||
Long id,
|
||||
String username,
|
||||
String email,
|
||||
String phoneNumber,
|
||||
LocalDateTime createdAt,
|
||||
String lastSchoolStudentId,
|
||||
String lastSchoolSemester,
|
||||
|
||||
@@ -55,11 +55,15 @@ public class AuthService {
|
||||
if (userRepository.existsByEmail(request.email())) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "邮箱已存在");
|
||||
}
|
||||
if (userRepository.existsByPhoneNumber(request.phoneNumber())) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "手机号已存在");
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(request.username());
|
||||
user.setDisplayName(request.username());
|
||||
user.setEmail(request.email());
|
||||
user.setPhoneNumber(request.phoneNumber());
|
||||
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
user.setRole(UserRole.USER);
|
||||
user.setPreferredLanguage("zh-CN");
|
||||
@@ -127,9 +131,14 @@ public class AuthService {
|
||||
if (!user.getEmail().equalsIgnoreCase(nextEmail) && userRepository.existsByEmail(nextEmail)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "邮箱已存在");
|
||||
}
|
||||
String nextPhoneNumber = request.phoneNumber().trim();
|
||||
if (!nextPhoneNumber.equals(user.getPhoneNumber()) && userRepository.existsByPhoneNumber(nextPhoneNumber)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "手机号已存在");
|
||||
}
|
||||
|
||||
user.setDisplayName(request.displayName().trim());
|
||||
user.setEmail(nextEmail);
|
||||
user.setPhoneNumber(nextPhoneNumber);
|
||||
user.setBio(normalizeOptionalText(request.bio()));
|
||||
user.setPreferredLanguage(normalizePreferredLanguage(request.preferredLanguage()));
|
||||
return toProfile(userRepository.save(user));
|
||||
@@ -245,6 +254,7 @@ public class AuthService {
|
||||
user.getUsername(),
|
||||
user.getDisplayName(),
|
||||
user.getEmail(),
|
||||
user.getPhoneNumber(),
|
||||
user.getBio(),
|
||||
user.getPreferredLanguage(),
|
||||
buildAvatarUrl(user),
|
||||
|
||||
@@ -31,6 +31,9 @@ public class User {
|
||||
@Column(nullable = false, length = 128, unique = true)
|
||||
private String email;
|
||||
|
||||
@Column(name = "phone_number", length = 32, unique = true)
|
||||
private String phoneNumber;
|
||||
|
||||
@Column(name = "password_hash", nullable = false, length = 255)
|
||||
private String passwordHash;
|
||||
|
||||
@@ -108,6 +111,14 @@ public class User {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getPhoneNumber() {
|
||||
return phoneNumber;
|
||||
}
|
||||
|
||||
public void setPhoneNumber(String phoneNumber) {
|
||||
this.phoneNumber = phoneNumber;
|
||||
}
|
||||
|
||||
public String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
boolean existsByEmail(String email);
|
||||
|
||||
boolean existsByPhoneNumber(String phoneNumber);
|
||||
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
long countByLastSchoolStudentIdIsNotNull();
|
||||
@@ -23,7 +25,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
select u from User u
|
||||
where (:query is null or :query = ''
|
||||
or lower(u.username) like lower(concat('%', :query, '%'))
|
||||
or lower(u.email) like lower(concat('%', :query, '%')))
|
||||
or lower(u.email) like lower(concat('%', :query, '%'))
|
||||
or u.phoneNumber like concat('%', :query, '%'))
|
||||
""")
|
||||
Page<User> searchByUsernameOrEmail(@Param("query") String query, Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import com.yoyuzh.auth.PasswordPolicy;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record RegisterRequest(
|
||||
@NotBlank @Size(min = 3, max = 64) String username,
|
||||
@NotBlank @Email @Size(max = 128) String email,
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^1\\d{10}$", message = "请输入有效的11位手机号")
|
||||
String phoneNumber,
|
||||
@NotBlank @Size(min = 10, max = 64, message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符") String password
|
||||
) {
|
||||
|
||||
|
||||
@@ -2,11 +2,15 @@ package com.yoyuzh.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record UpdateUserProfileRequest(
|
||||
@NotBlank @Size(min = 2, max = 64) String displayName,
|
||||
@NotBlank @Email @Size(max = 128) String email,
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^1\\d{10}$", message = "请输入有效的11位手机号")
|
||||
String phoneNumber,
|
||||
@Size(max = 280) String bio,
|
||||
@Size(min = 2, max = 16) String preferredLanguage
|
||||
) {
|
||||
|
||||
@@ -9,6 +9,7 @@ public record UserProfileResponse(
|
||||
String username,
|
||||
String displayName,
|
||||
String email,
|
||||
String phoneNumber,
|
||||
String bio,
|
||||
String preferredLanguage,
|
||||
String avatarUrl,
|
||||
@@ -16,6 +17,6 @@ public record UserProfileResponse(
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
public UserProfileResponse(Long id, String username, String email, LocalDateTime createdAt) {
|
||||
this(id, username, username, email, null, "zh-CN", null, UserRole.USER, createdAt);
|
||||
this(id, username, username, email, null, null, "zh-CN", null, UserRole.USER, createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
server:
|
||||
address: 127.0.0.1
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
|
||||
@@ -63,6 +63,7 @@ class AdminControllerIntegrationTest {
|
||||
portalUser = new User();
|
||||
portalUser.setUsername("alice");
|
||||
portalUser.setEmail("alice@example.com");
|
||||
portalUser.setPhoneNumber("13800138000");
|
||||
portalUser.setPasswordHash("encoded-password");
|
||||
portalUser.setCreatedAt(LocalDateTime.now());
|
||||
portalUser.setLastSchoolStudentId("20230001");
|
||||
@@ -72,6 +73,7 @@ class AdminControllerIntegrationTest {
|
||||
secondaryUser = new User();
|
||||
secondaryUser.setUsername("bob");
|
||||
secondaryUser.setEmail("bob@example.com");
|
||||
secondaryUser.setPhoneNumber("13900139000");
|
||||
secondaryUser.setPasswordHash("encoded-password");
|
||||
secondaryUser.setCreatedAt(LocalDateTime.now().minusDays(1));
|
||||
secondaryUser = userRepository.save(secondaryUser);
|
||||
@@ -106,6 +108,7 @@ class AdminControllerIntegrationTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.items[0].username").value("alice"))
|
||||
.andExpect(jsonPath("$.data.items[0].phoneNumber").value("13800138000"))
|
||||
.andExpect(jsonPath("$.data.items[0].lastSchoolStudentId").value("20230001"))
|
||||
.andExpect(jsonPath("$.data.items[0].role").value("USER"))
|
||||
.andExpect(jsonPath("$.data.items[0].banned").value(false));
|
||||
@@ -125,6 +128,12 @@ class AdminControllerIntegrationTest {
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].username").value("alice"));
|
||||
|
||||
mockMvc.perform(get("/api/admin/users?page=0&size=10&query=13900139000"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].username").value("bob"))
|
||||
.andExpect(jsonPath("$.data.items[0].phoneNumber").value("13900139000"));
|
||||
|
||||
mockMvc.perform(patch("/api/admin/users/{userId}/role", portalUser.getId())
|
||||
.contentType("application/json")
|
||||
.content("""
|
||||
|
||||
@@ -45,6 +45,7 @@ class AuthControllerValidationTest {
|
||||
{
|
||||
"username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"phoneNumber": "13800138000",
|
||||
"password": "weakpass"
|
||||
}
|
||||
"""))
|
||||
@@ -53,6 +54,23 @@ class AuthControllerValidationTest {
|
||||
.andExpect(jsonPath("$.msg").value("密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnReadablePhoneValidationMessage() throws Exception {
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"phoneNumber": "12345",
|
||||
"password": "StrongPass1!"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(1000))
|
||||
.andExpect(jsonPath("$.msg").value("请输入有效的11位手机号"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExposeRefreshEndpointContract() throws Exception {
|
||||
AuthResponse response = AuthResponse.issued(
|
||||
|
||||
@@ -62,9 +62,10 @@ class AuthServiceTest {
|
||||
|
||||
@Test
|
||||
void shouldRegisterUserWithEncryptedPassword() {
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "StrongPass1!");
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!");
|
||||
when(userRepository.existsByUsername("alice")).thenReturn(false);
|
||||
when(userRepository.existsByEmail("alice@example.com")).thenReturn(false);
|
||||
when(userRepository.existsByPhoneNumber("13800138000")).thenReturn(false);
|
||||
when(passwordEncoder.encode("StrongPass1!")).thenReturn("encoded-password");
|
||||
when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
|
||||
User user = invocation.getArgument(0);
|
||||
@@ -81,13 +82,14 @@ class AuthServiceTest {
|
||||
assertThat(response.accessToken()).isEqualTo("access-token");
|
||||
assertThat(response.refreshToken()).isEqualTo("refresh-token");
|
||||
assertThat(response.user().username()).isEqualTo("alice");
|
||||
assertThat(response.user().phoneNumber()).isEqualTo("13800138000");
|
||||
verify(passwordEncoder).encode("StrongPass1!");
|
||||
verify(fileService).ensureDefaultDirectories(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectDuplicateUsernameOnRegister() {
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "StrongPass1!");
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!");
|
||||
when(userRepository.existsByUsername("alice")).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> authService.register(request))
|
||||
@@ -95,6 +97,18 @@ class AuthServiceTest {
|
||||
.hasMessageContaining("用户名已存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectDuplicatePhoneNumberOnRegister() {
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!");
|
||||
when(userRepository.existsByUsername("alice")).thenReturn(false);
|
||||
when(userRepository.existsByEmail("alice@example.com")).thenReturn(false);
|
||||
when(userRepository.existsByPhoneNumber("13800138000")).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> authService.register(request))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("手机号已存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLoginAndReturnToken() {
|
||||
LoginRequest request = new LoginRequest("alice", "plain-password");
|
||||
@@ -188,6 +202,7 @@ class AuthServiceTest {
|
||||
user.setUsername("alice");
|
||||
user.setDisplayName("Alice");
|
||||
user.setEmail("alice@example.com");
|
||||
user.setPhoneNumber("13800138000");
|
||||
user.setBio("old bio");
|
||||
user.setPreferredLanguage("zh-CN");
|
||||
user.setRole(UserRole.USER);
|
||||
@@ -196,18 +211,21 @@ class AuthServiceTest {
|
||||
UpdateUserProfileRequest request = new UpdateUserProfileRequest(
|
||||
"Alicia",
|
||||
"newalice@example.com",
|
||||
"13900139000",
|
||||
"new bio",
|
||||
"en-US"
|
||||
);
|
||||
|
||||
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
|
||||
when(userRepository.existsByEmail("newalice@example.com")).thenReturn(false);
|
||||
when(userRepository.existsByPhoneNumber("13900139000")).thenReturn(false);
|
||||
when(userRepository.save(user)).thenReturn(user);
|
||||
|
||||
var response = authService.updateProfile("alice", request);
|
||||
|
||||
assertThat(response.displayName()).isEqualTo("Alicia");
|
||||
assertThat(response.email()).isEqualTo("newalice@example.com");
|
||||
assertThat(response.phoneNumber()).isEqualTo("13900139000");
|
||||
assertThat(response.bio()).isEqualTo("new bio");
|
||||
assertThat(response.preferredLanguage()).isEqualTo("en-US");
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class RegisterRequestValidationTest {
|
||||
|
||||
@Test
|
||||
void shouldRejectWeakPassword() {
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "weakpass");
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "weakpass");
|
||||
|
||||
var violations = validator.validate(request);
|
||||
|
||||
@@ -24,10 +24,21 @@ class RegisterRequestValidationTest {
|
||||
|
||||
@Test
|
||||
void shouldAcceptStrongPassword() {
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "StrongPass1!");
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!");
|
||||
|
||||
var violations = validator.validate(request);
|
||||
|
||||
assertThat(violations).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidPhoneNumber() {
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "12345", "StrongPass1!");
|
||||
|
||||
var violations = validator.validate(request);
|
||||
|
||||
assertThat(violations)
|
||||
.extracting(violation -> violation.getMessage())
|
||||
.contains("请输入有效的11位手机号");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ export function PortalAdminUsersList() {
|
||||
return (
|
||||
<List
|
||||
actions={<UsersListActions />}
|
||||
filters={[<SearchInput key="query" source="query" alwaysOn placeholder="搜索用户名或邮箱" />]}
|
||||
filters={[<SearchInput key="query" source="query" alwaysOn placeholder="搜索用户名、邮箱或手机号" />]}
|
||||
perPage={25}
|
||||
resource="users"
|
||||
title="用户管理"
|
||||
@@ -161,6 +161,7 @@ export function PortalAdminUsersList() {
|
||||
<TextField source="id" label="ID" />
|
||||
<TextField source="username" label="用户名" />
|
||||
<TextField source="email" label="邮箱" />
|
||||
<TextField source="phoneNumber" label="手机号" emptyText="-" />
|
||||
<FunctionField<AdminUser>
|
||||
label="角色"
|
||||
render={(record) => <Chip label={record.role} size="small" color={record.role === 'ADMIN' ? 'primary' : 'default'} />}
|
||||
|
||||
@@ -138,6 +138,7 @@ export function Layout() {
|
||||
}, [user]);
|
||||
|
||||
const email = user?.email || '暂无邮箱';
|
||||
const phoneNumber = user?.phoneNumber || '未设置手机号';
|
||||
const roleLabel = getRoleLabel(user?.role);
|
||||
const avatarFallback = (displayName || 'Y').charAt(0).toUpperCase();
|
||||
const displayedAvatarUrl = avatarPreviewUrl || avatarSourceUrl;
|
||||
@@ -256,6 +257,7 @@ export function Layout() {
|
||||
body: {
|
||||
displayName: profileDraft.displayName.trim(),
|
||||
email: profileDraft.email.trim(),
|
||||
phoneNumber: profileDraft.phoneNumber.trim(),
|
||||
bio: profileDraft.bio,
|
||||
preferredLanguage: profileDraft.preferredLanguage,
|
||||
},
|
||||
@@ -497,11 +499,15 @@ export function Layout() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">手机绑定</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">当前项目暂未实现短信绑定流程</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">当前手机号:{phoneNumber}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" disabled className="border-white/10 text-slate-500">
|
||||
暂未开放
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-white/10 hover:bg-white/10 text-slate-300"
|
||||
onClick={() => setActiveModal('settings')}
|
||||
>
|
||||
更改
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -586,6 +592,16 @@ export function Layout() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">手机号</label>
|
||||
<Input
|
||||
type="tel"
|
||||
value={profileDraft.phoneNumber}
|
||||
onChange={(event) => handleProfileDraftChange('phoneNumber', event.target.value)}
|
||||
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">个人简介</label>
|
||||
<textarea
|
||||
|
||||
@@ -20,6 +20,7 @@ test('buildAccountDraft prefers display name and fills fallback values', () => {
|
||||
assert.deepEqual(buildAccountDraft(profile), {
|
||||
displayName: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
phoneNumber: '',
|
||||
bio: '',
|
||||
preferredLanguage: 'zh-CN',
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AdminUserRole, UserProfile } from '@/src/lib/types';
|
||||
export interface AccountDraft {
|
||||
displayName: string;
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
bio: string;
|
||||
preferredLanguage: string;
|
||||
}
|
||||
@@ -11,6 +12,7 @@ export function buildAccountDraft(profile: UserProfile): AccountDraft {
|
||||
return {
|
||||
displayName: profile.displayName || profile.username,
|
||||
email: profile.email,
|
||||
phoneNumber: profile.phoneNumber || '',
|
||||
bio: profile.bio || '',
|
||||
preferredLanguage: profile.preferredLanguage || 'zh-CN',
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface UserProfile {
|
||||
username: string;
|
||||
displayName?: string | null;
|
||||
email: string;
|
||||
phoneNumber?: string | null;
|
||||
bio?: string | null;
|
||||
preferredLanguage?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
@@ -22,6 +23,7 @@ export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
phoneNumber: string | null;
|
||||
createdAt: string;
|
||||
lastSchoolStudentId: string | null;
|
||||
lastSchoolSemester: string | null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { LogIn, User, Lock, UserPlus, Mail, ArrowLeft } from 'lucide-react';
|
||||
import { LogIn, User, Lock, UserPlus, Mail, ArrowLeft, Phone } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
@@ -22,6 +22,7 @@ export default function Login() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [registerUsername, setRegisterUsername] = useState('');
|
||||
const [registerEmail, setRegisterEmail] = useState('');
|
||||
const [registerPhoneNumber, setRegisterPhoneNumber] = useState('');
|
||||
const [registerPassword, setRegisterPassword] = useState('');
|
||||
|
||||
function switchMode(nextIsLogin: boolean) {
|
||||
@@ -80,6 +81,7 @@ export default function Login() {
|
||||
body: {
|
||||
username: registerUsername.trim(),
|
||||
email: registerEmail.trim(),
|
||||
phoneNumber: registerPhoneNumber.trim(),
|
||||
password: registerPassword,
|
||||
},
|
||||
});
|
||||
@@ -284,6 +286,20 @@ export default function Login() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300 ml-1">手机号</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder="请输入11位手机号"
|
||||
className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]"
|
||||
value={registerPhoneNumber}
|
||||
onChange={(event) => setRegisterPhoneNumber(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300 ml-1">密码</label>
|
||||
<div className="relative">
|
||||
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
|
||||
import { apiRequest } from '@/src/lib/api';
|
||||
import { apiDownload, apiRequest } from '@/src/lib/api';
|
||||
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
|
||||
import { shouldLoadAvatarWithAuth } from '@/src/components/layout/account-utils';
|
||||
import { getOverviewCacheKey, getSchoolResultsCacheKey, readStoredSchoolQuery, writeStoredSchoolQuery } from '@/src/lib/page-cache';
|
||||
import { cacheLatestSchoolData, fetchLatestSchoolData } from '@/src/lib/school';
|
||||
import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session';
|
||||
@@ -74,6 +75,7 @@ export default function Overview() {
|
||||
const [grades, setGrades] = useState<GradeResponse[]>(cachedOverview?.grades ?? cachedSchoolResults?.grades ?? []);
|
||||
const [loadingError, setLoadingError] = useState('');
|
||||
const [retryToken, setRetryToken] = useState(0);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
|
||||
const currentHour = new Date().getHours();
|
||||
let greeting = '晚上好';
|
||||
@@ -219,8 +221,53 @@ export default function Overview() {
|
||||
};
|
||||
}, [retryToken]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
async function loadAvatar() {
|
||||
if (!profile?.avatarUrl) {
|
||||
if (active) {
|
||||
setAvatarUrl(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldLoadAvatarWithAuth(profile.avatarUrl)) {
|
||||
if (active) {
|
||||
setAvatarUrl(profile.avatarUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiDownload(profile.avatarUrl);
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
if (active) {
|
||||
setAvatarUrl(objectUrl);
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setAvatarUrl(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadAvatar();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [profile?.avatarUrl]);
|
||||
|
||||
const latestSemester = grades[0]?.semester ?? '--';
|
||||
const previewCourses = schedule.slice(0, 3);
|
||||
const profileDisplayName = profile?.displayName || profile?.username || '未登录';
|
||||
const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -396,11 +443,15 @@ export default function Overview() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 p-4 rounded-xl bg-white/[0.02] border border-white/5">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center text-white font-bold text-xl shadow-lg">
|
||||
{(profile?.username?.[0] ?? 'T').toUpperCase()}
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center text-white font-bold text-xl shadow-lg overflow-hidden">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
profileAvatarFallback
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-white truncate">{profile?.username ?? '未登录'}</p>
|
||||
<p className="text-sm font-semibold text-white truncate">{profileDisplayName}</p>
|
||||
<p className="text-xs text-slate-400 truncate mt-0.5">{profile?.email ?? '暂无邮箱'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user