管理面板功能完善,注册需要手机号

This commit is contained in:
yoyuzh
2026-03-20 10:35:04 +08:00
parent ff8d47f44f
commit 944ab6dbf8
21 changed files with 213 additions and 15 deletions

View File

@@ -82,6 +82,23 @@ Important:
6. Use `reviewer` before final delivery, especially for cross-layer changes or auth/files/storage flows. 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. 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 ## 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`. - Do not run `npm` commands at the repository root. This repo has a root `package-lock.json` but no root `package.json`.

View File

@@ -124,6 +124,7 @@ public class AdminService {
user.getId(), user.getId(),
user.getUsername(), user.getUsername(),
user.getEmail(), user.getEmail(),
user.getPhoneNumber(),
user.getCreatedAt(), user.getCreatedAt(),
user.getLastSchoolStudentId(), user.getLastSchoolStudentId(),
user.getLastSchoolSemester(), user.getLastSchoolSemester(),

View File

@@ -8,6 +8,7 @@ public record AdminUserResponse(
Long id, Long id,
String username, String username,
String email, String email,
String phoneNumber,
LocalDateTime createdAt, LocalDateTime createdAt,
String lastSchoolStudentId, String lastSchoolStudentId,
String lastSchoolSemester, String lastSchoolSemester,

View File

@@ -55,11 +55,15 @@ public class AuthService {
if (userRepository.existsByEmail(request.email())) { if (userRepository.existsByEmail(request.email())) {
throw new BusinessException(ErrorCode.UNKNOWN, "邮箱已存在"); throw new BusinessException(ErrorCode.UNKNOWN, "邮箱已存在");
} }
if (userRepository.existsByPhoneNumber(request.phoneNumber())) {
throw new BusinessException(ErrorCode.UNKNOWN, "手机号已存在");
}
User user = new User(); User user = new User();
user.setUsername(request.username()); user.setUsername(request.username());
user.setDisplayName(request.username()); user.setDisplayName(request.username());
user.setEmail(request.email()); user.setEmail(request.email());
user.setPhoneNumber(request.phoneNumber());
user.setPasswordHash(passwordEncoder.encode(request.password())); user.setPasswordHash(passwordEncoder.encode(request.password()));
user.setRole(UserRole.USER); user.setRole(UserRole.USER);
user.setPreferredLanguage("zh-CN"); user.setPreferredLanguage("zh-CN");
@@ -127,9 +131,14 @@ public class AuthService {
if (!user.getEmail().equalsIgnoreCase(nextEmail) && userRepository.existsByEmail(nextEmail)) { if (!user.getEmail().equalsIgnoreCase(nextEmail) && userRepository.existsByEmail(nextEmail)) {
throw new BusinessException(ErrorCode.UNKNOWN, "邮箱已存在"); 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.setDisplayName(request.displayName().trim());
user.setEmail(nextEmail); user.setEmail(nextEmail);
user.setPhoneNumber(nextPhoneNumber);
user.setBio(normalizeOptionalText(request.bio())); user.setBio(normalizeOptionalText(request.bio()));
user.setPreferredLanguage(normalizePreferredLanguage(request.preferredLanguage())); user.setPreferredLanguage(normalizePreferredLanguage(request.preferredLanguage()));
return toProfile(userRepository.save(user)); return toProfile(userRepository.save(user));
@@ -245,6 +254,7 @@ public class AuthService {
user.getUsername(), user.getUsername(),
user.getDisplayName(), user.getDisplayName(),
user.getEmail(), user.getEmail(),
user.getPhoneNumber(),
user.getBio(), user.getBio(),
user.getPreferredLanguage(), user.getPreferredLanguage(),
buildAvatarUrl(user), buildAvatarUrl(user),

View File

@@ -31,6 +31,9 @@ public class User {
@Column(nullable = false, length = 128, unique = true) @Column(nullable = false, length = 128, unique = true)
private String email; private String email;
@Column(name = "phone_number", length = 32, unique = true)
private String phoneNumber;
@Column(name = "password_hash", nullable = false, length = 255) @Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash; private String passwordHash;
@@ -108,6 +111,14 @@ public class User {
this.email = email; this.email = email;
} }
public String getPhoneNumber() {
return phoneNumber;
}
public void setPhoneNumber(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
public String getPasswordHash() { public String getPasswordHash() {
return passwordHash; return passwordHash;
} }

View File

@@ -13,6 +13,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByEmail(String email); boolean existsByEmail(String email);
boolean existsByPhoneNumber(String phoneNumber);
Optional<User> findByUsername(String username); Optional<User> findByUsername(String username);
long countByLastSchoolStudentIdIsNotNull(); long countByLastSchoolStudentIdIsNotNull();
@@ -23,7 +25,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
select u from User u select u from User u
where (:query is null or :query = '' where (:query is null or :query = ''
or lower(u.username) like lower(concat('%', :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); Page<User> searchByUsernameOrEmail(@Param("query") String query, Pageable pageable);
} }

View File

@@ -4,11 +4,15 @@ import com.yoyuzh.auth.PasswordPolicy;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
public record RegisterRequest( public record RegisterRequest(
@NotBlank @Size(min = 3, max = 64) String username, @NotBlank @Size(min = 3, max = 64) String username,
@NotBlank @Email @Size(max = 128) String email, @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 @NotBlank @Size(min = 10, max = 64, message = "密码至少10位且必须包含大写字母、小写字母、数字和特殊字符") String password
) { ) {

View File

@@ -2,11 +2,15 @@ package com.yoyuzh.auth.dto;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
public record UpdateUserProfileRequest( public record UpdateUserProfileRequest(
@NotBlank @Size(min = 2, max = 64) String displayName, @NotBlank @Size(min = 2, max = 64) String displayName,
@NotBlank @Email @Size(max = 128) String email, @NotBlank @Email @Size(max = 128) String email,
@NotBlank
@Pattern(regexp = "^1\\d{10}$", message = "请输入有效的11位手机号")
String phoneNumber,
@Size(max = 280) String bio, @Size(max = 280) String bio,
@Size(min = 2, max = 16) String preferredLanguage @Size(min = 2, max = 16) String preferredLanguage
) { ) {

View File

@@ -9,6 +9,7 @@ public record UserProfileResponse(
String username, String username,
String displayName, String displayName,
String email, String email,
String phoneNumber,
String bio, String bio,
String preferredLanguage, String preferredLanguage,
String avatarUrl, String avatarUrl,
@@ -16,6 +17,6 @@ public record UserProfileResponse(
LocalDateTime createdAt LocalDateTime createdAt
) { ) {
public UserProfileResponse(Long id, String username, String email, 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);
} }
} }

View File

@@ -1,4 +1,5 @@
server: server:
address: 127.0.0.1
port: 8080 port: 8080
spring: spring:

View File

@@ -63,6 +63,7 @@ class AdminControllerIntegrationTest {
portalUser = new User(); portalUser = new User();
portalUser.setUsername("alice"); portalUser.setUsername("alice");
portalUser.setEmail("alice@example.com"); portalUser.setEmail("alice@example.com");
portalUser.setPhoneNumber("13800138000");
portalUser.setPasswordHash("encoded-password"); portalUser.setPasswordHash("encoded-password");
portalUser.setCreatedAt(LocalDateTime.now()); portalUser.setCreatedAt(LocalDateTime.now());
portalUser.setLastSchoolStudentId("20230001"); portalUser.setLastSchoolStudentId("20230001");
@@ -72,6 +73,7 @@ class AdminControllerIntegrationTest {
secondaryUser = new User(); secondaryUser = new User();
secondaryUser.setUsername("bob"); secondaryUser.setUsername("bob");
secondaryUser.setEmail("bob@example.com"); secondaryUser.setEmail("bob@example.com");
secondaryUser.setPhoneNumber("13900139000");
secondaryUser.setPasswordHash("encoded-password"); secondaryUser.setPasswordHash("encoded-password");
secondaryUser.setCreatedAt(LocalDateTime.now().minusDays(1)); secondaryUser.setCreatedAt(LocalDateTime.now().minusDays(1));
secondaryUser = userRepository.save(secondaryUser); secondaryUser = userRepository.save(secondaryUser);
@@ -106,6 +108,7 @@ class AdminControllerIntegrationTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.items[0].username").value("alice")) .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].lastSchoolStudentId").value("20230001"))
.andExpect(jsonPath("$.data.items[0].role").value("USER")) .andExpect(jsonPath("$.data.items[0].role").value("USER"))
.andExpect(jsonPath("$.data.items[0].banned").value(false)); .andExpect(jsonPath("$.data.items[0].banned").value(false));
@@ -125,6 +128,12 @@ class AdminControllerIntegrationTest {
.andExpect(jsonPath("$.data.total").value(1)) .andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].username").value("alice")); .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()) mockMvc.perform(patch("/api/admin/users/{userId}/role", portalUser.getId())
.contentType("application/json") .contentType("application/json")
.content(""" .content("""

View File

@@ -45,6 +45,7 @@ class AuthControllerValidationTest {
{ {
"username": "alice", "username": "alice",
"email": "alice@example.com", "email": "alice@example.com",
"phoneNumber": "13800138000",
"password": "weakpass" "password": "weakpass"
} }
""")) """))
@@ -53,6 +54,23 @@ class AuthControllerValidationTest {
.andExpect(jsonPath("$.msg").value("密码至少10位且必须包含大写字母、小写字母、数字和特殊字符")); .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 @Test
void shouldExposeRefreshEndpointContract() throws Exception { void shouldExposeRefreshEndpointContract() throws Exception {
AuthResponse response = AuthResponse.issued( AuthResponse response = AuthResponse.issued(

View File

@@ -62,9 +62,10 @@ class AuthServiceTest {
@Test @Test
void shouldRegisterUserWithEncryptedPassword() { 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.existsByUsername("alice")).thenReturn(false);
when(userRepository.existsByEmail("alice@example.com")).thenReturn(false); when(userRepository.existsByEmail("alice@example.com")).thenReturn(false);
when(userRepository.existsByPhoneNumber("13800138000")).thenReturn(false);
when(passwordEncoder.encode("StrongPass1!")).thenReturn("encoded-password"); when(passwordEncoder.encode("StrongPass1!")).thenReturn("encoded-password");
when(userRepository.save(any(User.class))).thenAnswer(invocation -> { when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
User user = invocation.getArgument(0); User user = invocation.getArgument(0);
@@ -81,13 +82,14 @@ class AuthServiceTest {
assertThat(response.accessToken()).isEqualTo("access-token"); assertThat(response.accessToken()).isEqualTo("access-token");
assertThat(response.refreshToken()).isEqualTo("refresh-token"); assertThat(response.refreshToken()).isEqualTo("refresh-token");
assertThat(response.user().username()).isEqualTo("alice"); assertThat(response.user().username()).isEqualTo("alice");
assertThat(response.user().phoneNumber()).isEqualTo("13800138000");
verify(passwordEncoder).encode("StrongPass1!"); verify(passwordEncoder).encode("StrongPass1!");
verify(fileService).ensureDefaultDirectories(any(User.class)); verify(fileService).ensureDefaultDirectories(any(User.class));
} }
@Test @Test
void shouldRejectDuplicateUsernameOnRegister() { 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); when(userRepository.existsByUsername("alice")).thenReturn(true);
assertThatThrownBy(() -> authService.register(request)) assertThatThrownBy(() -> authService.register(request))
@@ -95,6 +97,18 @@ class AuthServiceTest {
.hasMessageContaining("用户名已存在"); .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 @Test
void shouldLoginAndReturnToken() { void shouldLoginAndReturnToken() {
LoginRequest request = new LoginRequest("alice", "plain-password"); LoginRequest request = new LoginRequest("alice", "plain-password");
@@ -188,6 +202,7 @@ class AuthServiceTest {
user.setUsername("alice"); user.setUsername("alice");
user.setDisplayName("Alice"); user.setDisplayName("Alice");
user.setEmail("alice@example.com"); user.setEmail("alice@example.com");
user.setPhoneNumber("13800138000");
user.setBio("old bio"); user.setBio("old bio");
user.setPreferredLanguage("zh-CN"); user.setPreferredLanguage("zh-CN");
user.setRole(UserRole.USER); user.setRole(UserRole.USER);
@@ -196,18 +211,21 @@ class AuthServiceTest {
UpdateUserProfileRequest request = new UpdateUserProfileRequest( UpdateUserProfileRequest request = new UpdateUserProfileRequest(
"Alicia", "Alicia",
"newalice@example.com", "newalice@example.com",
"13900139000",
"new bio", "new bio",
"en-US" "en-US"
); );
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
when(userRepository.existsByEmail("newalice@example.com")).thenReturn(false); when(userRepository.existsByEmail("newalice@example.com")).thenReturn(false);
when(userRepository.existsByPhoneNumber("13900139000")).thenReturn(false);
when(userRepository.save(user)).thenReturn(user); when(userRepository.save(user)).thenReturn(user);
var response = authService.updateProfile("alice", request); var response = authService.updateProfile("alice", request);
assertThat(response.displayName()).isEqualTo("Alicia"); assertThat(response.displayName()).isEqualTo("Alicia");
assertThat(response.email()).isEqualTo("newalice@example.com"); assertThat(response.email()).isEqualTo("newalice@example.com");
assertThat(response.phoneNumber()).isEqualTo("13900139000");
assertThat(response.bio()).isEqualTo("new bio"); assertThat(response.bio()).isEqualTo("new bio");
assertThat(response.preferredLanguage()).isEqualTo("en-US"); assertThat(response.preferredLanguage()).isEqualTo("en-US");
} }

View File

@@ -13,7 +13,7 @@ class RegisterRequestValidationTest {
@Test @Test
void shouldRejectWeakPassword() { 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); var violations = validator.validate(request);
@@ -24,10 +24,21 @@ class RegisterRequestValidationTest {
@Test @Test
void shouldAcceptStrongPassword() { 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); var violations = validator.validate(request);
assertThat(violations).isEmpty(); 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位手机号");
}
} }

View File

@@ -151,7 +151,7 @@ export function PortalAdminUsersList() {
return ( return (
<List <List
actions={<UsersListActions />} actions={<UsersListActions />}
filters={[<SearchInput key="query" source="query" alwaysOn placeholder="搜索用户名邮箱" />]} filters={[<SearchInput key="query" source="query" alwaysOn placeholder="搜索用户名邮箱或手机号" />]}
perPage={25} perPage={25}
resource="users" resource="users"
title="用户管理" title="用户管理"
@@ -161,6 +161,7 @@ export function PortalAdminUsersList() {
<TextField source="id" label="ID" /> <TextField source="id" label="ID" />
<TextField source="username" label="用户名" /> <TextField source="username" label="用户名" />
<TextField source="email" label="邮箱" /> <TextField source="email" label="邮箱" />
<TextField source="phoneNumber" label="手机号" emptyText="-" />
<FunctionField<AdminUser> <FunctionField<AdminUser>
label="角色" label="角色"
render={(record) => <Chip label={record.role} size="small" color={record.role === 'ADMIN' ? 'primary' : 'default'} />} render={(record) => <Chip label={record.role} size="small" color={record.role === 'ADMIN' ? 'primary' : 'default'} />}

View File

@@ -138,6 +138,7 @@ export function Layout() {
}, [user]); }, [user]);
const email = user?.email || '暂无邮箱'; const email = user?.email || '暂无邮箱';
const phoneNumber = user?.phoneNumber || '未设置手机号';
const roleLabel = getRoleLabel(user?.role); const roleLabel = getRoleLabel(user?.role);
const avatarFallback = (displayName || 'Y').charAt(0).toUpperCase(); const avatarFallback = (displayName || 'Y').charAt(0).toUpperCase();
const displayedAvatarUrl = avatarPreviewUrl || avatarSourceUrl; const displayedAvatarUrl = avatarPreviewUrl || avatarSourceUrl;
@@ -256,6 +257,7 @@ export function Layout() {
body: { body: {
displayName: profileDraft.displayName.trim(), displayName: profileDraft.displayName.trim(),
email: profileDraft.email.trim(), email: profileDraft.email.trim(),
phoneNumber: profileDraft.phoneNumber.trim(),
bio: profileDraft.bio, bio: profileDraft.bio,
preferredLanguage: profileDraft.preferredLanguage, preferredLanguage: profileDraft.preferredLanguage,
}, },
@@ -497,11 +499,15 @@ export function Layout() {
</div> </div>
<div> <div>
<p className="text-sm font-medium text-white"></p> <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>
</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> </Button>
</div> </div>
@@ -586,6 +592,16 @@ export function Layout() {
/> />
</div> </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"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label> <label className="text-sm font-medium text-slate-300"></label>
<textarea <textarea

View File

@@ -20,6 +20,7 @@ test('buildAccountDraft prefers display name and fills fallback values', () => {
assert.deepEqual(buildAccountDraft(profile), { assert.deepEqual(buildAccountDraft(profile), {
displayName: 'Alice', displayName: 'Alice',
email: 'alice@example.com', email: 'alice@example.com',
phoneNumber: '',
bio: '', bio: '',
preferredLanguage: 'zh-CN', preferredLanguage: 'zh-CN',
}); });

View File

@@ -3,6 +3,7 @@ import type { AdminUserRole, UserProfile } from '@/src/lib/types';
export interface AccountDraft { export interface AccountDraft {
displayName: string; displayName: string;
email: string; email: string;
phoneNumber: string;
bio: string; bio: string;
preferredLanguage: string; preferredLanguage: string;
} }
@@ -11,6 +12,7 @@ export function buildAccountDraft(profile: UserProfile): AccountDraft {
return { return {
displayName: profile.displayName || profile.username, displayName: profile.displayName || profile.username,
email: profile.email, email: profile.email,
phoneNumber: profile.phoneNumber || '',
bio: profile.bio || '', bio: profile.bio || '',
preferredLanguage: profile.preferredLanguage || 'zh-CN', preferredLanguage: profile.preferredLanguage || 'zh-CN',
}; };

View File

@@ -3,6 +3,7 @@ export interface UserProfile {
username: string; username: string;
displayName?: string | null; displayName?: string | null;
email: string; email: string;
phoneNumber?: string | null;
bio?: string | null; bio?: string | null;
preferredLanguage?: string | null; preferredLanguage?: string | null;
avatarUrl?: string | null; avatarUrl?: string | null;
@@ -22,6 +23,7 @@ export interface AdminUser {
id: number; id: number;
username: string; username: string;
email: string; email: string;
phoneNumber: string | null;
createdAt: string; createdAt: string;
lastSchoolStudentId: string | null; lastSchoolStudentId: string | null;
lastSchoolSemester: string | null; lastSchoolSemester: string | null;

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'motion/react'; 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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button'; import { Button } from '@/src/components/ui/button';
@@ -22,6 +22,7 @@ export default function Login() {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [registerUsername, setRegisterUsername] = useState(''); const [registerUsername, setRegisterUsername] = useState('');
const [registerEmail, setRegisterEmail] = useState(''); const [registerEmail, setRegisterEmail] = useState('');
const [registerPhoneNumber, setRegisterPhoneNumber] = useState('');
const [registerPassword, setRegisterPassword] = useState(''); const [registerPassword, setRegisterPassword] = useState('');
function switchMode(nextIsLogin: boolean) { function switchMode(nextIsLogin: boolean) {
@@ -80,6 +81,7 @@ export default function Login() {
body: { body: {
username: registerUsername.trim(), username: registerUsername.trim(),
email: registerEmail.trim(), email: registerEmail.trim(),
phoneNumber: registerPhoneNumber.trim(),
password: registerPassword, password: registerPassword,
}, },
}); });
@@ -284,6 +286,20 @@ export default function Login() {
/> />
</div> </div>
</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"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-300 ml-1"></label> <label className="text-sm font-medium text-slate-300 ml-1"></label>
<div className="relative"> <div className="relative">

View File

@@ -16,8 +16,9 @@ import {
import { Button } from '@/src/components/ui/button'; import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card'; 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 { 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 { getOverviewCacheKey, getSchoolResultsCacheKey, readStoredSchoolQuery, writeStoredSchoolQuery } from '@/src/lib/page-cache';
import { cacheLatestSchoolData, fetchLatestSchoolData } from '@/src/lib/school'; import { cacheLatestSchoolData, fetchLatestSchoolData } from '@/src/lib/school';
import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session'; 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 [grades, setGrades] = useState<GradeResponse[]>(cachedOverview?.grades ?? cachedSchoolResults?.grades ?? []);
const [loadingError, setLoadingError] = useState(''); const [loadingError, setLoadingError] = useState('');
const [retryToken, setRetryToken] = useState(0); const [retryToken, setRetryToken] = useState(0);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const currentHour = new Date().getHours(); const currentHour = new Date().getHours();
let greeting = '晚上好'; let greeting = '晚上好';
@@ -219,8 +221,53 @@ export default function Overview() {
}; };
}, [retryToken]); }, [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 latestSemester = grades[0]?.semester ?? '--';
const previewCourses = schedule.slice(0, 3); const previewCourses = schedule.slice(0, 3);
const profileDisplayName = profile?.displayName || profile?.username || '未登录';
const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase();
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -396,11 +443,15 @@ export default function Overview() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center gap-4 p-4 rounded-xl bg-white/[0.02] border border-white/5"> <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"> <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">
{(profile?.username?.[0] ?? 'T').toUpperCase()} {avatarUrl ? (
<img src={avatarUrl} alt="Avatar" className="w-full h-full object-cover" />
) : (
profileAvatarFallback
)}
</div> </div>
<div className="flex-1 min-w-0"> <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> <p className="text-xs text-slate-400 truncate mt-0.5">{profile?.email ?? '暂无邮箱'}</p>
</div> </div>
</div> </div>