Browse Source

✨ feat(silver-knowledge): add file upload functionality for silver knowledge management

- add cover image upload and preview features
- add file attachment upload and download functionality
- add database fields for file storage: coverImage, attachment, attachmentName
- create dedicated upload components: CoverImageUploader and AttachmentUploader
- update knowledge creation and editing forms with file upload sections
- display cover images and attachment links in knowledge list

📝 docs(silver-knowledge): add documentation for file upload features

- create silver-knowledge-upload-plan.md with implementation details
- create silver-knowledge-upload-usage.md with usage instructions
- document technical specifications and validation requirements

🔧 chore(database): add migration for silver knowledge file fields

- create migration to add cover_image, attachment and attachment_name columns
- update entity and DTO definitions to include new file fields
- update validation schemas for knowledge creation and updates
yourname 7 months ago
parent
commit
33ccb18d65

+ 70 - 0
silver-knowledge-upload-plan.md

@@ -0,0 +1,70 @@
+# 银龄智库管理页面文件上传功能实施计划
+
+## 需求概述
+在后台银龄智库管理页面新增知识表单中,增加知识封面图片上传和文件附件上传功能,采用已有的文件上传系统。
+
+## 实施步骤
+
+### 1. 后端修改
+
+#### 1.1 实体类修改
+文件:`src/server/modules/silver-users/silver-knowledge.entity.ts`
+- 添加封面图片字段:`coverImage`
+- 添加附件字段:`attachment`
+- 添加附件名称字段:`attachmentName`
+
+#### 1.2 DTO更新
+文件:`src/server/modules/silver-users/silver-knowledge.entity.ts`
+- 更新CreateSilverKnowledgeDto
+- 更新UpdateSilverKnowledgeDto
+- 更新SilverKnowledgeSchema
+
+### 2. 前端修改
+
+#### 2.1 创建文件上传组件
+基于MinioUploader创建专门的图片上传和文件上传组件
+
+#### 2.2 修改SilverKnowledges页面
+文件:`src/client/admin/pages/SilverKnowledges.tsx`
+- 在表单中添加封面图片上传区域
+- 在表单中添加文件附件上传区域
+- 在列表中显示封面图片预览
+- 在列表中显示附件下载链接
+
+### 3. 技术方案
+
+#### 3.1 封面图片
+- 使用MinioUploader组件,限制图片格式
+- 上传路径:`/silver-knowledges/covers/`
+- 支持图片预览
+- 限制文件大小:5MB
+
+#### 3.2 文件附件
+- 使用MinioUploader组件,支持多种格式
+- 上传路径:`/silver-knowledges/attachments/`
+- 支持文件下载
+- 限制文件大小:50MB
+
+#### 3.3 文件管理
+- 文件存储在MinIO
+- 数据库保存文件路径和文件名
+- 支持文件删除和替换
+
+## 预期效果
+
+### 创建/编辑表单
+- 封面图片上传区域:显示上传按钮和预览
+- 文件附件上传区域:显示上传按钮和文件名
+- 表单提交时包含文件信息
+
+### 列表显示
+- 封面列:显示小图预览,点击可查看大图
+- 附件列:显示文件名,点击可下载
+- 保持现有功能不变
+
+## 验证要点
+1. 图片上传成功并正确显示预览
+2. 文件上传成功并可正常下载
+3. 创建知识时包含文件信息
+4. 编辑知识时可替换文件
+5. 删除知识时清理相关文件(可选)

+ 78 - 0
silver-knowledge-upload-usage.md

@@ -0,0 +1,78 @@
+# 银龄智库文件上传功能使用指南
+
+## 已完成的功能
+
+### 1. 后端实体扩展
+- ✅ 在 `SilverKnowledge` 实体中添加了三个新字段:
+  - `coverImage`: 封面图片URL
+  - `attachment`: 附件文件URL
+  - `attachmentName`: 附件原始文件名
+
+### 2. API支持
+- ✅ 创建和更新DTO已支持新字段
+- ✅ 通用CRUD路由自动处理文件关联
+- ✅ 数据库迁移文件已创建
+
+### 3. 前端组件
+- ✅ 创建了专门的文件上传组件:
+  - `CoverImageUploader`: 封面图片上传组件
+  - `AttachmentUploader`: 附件文件上传组件
+
+### 4. 表单集成
+- ✅ 在银龄知识管理表单中添加了:
+  - 封面图片上传区域
+  - 文件附件上传区域
+  - 图片预览和附件下载功能
+
+### 5. 列表显示
+- ✅ 在知识列表中添加了:
+  - 封面图片预览列
+  - 附件下载链接列
+  - 支持点击查看大图
+
+## 使用方式
+
+### 创建新知识
+1. 进入"银龄智库管理"页面
+2. 点击"新增知识"按钮
+3. 填写基本信息(标题、内容、分类等)
+4. 上传封面图片(可选)
+5. 上传文件附件(可选)
+6. 提交保存
+
+### 编辑知识
+1. 在列表中找到要编辑的知识
+2. 点击"编辑"按钮
+3. 可以替换或删除封面图片
+4. 可以替换或删除附件文件
+5. 保存修改
+
+## 技术细节
+
+### 文件上传配置
+- **封面图片**:
+  - 路径:`/silver-knowledges/covers/`
+  - 限制:图片格式,最大5MB
+  - 支持格式:JPG、PNG、GIF
+
+- **附件文件**:
+  - 路径:`/silver-knowledges/attachments/`
+  - 限制:最大50MB
+  - 支持格式:PDF、Word、Excel、PPT、TXT、ZIP、RAR
+
+### 数据库迁移
+运行以下命令应用数据库变更:
+```bash
+pnpm typeorm migration:run
+```
+
+### 文件存储
+- 使用MinIO对象存储
+- 文件URL存储在数据库中
+- 支持文件下载和预览
+
+## 注意事项
+1. 确保MinIO服务已正确配置
+2. 文件上传需要网络连接
+3. 大文件上传可能需要较长时间
+4. 删除知识时不会自动删除关联文件(可后续优化)

+ 48 - 0
src/client/admin/components/AttachmentUploader.tsx

@@ -0,0 +1,48 @@
+import React from 'react';
+import MinioUploader from './MinioUploader';
+
+interface AttachmentUploaderProps {
+  value?: string;
+  fileName?: string;
+  onChange?: (url: string, fileName: string) => void;
+}
+
+const AttachmentUploader: React.FC<AttachmentUploaderProps> = ({ 
+  value, 
+  fileName, 
+  onChange 
+}) => {
+  const handleUploadSuccess = (fileKey: string, fileUrl: string, file: File) => {
+    onChange?.(fileUrl, file.name);
+  };
+
+  return (
+    <div>
+      <MinioUploader
+        uploadPath="/silver-knowledges/attachments/"
+        accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
+        maxSize={50}
+        multiple={false}
+        buttonText="上传附件"
+        tipText="支持 PDF、Word、Excel、PPT、TXT、ZIP、RAR 格式,最大 50MB"
+        onUploadSuccess={handleUploadSuccess}
+      />
+      {value && fileName && (
+        <div className="mt-2 p-2 bg-gray-50 rounded-md">
+          <span className="text-sm text-gray-600">已上传:</span>
+          <span className="text-sm font-medium">{fileName}</span>
+          <a 
+            href={value} 
+            target="_blank" 
+            rel="noopener noreferrer"
+            className="ml-2 text-blue-600 hover:text-blue-800 text-sm"
+          >
+            查看
+          </a>
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default AttachmentUploader;

+ 38 - 0
src/client/admin/components/CoverImageUploader.tsx

@@ -0,0 +1,38 @@
+import React from 'react';
+import MinioUploader from './MinioUploader';
+
+interface CoverImageUploaderProps {
+  value?: string;
+  onChange?: (url: string) => void;
+}
+
+const CoverImageUploader: React.FC<CoverImageUploaderProps> = ({ value, onChange }) => {
+  const handleUploadSuccess = (fileKey: string, fileUrl: string) => {
+    onChange?.(fileUrl);
+  };
+
+  return (
+    <div>
+      <MinioUploader
+        uploadPath="/silver-knowledges/covers/"
+        accept="image/*"
+        maxSize={5}
+        multiple={false}
+        buttonText="上传封面图片"
+        tipText="支持 JPG、PNG、GIF 格式,最大 5MB"
+        onUploadSuccess={handleUploadSuccess}
+      />
+      {value && (
+        <div className="mt-2">
+          <img 
+            src={value} 
+            alt="封面预览" 
+            className="w-32 h-32 object-cover rounded-md border"
+          />
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default CoverImageUploader;

+ 75 - 3
src/client/admin/pages/SilverKnowledges.tsx

@@ -1,9 +1,11 @@
 import React, { useState, useEffect } from 'react';
-import { Card, Table, Button, Modal, Form, Input, Select, message, Space, Tag, Popconfirm } from 'antd';
-import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
+import { Card, Table, Button, Modal, Form, Input, Select, message, Space, Tag, Popconfirm, Image } from 'antd';
+import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, PaperClipOutlined } from '@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 import type { InferResponseType, InferRequestType } from 'hono/client';
 import { silverKnowledgeClient } from '@/client/api';
+import CoverImageUploader from '@/client/admin/components/CoverImageUploader';
+import AttachmentUploader from '@/client/admin/components/AttachmentUploader';
 
 const { TextArea } = Input;
 const { Option } = Select;
@@ -58,7 +60,10 @@ const SilverKnowledges: React.FC = () => {
     setCurrentRecord(record);
     form.setFieldsValue({
       ...record,
-      tags: record.tags || undefined
+      tags: record.tags || undefined,
+      coverImage: record.coverImage || undefined,
+      attachment: record.attachment || undefined,
+      attachmentName: record.attachmentName || undefined
     });
     setModalVisible(true);
   };
@@ -114,6 +119,27 @@ const SilverKnowledges: React.FC = () => {
       key: 'id',
       width: 80,
     },
+    {
+      title: '封面',
+      dataIndex: 'coverImage',
+      key: 'coverImage',
+      width: 100,
+      render: (coverImage: string | null) =>
+        coverImage ? (
+          <Image
+            width={80}
+            height={60}
+            src={coverImage}
+            alt="封面"
+            className="object-cover rounded"
+            preview={{
+              mask: <EyeOutlined />
+            }}
+          />
+        ) : (
+          <span className="text-gray-400">无封面</span>
+        ),
+    },
     {
       title: '标题',
       dataIndex: 'title',
@@ -143,6 +169,26 @@ const SilverKnowledges: React.FC = () => {
         </Tag>
       ),
     },
+    {
+      title: '附件',
+      dataIndex: 'attachment',
+      key: 'attachment',
+      width: 100,
+      render: (attachment: string | null, record: SilverKnowledge) =>
+        attachment ? (
+          <a
+            href={attachment}
+            target="_blank"
+            rel="noopener noreferrer"
+            className="flex items-center text-blue-600 hover:text-blue-800"
+          >
+            <PaperClipOutlined className="mr-1" />
+            {record.attachmentName || '下载'}
+          </a>
+        ) : (
+          <span className="text-gray-400">无附件</span>
+        ),
+    },
     {
       title: '浏览量',
       dataIndex: 'viewCount',
@@ -318,6 +364,32 @@ const SilverKnowledges: React.FC = () => {
               <Option value={0}>草稿</Option>
             </Select>
           </Form.Item>
+
+          <Form.Item
+            label="封面图片"
+            name="coverImage"
+          >
+            <CoverImageUploader />
+          </Form.Item>
+
+          <Form.Item
+            label="附件"
+            name="attachment"
+          >
+            <Form.Item noStyle name="attachment">
+              <AttachmentUploader
+                value={form.getFieldValue('attachment')}
+                fileName={form.getFieldValue('attachmentName')}
+                onChange={(url, fileName) => {
+                  form.setFieldsValue({
+                    attachment: url,
+                    attachmentName: fileName
+                  });
+                }}
+              />
+            </Form.Item>
+            <Form.Item noStyle name="attachmentName" />
+          </Form.Item>
         </Form>
       </Modal>
     </div>

+ 23 - 0
src/server/migrations/1722348000000-AddKnowledgeFiles.ts

@@ -0,0 +1,23 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AddKnowledgeFiles1722348000000 implements MigrationInterface {
+    name = 'AddKnowledgeFiles1722348000000'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`
+            ALTER TABLE \`silver_knowledges\` 
+            ADD COLUMN \`cover_image\` varchar(500) NULL COMMENT '封面图片URL' AFTER \`like_count\`,
+            ADD COLUMN \`attachment\` varchar(500) NULL COMMENT '附件文件URL' AFTER \`cover_image\`,
+            ADD COLUMN \`attachment_name\` varchar(255) NULL COMMENT '附件原始文件名' AFTER \`attachment\`
+        `);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`
+            ALTER TABLE \`silver_knowledges\` 
+            DROP COLUMN \`cover_image\`,
+            DROP COLUMN \`attachment\`,
+            DROP COLUMN \`attachment_name\`
+        `);
+    }
+}

+ 20 - 2
src/server/modules/silver-users/silver-knowledge.entity.ts

@@ -36,6 +36,15 @@ export class SilverKnowledge {
   @Column({ name: 'like_count', type: 'int', default: 0 })
   likeCount!: number;
 
+  @Column({ name: 'cover_image', type: 'varchar', length: 500, nullable: true })
+  coverImage!: string | null;
+
+  @Column({ name: 'attachment', type: 'varchar', length: 500, nullable: true })
+  attachment!: string | null;
+
+  @Column({ name: 'attachment_name', type: 'varchar', length: 255, nullable: true })
+  attachmentName!: string | null;
+
   @Column({ name: 'user_id', type: 'int', unsigned: true })
   userId!: number;
 
@@ -62,6 +71,9 @@ export const SilverKnowledgeSchema = z.object({
   status: z.number().int().min(0).max(1).openapi({ description: '状态(0-草稿,1-发布)', example: 1 }),
   viewCount: z.number().int().nonnegative().openapi({ description: '浏览次数', example: 100 }),
   likeCount: z.number().int().nonnegative().openapi({ description: '点赞次数', example: 25 }),
+  coverImage: z.string().nullable().openapi({ description: '封面图片URL', example: 'https://example.com/cover.jpg' }),
+  attachment: z.string().nullable().openapi({ description: '附件文件URL', example: 'https://example.com/attachment.pdf' }),
+  attachmentName: z.string().nullable().openapi({ description: '附件原始文件名', example: '健康知识文档.pdf' }),
   userId: z.number().int().positive().openapi({ description: '用户ID', example: 1 }),
   createdAt: z.date().openapi({ description: '创建时间', example: '2024-01-01T00:00:00Z' }),
   updatedAt: z.date().openapi({ description: '更新时间', example: '2024-01-01T00:00:00Z' })
@@ -73,7 +85,10 @@ export const CreateSilverKnowledgeDto = z.object({
   categoryId: z.coerce.number().int().positive().openapi({ description: '分类ID', example: 1 }),
   tags: z.string().nullable().optional().openapi({ description: '标签,逗号分隔', example: '健康,养生,老年人' }),
   author: z.string().max(100).openapi({ description: '作者', example: '张医生' }),
-  status: z.coerce.number().int().min(0).max(1).default(1).openapi({ description: '状态(0-草稿,1-发布)', example: 1 })
+  status: z.coerce.number().int().min(0).max(1).default(1).openapi({ description: '状态(0-草稿,1-发布)', example: 1 }),
+  coverImage: z.string().nullable().optional().openapi({ description: '封面图片URL', example: 'https://example.com/cover.jpg' }),
+  attachment: z.string().nullable().optional().openapi({ description: '附件文件URL', example: 'https://example.com/attachment.pdf' }),
+  attachmentName: z.string().nullable().optional().openapi({ description: '附件原始文件名', example: '健康知识文档.pdf' })
 });
 
 export const UpdateSilverKnowledgeDto = z.object({
@@ -82,5 +97,8 @@ export const UpdateSilverKnowledgeDto = z.object({
   categoryId: z.coerce.number().int().positive().optional().openapi({ description: '分类ID', example: 1 }),
   tags: z.string().nullable().optional().openapi({ description: '标签,逗号分隔', example: '健康,养生,老年人' }),
   author: z.string().max(100).optional().openapi({ description: '作者', example: '张医生' }),
-  status: z.coerce.number().int().min(0).max(1).optional().openapi({ description: '状态(0-草稿,1-发布)', example: 1 })
+  status: z.coerce.number().int().min(0).max(1).optional().openapi({ description: '状态(0-草稿,1-发布)', example: 1 }),
+  coverImage: z.string().nullable().optional().openapi({ description: '封面图片URL', example: 'https://example.com/cover.jpg' }),
+  attachment: z.string().nullable().optional().openapi({ description: '附件文件URL', example: 'https://example.com/attachment.pdf' }),
+  attachmentName: z.string().nullable().optional().openapi({ description: '附件原始文件名', example: '健康知识文档.pdf' })
 });