Quellcode durchsuchen

feat(disability-person-ui): 完善照片上传功能,修复FileSelector样式

- 创建PhotoUploadField组件支持多个照片上传
- 创建PhotoPreview组件用于照片预览
- 集成聚合API处理照片数据
- 修复FileSelector图片宫格样式问题
- 更新测试以适配新组件
- 更新故事文档标记任务完成

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname vor 1 Tag
Ursprung
Commit
9b2cd8359c

+ 10 - 0
allin-packages/disability-person-management-ui/src/api/disabilityClient.ts

@@ -65,6 +65,16 @@ export type FindByIdCardResponse = InferResponseType<typeof disabilityClient.fin
 export type BatchCreateDisabledPersonsRequest = InferRequestType<typeof disabilityClient.batchCreateDisabledPersons.$post>['json'];
 export type BatchCreateDisabledPersonsResponse = InferResponseType<typeof disabilityClient.batchCreateDisabledPersons.$post, 200>;
 
+// 聚合API类型定义
+export type CreateAggregatedDisabledPersonRequest = InferRequestType<typeof disabilityClient.createAggregatedDisabledPerson.$post>['json'];
+export type CreateAggregatedDisabledPersonResponse = InferResponseType<typeof disabilityClient.createAggregatedDisabledPerson.$post, 200>;
+
+export type GetAggregatedDisabledPersonRequest = InferRequestType<typeof disabilityClient.getAggregatedDisabledPerson[':id']['$get']>['param'];
+export type GetAggregatedDisabledPersonResponse = InferResponseType<typeof disabilityClient.getAggregatedDisabledPerson[':id']['$get'], 200>;
+
+export type UpdateAggregatedDisabledPersonRequest = InferRequestType<typeof disabilityClient.updateAggregatedDisabledPerson[':id']['$put']>['json'];
+export type UpdateAggregatedDisabledPersonResponse = InferResponseType<typeof disabilityClient.updateAggregatedDisabledPerson[':id']['$put'], 200>;
+
 export {
   disabilityClientManager
 }

+ 143 - 35
allin-packages/disability-person-management-ui/src/components/DisabilityPersonManagement.tsx

@@ -19,7 +19,8 @@ import type { CreateDisabledPersonRequest, UpdateDisabledPersonRequest } from '.
 import { DISABILITY_TYPES, getDisabilityTypeLabel } from '@d8d/allin-enums';
 import { DISABILITY_LEVELS, getDisabilityLevelLabel } from '@d8d/allin-enums';
 import { AreaSelect } from '@d8d/area-management-ui/components';
-import { FileSelector } from '@d8d/file-management-ui/components';
+import PhotoUploadField, { type PhotoItem } from './PhotoUploadField';
+import PhotoPreview from './PhotoPreview';
 
 interface DisabilityPersonSearchParams {
   page: number;
@@ -35,6 +36,8 @@ const DisabilityPersonManagement: React.FC = () => {
   const [personToDelete, setPersonToDelete] = useState<number | null>(null);
   const [viewDialogOpen, setViewDialogOpen] = useState(false);
   const [personToView, setPersonToView] = useState<number | null>(null);
+  const [createPhotos, setCreatePhotos] = useState<PhotoItem[]>([]);
+  const [updatePhotos, setUpdatePhotos] = useState<PhotoItem[]>([]);
 
   // 表单实例 - 创建表单
   const createForm = useForm<CreateDisabledPersonRequest>({
@@ -85,12 +88,12 @@ const DisabilityPersonManagement: React.FC = () => {
     }
   });
 
-  // 查看详情查询
+  // 查看详情查询 - 使用聚合API获取包含照片的完整数据
   const { data: viewData } = useQuery({
-    queryKey: ['disabled-person-detail', personToView],
+    queryKey: ['disabled-person-aggregated-detail', personToView],
     queryFn: async () => {
       if (!personToView) return null;
-      const res = await disabilityClientManager.get().getDisabledPerson[':id'].$get({
+      const res = await disabilityClientManager.get().getAggregatedDisabledPerson[':id'].$get({
         param: { id: personToView }
       });
       if (res.status !== 200) throw new Error('获取残疾人详情失败');
@@ -102,7 +105,22 @@ const DisabilityPersonManagement: React.FC = () => {
   // 创建残疾人
   const createMutation = useMutation({
     mutationFn: async (data: CreateDisabledPersonRequest) => {
-      const res = await disabilityClientManager.get().createDisabledPerson.$post({ json: data });
+      // 准备聚合数据,包含照片
+      const aggregatedData = {
+        personInfo: data,
+        photos: createPhotos
+          .filter(photo => photo.fileId !== null)
+          .map(photo => ({
+            photoType: photo.photoType,
+            fileId: photo.fileId!,
+            canDownload: photo.canDownload
+          }))
+      };
+
+      // 使用聚合API创建残疾人信息
+      const res = await disabilityClientManager.get().createAggregatedDisabledPerson.$post({
+        json: aggregatedData
+      });
       if (res.status !== 200) throw new Error('创建残疾人失败');
       return await res.json();
     },
@@ -110,6 +128,7 @@ const DisabilityPersonManagement: React.FC = () => {
       toast.success('残疾人创建成功');
       setIsModalOpen(false);
       createForm.reset();
+      setCreatePhotos([]); // 重置照片状态
       refetch();
     },
     onError: (error) => {
@@ -120,8 +139,48 @@ const DisabilityPersonManagement: React.FC = () => {
   // 更新残疾人
   const updateMutation = useMutation({
     mutationFn: async (data: UpdateDisabledPersonRequest) => {
-      const res = await disabilityClientManager.get().updateDisabledPerson.$post({
-        json: data
+      // 准备聚合数据,包含照片
+      // 注意:我们需要从表单中获取完整数据,因为data只包含更新的字段
+      const formData = updateForm.getValues();
+
+      // 为必填字段提供默认值,确保类型安全
+      const personInfo = {
+        name: formData.name || '',
+        gender: formData.gender || '男',
+        idCard: formData.idCard || '',
+        disabilityId: formData.disabilityId || '',
+        disabilityType: formData.disabilityType || '',
+        disabilityLevel: formData.disabilityLevel || '',
+        idAddress: formData.idAddress || '',
+        phone: formData.phone || '',
+        province: formData.province || '',
+        city: formData.city || '',
+        // 可选字段
+        district: formData.district,
+        detailedAddress: formData.detailedAddress,
+        nation: formData.nation,
+        isMarried: formData.isMarried,
+        canDirectContact: formData.canDirectContact,
+        isInBlackList: formData.isInBlackList,
+        jobStatus: formData.jobStatus,
+        id: data.id // 确保包含ID
+      };
+
+      const aggregatedData = {
+        personInfo,
+        photos: updatePhotos
+          .filter(photo => photo.fileId !== null)
+          .map(photo => ({
+            photoType: photo.photoType,
+            fileId: photo.fileId!,
+            canDownload: photo.canDownload
+          }))
+      };
+
+      // 使用聚合API更新残疾人信息
+      const res = await disabilityClientManager.get().updateAggregatedDisabledPerson[':id']['$put']({
+        param: { id: data.id! },
+        json: aggregatedData
       });
       if (res.status !== 200) throw new Error('更新残疾人失败');
       return await res.json();
@@ -129,6 +188,7 @@ const DisabilityPersonManagement: React.FC = () => {
     onSuccess: () => {
       toast.success('残疾人更新成功');
       setIsModalOpen(false);
+      setUpdatePhotos([]); // 重置照片状态
       refetch();
     },
     onError: (error) => {
@@ -165,6 +225,7 @@ const DisabilityPersonManagement: React.FC = () => {
   const handleCreateOpen = () => {
     setIsCreateForm(true);
     createForm.reset();
+    setCreatePhotos([]); // 重置创建照片状态
     setIsModalOpen(true);
   };
 
@@ -172,6 +233,32 @@ const DisabilityPersonManagement: React.FC = () => {
   const handleEditOpen = (person: any) => {
     setIsCreateForm(false);
     updateForm.reset(person);
+    setUpdatePhotos([]); // 先重置,然后加载已有照片
+
+    // 加载聚合数据获取照片信息
+    if (person.id) {
+      disabilityClientManager.get().getAggregatedDisabledPerson[':id']['$get']({
+        param: { id: person.id }
+      }).then(async (res) => {
+        if (res.status === 200) {
+          const aggregatedData = await res.json();
+          if (aggregatedData && aggregatedData.photos) {
+            // 转换照片数据格式
+            const photos: PhotoItem[] = aggregatedData.photos.map((photo: any) => ({
+              photoType: photo.photoType,
+              fileId: photo.fileId,
+              canDownload: photo.canDownload,
+              tempId: `existing-${photo.id || Date.now()}`
+            }));
+            setUpdatePhotos(photos);
+          }
+        }
+      }).catch(error => {
+        console.error('加载照片数据失败:', error);
+        toast.error('加载照片数据失败');
+      });
+    }
+
     setIsModalOpen(true);
   };
 
@@ -548,21 +635,13 @@ const DisabilityPersonManagement: React.FC = () => {
                       </div>
                     </div>
 
-                    <div>
-                      <FormLabel>照片上传</FormLabel>
-                      <div className="mt-2">
-                        <FileSelector
-                          value={null}
-                          onChange={() => {
-                            // 这里需要处理文件ID的存储
-                            // 由于后端Schema没有photoFileId字段,暂时注释
-                            // createForm.setValue('photoFileId', fileId as number);
-                          }}
-                          accept="image/*"
-                          filterType="image"
-                          placeholder="选择或上传照片"
-                        />
-                      </div>
+                    <div className="col-span-full">
+                      <PhotoUploadField
+                        value={createPhotos}
+                        onChange={setCreatePhotos}
+                        photoTypes={['身份证照片', '残疾证照片', '个人照片', '其他照片']}
+                        maxPhotos={5}
+                      />
                     </div>
                   </div>
                 </form>
@@ -781,6 +860,16 @@ const DisabilityPersonManagement: React.FC = () => {
                         )}
                       />
                     </div>
+
+                    {/* 照片上传 */}
+                    <div className="col-span-full">
+                      <PhotoUploadField
+                        value={updatePhotos}
+                        onChange={setUpdatePhotos}
+                        photoTypes={['身份证照片', '残疾证照片', '个人照片', '其他照片']}
+                        maxPhotos={5}
+                      />
+                    </div>
                   </div>
                 </div>
               </form>
@@ -817,65 +906,84 @@ const DisabilityPersonManagement: React.FC = () => {
               <div className="grid grid-cols-2 gap-4">
                 <div>
                   <label className="text-sm font-medium">姓名</label>
-                  <p className="text-sm text-muted-foreground">{viewData.name}</p>
+                  <p className="text-sm text-muted-foreground">{viewData.personInfo.name}</p>
                 </div>
                 <div>
                   <label className="text-sm font-medium">性别</label>
-                  <p className="text-sm text-muted-foreground">{viewData.gender}</p>
+                  <p className="text-sm text-muted-foreground">{viewData.personInfo.gender}</p>
                 </div>
                 <div>
                   <label className="text-sm font-medium">身份证号</label>
-                  <p className="text-sm text-muted-foreground">{viewData.idCard}</p>
+                  <p className="text-sm text-muted-foreground">{viewData.personInfo.idCard}</p>
                 </div>
                 <div>
                   <label className="text-sm font-medium">残疾证号</label>
-                  <p className="text-sm text-muted-foreground">{viewData.disabilityId}</p>
+                  <p className="text-sm text-muted-foreground">{viewData.personInfo.disabilityId}</p>
                 </div>
                 <div>
                   <label className="text-sm font-medium">残疾类型</label>
-                  <p className="text-sm text-muted-foreground">{viewData.disabilityType}</p>
+                  <p className="text-sm text-muted-foreground">{viewData.personInfo.disabilityType}</p>
                 </div>
                 <div>
                   <label className="text-sm font-medium">残疾等级</label>
-                  <p className="text-sm text-muted-foreground">{viewData.disabilityLevel}</p>
+                  <p className="text-sm text-muted-foreground">{viewData.personInfo.disabilityLevel}</p>
                 </div>
                 <div>
                   <label className="text-sm font-medium">联系电话</label>
-                  <p className="text-sm text-muted-foreground">{viewData.phone}</p>
+                  <p className="text-sm text-muted-foreground">{viewData.personInfo.phone}</p>
                 </div>
                 <div>
                   <label className="text-sm font-medium">身份证地址</label>
-                  <p className="text-sm text-muted-foreground">{viewData.idAddress}</p>
+                  <p className="text-sm text-muted-foreground">{viewData.personInfo.idAddress}</p>
                 </div>
                 <div className="col-span-1 md:col-span-2">
                   <label className="text-sm font-medium">居住地址</label>
                   <p className="text-sm text-muted-foreground">
-                    {viewData.province} {viewData.city} {viewData.district} {viewData.detailedAddress}
+                    {viewData.personInfo.province} {viewData.personInfo.city} {viewData.personInfo.district} {viewData.personInfo.detailedAddress}
                   </p>
                 </div>
                 <div>
                   <label className="text-sm font-medium">民族</label>
-                  <p className="text-sm text-muted-foreground">{viewData.nation || '未填写'}</p>
+                  <p className="text-sm text-muted-foreground">{viewData.personInfo.nation || '未填写'}</p>
                 </div>
                 <div>
                   <label className="text-sm font-medium">婚姻状况</label>
                   <p className="text-sm text-muted-foreground">
-                    {viewData.isMarried === 1 ? '已婚' : viewData.isMarried === 0 ? '未婚' : '未知'}
+                    {viewData.personInfo.isMarried === 1 ? '已婚' : viewData.personInfo.isMarried === 0 ? '未婚' : '未知'}
                   </p>
                 </div>
                 <div>
                   <label className="text-sm font-medium">创建时间</label>
                   <p className="text-sm text-muted-foreground">
-                    {format(new Date(viewData.createTime), 'yyyy-MM-dd HH:mm:ss')}
+                    {format(new Date(viewData.personInfo.createTime), 'yyyy-MM-dd HH:mm:ss')}
                   </p>
                 </div>
                 <div>
                   <label className="text-sm font-medium">更新时间</label>
                   <p className="text-sm text-muted-foreground">
-                    {format(new Date(viewData.updateTime), 'yyyy-MM-dd HH:mm:ss')}
+                    {format(new Date(viewData.personInfo.updateTime), 'yyyy-MM-dd HH:mm:ss')}
                   </p>
                 </div>
               </div>
+
+              {/* 照片预览 */}
+              {viewData.photos && viewData.photos.length > 0 && (
+                <div className="mt-6 pt-6 border-t">
+                  <PhotoPreview
+                    photos={viewData.photos.map((photo: any) => ({
+                      id: photo.id,
+                      photoType: photo.photoType,
+                      fileId: photo.fileId,
+                      fileUrl: photo.fileUrl,
+                      fileName: `照片-${photo.photoType}`,
+                      canDownload: photo.canDownload,
+                      uploadTime: photo.uploadTime
+                    }))}
+                    title="照片"
+                    showDownloadButton={true}
+                  />
+                </div>
+              )}
             </div>
           )}
 

+ 168 - 0
allin-packages/disability-person-management-ui/src/components/PhotoPreview.tsx

@@ -0,0 +1,168 @@
+import React from 'react';
+import { Card, CardContent } from '@d8d/shared-ui-components/components/ui/card';
+import { Image as ImageIcon, Download, Eye } from 'lucide-react';
+import { Button } from '@d8d/shared-ui-components/components/ui/button';
+import { Badge } from '@d8d/shared-ui-components/components/ui/badge';
+
+export interface PhotoPreviewItem {
+  id?: number;
+  photoType: string;
+  fileId: number;
+  fileUrl?: string;
+  fileName?: string;
+  canDownload: number;
+  uploadTime?: string;
+}
+
+export interface PhotoPreviewProps {
+  photos: PhotoPreviewItem[];
+  title?: string;
+  showDownloadButton?: boolean;
+  onDownload?: (photo: PhotoPreviewItem) => void;
+  onView?: (photo: PhotoPreviewItem) => void;
+}
+
+export const PhotoPreview: React.FC<PhotoPreviewProps> = ({
+  photos,
+  title = '照片预览',
+  showDownloadButton = true,
+  onDownload,
+  onView,
+}) => {
+  if (photos.length === 0) {
+    return (
+      <Card>
+        <CardContent className="flex flex-col items-center justify-center py-8">
+          <ImageIcon className="h-12 w-12 text-gray-400 mb-4" />
+          <p className="text-gray-600">暂无照片</p>
+        </CardContent>
+      </Card>
+    );
+  }
+
+  const getPhotoTypeColor = (photoType: string) => {
+    switch (photoType) {
+      case '身份证照片':
+        return 'bg-blue-100 text-blue-800';
+      case '残疾证照片':
+        return 'bg-green-100 text-green-800';
+      case '个人照片':
+        return 'bg-purple-100 text-purple-800';
+      default:
+        return 'bg-gray-100 text-gray-800';
+    }
+  };
+
+  const handleDownload = (photo: PhotoPreviewItem) => {
+    if (onDownload) {
+      onDownload(photo);
+    } else if (photo.fileUrl) {
+      window.open(photo.fileUrl, '_blank');
+    }
+  };
+
+  const handleView = (photo: PhotoPreviewItem) => {
+    if (onView) {
+      onView(photo);
+    } else if (photo.fileUrl) {
+      window.open(photo.fileUrl, '_blank');
+    }
+  };
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <h3 className="text-lg font-medium">{title}</h3>
+        <Badge variant="outline" className="text-sm">
+          共 {photos.length} 张照片
+        </Badge>
+      </div>
+
+      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
+        {photos.map((photo, index) => (
+          <Card key={photo.id || index} className="overflow-hidden hover:shadow-md transition-shadow">
+            <CardContent className="p-0">
+              {/* 照片预览区域 */}
+              <div className="relative aspect-square bg-gray-100">
+                {photo.fileUrl ? (
+                  <div className="relative w-full h-full group">
+                    <img
+                      src={photo.fileUrl}
+                      alt={photo.photoType}
+                      className="w-full h-full object-cover"
+                      loading="lazy"
+                    />
+                    <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200 flex items-center justify-center opacity-0 group-hover:opacity-100">
+                      <div className="flex space-x-2">
+                        <Button
+                          type="button"
+                          size="sm"
+                          variant="secondary"
+                          className="bg-white/90 hover:bg-white"
+                          onClick={() => handleView(photo)}
+                        >
+                          <Eye className="h-4 w-4 mr-1" />
+                          查看
+                        </Button>
+                        {showDownloadButton && photo.canDownload === 1 && (
+                          <Button
+                            type="button"
+                            size="sm"
+                            variant="secondary"
+                            className="bg-white/90 hover:bg-white"
+                            onClick={() => handleDownload(photo)}
+                          >
+                            <Download className="h-4 w-4 mr-1" />
+                            下载
+                          </Button>
+                        )}
+                      </div>
+                    </div>
+                  </div>
+                ) : (
+                  <div className="w-full h-full flex flex-col items-center justify-center text-gray-400">
+                    <ImageIcon className="h-12 w-12 mb-2" />
+                    <p className="text-sm">无预览</p>
+                  </div>
+                )}
+              </div>
+
+              {/* 照片信息区域 */}
+              <div className="p-3 space-y-2">
+                <div className="flex items-center justify-between">
+                  <Badge className={getPhotoTypeColor(photo.photoType)}>
+                    {photo.photoType}
+                  </Badge>
+                  {photo.canDownload === 1 && (
+                    <Badge variant="outline" className="text-xs">
+                      可下载
+                    </Badge>
+                  )}
+                </div>
+
+                <div className="space-y-1">
+                  <p className="text-sm font-medium truncate">
+                    {photo.fileName || `照片 ${index + 1}`}
+                  </p>
+                  {photo.uploadTime && (
+                    <p className="text-xs text-muted-foreground">
+                      上传时间: {new Date(photo.uploadTime).toLocaleDateString()}
+                    </p>
+                  )}
+                </div>
+              </div>
+            </CardContent>
+          </Card>
+        ))}
+      </div>
+
+      <div className="text-sm text-muted-foreground">
+        <p>• 点击"查看"按钮可以放大预览照片</p>
+        <p>• 标记为"可下载"的照片支持下载功能</p>
+        <p>• 照片类型通过不同颜色的标签区分</p>
+      </div>
+    </div>
+  );
+};
+
+export default PhotoPreview;

+ 216 - 0
allin-packages/disability-person-management-ui/src/components/PhotoUploadField.tsx

@@ -0,0 +1,216 @@
+import React, { useState } from 'react';
+import { Button } from '@d8d/shared-ui-components/components/ui/button';
+import { Card, CardContent } from '@d8d/shared-ui-components/components/ui/card';
+import { Label } from '@d8d/shared-ui-components/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@d8d/shared-ui-components/components/ui/select';
+import { Switch } from '@d8d/shared-ui-components/components/ui/switch';
+import { Plus, Trash2, Image as ImageIcon } from 'lucide-react';
+import { FileSelector } from '@d8d/file-management-ui/components';
+import { toast } from 'sonner';
+
+export interface PhotoItem {
+  photoType: string;
+  fileId: number | null;
+  canDownload: number;
+  tempId?: string; // 临时ID用于React key
+}
+
+export interface PhotoUploadFieldProps {
+  value?: PhotoItem[];
+  onChange?: (photos: PhotoItem[]) => void;
+  photoTypes?: string[];
+  maxPhotos?: number;
+}
+
+export const PhotoUploadField: React.FC<PhotoUploadFieldProps> = ({
+  value = [],
+  onChange,
+  photoTypes = ['身份证照片', '残疾证照片', '个人照片', '其他照片'],
+  maxPhotos = 5,
+}) => {
+  const [photos, setPhotos] = useState<PhotoItem[]>(value);
+
+  const handleAddPhoto = () => {
+    if (photos.length >= maxPhotos) {
+      toast.warning(`最多只能上传 ${maxPhotos} 张照片`);
+      return;
+    }
+
+    const newPhoto: PhotoItem = {
+      photoType: photoTypes[0],
+      fileId: null,
+      canDownload: 0,
+      tempId: `temp-${Date.now()}-${Math.random()}`,
+    };
+
+    const newPhotos = [...photos, newPhoto];
+    setPhotos(newPhotos);
+    onChange?.(newPhotos);
+  };
+
+  const handleRemovePhoto = (index: number) => {
+    const newPhotos = photos.filter((_, i) => i !== index);
+    setPhotos(newPhotos);
+    onChange?.(newPhotos);
+  };
+
+  const handlePhotoTypeChange = (index: number, photoType: string) => {
+    const newPhotos = [...photos];
+    newPhotos[index] = { ...newPhotos[index], photoType };
+    setPhotos(newPhotos);
+    onChange?.(newPhotos);
+  };
+
+  const handleFileIdChange = (index: number, fileId: number | null) => {
+    const newPhotos = [...photos];
+    newPhotos[index] = { ...newPhotos[index], fileId };
+    setPhotos(newPhotos);
+    onChange?.(newPhotos);
+  };
+
+  const handleCanDownloadChange = (index: number, canDownload: number) => {
+    const newPhotos = [...photos];
+    newPhotos[index] = { ...newPhotos[index], canDownload };
+    setPhotos(newPhotos);
+    onChange?.(newPhotos);
+  };
+
+  const getPhotoTypeLabel = (photoType: string) => {
+    return photoType;
+  };
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <Label>照片上传</Label>
+        <Button
+          type="button"
+          variant="outline"
+          size="sm"
+          onClick={handleAddPhoto}
+          disabled={photos.length >= maxPhotos}
+          data-testid="add-photo-button"
+        >
+          <Plus className="h-4 w-4 mr-2" />
+          添加照片
+        </Button>
+      </div>
+
+      {photos.length === 0 ? (
+        <Card>
+          <CardContent className="flex flex-col items-center justify-center py-8">
+            <ImageIcon className="h-12 w-12 text-gray-400 mb-4" />
+            <p className="text-gray-600 mb-2">暂无照片</p>
+            <p className="text-sm text-gray-500 mb-4">点击"添加照片"按钮上传照片</p>
+            <Button type="button" variant="outline" onClick={handleAddPhoto}>
+              <Plus className="h-4 w-4 mr-2" />
+              添加第一张照片
+            </Button>
+          </CardContent>
+        </Card>
+      ) : (
+        <div className="space-y-4">
+          {photos.map((photo, index) => (
+            <Card key={photo.tempId || index} className="overflow-hidden">
+              <CardContent className="p-4">
+                <div className="flex items-start justify-between mb-4">
+                  <div className="flex items-center space-x-2">
+                    <div className="bg-primary/10 text-primary rounded-full w-6 h-6 flex items-center justify-center text-sm font-medium">
+                      {index + 1}
+                    </div>
+                    <h4 className="font-medium">照片 {index + 1}</h4>
+                  </div>
+                  <Button
+                    type="button"
+                    variant="ghost"
+                    size="sm"
+                    onClick={() => handleRemovePhoto(index)}
+                    className="text-red-500 hover:text-red-700 hover:bg-red-50"
+                  >
+                    <Trash2 className="h-4 w-4" />
+                  </Button>
+                </div>
+
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                  {/* 照片类型选择 */}
+                  <div className="space-y-2">
+                    <Label htmlFor={`photo-type-${index}`}>照片类型 *</Label>
+                    <Select
+                      value={photo.photoType}
+                      onValueChange={(value) => handlePhotoTypeChange(index, value)}
+                    >
+                      <SelectTrigger id={`photo-type-${index}`}>
+                        <SelectValue placeholder="选择照片类型" />
+                      </SelectTrigger>
+                      <SelectContent>
+                        {photoTypes.map((type) => (
+                          <SelectItem key={type} value={type}>
+                            {getPhotoTypeLabel(type)}
+                          </SelectItem>
+                        ))}
+                      </SelectContent>
+                    </Select>
+                  </div>
+
+                  {/* 文件选择器 */}
+                  <div className="space-y-2">
+                    <Label>照片文件 *</Label>
+                    <FileSelector
+                      value={photo.fileId}
+                      onChange={(fileId) => handleFileIdChange(index, fileId as number | null)}
+                      accept="image/*"
+                      filterType="image"
+                      placeholder="选择或上传照片"
+                      showPreview={true}
+                      previewSize="medium"
+                    />
+                  </div>
+
+                  {/* 是否可下载开关 */}
+                  <div className="flex items-center space-x-2">
+                    <Switch
+                      id={`can-download-${index}`}
+                      checked={photo.canDownload === 1}
+                      onCheckedChange={(checked) => handleCanDownloadChange(index, checked ? 1 : 0)}
+                    />
+                    <Label htmlFor={`can-download-${index}`}>允许下载</Label>
+                  </div>
+                </div>
+
+                {/* 验证提示 */}
+                {!photo.fileId && (
+                  <p className="text-sm text-amber-600 mt-2">
+                    请选择照片文件
+                  </p>
+                )}
+              </CardContent>
+            </Card>
+          ))}
+
+          {photos.length < maxPhotos && (
+            <div className="text-center">
+              <Button
+                type="button"
+                variant="outline"
+                onClick={handleAddPhoto}
+                className="w-full"
+              >
+                <Plus className="h-4 w-4 mr-2" />
+                添加更多照片({photos.length}/{maxPhotos})
+              </Button>
+            </div>
+          )}
+        </div>
+      )}
+
+      <div className="text-sm text-muted-foreground">
+        <p>• 最多可上传 {maxPhotos} 张照片</p>
+        <p>• 每张照片需要选择照片类型和上传文件</p>
+        <p>• 支持的照片格式:JPG、PNG、GIF等图片格式</p>
+        <p>• 文件大小限制:10MB</p>
+      </div>
+    </div>
+  );
+};
+
+export default PhotoUploadField;

+ 5 - 5
allin-packages/disability-person-management-ui/tests/integration/disability-person.integration.test.tsx

@@ -557,12 +557,12 @@ describe('残疾人个人管理集成测试', () => {
       expect(screen.getByTestId('create-disabled-person-dialog-title')).toBeInTheDocument();
     });
 
-    // 验证文件选择器存在
-    expect(screen.getByTestId('file-selector')).toBeInTheDocument();
+    // 验证照片上传组件存在
+    expect(screen.getByTestId('add-photo-button')).toBeInTheDocument();
 
-    // 点击文件选择器按钮
-    const fileSelectorButton = screen.getByTestId('file-selector-button');
-    fireEvent.click(fileSelectorButton);
+    // 点击添加照片按钮
+    const addPhotoButton = screen.getByTestId('add-photo-button');
+    fireEvent.click(addPhotoButton);
   });
 
   it('应该测试枚举选择器集成', async () => {

+ 18 - 14
docs/stories/008.006.transplant-disability-person-management-ui.story.md

@@ -1,7 +1,7 @@
 # Story 008.006: 移植残疾人个人管理UI(disability_person → @d8d/allin-disability-person-management-ui)
 
 ## Status
-In Progress - 需要完善照片上传功能
+Ready for Review - 照片上传功能已完善
 
 ## Story
 **As a** 开发者,
@@ -63,7 +63,7 @@ In Progress - 需要完善照片上传功能
       - `allin-packages/platform-management-ui/src/api/types.ts` - 平台管理UI类型定义
       - `allin-packages/disability-management-ui/src/api/disabilityClient.ts` - 残疾人管理UI API客户端(最新实现)
 
-- [ ] 任务3:实现文件上传集成 (AC: 3)
+- [x] 任务3:实现文件上传集成 (AC: 3)
   - [x] 分析源系统照片上传逻辑:`allin_system-master/client/app/admin/dashboard/disability_person/AddDisabledPersonModal.tsx`
     - **源文件**:`allin_system-master/client/app/admin/dashboard/disability_person/AddDisabledPersonModal.tsx`
     - **查看要点**:照片上传组件、文件处理逻辑、预览功能
@@ -83,16 +83,16 @@ In Progress - 需要完善照片上传功能
     - **源文件**:`allin-packages/disability-module/src/routes/aggregated.routes.ts`、`src/services/aggregated.service.ts`
     - **查看要点**:新系统聚合API路由、照片验证逻辑、文件ID管理
     - **关键发现**:新系统使用`fileId`字段引用文件模块,验证文件ID有效性,自动添加`fileUrl`
-  - [ ] 创建照片上传组件:`src/components/PhotoUploadField.tsx`
+  - [x] 创建照片上传组件:`src/components/PhotoUploadField.tsx`
     - **目标文件**:`allin-packages/disability-person-management-ui/src/components/PhotoUploadField.tsx`
     - **功能**:集成`FileSelector`组件,支持**多个照片上传**、照片类型选择、是否可下载选项
     - **参考文件**:`packages/file-management-ui/src/components/FileSelector.tsx`
     - **关键功能**:支持动态添加/移除照片,每个照片独立管理
-  - [ ] 集成照片上传到表单:在残疾人个人表单中添加照片上传字段
+  - [x] 集成照片上传到表单:在残疾人个人表单中添加照片上传字段
     - **字段**:`photos`(照片数组字段)
     - **表单集成**:使用`PhotoUploadField`组件,表单接收照片数组参数
     - **数据结构**:每个照片包含`photoType`、`fileId`、`canDownload`字段
-  - [ ] 创建照片预览组件:`src/components/PhotoPreview.tsx`
+  - [x] 创建照片预览组件:`src/components/PhotoPreview.tsx`
     - **目标文件**:`allin-packages/disability-person-management-ui/src/components/PhotoPreview.tsx`
     - **功能**:使用`FilePreview`组件显示多个照片
     - **参考文件**:`packages/file-management-ui/src/components/FilePreview.tsx`
@@ -409,11 +409,14 @@ Claude Code (d8d-model)
 5. **集成测试编写**:编写了完整的集成测试,覆盖CRUD流程、文件上传集成、区域选择器集成、枚举选择器集成等场景
 6. **类型推导最佳实践**:遵循史诗008经验总结,使用RPC推断类型(`InferRequestType`和`InferResponseType`),避免直接导入schema类型
 7. **测试验证完成**:所有集成测试通过,类型检查完成,组件集成功能验证正常
-8. **照片上传功能差距**:发现当前实现与原系统的差距:
-   - ❌ 原系统支持**多个照片上传**,当前只支持单个
-   - ❌ 原系统每个照片有`photoType`字段,当前缺少
-   - ❌ 原系统每个照片有`canDownload`选项,当前缺少
-   - ✅ 已使用FileSelector组件,但需要扩展功能
+8. **照片上传功能差距已解决**:
+   - ✅ 已实现**多个照片上传**功能,支持动态添加/移除照片
+   - ✅ 每个照片包含`photoType`字段,支持照片类型选择
+   - ✅ 每个照片包含`canDownload`选项,支持是否可下载设置
+   - ✅ 创建专用`PhotoUploadField`组件,集成FileSelector组件
+   - ✅ 创建`PhotoPreview`组件用于照片预览
+   - ✅ 集成聚合API,支持照片数据完整传输
+   - ✅ 更新查看详情功能,显示照片预览
 
 ### File List
 **新创建的文件:**
@@ -428,13 +431,14 @@ Claude Code (d8d-model)
 - `allin-packages/disability-person-management-ui/src/components/DisabilityPersonManagement.tsx` - 主管理组件
 - `allin-packages/disability-person-management-ui/tests/integration/disability-person.integration.test.tsx` - 集成测试文件
 
-**需要创建的文件:**
-- `allin-packages/disability-person-management-ui/src/components/PhotoUploadField.tsx` - 多个照片上传组件(创建)
-- `allin-packages/disability-person-management-ui/src/components/PhotoPreview.tsx` - 多个照片预览组件(创建)
+**创建的文件:**
+- `allin-packages/disability-person-management-ui/src/components/PhotoUploadField.tsx` - 多个照片上传组件(创建)
+- `allin-packages/disability-person-management-ui/src/components/PhotoPreview.tsx` - 多个照片预览组件(创建)
 
 **修改的文件:**
 - `docs/stories/008.006.transplant-disability-person-management-ui.story.md` - 更新任务完成状态和开发记录
-- `allin-packages/disability-person-management-ui/src/components/DisabilityPersonManagement.tsx` - 需要更新以支持多个照片上传
+- `allin-packages/disability-person-management-ui/src/components/DisabilityPersonManagement.tsx` - 已更新以支持多个照片上传
+- `allin-packages/disability-person-management-ui/src/api/disabilityClient.ts` - 添加聚合API类型定义
 
 ## QA Results
 *Results from QA Agent QA review of the completed story implementation*

+ 5 - 5
packages/file-management-ui/src/components/FileSelector.tsx

@@ -396,11 +396,11 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
                   </CardContent>
                 </Card>
               ) : (
-                <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
+                <div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-7 gap-3">
                   {/* 上传区域 - 作为第一项 */}
-                  <div className="relative cursor-pointer transition-all duration-200">
-                    <div className="rounded-lg border-2 border-dashed border-gray-300 hover:border-primary transition-colors hover:scale-105">
-                      <div className="p-2 h-20 flex items-center justify-center">
+                  <div className="relative cursor-pointer transition-all duration-200 aspect-square max-w-full max-h-48">
+                    <div className="rounded-lg border-2 border-dashed border-gray-300 hover:border-primary transition-colors hover:scale-105 h-full">
+                      <div className="p-2 h-full flex items-center justify-center">
                         <MinioUploader
                           uploadPath={uploadPath}
                           accept={accept}
@@ -430,7 +430,7 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
                     >
                       <div
                         className={cn(
-                          "relative rounded-lg overflow-hidden border-2 aspect-square",
+                          "relative rounded-lg overflow-hidden border-2 aspect-square max-w-full max-h-48",
                           isSelected(file.id)
                             ? "border-primary ring-2 ring-primary ring-offset-2"
                             : "border-gray-200 hover:border-primary"