Просмотр исходного кода

✨ feat(profile): 重构个人资料编辑页面并优化头像上传功能

- 重构ProfileEditPage页面UI,使用shadcn组件库实现现代化界面设计
- 添加表单验证功能,使用zod定义表单schema并实现前端验证
- 优化头像上传组件,从使用URL改为使用fileId进行头像管理
- 改进页面加载状态,添加骨架屏提升用户体验
- 优化表单数据处理逻辑,区分新用户和已有用户的表单填充策略

♻️ refactor(api): 修改银龄用户档案接口数据结构

- 将avatarUrl字段替换为avatarFileId,使用文件ID管理头像资源
- 调整ProfileEditPage与后端API的交互逻辑,支持新的头像管理方式
- 优化用户信息更新流程,分离用户基础信息和档案信息的更新逻辑
yourname 7 месяцев назад
Родитель
Сommit
0bd2009b57

+ 4 - 4
src/client/mobile/components/AvatarUpload.tsx

@@ -27,7 +27,7 @@ const COLORS = {
 
 interface AvatarUploadProps {
   currentAvatar?: string | null;
-  onUploadSuccess?: (avatarUrl: string) => void;
+  onUploadSuccess?: (avatarFileId: number) => void;
   size?: number;
 }
 
@@ -111,16 +111,16 @@ export const AvatarUpload: React.FC<AvatarUploadProps> = ({
         throw new Error('更新用户头像失败');
       }
 
-      return result.fileUrl;
+      return result.fileId;
     },
-    onSuccess: (avatarUrl) => {
+    onSuccess: (fileId) => {
       // 清除相关缓存
       queryClient.invalidateQueries({ queryKey: ['userProfile'] });
       queryClient.invalidateQueries({ queryKey: ['currentUser'] });
       queryClient.invalidateQueries({ queryKey: ['silverUserProfile'] });
       
       if (onUploadSuccess) {
-        onUploadSuccess(avatarUrl);
+        onUploadSuccess(fileId);
       }
     },
     onError: (error) => {

+ 387 - 125
src/client/mobile/pages/ProfileEditPage.tsx

@@ -2,21 +2,72 @@ import React, { useState, useEffect } from 'react';
 import { useAuth } from '../hooks/AuthProvider';
 import { useNavigate } from 'react-router-dom';
 import { silverUserProfileClient, userClient } from '@/client/api';
-import { Form, Input, Button, DatePicker, Select } from 'antd';
-import dayjs from 'dayjs';
-import { toast } from 'react-toastify';
-import { SimpleAvatarUpload } from '@/client/mobile/components/SimpleAvatarUpload';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { z } from 'zod';
+import { toast } from 'sonner';
+import { InferRequestType, InferResponseType } from 'hono/client';
 
-const { Option } = Select;
+// 导入 shadcn 组件
+import { Button } from '@/client/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/client/components/ui/card';
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/client/components/ui/form';
+import { Input } from '@/client/components/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
+import { Textarea } from '@/client/components/ui/textarea';
+import { Skeleton } from '@/client/components/ui/skeleton';
+import { ChevronLeft, User, Mail, Phone, Building, FileText, Award, BookOpen } from 'lucide-react';
+
+// 保留现有的 AvatarUpload 组件
+import { AvatarUpload } from '@/client/mobile/components/AvatarUpload';
+
+// 类型定义
+type CreateProfileRequest = InferRequestType<typeof silverUserProfileClient.$post>['json'];
+type UpdateProfileRequest = InferRequestType<typeof silverUserProfileClient[':id']['$put']>['json'];
+type UpdateUserRequest = InferRequestType<typeof userClient[':id']['$put']>['json'];
+type ProfileResponse = InferResponseType<typeof silverUserProfileClient.$get, 200>['data'][0];
+
+// 表单 Schema
+const profileFormSchema = z.object({
+  username: z.string().min(2, '用户名至少2个字符').max(20, '用户名最多20个字符'),
+  email: z.string().email('请输入有效的邮箱地址'),
+  phone: z.string().min(11, '手机号至少11位').max(20, '手机号最多20位'),
+  realName: z.string().min(1, '请输入真实姓名').max(50, '真实姓名最多50个字符'),
+  age: z.coerce.number().min(1, '年龄必须大于0').max(120, '年龄不能超过120岁'),
+  gender: z.coerce.number().min(1).max(3),
+  organization: z.string().max(255, '所属机构最多255个字符').optional(),
+  personalIntro: z.string().max(500, '个人简介最多500个字符').optional(),
+  personalSkills: z.string().max(500, '个人技能最多500个字符').optional(),
+  personalExperience: z.string().max(500, '个人经历最多500个字符').optional(),
+});
+
+type ProfileFormData = z.infer<typeof profileFormSchema>;
 
 const ProfileEditPage: React.FC = () => {
   const { user } = useAuth();
   const navigate = useNavigate();
-  const [form] = Form.useForm();
   const [loading, setLoading] = useState(false);
-  const [profile, setProfile] = useState<any>(null);
+  const [profile, setProfile] = useState<ProfileResponse | null>(null);
+  const [isLoading, setIsLoading] = useState(true);
   const [avatarFileId, setAvatarFileId] = useState<number | null>(null);
 
+  // 初始化表单
+  const form = useForm<ProfileFormData>({
+    resolver: zodResolver(profileFormSchema),
+    defaultValues: {
+      username: '',
+      email: '',
+      phone: '',
+      realName: '',
+      age: 18,
+      gender: 1,
+      organization: '',
+      personalIntro: '',
+      personalSkills: '',
+      personalExperience: '',
+    },
+  });
+
   useEffect(() => {
     if (user) {
       loadProfile();
@@ -24,44 +75,55 @@ const ProfileEditPage: React.FC = () => {
   }, [user]);
 
   const loadProfile = async () => {
+    if (!user) return;
+
     try {
+      setIsLoading(true);
       const response = await silverUserProfileClient.$get({
         query: { filters: JSON.stringify({ userId: user.id }) }
       });
-      
+
       if (response.status === 200) {
         const data = await response.json();
         if (data.data && data.data.length > 0) {
           const profileData = data.data[0];
           setProfile(profileData);
           setAvatarFileId(profileData.avatarFileId || null);
-          
-          form.setFieldsValue({
+
+          // 填充表单数据
+          form.reset({
+            username: user.username || '',
+            email: user.email || '',
+            phone: profileData.phone || user.phone || '',
             realName: profileData.realName || '',
-            age: profileData.age || '',
+            age: profileData.age || 18,
             gender: profileData.gender || 1,
-            phone: profileData.phone || '',
-            email: profileData.email || '',
             organization: profileData.organization || '',
             personalIntro: profileData.personalIntro || '',
             personalSkills: profileData.personalSkills || '',
-            personalExperience: profileData.personalExperience || ''
+            personalExperience: profileData.personalExperience || '',
           });
         } else {
-          form.setFieldsValue({
-            userId: user.id,
+          // 新用户,使用现有用户信息填充
+          form.reset({
+            username: user.username || '',
+            email: user.email || '',
             phone: user.phone || '',
-            email: user.email || ''
+            realName: '',
+            age: 18,
+            gender: 1,
+            organization: '',
+            personalIntro: '',
+            personalSkills: '',
+            personalExperience: '',
           });
         }
-        
-        form.setFieldsValue({
-          username: user.username
-        });
       }
     } catch (error) {
       console.error('加载个人档案失败:', error);
       toast.error('加载个人档案失败');
+    } finally {
+      setIsLoading(false);
     }
   };
 
@@ -69,53 +131,59 @@ const ProfileEditPage: React.FC = () => {
     setAvatarFileId(fileId);
   };
 
-  const handleSubmit = async (values: any) => {
+  const onSubmit = async (data: ProfileFormData) => {
     if (!user) return;
-    
+
     setLoading(true);
     try {
       // 更新用户信息
+      const userUpdateData: UpdateUserRequest = {
+        username: data.username,
+        email: data.email,
+        phone: data.phone,
+      };
+
       const userUpdateResponse = await userClient[':id'].$put({
         param: { id: user.id },
-        json: {
-          username: values.username,
-          email: values.email,
-          phone: values.phone
-        }
+        json: userUpdateData,
       });
 
       if (userUpdateResponse.status === 200) {
         // 更新银龄档案
-        const profileData = {
+        const profileData: CreateProfileRequest | UpdateProfileRequest = {
           userId: user.id,
-          realName: values.realName,
-          age: values.age,
-          gender: values.gender,
-          phone: values.phone,
-          email: values.email,
+          realName: data.realName,
+          age: data.age,
+          gender: data.gender as 1 | 2 | 3,
+          phone: data.phone,
+          email: data.email,
           avatarFileId: avatarFileId || undefined,
-          organization: values.organization,
-          personalIntro: values.personalIntro,
-          personalSkills: values.personalSkills,
-          personalExperience: values.personalExperience
+          organization: data.organization,
+          personalIntro: data.personalIntro,
+          personalSkills: data.personalSkills,
+          personalExperience: data.personalExperience,
         };
 
         let response;
         if (profile?.id) {
           response = await silverUserProfileClient[':id'].$put({
             param: { id: profile.id },
-            json: profileData
+            json: profileData as UpdateProfileRequest,
           });
         } else {
           response = await silverUserProfileClient.$post({
-            json: profileData
+            json: profileData as CreateProfileRequest,
           });
         }
 
-        if (response.status === 200) {
+        if (response.status === 200 || response.status === 201) {
           toast.success('个人信息更新成功');
           navigate('/profile');
+        } else {
+          throw new Error('更新个人档案失败');
         }
+      } else {
+        throw new Error('更新用户信息失败');
       }
     } catch (error) {
       console.error('更新失败:', error);
@@ -127,12 +195,39 @@ const ProfileEditPage: React.FC = () => {
 
   if (!user) {
     return (
-      <div className="min-h-screen bg-gray-50 flex items-center justify-center">
-        <div className="text-center">
-          <p className="text-gray-600 mb-4">请先登录</p>
-          <Button type="primary" onClick={() => navigate('/login')}>
-            去登录
-          </Button>
+      <div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
+        <Card className="w-full max-w-sm">
+          <CardContent className="pt-6">
+            <div className="text-center">
+              <p className="text-gray-600 mb-4">请先登录</p>
+              <Button onClick={() => navigate('/login')} className="w-full">
+                去登录
+              </Button>
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+    );
+  }
+
+  if (isLoading) {
+    return (
+      <div className="min-h-screen bg-gray-50">
+        <div className="bg-white border-b">
+          <div className="flex items-center p-4">
+            <Button variant="ghost" size="sm" onClick={() => navigate('/profile')} className="p-0">
+              <ChevronLeft className="h-5 w-5" />
+            </Button>
+            <h1 className="flex-1 text-center text-lg font-semibold">编辑个人信息</h1>
+          </div>
+        </div>
+        
+        <div className="p-4 space-y-4">
+          <Skeleton className="h-10 w-full" />
+          <Skeleton className="h-10 w-full" />
+          <Skeleton className="h-10 w-full" />
+          <Skeleton className="h-24 w-full" />
+          <Skeleton className="h-10 w-full" />
         </div>
       </div>
     );
@@ -140,93 +235,260 @@ const ProfileEditPage: React.FC = () => {
 
   return (
     <div className="min-h-screen bg-gray-50">
-      <div className="bg-white">
-        <div className="flex items-center p-4 border-b">
-          <Button type="text" onClick={() => navigate('/profile')}>
-            返回
+      {/* 头部导航 */}
+      <div className="bg-white border-b">
+        <div className="flex items-center p-4">
+          <Button 
+            variant="ghost" 
+            size="sm" 
+            onClick={() => navigate('/profile')} 
+            className="p-0 hover:bg-transparent"
+          >
+            <ChevronLeft className="h-5 w-5" />
           </Button>
           <h1 className="flex-1 text-center text-lg font-semibold">编辑个人信息</h1>
+          <div className="w-10" />
         </div>
       </div>
 
       <div className="p-4">
-        <Form form={form} layout="vertical" onFinish={handleSubmit}>
-          {/* 头像上传 */}
-          <Form.Item label="头像">
-            <div className="flex items-center space-x-4">
-              <SimpleAvatarUpload
-                currentAvatar={avatarFileId ? profile?.avatarFile?.fullUrl : ''}
-                size={80}
-                onUploadSuccess={handleAvatarChange}
-                fileId={avatarFileId}
-              />
-              <span className="text-sm text-gray-500">点击头像上传新照片</span>
-            </div>
-          </Form.Item>
+        <Form {...form}>
+          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+            {/* 头像上传 */}
+            <Card>
+              <CardHeader className="pb-3">
+                <CardTitle className="text-base">头像设置</CardTitle>
+              </CardHeader>
+              <CardContent>
+                <div className="flex items-center space-x-4">
+                  <AvatarUpload
+                    currentAvatar={avatarFileId ? profile?.avatarFile?.fullUrl : ''}
+                    size={80}
+                    onUploadSuccess={handleAvatarChange}
+                  />
+                  <div>
+                    <p className="text-sm text-gray-600">点击头像上传新照片</p>
+                    <p className="text-xs text-gray-500 mt-1">支持 JPG、PNG 格式,最大 2MB</p>
+                  </div>
+                </div>
+              </CardContent>
+            </Card>
 
-          {/* 基础信息 */}
-          <Form.Item
-            name="username"
-            label="用户名"
-            rules={[{ required: true, message: '请输入用户名' }]}
-          >
-            <Input placeholder="请输入用户名" />
-          </Form.Item>
+            {/* 基础信息 */}
+            <Card>
+              <CardHeader className="pb-3">
+                <CardTitle className="text-base flex items-center gap-2">
+                  <User className="h-4 w-4" />
+                  基础信息
+                </CardTitle>
+              </CardHeader>
+              <CardContent className="space-y-4">
+                <FormField
+                  control={form.control}
+                  name="username"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>用户名 *</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入用户名" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
 
-          <Form.Item
-            name="email"
-            label="邮箱"
-            rules={[{ required: true, type: 'email', message: '请输入有效邮箱' }]}
-          >
-            <Input placeholder="请输入邮箱" />
-          </Form.Item>
+                <FormField
+                  control={form.control}
+                  name="email"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>邮箱 *</FormLabel>
+                      <FormControl>
+                        <Input type="email" placeholder="请输入邮箱" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
 
-          <Form.Item
-            name="phone"
-            label="手机号"
-            rules={[{ required: true, message: '请输入手机号' }]}
-          >
-            <Input placeholder="请输入手机号" />
-          </Form.Item>
-
-          {/* 银龄档案信息 */}
-          <Form.Item name="realName" label="真实姓名">
-            <Input placeholder="请输入真实姓名" />
-          </Form.Item>
-
-          <Form.Item name="gender" label="性别">
-             <Select placeholder="请选择性别">
-               <Option value={1}>男</Option>
-               <Option value={2}>女</Option>
-               <Option value={3}>其他</Option>
-             </Select>
-          </Form.Item>
-
-          <Form.Item name="age" label="年龄">
-            <Input type="number" placeholder="请输入年龄" />
-          </Form.Item>
-
-          <Form.Item name="organization" label="所属机构">
-            <Input placeholder="请输入所属机构" />
-          </Form.Item>
-
-          <Form.Item name="personalIntro" label="个人简介">
-            <Input.TextArea rows={4} placeholder="请简单介绍自己" maxLength={500} />
-          </Form.Item>
-
-          <Form.Item name="personalSkills" label="个人技能">
-            <Input.TextArea rows={4} placeholder="请输入个人技能" maxLength={500} />
-          </Form.Item>
-
-          <Form.Item name="personalExperience" label="个人经历">
-            <Input.TextArea rows={4} placeholder="请输入个人经历" maxLength={500} />
-          </Form.Item>
-
-          <Form.Item>
-            <Button type="primary" htmlType="submit" loading={loading} block>
-              保存修改
-            </Button>
-          </Form.Item>
+                <FormField
+                  control={form.control}
+                  name="phone"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>手机号 *</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入手机号" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+              </CardContent>
+            </Card>
+
+            {/* 银龄档案信息 */}
+            <Card>
+              <CardHeader className="pb-3">
+                <CardTitle className="text-base flex items-center gap-2">
+                  <Award className="h-4 w-4" />
+                  银龄档案信息
+                </CardTitle>
+              </CardHeader>
+              <CardContent className="space-y-4">
+                <FormField
+                  control={form.control}
+                  name="realName"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>真实姓名 *</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入真实姓名" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <div className="grid grid-cols-2 gap-4">
+                  <FormField
+                    control={form.control}
+                    name="age"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>年龄 *</FormLabel>
+                        <FormControl>
+                          <Input type="number" placeholder="请输入年龄" {...field} />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  <FormField
+                    control={form.control}
+                    name="gender"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>性别 *</FormLabel>
+                        <Select onValueChange={(value) => field.onChange(Number(value))} value={field.value.toString()}>
+                          <FormControl>
+                            <SelectTrigger>
+                              <SelectValue placeholder="请选择性别" />
+                            </SelectTrigger>
+                          </FormControl>
+                          <SelectContent>
+                            <SelectItem value="1">男</SelectItem>
+                            <SelectItem value="2">女</SelectItem>
+                            <SelectItem value="3">其他</SelectItem>
+                          </SelectContent>
+                        </Select>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+                </div>
+
+                <FormField
+                  control={form.control}
+                  name="organization"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>所属机构</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入所属机构" {...field} />
+                      </FormControl>
+                      <FormDescription>如:社区服务中心、学校等</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+              </CardContent>
+            </Card>
+
+            {/* 个人简介 */}
+            <Card>
+              <CardHeader className="pb-3">
+                <CardTitle className="text-base flex items-center gap-2">
+                  <FileText className="h-4 w-4" />
+                  个人简介
+                </CardTitle>
+              </CardHeader>
+              <CardContent className="space-y-4">
+                <FormField
+                  control={form.control}
+                  name="personalIntro"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>个人简介</FormLabel>
+                      <FormControl>
+                        <Textarea 
+                          placeholder="请简单介绍自己,例如:退休教师,热爱教育事业..." 
+                          className="resize-none" 
+                          rows={3}
+                          {...field}
+                        />
+                      </FormControl>
+                      <FormDescription>简要介绍您的背景和兴趣</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={form.control}
+                  name="personalSkills"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>个人技能</FormLabel>
+                      <FormControl>
+                        <Textarea 
+                          placeholder="请输入个人技能,例如:书法、绘画、音乐教学..." 
+                          className="resize-none" 
+                          rows={3}
+                          {...field}
+                        />
+                      </FormControl>
+                      <FormDescription>您擅长或可以分享的技能</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={form.control}
+                  name="personalExperience"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>个人经历</FormLabel>
+                      <FormControl>
+                        <Textarea 
+                          placeholder="请输入个人经历,例如:从事教育工作40年,经验丰富..." 
+                          className="resize-none" 
+                          rows={3}
+                          {...field}
+                        />
+                      </FormControl>
+                      <FormDescription>您的工作或生活经历</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+              </CardContent>
+            </Card>
+
+            {/* 保存按钮 */}
+            <div className="pt-4">
+              <Button 
+                type="submit" 
+                className="w-full" 
+                size="lg" 
+                disabled={loading}
+              >
+                {loading ? '保存中...' : '保存修改'}
+              </Button>
+            </div>
+          </form>
         </Form>
       </div>
     </div>

+ 4 - 1
src/server/modules/silver-users/silver-user-profile.entity.ts

@@ -217,7 +217,10 @@ export const UpdateSilverUserProfileDto = z.object({
   nickname: z.string().max(50).optional().openapi({ description: '昵称', example: '张大爷' }),
   organization: z.string().max(255).optional().openapi({ description: '所属组织/机构', example: '社区服务中心' }),
   email: z.string().max(255).email().optional().openapi({ description: '电子邮箱', example: 'example@email.com' }),
-  avatarUrl: z.string().url().optional().openapi({ description: '头像URL', example: 'https://example.com/avatar.jpg' }),
+  avatarFileId: z.number().int().positive().optional().openapi({
+    example: 1,
+    description: '头像文件ID'
+  }),
   personalIntro: z.string().optional().openapi({ description: '个人简介', example: '退休教师,热爱教育事业' }),
   personalSkills: z.string().optional().openapi({ description: '个人技能', example: '书法、绘画、音乐教学' }),
   personalExperience: z.string().optional().openapi({ description: '个人经历', example: '从事教育工作40年,经验丰富' }),