Răsfoiți Sursa

feat: OSS文件管理后台 + 标签系统 + 11K文件回写file_record

Xiaogang Liao 1 lună în urmă
părinte
comite
3ca910c693

+ 48 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -8737,5 +8737,53 @@ private void runMigration100() {
 			// 修改失败,忽略错误(幂等)
 		}
 
+		// 迁移221: file_record表添加file_type字段
+		ensureColumn("file_record", "file_type", "VARCHAR(20) DEFAULT NULL COMMENT '文件类型: image/pdf/video/audio/other'");
+
+		// 迁移222: 创建 file_tag 表(文件标签字典)
+		try {
+			jdbcTemplate.execute(
+				"CREATE TABLE IF NOT EXISTS file_tag (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"name VARCHAR(50) NOT NULL COMMENT '标签名称', " +
+				"color VARCHAR(7) DEFAULT '#409EFF' COMMENT '标签颜色', " +
+				"sort_order INT DEFAULT 0 COMMENT '排序号', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"UNIQUE KEY uk_name (name)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件标签字典'"
+			);
+			log.info("已创建file_tag表");
+		} catch (Exception e) {
+			log.warn("创建file_tag表失败: " + e.getMessage());
+		}
+
+		String[][] presetTags = {
+			{"测评报告", "#409EFF"}, {"健康报告", "#67C23A"}, {"文章配图", "#E6A23C"},
+			{"成长档案", "#909399"}, {"身", "#FF8C42"}, {"智", "#6366F1"},
+			{"富", "#F59E0B"}, {"行", "#10B981"}, {"心", "#FF6B9D"}, {"重要", "#F56C6C"}
+		};
+		for (int i = 0; i < presetTags.length; i++) {
+			try {
+				jdbcTemplate.update("INSERT IGNORE INTO file_tag (name, color, sort_order) VALUES (?, ?, ?)",
+					presetTags[i][0], presetTags[i][1], i);
+			} catch (Exception e) { /* 忽略重复 */ }
+		}
+		log.info("已预置文件标签");
+
+		// 迁移223: 创建 file_tag_rel 表
+		try {
+			jdbcTemplate.execute(
+				"CREATE TABLE IF NOT EXISTS file_tag_rel (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"file_id BIGINT NOT NULL COMMENT 'file_record.id', " +
+				"tag_id BIGINT NOT NULL COMMENT 'file_tag.id', " +
+				"UNIQUE KEY uk_file_tag (file_id, tag_id), " +
+				"INDEX idx_tag (tag_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件-标签关联'"
+			);
+			log.info("已创建file_tag_rel表");
+		} catch (Exception e) {
+			log.warn("创建file_tag_rel表失败: " + e.getMessage());
+		}
 	}
 }

+ 176 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/FileRecordController.java

@@ -0,0 +1,176 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FileRecord;
+import com.etotem.cfc.entity.FileTag;
+import com.etotem.cfc.entity.FileTagRel;
+import com.etotem.cfc.mapper.FileRecordMapper;
+import com.etotem.cfc.mapper.FileTagMapper;
+import com.etotem.cfc.mapper.FileTagRelMapper;
+import com.etotem.cfc.service.StorageService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/admin/file-record")
+public class FileRecordController {
+
+    private static final Logger log = LoggerFactory.getLogger(FileRecordController.class);
+
+    @Resource
+    private FileRecordMapper fileRecordMapper;
+
+    @Resource
+    private FileTagMapper fileTagMapper;
+
+    @Resource
+    private FileTagRelMapper fileTagRelMapper;
+
+    @Resource
+    private StorageService storageService;
+
+    @PostMapping("/list")
+    public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params,
+                                            @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        int pageNo = Integer.parseInt(params.getOrDefault("page", "1").toString());
+        int pageSize = Integer.parseInt(params.getOrDefault("pageSize", "20").toString());
+        String category = params.get("category") != null ? params.get("category").toString() : null;
+        String fileType = params.get("fileType") != null ? params.get("fileType").toString() : null;
+        String search = params.get("search") != null ? params.get("search").toString() : null;
+        String dateFrom = params.get("dateFrom") != null ? params.get("dateFrom").toString() : null;
+        String dateTo = params.get("dateTo") != null ? params.get("dateTo").toString() : null;
+        @SuppressWarnings("unchecked")
+        List<Integer> tagIds = params.get("tagIds") != null ? (List<Integer>) params.get("tagIds") : null;
+
+        LambdaQueryWrapper<FileRecord> wrapper = new LambdaQueryWrapper<>();
+        if (category != null && !category.isEmpty()) wrapper.eq(FileRecord::getCategory, category);
+        if (fileType != null && !fileType.isEmpty()) wrapper.eq(FileRecord::getFileType, fileType);
+        if (search != null && !search.isEmpty()) {
+            wrapper.and(w -> w.like(FileRecord::getOriginalFilename, search)
+                    .or().like(FileRecord::getDescription, search));
+        }
+        if (dateFrom != null && !dateFrom.isEmpty()) wrapper.ge(FileRecord::getCreatedAt, dateFrom + " 00:00:00");
+        if (dateTo != null && !dateTo.isEmpty()) wrapper.le(FileRecord::getCreatedAt, dateTo + " 23:59:59");
+        wrapper.orderByDesc(FileRecord::getCreatedAt);
+
+        IPage<FileRecord> page = fileRecordMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
+
+        List<Long> fileIds = page.getRecords().stream().map(FileRecord::getId).collect(Collectors.toList());
+        Map<Long, List<FileTag>> fileTagsMap = new HashMap<>();
+        if (!fileIds.isEmpty()) {
+            List<FileTagRel> rels = fileTagRelMapper.selectList(
+                    new LambdaQueryWrapper<FileTagRel>().in(FileTagRel::getFileId, fileIds));
+            if (!rels.isEmpty()) {
+                Set<Long> tagIdSet = rels.stream().map(FileTagRel::getTagId).collect(Collectors.toSet());
+                List<FileTag> tags = fileTagMapper.selectBatchIds(tagIdSet);
+                Map<Long, FileTag> tagMap = tags.stream().collect(Collectors.toMap(FileTag::getId, t -> t));
+                for (FileTagRel rel : rels) {
+                    fileTagsMap.computeIfAbsent(rel.getFileId(), k -> new ArrayList<>()).add(tagMap.get(rel.getTagId()));
+                }
+            }
+        }
+
+        List<FileRecord> filteredRecords = page.getRecords();
+        if (tagIds != null && !tagIds.isEmpty()) {
+            filteredRecords = filteredRecords.stream()
+                    .filter(r -> {
+                        List<FileTag> ft = fileTagsMap.get(r.getId());
+                        return ft != null && ft.stream().anyMatch(t -> tagIds.contains(t.getId().intValue()));
+                    }).collect(Collectors.toList());
+        }
+
+        List<Map<String, Object>> records = new ArrayList<>();
+        for (FileRecord r : filteredRecords) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("id", r.getId());
+            item.put("hash", r.getHash());
+            item.put("url", r.getUrl());
+            item.put("fileSize", r.getFileSize());
+            item.put("contentType", r.getContentType());
+            item.put("originalFilename", r.getOriginalFilename());
+            item.put("category", r.getCategory());
+            item.put("fileType", r.getFileType());
+            item.put("description", r.getDescription());
+            item.put("createdAt", r.getCreatedAt());
+            item.put("updatedAt", r.getUpdatedAt());
+            item.put("tags", fileTagsMap.getOrDefault(r.getId(), Collections.emptyList()));
+            records.add(item);
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("records", records);
+        result.put("total", page.getTotal());
+        result.put("page", pageNo);
+        result.put("pageSize", pageSize);
+        return Result.success(result);
+    }
+
+    @PostMapping("/update")
+    public Result<String> update(@RequestBody Map<String, Object> params,
+                                 @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        Long id = Long.valueOf(params.get("id").toString());
+        FileRecord record = fileRecordMapper.selectById(id);
+        if (record == null) return Result.error("文件不存在");
+        if (params.get("description") != null) record.setDescription(params.get("description").toString());
+        fileRecordMapper.updateById(record);
+        if (params.get("tagIds") != null) {
+            @SuppressWarnings("unchecked")
+            List<Integer> tagIds = (List<Integer>) params.get("tagIds");
+            fileTagRelMapper.delete(new LambdaQueryWrapper<FileTagRel>().eq(FileTagRel::getFileId, id));
+            for (Integer tagId : tagIds) {
+                FileTagRel rel = new FileTagRel();
+                rel.setFileId(id);
+                rel.setTagId(tagId.longValue());
+                fileTagRelMapper.insert(rel);
+            }
+        }
+        return Result.success("更新成功");
+    }
+
+    @PostMapping("/delete")
+    public Result<String> deleteFile(@RequestBody Map<String, Object> params,
+                                     @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        Long id = Long.valueOf(params.get("id").toString());
+        FileRecord record = fileRecordMapper.selectById(id);
+        if (record == null) return Result.error("文件不存在");
+        try { storageService.deleteFile(record.getUrl()); }
+        catch (Exception e) { log.warn("OSS删除失败: {}", record.getUrl(), e); }
+        fileTagRelMapper.delete(new LambdaQueryWrapper<FileTagRel>().eq(FileTagRel::getFileId, id));
+        fileRecordMapper.deleteById(id);
+        return Result.success("删除成功");
+    }
+
+    @PostMapping("/tags")
+    public Result<List<FileTag>> getTags(@RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        return Result.success(fileTagMapper.selectList(
+                new LambdaQueryWrapper<FileTag>().orderByAsc(FileTag::getSortOrder)));
+    }
+
+    @PostMapping("/stats")
+    public Result<Map<String, Object>> stats(@RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        Map<String, Object> stats = new LinkedHashMap<>();
+        stats.put("byCategory", fileRecordMapper.selectMaps(
+                new QueryWrapper<FileRecord>().select("category, COUNT(*) AS cnt").groupBy("category")));
+        stats.put("byFileType", fileRecordMapper.selectMaps(
+                new QueryWrapper<FileRecord>().select("file_type, COUNT(*) AS cnt").groupBy("file_type")));
+        stats.put("total", fileRecordMapper.selectCount(null));
+        List<Map<String, Object>> ts = fileRecordMapper.selectMaps(
+                new QueryWrapper<FileRecord>().select("SUM(file_size) AS total_bytes"));
+        stats.put("totalBytes", ts.get(0).get("total_bytes"));
+        return Result.success(stats);
+    }
+}

+ 19 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FileTag.java

@@ -0,0 +1,19 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("file_tag")
+public class FileTag implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String name;
+    private String color;
+    private Integer sortOrder;
+    private Date createdAt;
+}

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FileTagRel.java

@@ -0,0 +1,16 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+
+@Data
+@TableName("file_tag_rel")
+public class FileTagRel implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long fileId;
+    private Long tagId;
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/FileTagMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.FileTag;
+
+public interface FileTagMapper extends BaseMapper<FileTag> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/FileTagRelMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.FileTagRel;
+
+public interface FileTagRelMapper extends BaseMapper<FileTagRel> {
+}

+ 20 - 0
cfc-backend/src/main/resources/schema.sql

@@ -1099,6 +1099,7 @@ CREATE TABLE IF NOT EXISTS products (
     dimension_weights JSON DEFAULT NULL COMMENT '五维权重百分比: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0}',
     profit_rate INT DEFAULT 0 COMMENT '平台利润率(千分比)',
     sort_order INT DEFAULT 0 COMMENT '排序(值越小越靠前)',
+    points_multiplier DECIMAL(10,2) DEFAULT 1.00 COMMENT '平台积分倍数,1元=1积分×倍数',
     points_multiplier DECIMAL(4,2) NOT NULL DEFAULT 1.00 COMMENT '平台积分倍数,1元=1积分×倍数',
     delivery_method TINYINT DEFAULT 1 COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人',
     purchase_count_threshold INT DEFAULT 0 COMMENT '最小购买数量限制',
@@ -4650,6 +4651,25 @@ CREATE TABLE IF NOT EXISTS file_record (
     INDEX idx_created_at (created_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件记录表(去重&维护)';
 
+-- 文件标签字典(迁移222)
+CREATE TABLE IF NOT EXISTS file_tag (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    name VARCHAR(50) NOT NULL COMMENT '标签名称',
+    color VARCHAR(7) DEFAULT '#409EFF' COMMENT '标签颜色(十六进制)',
+    sort_order INT DEFAULT 0 COMMENT '排序号',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_name (name)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件标签字典';
+
+-- 文件-标签关联(迁移223)
+CREATE TABLE IF NOT EXISTS file_tag_rel (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    file_id BIGINT NOT NULL COMMENT 'file_record.id',
+    tag_id BIGINT NOT NULL COMMENT 'file_tag.id',
+    UNIQUE KEY uk_file_tag (file_id, tag_id),
+    INDEX idx_tag (tag_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件-标签关联';
+
 -- 通关解锁关卡定义
 CREATE TABLE IF NOT EXISTS unlock_gates (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 21 - 0
cfc-web/src/api/file.js

@@ -0,0 +1,21 @@
+import axios from 'axios'
+
+export function listFiles(params) {
+  return axios.post('/api/admin/file-record/list', params)
+}
+
+export function updateFile(params) {
+  return axios.post('/api/admin/file-record/update', params)
+}
+
+export function deleteFile(params) {
+  return axios.post('/api/admin/file-record/delete', params)
+}
+
+export function getFileTags() {
+  return axios.post('/api/admin/file-record/tags')
+}
+
+export function getFileStats() {
+  return axios.post('/api/admin/file-record/stats')
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -713,6 +713,12 @@ const routes = [
         name: 'NutritionProducts',
         component: () => import('@/views/admin/NutritionProducts.vue'),
         meta: { title: '营养产品管理', perm: 'health:*' }
+      },
+      {
+        path: 'file-manage',
+        name: 'FileManage',
+        component: () => import('@/views/admin/FileManage'),
+        meta: { title: '文件管理', perm: 'system:config' }
       }
     ]
   }

+ 1 - 0
cfc-web/src/views/Layout.vue

@@ -285,6 +285,7 @@ export default {
                 { path: '/badge-manage', label: '勋章管理', icon: 'el-icon-medal', perm: 'system:config' },
                 { path: '/virtual-goods-config', label: '虚拟支付道具', icon: 'el-icon-goods', perm: 'system:config' },
                 { path: '/gates', label: '关卡配置', icon: 'el-icon-key', perm: 'system:config' },
+                { path: '/file-manage', label: '文件管理', icon: 'el-icon-files', perm: 'system:config' },
               ]},
             // --- 配置中心 ---
             { title: '配置中心', icon: 'el-icon-setting', perm: 'system:config',

+ 188 - 0
cfc-web/src/views/admin/FileManage.vue

@@ -0,0 +1,188 @@
+<template>
+  <div class="file-manage">
+    <el-card>
+      <el-row :gutter="16" style="margin-bottom:16px">
+        <el-col :span="6">
+          <el-card shadow="never" class="stat-card">
+            <div class="stat-value">{{ stats.total || 0 }}</div>
+            <div class="stat-label">总文件数</div>
+          </el-card>
+        </el-col>
+        <el-col :span="6">
+          <el-card shadow="never" class="stat-card">
+            <div class="stat-value">{{ formatSize(stats.totalBytes || 0) }}</div>
+            <div class="stat-label">总大小</div>
+          </el-card>
+        </el-col>
+        <el-col :span="12">
+          <el-card shadow="never" class="stat-card">
+            <div style="display:flex;gap:12px;flex-wrap:wrap">
+              <span v-for="c in stats.byCategory || []" :key="c.category">
+                <el-tag size="small">{{ c.category }}: {{ c.cnt }}</el-tag>
+              </span>
+            </div>
+            <div class="stat-label">分类分布</div>
+          </el-card>
+        </el-col>
+      </el-row>
+
+      <div class="filter-bar">
+        <el-select v-model="query.category" placeholder="分类" clearable style="width:130px">
+          <el-option label="全部" value=""></el-option>
+          <el-option label="articles" value="articles"></el-option>
+          <el-option label="kb-images" value="kb-images"></el-option>
+          <el-option label="dan-reports" value="dan-reports"></el-option>
+          <el-option label="assessment-reports" value="assessment-reports"></el-option>
+          <el-option label="health-reports" value="health-reports"></el-option>
+          <el-option label="general" value="general"></el-option>
+        </el-select>
+        <el-select v-model="query.fileType" placeholder="文件类型" clearable style="width:120px">
+          <el-option label="全部" value=""></el-option>
+          <el-option label="image" value="image"></el-option>
+          <el-option label="pdf" value="pdf"></el-option>
+          <el-option label="video" value="video"></el-option>
+          <el-option label="audio" value="audio"></el-option>
+          <el-option label="other" value="other"></el-option>
+        </el-select>
+        <el-select v-model="query.tagIds" placeholder="标签" multiple clearable style="width:200px">
+          <el-option v-for="t in tagOptions" :key="t.id" :label="t.name" :value="t.id"></el-option>
+        </el-select>
+        <el-input v-model="query.search" placeholder="搜索文件名/描述" style="width:200px" @keyup.enter.native="loadData"></el-input>
+        <el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" style="width:240px"></el-date-picker>
+        <el-button type="primary" @click="loadData">查询</el-button>
+      </div>
+
+      <el-table :data="tableData" border stripe v-loading="loading" style="margin-top:16px">
+        <el-table-column prop="id" label="ID" width="70"></el-table-column>
+        <el-table-column label="预览" width="70">
+          <template slot-scope="scope">
+            <el-image v-if="scope.row.fileType === 'image'" :src="scope.row.url" style="width:40px;height:40px;border-radius:4px" fit="cover" :preview-src-list="[scope.row.url]"></el-image>
+            <i v-else-if="scope.row.fileType === 'pdf'" class="el-icon-document" style="font-size:28px;color:#F56C6C;display:block;text-align:center"></i>
+            <i v-else-if="scope.row.fileType === 'video'" class="el-icon-video-camera" style="font-size:28px;color:#409EFF;display:block;text-align:center"></i>
+            <i v-else-if="scope.row.fileType === 'audio'" class="el-icon-headset" style="font-size:28px;color:#67C23A;display:block;text-align:center"></i>
+            <i v-else class="el-icon-document" style="font-size:28px;color:#909399;display:block;text-align:center"></i>
+          </template>
+        </el-table-column>
+        <el-table-column prop="originalFilename" label="文件名" min-width="180" show-overflow-tooltip></el-table-column>
+        <el-table-column prop="fileType" label="类型" width="70">
+          <template slot-scope="scope">
+            <el-tag size="mini" :type="typeTag(scope.row.fileType)">{{ scope.row.fileType || '?' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="fileSize" label="大小" width="90">
+          <template slot-scope="scope">{{ formatSize(scope.row.fileSize) }}</template>
+        </el-table-column>
+        <el-table-column prop="category" label="分类" width="110"></el-table-column>
+        <el-table-column label="标签" min-width="140">
+          <template slot-scope="scope">
+            <el-tag v-for="t in (scope.row.tags || [])" :key="t.id" size="mini" style="margin-right:4px;margin-bottom:2px;border:0" :style="{ backgroundColor: t.color + '22', color: t.color }">{{ t.name }}</el-tag>
+            <span v-if="!scope.row.tags || scope.row.tags.length === 0" class="no-tag">-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="description" label="描述" min-width="120" show-overflow-tooltip></el-table-column>
+        <el-table-column prop="createdAt" label="上传时间" width="160"></el-table-column>
+        <el-table-column label="操作" width="120" fixed="right">
+          <template slot-scope="scope">
+            <el-button size="mini" type="text" @click="openEdit(scope.row)">编辑</el-button>
+            <el-button size="mini" type="text" style="color:#F56C6C" @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination @size-change="handleSizeChange" @current-change="handleCurrentChange" :current-page="query.page" :page-size="query.pageSize" :total="total" layout="total, sizes, prev, pager, next, jumper" :page-sizes="[10, 20, 50, 100]" style="margin-top:16px"></el-pagination>
+    </el-card>
+
+    <el-dialog title="编辑文件" :visible.sync="editVisible" width="500px">
+      <el-form label-width="80px">
+        <el-form-item label="文件名"><el-input v-model="editForm.originalFilename" disabled></el-input></el-form-item>
+        <el-form-item label="URL">
+          <el-input v-model="editForm.url" disabled>
+            <el-button slot="append" size="mini" @click="copyUrl(editForm.url)">复制</el-button>
+          </el-input>
+        </el-form-item>
+        <el-form-item label="描述"><el-input v-model="editForm.description" type="textarea" :rows="3" maxlength="500"></el-input></el-form-item>
+        <el-form-item label="标签">
+          <el-select v-model="editForm.tagIds" multiple placeholder="选择标签" style="width:100%">
+            <el-option v-for="t in tagOptions" :key="t.id" :label="t.name" :value="t.id">
+              <span :style="{ color: t.color }">{{ t.name }}</span>
+            </el-option>
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="editVisible = false">取消</el-button>
+        <el-button type="primary" @click="saveEdit" :loading="saving">保存</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listFiles, updateFile, deleteFile, getFileTags, getFileStats } from '@/api/file'
+
+export default {
+  name: 'FileManage',
+  data() {
+    return {
+      tableData: [], total: 0, loading: false, stats: {}, tagOptions: [], dateRange: null,
+      query: { page: 1, pageSize: 20, category: '', fileType: '', tagIds: [], search: '', dateFrom: '', dateTo: '' },
+      editVisible: false, saving: false,
+      editForm: { id: null, url: '', originalFilename: '', description: '', tagIds: [] }
+    }
+  },
+  mounted() { this.loadStats(); this.loadTags(); this.loadData() },
+  methods: {
+    async loadData() {
+      this.loading = true
+      try {
+        if (this.dateRange && this.dateRange.length === 2) {
+          this.query.dateFrom = this.dateRange[0].toISOString().slice(0, 10)
+          this.query.dateTo = this.dateRange[1].toISOString().slice(0, 10)
+        } else { this.query.dateFrom = ''; this.query.dateTo = '' }
+        const res = await listFiles(this.query)
+        if (res.data.code === 200) { this.tableData = res.data.data.records; this.total = res.data.data.total }
+      } catch (e) { this.$message.error('加载失败') }
+      this.loading = false
+    },
+    async loadStats() { try { const res = await getFileStats(); if (res.data.code === 200) this.stats = res.data.data } catch (e) {} },
+    async loadTags() { try { const res = await getFileTags(); if (res.data.code === 200) this.tagOptions = res.data.data } catch (e) {} },
+    openEdit(row) {
+      this.editForm = { id: row.id, url: row.url, originalFilename: row.originalFilename, description: row.description || '', tagIds: (row.tags || []).map(t => t.id) }
+      this.editVisible = true
+    },
+    async saveEdit() {
+      this.saving = true
+      try {
+        const res = await updateFile({ id: this.editForm.id, description: this.editForm.description, tagIds: this.editForm.tagIds })
+        if (res.data.code === 200) { this.$message.success('更新成功'); this.editVisible = false; this.loadData() }
+        else this.$message.error(res.data.message || '更新失败')
+      } catch (e) { this.$message.error('更新失败') }
+      this.saving = false
+    },
+    handleDelete(row) {
+      this.$confirm('确定删除该文件?删除后不可恢复。', '警告', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
+        .then(async () => {
+          try { const res = await deleteFile({ id: row.id }); if (res.data.code === 200) { this.$message.success('删除成功'); this.loadData(); this.loadStats() } else this.$message.error(res.data.message || '删除失败') }
+          catch (e) { this.$message.error('删除失败') }
+        }).catch(() => {})
+    },
+    copyUrl(url) { const input = document.createElement('input'); input.value = url; document.body.appendChild(input); input.select(); document.execCommand('copy'); document.body.removeChild(input); this.$message.success('已复制') },
+    handleSizeChange(size) { this.query.pageSize = size; this.query.page = 1; this.loadData() },
+    handleCurrentChange(page) { this.query.page = page; this.loadData() },
+    formatSize(bytes) {
+      if (!bytes) return '0B'; const units = ['B', 'KB', 'MB', 'GB']; let i = 0; let size = bytes
+      while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
+      return size.toFixed(i > 0 ? 1 : 0) + units[i]
+    },
+    typeTag(type) { return { image: 'success', pdf: 'danger', video: 'primary', audio: 'warning' }[type] || 'info' }
+  }
+}
+</script>
+
+<style scoped>
+.filter-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
+.stat-card { text-align: center; }
+.stat-value { font-size: 24px; font-weight: bold; color: #303133; }
+.stat-label { font-size: 12px; color: #909399; margin-top: 4px; }
+.no-tag { color: #C0C4CC; }
+</style>

+ 224 - 0
scripts/backfill-oss-to-db.py

@@ -0,0 +1,224 @@
+#!/usr/bin/env python3
+"""
+将京东云 OSS 上的所有文件回写进 file_record 表。
+
+策略:
+1. 遍历 OSS bucket 所有对象(分页)
+2. 并发下载每个对象内容,计算 SHA-256
+3. 写入 file_record 表(hash 唯一,已存在则跳过)
+
+用法:
+    python3 scripts/backfill-oss-to-db.py
+    python3 scripts/backfill-oss-to-db.py --dry-run        # 仅统计不上传
+    python3 scripts/backfill-oss-to-db.py --workers 20     # 并发数
+"""
+
+import boto3
+import hashlib
+import pymysql
+from botocore.config import Config
+from concurrent.futures import ThreadPoolExecutor, as_completed
+import argparse
+import logging
+import threading
+from datetime import datetime
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(message)s",
+    datefmt="%H:%M:%S",
+)
+log = logging.getLogger("backfill")
+
+ENDPOINT = "s3.cn-north-1.jdcloud-oss.com"
+ACCESS_KEY = "JDC_F351EC3CB1F8593204ABCDA2CBF2"
+SECRET_KEY = "970C0C6985A6BF29D0C79AE3693B9899"
+BUCKET = "cfc"
+REGION = "cn-north-1"
+
+DB_HOST = "mysql-internet-cn-north-1-23feae22680e4dfa.rds.jdcloud.com"
+DB_PORT = 3306
+DB_USER = "cfc"
+DB_PASS = "cfc@1314"
+DB_NAME = "zxyj"
+
+PUBLIC_URL_PREFIX = f"https://{BUCKET}.{ENDPOINT}/"
+
+# 测试目录,跳过
+SKIP_PREFIXES = ("test-connectivity",)
+
+db_lock = threading.Lock()
+
+
+def get_s3():
+    return boto3.client(
+        "s3",
+        endpoint_url=f"https://{ENDPOINT}",
+        aws_access_key_id=ACCESS_KEY,
+        aws_secret_access_key=SECRET_KEY,
+        config=Config(s3={"addressing_style": "path"}, signature_version="s3v4"),
+        region_name=REGION,
+    )
+
+
+def list_all_objects(s3):
+    """列出所有对象,返回 [{Key, Size, ContentType, LastModified}]"""
+    objects = []
+    marker = None
+    while True:
+        if marker:
+            resp = s3.list_objects_v2(Bucket=BUCKET, MaxKeys=1000, StartAfter=marker)
+        else:
+            resp = s3.list_objects_v2(Bucket=BUCKET, MaxKeys=1000)
+        if "Contents" not in resp:
+            break
+        for obj in resp["Contents"]:
+            key = obj["Key"]
+            if key.startswith(SKIP_PREFIXES):
+                continue
+            objects.append({"Key": key, "Size": obj["Size"], "LastModified": obj["LastModified"]})
+            marker = key
+        if not resp.get("IsTruncated"):
+            break
+    return objects
+
+
+def guess_content_type(key):
+    """根据 key 扩展名猜测 Content-Type"""
+    ext = key[key.rfind(".") + 1:].lower() if "." in key else ""
+    mapping = {
+        "jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
+        "gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp",
+        "svg": "image/svg+xml", "pdf": "application/pdf",
+        "mp4": "video/mp4", "avi": "video/x-msvideo", "mov": "video/quicktime",
+        "mp3": "audio/mpeg", "wav": "audio/wav", "m4a": "audio/mp4",
+        "txt": "text/plain", "json": "application/json", "html": "text/html",
+        "doc": "application/msword", "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+        "xls": "application/vnd.ms-excel", "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+        "zip": "application/zip",
+    }
+    return mapping.get(ext, "application/octet-stream")
+
+
+def guess_file_type(content_type):
+    if content_type.startswith("image/"):
+        return "image"
+    if content_type == "application/pdf" or content_type.endswith("pdf"):
+        return "pdf"
+    if content_type.startswith("video/"):
+        return "video"
+    if content_type.startswith("audio/"):
+        return "audio"
+    return "other"
+
+
+def guess_category(key):
+    """根据 key 路径推断分类"""
+    parts = key.split("/")
+    if len(parts) >= 3 and parts[1] == "health-reports":
+        return "health-reports"
+    if len(parts) >= 2:
+        return parts[0]
+    return "general"
+
+
+def process_object(s3, obj, db):
+    """下载对象、计算 hash、写库。返回 ('ok'|'skip'|'fail', key)"""
+    key = obj["Key"]
+    url = PUBLIC_URL_PREFIX + key
+    content_type = guess_content_type(key)
+    file_type = guess_file_type(content_type)
+    category = guess_category(key)
+    original_name = key.split("/")[-1]
+
+    try:
+        # 下载并计算 SHA-256
+        resp = s3.get_object(Bucket=BUCKET, Key=key)
+        sha = hashlib.sha256()
+        size = 0
+        body = resp["Body"]
+        while True:
+            chunk = body.read(65536)
+            if not chunk:
+                break
+            sha.update(chunk)
+            size += len(chunk)
+        file_hash = sha.hexdigest()
+
+        now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        with db_lock:
+            with db.cursor() as cursor:
+                cursor.execute("SELECT id FROM file_record WHERE hash = %s", (file_hash,))
+                if cursor.fetchone():
+                    return "skip", key
+                cursor.execute(
+                    "INSERT INTO file_record (hash, url, file_size, content_type, "
+                    "original_filename, category, file_type, created_at, updated_at) "
+                    "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
+                    (file_hash, url, size, content_type, original_name,
+                     category, file_type, now, now),
+                )
+            db.commit()
+        return "ok", key
+    except Exception as e:
+        log.error("[FAIL] %s: %s", key, e)
+        return "fail", key
+
+
+def main():
+    parser = argparse.ArgumentParser(description="回写 OSS 文件到 file_record 表")
+    parser.add_argument("--dry-run", action="store_true", help="仅统计")
+    parser.add_argument("--workers", type=int, default=20, help="并发数")
+    parser.add_argument("--limit", type=int, default=0, help="处理前 N 个(测试用,0=全部)")
+    args = parser.parse_args()
+
+    s3 = get_s3()
+    log.info("连接 OSS 成功,正在列举对象...")
+    objects = list_all_objects(s3)
+    log.info("OSS 对象总数: %d", len(objects))
+
+    if args.limit > 0:
+        objects = objects[:args.limit]
+        log.info("限制处理前 %d 个", args.limit)
+
+    if args.dry_run:
+        log.info("DRY-RUN 模式,仅统计不写库")
+        by_type = {}
+        for o in objects:
+            ct = guess_file_type(guess_content_type(o["Key"]))
+            by_type[ct] = by_type.get(ct, 0) + 1
+        total_size = sum(o["Size"] for o in objects)
+        log.info("文件类型分布: %s", by_type)
+        log.info("文件总大小: %.1f MB", total_size / 1024 / 1024)
+        return
+
+    db = pymysql.connect(
+        host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASS,
+        database=DB_NAME, charset="utf8mb4",
+    )
+    log.info("MySQL 连接成功")
+
+    ok = skip = fail = 0
+    try:
+        with ThreadPoolExecutor(max_workers=args.workers) as pool:
+            futures = [pool.submit(process_object, s3, obj, db) for obj in objects]
+            for i, fut in enumerate(as_completed(futures), 1):
+                status, key = fut.result()
+                if status == "ok":
+                    ok += 1
+                elif status == "skip":
+                    skip += 1
+                else:
+                    fail += 1
+                if i % 200 == 0:
+                    log.info("进度: %d/%d (ok=%d skip=%d fail=%d)",
+                             i, len(objects), ok, skip, fail)
+    finally:
+        db.close()
+
+    log.info("=" * 50)
+    log.info("回写完成: 新增=%d, 跳过=%d, 失败=%d", ok, skip, fail)
+
+
+if __name__ == "__main__":
+    main()