Browse Source

feat(web): 添加营养产品管理后台页面(列表+详情+菜单)

Xiaogang Liao 3 months ago
parent
commit
160cf39c9c

+ 17 - 11
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFoodController.java

@@ -1,5 +1,6 @@
 package com.etotem.cfc.controller.admin;
 
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.Food;
 import com.etotem.cfc.service.FoodService;
@@ -8,10 +9,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 import java.util.stream.Collectors;
 
 @Tag(name = "\u7ba1\u7406\u7aef-\u98df\u6750\u7ba1\u7406")
@@ -24,12 +22,15 @@ public class AdminFoodController {
 
     @Operation(summary = "\u98df\u6750\u5217\u8868")
     @PostMapping("/list")
-    public Result<List<Map<String, Object>>> list(@RequestBody Map<String, Object> params) {
+    public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params) {
+        long page = params.get("page") != null ? ((Number) params.get("page")).longValue() : 1;
+        long size = params.get("size") != null ? ((Number) params.get("size")).longValue() : 20;
         String category = (String) params.get("category");
-        List<Food> foods = foodService.listAll(category, "active");
-        List<Map<String, Object>> result = new ArrayList<>();
-        for (Food food : foods) {
-            Map<String, Object> item = new java.util.HashMap<>();
+        String keyword = (String) params.get("keyword");
+        Page<Food> pageResult = foodService.listPage(page, size, category, keyword, "active");
+        List<Map<String, Object>> records = new ArrayList<>();
+        for (Food food : pageResult.getRecords()) {
+            Map<String, Object> item = new HashMap<>();
             item.put("id", food.getId());
             item.put("name", food.getName());
             item.put("category", food.getCategory());
@@ -52,9 +53,14 @@ public class AdminFoodController {
             item.put("createdAt", food.getCreatedAt());
             item.put("updatedAt", food.getUpdatedAt());
             item.put("months", foodService.getMonths(food.getId()));
-            result.add(item);
+            records.add(item);
         }
-        return Result.success(result);
+        Map<String, Object> data = new HashMap<>();
+        data.put("records", records);
+        data.put("total", pageResult.getTotal());
+        data.put("page", pageResult.getCurrent());
+        data.put("size", pageResult.getSize());
+        return Result.success(data);
     }
 
     @Operation(summary = "\u65b0\u589e\u98df\u6750")

+ 4 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietMealConfigController.java

@@ -16,9 +16,10 @@ public class DietMealConfigController {
 
     @PostMapping("/config")
     public Result<Map> getConfig(
-            @RequestAttribute("familyId") Long familyId,
-            @RequestParam String dateType,
-            @RequestParam String mealType) {
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
+            @RequestBody Map<String, Object> request) {
+        String dateType = request.get("date_type") != null ? request.get("date_type").toString() : null;
+        String mealType = request.get("meal_type") != null ? request.get("meal_type").toString() : null;
         return Result.success(dietMealConfigService.getConfig(familyId, dateType, mealType));
     }
 

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/FileStorageService.java

@@ -41,7 +41,7 @@ public class FileStorageService {
         if (contentType == null || !allowedTypes.contains(contentType)) {
             throw new IllegalArgumentException("不支持的文件类型,仅支持: " + allowedTypes);
         }
-        String url = storageService.storeFile(file);
+        String url = storageService.storeFile(file, subDir);
         log.info("文件已保存: original={}, url={}", file.getOriginalFilename(), url);
         return url;
     }

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FoodService.java

@@ -32,6 +32,19 @@ public class FoodService {
         return foodMapper.selectList(wrapper);
     }
 
+    /**
+     * 分页查询食材列表,支持分类、关键字、状态筛选
+     */
+    public Page<Food> listPage(long page, long size, String category, String keyword, String status) {
+        LambdaQueryWrapper<Food> wrapper = new LambdaQueryWrapper<Food>()
+                .eq(status != null, Food::getStatus, status)
+                .eq(category != null && !category.isEmpty(), Food::getCategory, category)
+                .like(keyword != null && !keyword.isEmpty(), Food::getName, keyword)
+                .orderByAsc(Food::getSortOrder)
+                .orderByAsc(Food::getId);
+        return foodMapper.selectPage(new Page<>(page, size), wrapper);
+    }
+
     public List<Food> listByPriceLevel(int maxLevel) {
         return foodMapper.selectList(
                 new LambdaQueryWrapper<Food>()

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/StorageService.java

@@ -11,6 +11,11 @@ public interface StorageService extends StorageServiceInterface {
      */
     String storeFile(MultipartFile file);
 
+    /**
+     * 存储文件并返回访问URL(带分类)
+     */
+    String storeFile(MultipartFile file, String category);
+
     /**
      * 删除文件
      */

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -670,7 +670,7 @@ private FamilyInvitationService familyInvitationService;
      */
     private boolean hasRole(User user, String role) {
         List<String> roles = user.getRoles();
-        return roles.contains(role);
+        return roles != null && roles.contains(role);
     }
 
 

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/api/StorageServiceInterface.java

@@ -9,6 +9,11 @@ public interface StorageServiceInterface {
      */
     String storeFile(MultipartFile file);
 
+    /**
+     * 存储文件并返回访问URL(带分类)
+     */
+    String storeFile(MultipartFile file, String category);
+
     /**
      * 删除文件
      */

+ 70 - 19
cfc-backend/src/main/java/com/etotem/cfc/service/impl/JdCloudStorageService.java

@@ -7,6 +7,9 @@ import com.amazonaws.services.s3.AmazonS3ClientBuilder;
 import com.amazonaws.services.s3.model.ObjectMetadata;
 import com.amazonaws.services.s3.model.PutObjectRequest;
 import com.amazonaws.services.s3.model.S3Object;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FileRecord;
+import com.etotem.cfc.mapper.FileRecordMapper;
 import com.etotem.cfc.service.StorageService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -16,7 +19,8 @@ import org.springframework.stereotype.Service;
 import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.PostConstruct;
-import java.io.File;
+import javax.annotation.Resource;
+import java.io.ByteArrayInputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -24,14 +28,17 @@ import java.net.URI;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
+import java.util.Date;
 import java.util.UUID;
 
 /**
  * 京东云对象存储(OSS)存储实现。
  * 通过 S3 兼容 API(aws-java-sdk-s3)访问京东云对象存储。
- * 仅在 storage.type=jdcloud 时启用,否则使用 LocalDiskStorageService 兜底。
+ * 文件上传前计算 SHA-256 哈希,通过 file_record 表去重。
  */
 @Service
 @ConditionalOnProperty(name = "storage.type", havingValue = "jdcloud", matchIfMissing = false)
@@ -54,6 +61,9 @@ public class JdCloudStorageService implements StorageService {
     @Value("${jdcloud.region:cn-north-1}")
     private String region;
 
+    @Resource
+    private FileRecordMapper fileRecordMapper;
+
     private AmazonS3 s3Client;
 
     @PostConstruct
@@ -69,22 +79,60 @@ public class JdCloudStorageService implements StorageService {
 
     @Override
     public String storeFile(MultipartFile file) {
+        return storeFile(file, "general");
+    }
+
+    @Override
+    public String storeFile(MultipartFile file, String category) {
         try {
-            // 生成日期路径:2026/08/11/
+            byte[] fileBytes = file.getBytes();
+            String hash = sha256(fileBytes);
+
+            String dbCategory = category != null ? category : "general";
+
+            // 查重:按 hash 查找已有记录
+            FileRecord existing = fileRecordMapper.selectOne(
+                    new LambdaQueryWrapper<FileRecord>().eq(FileRecord::getHash, hash));
+            if (existing != null) {
+                log.info("文件已存在(去重命中): hash={}, url={}, category={}",
+                        hash, existing.getUrl(), existing.getCategory());
+                return existing.getUrl();
+            }
+
+            // 生成 key 并上传
             String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
             String key = "uploads/" + datePath + "/" + generateFilename(file.getOriginalFilename());
 
             ObjectMetadata metadata = new ObjectMetadata();
-            metadata.setContentLength(file.getSize());
+            metadata.setContentLength(fileBytes.length);
             if (file.getContentType() != null) {
                 metadata.setContentType(file.getContentType());
             }
-
-            try (InputStream inputStream = file.getInputStream()) {
-                s3Client.putObject(new PutObjectRequest(bucket, key, inputStream, metadata));
+            try {
+                metadata.setContentMD5(com.amazonaws.util.Base64.encodeAsString(
+                        MessageDigest.getInstance("MD5").digest(fileBytes)));
+            } catch (NoSuchAlgorithmException e) {
+                log.warn("MD5 不可用,跳过 Content-MD5 设置");
             }
 
-            return buildPublicUrl(key);
+            s3Client.putObject(new PutObjectRequest(bucket, key,
+                    new ByteArrayInputStream(fileBytes), metadata));
+
+            String url = buildPublicUrl(key);
+
+            // 记录到 file_record 表
+            FileRecord record = new FileRecord();
+            record.setHash(hash);
+            record.setUrl(url);
+            record.setFileSize((long) fileBytes.length);
+            record.setContentType(file.getContentType());
+            record.setOriginalFilename(file.getOriginalFilename());
+            record.setCategory(dbCategory);
+            record.setCreatedAt(new Date());
+            record.setUpdatedAt(new Date());
+            fileRecordMapper.insert(record);
+
+            return url;
         } catch (IOException e) {
             throw new RuntimeException("京东云文件存储失败", e);
         }
@@ -129,9 +177,6 @@ public class JdCloudStorageService implements StorageService {
         }
     }
 
-    /**
-     * 生成唯一文件名(保留原扩展名)
-     */
     private String generateFilename(String originalFilename) {
         String extension = "";
         if (originalFilename != null && originalFilename.contains(".")) {
@@ -140,16 +185,10 @@ public class JdCloudStorageService implements StorageService {
         return UUID.randomUUID().toString() + extension;
     }
 
-    /**
-     * 构建公网访问URL
-     */
     private String buildPublicUrl(String key) {
         return "https://" + bucket + "." + endpoint + "/" + key;
     }
 
-    /**
-     * 从URL中提取对象key
-     */
     private String extractKey(String fileUrl) {
         if (fileUrl == null || fileUrl.isEmpty()) {
             return "";
@@ -157,14 +196,26 @@ public class JdCloudStorageService implements StorageService {
         try {
             URI uri = URI.create(fileUrl);
             String path = uri.getPath();
-            // 去掉开头的 /
             return path.startsWith("/") ? path.substring(1) : path;
         } catch (Exception e) {
-            // 兼容相对路径 /uploads/xxx
             if (fileUrl.startsWith("/uploads/")) {
                 return fileUrl.substring("/uploads/".length());
             }
             return fileUrl;
         }
     }
+
+    private String sha256(byte[] data) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            byte[] digest = md.digest(data);
+            StringBuilder sb = new StringBuilder();
+            for (byte b : digest) {
+                sb.append(String.format("%02x", b));
+            }
+            return sb.toString();
+        } catch (NoSuchAlgorithmException e) {
+            throw new RuntimeException("SHA-256 不可用", e);
+        }
+    }
 }

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/LocalDiskStorageService.java

@@ -2,6 +2,7 @@ package com.etotem.cfc.service.impl;
 
 import com.etotem.cfc.service.StorageService;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.stereotype.Service;
 import org.springframework.web.multipart.MultipartFile;
 
@@ -15,6 +16,7 @@ import java.time.format.DateTimeFormatter;
 import java.util.UUID;
 
 @Service
+@ConditionalOnProperty(name = "storage.type", havingValue = "local", matchIfMissing = true)
 public class LocalDiskStorageService implements StorageService {
 
     @Value("${upload.base-dir:/data/cfc-uploads}")
@@ -22,6 +24,11 @@ public class LocalDiskStorageService implements StorageService {
 
     @Override
     public String storeFile(MultipartFile file) {
+        return storeFile(file, null);
+    }
+
+    @Override
+    public String storeFile(MultipartFile file, String category) {
         try {
             // 生成日期路径:2026/04/05/
             String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-b6c4655e2aaae9f51f7f344ad7a83d0f93624452
+c9cda91f5b297b3542354f404d9b30831a466f3e

+ 47 - 0
cfc-web/src/api/nutritionProduct.js

@@ -0,0 +1,47 @@
+import request from '@/utils/request'
+
+export function getCategoryList() {
+  return request({
+    url: '/api/nutrition/product/category/list',
+    method: 'post'
+  })
+}
+
+export function getBrandList() {
+  return request({
+    url: '/api/nutrition/product/brand/list',
+    method: 'post'
+  })
+}
+
+export function searchProducts(params) {
+  return request({
+    url: '/api/nutrition/product/search',
+    method: 'post',
+    data: params
+  })
+}
+
+export function getProductDetail(productId) {
+  return request({
+    url: '/api/nutrition/product/detail',
+    method: 'post',
+    data: { productId }
+  })
+}
+
+export function recommendProducts(params) {
+  return request({
+    url: '/api/nutrition/product/recommend',
+    method: 'post',
+    data: params
+  })
+}
+
+export function priceCompare(productId) {
+  return request({
+    url: '/api/nutrition/product/price-compare',
+    method: 'post',
+    data: { productId }
+  })
+}

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

@@ -717,6 +717,13 @@ const routes = [
         name: 'LangGraphAdmin',
         component: () => import('@/views/admin/LangGraphAdmin.vue'),
         meta: { title: 'LangGraph 管理', perm: 'system:config' }
+      },
+      // ========== 营养产品管理 ==========
+      {
+        path: 'nutrition-products',
+        name: 'NutritionProducts',
+        component: () => import('@/views/admin/NutritionProducts.vue'),
+        meta: { title: '营养产品管理', perm: 'health:*' }
       }
     ]
   }

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

@@ -244,6 +244,7 @@ export default {
             { path: '/recipes', label: '食谱管理', icon: 'el-icon-dish', perm: 'diet:recipes' },
             { path: '/seasonal-foods', label: '应季食材', icon: 'el-icon-sunny', perm: 'diet:seasonal' },
             { path: '/langgraph-admin', label: 'LangGraph 管理', icon: 'el-icon-cpu', perm: 'system:config' },
+            { path: '/nutrition-products', label: '营养产品', icon: 'el-icon-first-aid-kit', perm: 'health:*' },
           ]},
 
         // ===== 报告解析系统 (admin) =====

+ 327 - 141
cfc-web/src/views/admin/Foods.vue

@@ -1,125 +1,217 @@
 <template>
   <div class="foods admin-page">
-    <el-card>
+    <el-card shadow="never">
       <div slot="header" class="admin-page-header">
-        <span class="admin-page-title">食材管理</span>
-        <div>
-          <el-button type="primary" size="mini" @click="openCreate">新建食材</el-button>
-          <el-input v-model="keyword" placeholder="搜索..." prefix-icon="el-icon-search" clearable @keyup.enter.native="handleSearch" @clear="handleSearch" />
+        <span class="admin-page-title">
+          <i class="el-icon-apple" style="color: #67c23a; margin-right: 8px;"></i>
+          食材管理
+        </span>
+        <div class="header-actions">
+          <el-input
+            v-model="keyword"
+            placeholder="搜索食材名称..."
+            prefix-icon="el-icon-search"
+            clearable
+            size="mini"
+            style="width: 200px; margin-right: 8px;"
+            @keyup.enter.native="handleSearch"
+            @clear="handleSearch"
+          />
           <el-select
             v-model="categoryFilter"
             placeholder="分类筛选"
             size="mini"
             clearable
-            @change="loadList"
+            style="width: 140px; margin-right: 8px;"
+            @change="handleSearch"
           >
             <el-option label="全部分类" value="" />
-            <el-option label="蔬菜" value="蔬菜" />
-            <el-option label="水果" value="水果" />
-            <el-option label="肉类" value="肉类" />
-            <el-option label="水产" value="水产" />
-            <el-option label="蛋奶" value="蛋奶" />
-            <el-option label="豆类" value="豆类" />
-            <el-option label="谷物" value="谷物" />
-            <el-option label="坚果" value="坚果" />
-            <el-option label="调味品" value="调味品" />
-            <el-option label="其他" value="其他" />
+            <el-option v-for="opt in categoryOptions" :key="opt" :label="opt" :value="opt" />
           </el-select>
+          <el-button type="primary" size="mini" icon="el-icon-plus" @click="openCreate">新建食材</el-button>
         </div>
       </div>
-      <div class="table-scroll-wrap-lg">
-        <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe>
-        <el-table-column prop="id" label="ID" width="80" />
-        <el-table-column prop="name" label="名称" />
-        <el-table-column prop="category" label="分类" width="100" />
-        <el-table-column prop="calories" label="热量(kcal)" width="100" />
-        <el-table-column prop="protein" label="蛋白质(g)" width="100" />
-        <el-table-column prop="carbs" label="碳水(g)" width="100" />
-        <el-table-column prop="fat" label="脂肪(g)" width="100" />
-        <el-table-column prop="fiber" label="膳食纤维(g)" width="100" />
-        <el-table-column prop="energyKj" label="能量(kJ)" width="100" />
-        <el-table-column prop="starch" label="淀粉(g)" width="100" />
-        <el-table-column prop="cholesterol" label="胆固醇(mg)" width="100" />
-        <el-table-column label="应季月份" width="180">
+
+      <el-table
+        :data="list"
+        v-loading="loading"
+        border
+        stripe
+        size="mini"
+        style="width: 100%"
+        :max-height="tableHeight"
+      >
+        <el-table-column type="index" label="#" width="50" align="center" />
+        <el-table-column prop="name" label="名称" min-width="120" fixed>
+          <template slot-scope="{ row }">
+            <span style="font-weight: 500;">{{ row.name }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="category" label="分类" width="120" align="center">
+          <template slot-scope="{ row }">
+            <el-tag :type="categoryTagType(row.category)" size="mini" effect="plain">
+              {{ row.category }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="热量" width="100" align="right">
+          <template slot-scope="{ row }">
+            <span class="num-val">{{ row.calories != null ? row.calories : '-' }}</span>
+            <span class="num-unit">kcal</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="蛋白质" width="90" align="right">
+          <template slot-scope="{ row }">
+            <span class="num-val">{{ row.protein != null ? row.protein : '-' }}</span>
+            <span class="num-unit">g</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="脂肪" width="80" align="right">
+          <template slot-scope="{ row }">
+            <span class="num-val">{{ row.fat != null ? row.fat : '-' }}</span>
+            <span class="num-unit">g</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="碳水" width="80" align="right">
+          <template slot-scope="{ row }">
+            <span class="num-val">{{ row.carbs != null ? row.carbs : '-' }}</span>
+            <span class="num-unit">g</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="膳食纤维" width="90" align="right">
+          <template slot-scope="{ row }">
+            <span class="num-val">{{ row.fiber != null ? row.fiber : '-' }}</span>
+            <span class="num-unit">g</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="能量(kJ)" width="90" align="right">
+          <template slot-scope="{ row }">
+            <span class="num-val">{{ row.energyKj != null ? row.energyKj : '-' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="胆固醇" width="90" align="right">
+          <template slot-scope="{ row }">
+            <span v-if="row.cholesterol != null" class="num-val">{{ row.cholesterol }}<span class="num-unit">mg</span></span>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="status" label="状态" width="80" align="center">
           <template slot-scope="{ row }">
-            <span v-if="row.months && row.months.length">{{ row.months.map(function(m) { return m + '月' }).join('、') }}</span>
-            <span v-else style="color:#999">未设置</span>
+            <el-tag :type="row.status === 'active' ? 'success' : 'info'" size="mini" effect="dark">
+              {{ row.status === 'active' ? '启用' : '停用' }}
+            </el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="status" label="状态" width="80" />
-        <el-table-column label="操作" width="200" fixed="right">
+        <el-table-column label="操作" width="180" fixed="right" align="center">
           <template slot-scope="{ row }">
-            <el-button size="mini" type="primary" @click="editItem(row)">编辑</el-button>
+            <el-button size="mini" type="primary" plain @click="editItem(row)">编辑</el-button>
             <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
-              <el-button size="mini">
+              <el-button size="mini" plain>
                 更多<i class="el-icon-arrow-down el-icon--right"></i>
               </el-button>
               <el-dropdown-menu slot="dropdown">
-                <el-dropdown-item command="delete" icon="el-icon-delete">删除</el-dropdown-item>
+                <el-dropdown-item command="detail" icon="el-icon-view">查看详情</el-dropdown-item>
+                <el-dropdown-item command="delete" icon="el-icon-delete" divided style="color: #f56c6c;">删除</el-dropdown-item>
               </el-dropdown-menu>
             </el-dropdown>
           </template>
         </el-table-column>
-        </el-table>
+      </el-table>
+
+      <div class="pagination-wrap">
+        <el-pagination
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+          :current-page="page"
+          :page-sizes="[10, 20, 50, 100]"
+          :page-size="size"
+          layout="total, sizes, prev, pager, next, jumper"
+          :total="total"
+        />
       </div>
     </el-card>
 
-    <el-dialog :title="editId ? '编辑食材' : '新建食材'" :visible.sync="dialogVisible" width="600px">
-      <el-form label-width="120px">
-        <el-form-item label="名称">
-          <el-input v-model="form.name" />
-        </el-form-item>
-        <el-form-item label="分类">
-          <el-select v-model="form.category" style="width:100%">
-            <el-option label="蔬菜" value="蔬菜" />
-            <el-option label="水果" value="水果" />
-            <el-option label="肉类" value="肉类" />
-            <el-option label="水产" value="水产" />
-            <el-option label="蛋奶" value="蛋奶" />
-            <el-option label="豆类" value="豆类" />
-            <el-option label="谷物" value="谷物" />
-            <el-option label="坚果" value="坚果" />
-            <el-option label="调味品" value="调味品" />
-            <el-option label="其他" value="其他" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="热量(kcal/100g)">
-          <el-input-number v-model="form.calories" :min="0" style="width:100%" />
-        </el-form-item>
-        <el-form-item label="蛋白质(g)">
-          <el-input-number v-model="form.protein" :min="0" :precision="1" style="width:100%" />
-        </el-form-item>
-        <el-form-item label="碳水(g)">
-          <el-input-number v-model="form.carbs" :min="0" :precision="1" style="width:100%" />
-        </el-form-item>
-        <el-form-item label="脂肪(g)">
-          <el-input-number v-model="form.fat" :min="0" :precision="1" style="width:100%" />
-        </el-form-item>
-        <el-form-item label="膳食纤维(g)">
-          <el-input-number v-model="form.fiber" :min="0" :precision="1" style="width:100%" />
-        </el-form-item>
-        <el-form-item label="能量(kJ/100g)">
-          <el-input-number v-model="form.energyKj" :min="0" style="width:100%" />
-        </el-form-item>
-        <el-form-item label="淀粉(g/100g)">
-          <el-input-number v-model="form.starch" :min="0" :precision="1" style="width:100%" />
-        </el-form-item>
-        <el-form-item label="胆固醇(mg/100g)">
-          <el-input-number v-model="form.cholesterol" :min="0" :precision="1" style="width:100%" />
-        </el-form-item>
+    <!-- 编辑/新建对话框 -->
+    <el-dialog
+      :title="editId ? '编辑食材' : '新建食材'"
+      :visible.sync="dialogVisible"
+      width="680px"
+      :close-on-click-modal="false"
+    >
+      <el-form label-width="130px" size="small">
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="名称">
+              <el-input v-model="form.name" placeholder="食材名称" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="分类">
+              <el-select v-model="form.category" style="width:100%" placeholder="选择分类">
+                <el-option v-for="opt in categoryOptions" :key="opt" :label="opt" :value="opt" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="热量(kcal/100g)">
+              <el-input-number v-model="form.calories" :min="0" :precision="1" style="width:100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="能量(kJ/100g)">
+              <el-input-number v-model="form.energyKj" :min="0" :precision="1" style="width:100%" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="8">
+            <el-form-item label="蛋白质(g)">
+              <el-input-number v-model="form.protein" :min="0" :precision="1" style="width:100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="碳水(g)">
+              <el-input-number v-model="form.carbs" :min="0" :precision="1" style="width:100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="脂肪(g)">
+              <el-input-number v-model="form.fat" :min="0" :precision="1" style="width:100%" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="8">
+            <el-form-item label="膳食纤维(g)">
+              <el-input-number v-model="form.fiber" :min="0" :precision="1" style="width:100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="淀粉(g)">
+              <el-input-number v-model="form.starch" :min="0" :precision="1" style="width:100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="胆固醇(mg)">
+              <el-input-number v-model="form.cholesterol" :min="0" :precision="1" style="width:100%" />
+            </el-form-item>
+          </el-col>
+        </el-row>
         <el-form-item label="单位">
-          <el-input v-model="form.unit" placeholder="如: 克、个、份" />
+          <el-input v-model="form.unit" placeholder="如: 克、个、份" style="width: 200px;" />
         </el-form-item>
         <el-form-item label="应季月份">
           <el-checkbox-group v-model="form.months">
-            <el-checkbox v-for="m in 12" :key="m" :label="m">{{ m }}月</el-checkbox>
+            <el-checkbox v-for="m in 12" :key="m" :label="m" border size="mini">{{ m }}月</el-checkbox>
           </el-checkbox-group>
         </el-form-item>
         <el-form-item label="状态">
-          <el-select v-model="form.status" style="width:100%">
-            <el-option label="启用" value="active" />
-            <el-option label="停用" value="inactive" />
-          </el-select>
+          <el-radio-group v-model="form.status">
+            <el-radio label="active">启用</el-radio>
+            <el-radio label="inactive">停用</el-radio>
+          </el-radio-group>
         </el-form-item>
       </el-form>
       <div slot="footer">
@@ -127,45 +219,79 @@
         <el-button type="primary" :loading="submitting" @click="saveItem">保存</el-button>
       </div>
     </el-dialog>
+
+    <!-- 详情对话框 -->
+    <el-dialog title="食材详情" :visible.sync="detailVisible" width="500px">
+      <el-descriptions :column="2" border size="small" v-if="detailData">
+        <el-descriptions-item label="名称" :span="2">{{ detailData.name }}</el-descriptions-item>
+        <el-descriptions-item label="分类">{{ detailData.category }}</el-descriptions-item>
+        <el-descriptions-item label="状态">
+          <el-tag :type="detailData.status === 'active' ? 'success' : 'info'" size="mini">{{ detailData.status === 'active' ? '启用' : '停用' }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="热量(kcal)">{{ detailData.calories || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="能量(kJ)">{{ detailData.energyKj || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="蛋白质(g)">{{ detailData.protein || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="脂肪(g)">{{ detailData.fat || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="碳水(g)">{{ detailData.carbs || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="膳食纤维(g)">{{ detailData.fiber || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="淀粉(g)">{{ detailData.starch || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="胆固醇(mg)">{{ detailData.cholesterol || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="单位">{{ detailData.unit || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="排序">{{ detailData.sortOrder != null ? detailData.sortOrder : '-' }}</el-descriptions-item>
+        <el-descriptions-item label="应季月份" :span="2">
+          <span v-if="detailData.months && detailData.months.length">{{ detailData.months.map(function(m) { return m + '月' }).join('、') }}</span>
+          <span v-else style="color:#999">未设置</span>
+        </el-descriptions-item>
+      </el-descriptions>
+    </el-dialog>
   </div>
 </template>
 
 <script>
-import { getFoodDetail, saveFoodMonths } from '@/api/admin'
+import { getFoodList, createFood, updateFood, deleteFood, getFoodDetail, saveFoodMonths } from '@/api/admin'
 
 export default {
   name: 'Foods',
-  computed: {
-    tableHeight() {
-      return window.innerHeight - 340
-    }
-  },
   data() {
     return {
       submitting: false,
       loading: false,
       list: [],
-      dialogVisible: false,
+      total: 0,
+      page: 1,
+      size: 20,
       categoryFilter: '',
       keyword: '',
       editId: null,
+      dialogVisible: false,
+      detailVisible: false,
+      detailData: null,
+      categoryOptions: [
+        '蔬菜', '水果', '水产品', '肉类', '干果',
+        '主食', '豆类及豆制品', '蛋类', '乳制品', '快餐', '汤'
+      ],
       form: {
         name: '',
         category: '',
-        calories: 0,
-        protein: 0,
-        carbs: 0,
-        fat: 0,
-        fiber: 0,
-        energyKj: 0,
-        starch: 0,
-        cholesterol: 0,
+        calories: null,
+        protein: null,
+        carbs: null,
+        fat: null,
+        fiber: null,
+        energyKj: null,
+        starch: null,
+        cholesterol: null,
         unit: '克',
         status: 'active',
         months: []
       }
     }
   },
+  computed: {
+    tableHeight() {
+      return window.innerHeight - 340
+    }
+  },
   created() {
     this.loadList()
   },
@@ -173,13 +299,14 @@ export default {
     async loadList() {
       this.loading = true
       try {
-        const baseURL = process.env.VUE_APP_BASE_API || ''
-        const token = localStorage.getItem('token')
-        const axios = this.$axios || (await import('axios')).default
-        const res = await axios.post(baseURL + '/api/admin/foods/list', { category: this.categoryFilter || undefined, keyword: this.keyword || undefined }, {
-          headers: { Authorization: 'Bearer ' + token }
+        const res = await getFoodList({
+          page: this.page,
+          size: this.size,
+          category: this.categoryFilter || undefined,
+          keyword: this.keyword || undefined
         })
-        this.list = res.data && res.data.data || []
+        this.list = res.data && res.data.records || []
+        this.total = res.data && res.data.total || 0
       } catch (e) {
         this.$message && this.$message.error && this.$message.error('加载失败')
       } finally {
@@ -190,9 +317,18 @@ export default {
       this.page = 1
       this.loadList()
     },
+    handleSizeChange(val) {
+      this.size = val
+      this.page = 1
+      this.loadList()
+    },
+    handleCurrentChange(val) {
+      this.page = val
+      this.loadList()
+    },
     openCreate() {
       this.editId = null
-      this.form = { name: '', category: '', calories: 0, protein: 0, carbs: 0, fat: 0, fiber: 0, energyKj: 0, starch: 0, cholesterol: 0, unit: '克', status: 'active', months: [] }
+      this.form = { name: '', category: '', calories: null, protein: null, carbs: null, fat: null, fiber: null, energyKj: null, starch: null, cholesterol: null, unit: '克', status: 'active', months: [] }
       this.dialogVisible = true
     },
     async editItem(row) {
@@ -200,22 +336,23 @@ export default {
       this.form = {
         name: row.name || '',
         category: row.category || '',
-        calories: row.calories || 0,
-        protein: row.protein || 0,
-        carbs: row.carbs || 0,
-        fat: row.fat || 0,
-        fiber: row.fiber || 0,
-        energyKj: row.energyKj || 0,
-        starch: row.starch || 0,
-        cholesterol: row.cholesterol || 0,
+        calories: row.calories,
+        protein: row.protein,
+        carbs: row.carbs,
+        fat: row.fat,
+        fiber: row.fiber,
+        energyKj: row.energyKj,
+        starch: row.starch,
+        cholesterol: row.cholesterol,
         unit: row.unit || '克',
         status: row.status || 'active',
         months: []
       }
+      // Load months for editing
       try {
         const res = await getFoodDetail(row.id)
-        if (res.data && res.data.months) {
-          this.form.months = res.data.months
+        if (res.data && res.data.data && res.data.data.months) {
+          this.form.months = res.data.data.months
         }
       } catch (e) {
         // months stay empty
@@ -223,64 +360,113 @@ export default {
       this.dialogVisible = true
     },
     async saveItem() {
+      this.submitting = true
       try {
-        const baseURL = process.env.VUE_APP_BASE_API || ''
-        const token = localStorage.getItem('token')
-        const axios = this.$axios || (await import('axios')).default
         const data = { ...this.form }
         const months = data.months
         delete data.months
         let foodId = this.editId
         if (foodId) {
           data.id = foodId
-          await axios.post(baseURL + '/api/admin/foods/update', data, {
-            headers: { Authorization: 'Bearer ' + token }
-          })
-          this.$message && this.$message.success && this.$message.success('更新成功')
+          await updateFood(data)
+          this.$message.success('更新成功')
         } else {
-          const res = await axios.post(baseURL + '/api/admin/foods/create', data, {
-            headers: { Authorization: 'Bearer ' + token }
-          })
+          const res = await createFood(data)
           foodId = res.data && res.data.data && res.data.data.id
-          this.$message && this.$message.success && this.$message.success('创建成功')
+          this.$message.success('创建成功')
         }
         // Save seasonal months
         if (foodId && months && months.length > 0) {
           try {
             await saveFoodMonths(foodId, months)
           } catch (e) {
-            this.$message && this.$message.warning && this.$message.warning('应季月份保存失败')
+            this.$message.warning('应季月份保存失败')
           }
         }
         this.dialogVisible = false
         this.loadList()
       } catch (e) {
-        this.$message && this.$message.error && this.$message.error('保存失败')
+        this.$message.error('保存失败')
+      } finally {
+        this.submitting = false
       }
     },
-    async deleteItem(row) {
+    deleteItem(row) {
       this.$confirm('确定删除 "' + row.name + '" 吗?', '提示', { type: 'warning' }).then(async () => {
         try {
-          const baseURL = process.env.VUE_APP_BASE_API || ''
-          const token = localStorage.getItem('token')
-          const axios = this.$axios || (await import('axios')).default
-          await axios.post(baseURL + '/api/admin/foods/delete', { id: row.id }, {
-            headers: { Authorization: 'Bearer ' + token }
-          })
-          this.$message && this.$message.success && this.$message.success('删除成功')
+          await deleteFood(row.id)
+          this.$message.success('删除成功')
           this.loadList()
         } catch (e) {
-          this.$message && this.$message.error && this.$message.error('删除失败')
+          this.$message.error('删除失败')
         }
       }).catch(() => {})
     },
+    showDetail(row) {
+      this.detailData = row
+      this.detailVisible = true
+    },
     handleActionCmd(row, cmd) {
       switch (cmd) {
         case 'delete':
           this.deleteItem(row)
           break
+        case 'detail':
+          this.showDetail(row)
+          break
       }
+    },
+    categoryTagType(category) {
+      const map = {
+        '蔬菜': 'success',
+        '水果': 'warning',
+        '水产品': 'primary',
+        '肉类': 'danger',
+        '干果': 'warning',
+        '主食': '',
+        '豆类及豆制品': 'success',
+        '蛋类': 'danger',
+        '乳制品': 'primary',
+        '快餐': 'info',
+        '汤': ''
+      }
+      return map[category] || ''
     }
   }
 }
 </script>
+
+<style scoped>
+.header-actions {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
+.pagination-wrap {
+  display: flex;
+  justify-content: flex-end;
+  padding: 16px 0 0;
+}
+
+.num-val {
+  font-weight: 500;
+  font-family: 'Helvetica Neue', Arial, sans-serif;
+}
+
+.num-unit {
+  font-size: 11px;
+  color: #999;
+  margin-left: 2px;
+}
+
+.el-checkbox.is-bordered {
+  margin-right: 6px;
+  margin-bottom: 4px;
+}
+
+.el-descriptions {
+  margin-top: 8px;
+}
+</style>

+ 285 - 0
cfc-web/src/views/admin/NutritionProducts.vue

@@ -0,0 +1,285 @@
+<template>
+  <div class="nutrition-products admin-page">
+    <el-card shadow="never">
+      <div slot="header" class="admin-page-header">
+        <span class="admin-page-title">
+          <i class="el-icon-first-aid-kit" style="color: #67c23a; margin-right: 8px;"></i>
+          营养产品管理
+        </span>
+        <div class="header-actions">
+          <el-input
+            v-model="keyword"
+            placeholder="搜索产品名称..."
+            prefix-icon="el-icon-search"
+            clearable
+            size="mini"
+            style="width: 200px; margin-right: 8px;"
+            @keyup.enter.native="handleSearch"
+            @clear="handleSearch"
+          />
+          <el-select
+            v-model="categoryFilter"
+            placeholder="分类筛选"
+            size="mini"
+            clearable
+            style="width: 140px; margin-right: 8px;"
+            @change="handleSearch"
+          >
+            <el-option label="全部分类" value="" />
+            <el-option v-for="opt in categoryOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+          <el-select
+            v-model="brandFilter"
+            placeholder="品牌筛选"
+            size="mini"
+            clearable
+            style="width: 140px; margin-right: 8px;"
+            @change="handleSearch"
+          >
+            <el-option label="全部品牌" value="" />
+            <el-option v-for="opt in brandOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+        </div>
+      </div>
+
+      <el-table
+        :data="list"
+        v-loading="loading"
+        border
+        stripe
+        size="mini"
+        style="width: 100%"
+        :max-height="tableHeight"
+      >
+        <el-table-column type="index" label="#" width="50" align="center" />
+        <el-table-column label="产品名称" min-width="200">
+          <template slot-scope="{ row }">
+            <span style="font-weight: 500;">{{ row.name }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="品牌" width="120" align="center">
+          <template slot-scope="{ row }">
+            <span>{{ brandName(row.brandId) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="分类" width="100" align="center">
+          <template slot-scope="{ row }">
+            <el-tag size="mini" effect="plain">{{ categoryName(row.categoryId) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="剂型" width="80" align="center">
+          <template slot-scope="{ row }">{{ row.form || '-' }}</template>
+        </el-table-column>
+        <el-table-column label="净含量" width="120" align="center">
+          <template slot-scope="{ row }">{{ row.netContent || '-' }}</template>
+        </el-table-column>
+        <el-table-column label="适用人群" width="120">
+          <template slot-scope="{ row }">{{ row.suitableFor || '-' }}</template>
+        </el-table-column>
+        <el-table-column label="关联商品" width="120" align="center">
+          <template slot-scope="{ row }">
+            <el-tag v-if="row.localProductId" type="success" size="mini">已关联</el-tag>
+            <span v-else class="no-data">-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="120" align="center" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button type="text" size="mini" @click="showDetail(row.id)">详情</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 详情对话框 -->
+    <el-dialog
+      title="营养产品详情"
+      :visible.sync="detailVisible"
+      width="800px"
+      :close-on-click-modal="false"
+    >
+      <div v-loading="detailLoading" v-if="detailData" style="max-height: 60vh; overflow-y: auto;">
+        <el-descriptions :column="2" border size="mini">
+          <el-descriptions-item label="产品名称" :span="2">{{ detailData.product.name }}</el-descriptions-item>
+          <el-descriptions-item label="品牌">{{ brandName(detailData.product.brandId) }}</el-descriptions-item>
+          <el-descriptions-item label="分类">{{ categoryName(detailData.product.categoryId) }}</el-descriptions-item>
+          <el-descriptions-item label="剂型">{{ detailData.product.form || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="净含量">{{ detailData.product.netContent || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="保质期">{{ detailData.product.shelfLife || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="适用人群">{{ detailData.product.suitableFor || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="关联商品ID" :span="2">
+            <el-tag v-if="detailData.product.localProductId" type="success">
+              {{ detailData.product.localProductId }}
+            </el-tag>
+            <span v-else class="no-data">未关联</span>
+          </el-descriptions-item>
+          <el-descriptions-item label="产品描述" :span="2">
+            {{ detailData.product.description || '-' }}
+          </el-descriptions-item>
+        </el-descriptions>
+
+        <el-divider content-position="left">成份明细</el-divider>
+        <el-table v-if="detailData.ingredients && detailData.ingredients.length" :data="detailData.ingredients" size="mini" border stripe>
+          <el-table-column prop="name" label="成分名" min-width="150" />
+          <el-table-column prop="ingredientType" label="类型" width="100" />
+          <el-table-column prop="strain" label="菌株号" width="120" />
+          <el-table-column label="每份含量" width="120">
+            <template slot-scope="{ row }">{{ row.amountPerServing }} {{ row.unit }}</template>
+          </el-table-column>
+          <el-table-column prop="purpose" label="功效" min-width="150" />
+        </el-table>
+        <div v-else class="no-data">暂无成份数据</div>
+
+        <el-divider content-position="left">平台价格</el-divider>
+        <el-table v-if="detailData.platformListings && detailData.platformListings.length" :data="detailData.platformListings" size="mini" border stripe>
+          <el-table-column prop="platform" label="平台" width="100" />
+          <el-table-column label="当前价格" width="120">
+            <template slot-scope="{ row }">¥{{ row.currentPrice }}</template>
+          </el-table-column>
+          <el-table-column label="原价" width="120">
+            <template slot-scope="{ row }">¥{{ row.originalPrice || '-' }}</template>
+          </el-table-column>
+          <el-table-column prop="priceUnit" label="价格单位" width="100" />
+          <el-table-column prop="salesCount" label="销量" width="80" />
+          <el-table-column prop="platformUrl" label="链接" min-width="200">
+            <template slot-scope="{ row }">
+              <el-link v-if="row.platformUrl" :href="row.platformUrl" target="_blank" type="primary" :underline="false">
+                {{ row.platformUrl | truncate(40) }}
+              </el-link>
+              <span v-else class="no-data">-</span>
+            </template>
+          </el-table-column>
+        </el-table>
+        <div v-else class="no-data">暂无平台价格数据</div>
+
+        <el-divider content-position="left">本平台关联商品</el-divider>
+        <div v-if="detailData.localProduct">
+          <el-tag type="success">已关联商品 ID: {{ detailData.localProduct.id }}</el-tag>
+          <p style="margin-top: 8px;">名称: {{ detailData.localProduct.name }}</p>
+          <p>价格: ¥{{ (detailData.localProduct.price / 100).toFixed(2) }}</p>
+        </div>
+        <div v-else class="no-data">未关联本平台商品</div>
+      </div>
+      <div v-else-if="detailLoading" style="text-align: center; padding: 40px;">
+        <i class="el-icon-loading" style="font-size: 24px;"></i>
+      </div>
+      <div v-else class="no-data" style="text-align: center; padding: 40px;">未找到产品信息</div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getCategoryList, getBrandList, searchProducts, getProductDetail } from '@/api/nutritionProduct'
+
+export default {
+  name: 'NutritionProducts',
+  data() {
+    return {
+      keyword: '',
+      categoryFilter: '',
+      brandFilter: '',
+      list: [],
+      loading: false,
+      categories: [],
+      brands: [],
+      detailVisible: false,
+      detailLoading: false,
+      detailData: null,
+      tableHeight: window.innerHeight - 220
+    }
+  },
+  computed: {
+    categoryOptions() {
+      return this.categories.map(c => ({ value: c.id, label: c.name }))
+    },
+    brandOptions() {
+      return this.brands.map(b => ({ value: b.id, label: b.name }))
+    }
+  },
+  created() {
+    this.loadCategories()
+    this.loadBrands()
+    this.loadList()
+  },
+  methods: {
+    async loadCategories() {
+      try {
+        const res = await getCategoryList()
+        this.categories = res.data || []
+      } catch (e) {
+        // ignore
+      }
+    },
+    async loadBrands() {
+      try {
+        const res = await getBrandList()
+        this.brands = res.data || []
+      } catch (e) {
+        // ignore
+      }
+    },
+    async loadList() {
+      this.loading = true
+      try {
+        const params = {}
+        if (this.keyword) params.name = this.keyword
+        if (this.categoryFilter) params.categoryId = this.categoryFilter
+        if (this.brandFilter) params.brandId = this.brandFilter
+        const res = await searchProducts(params)
+        this.list = res.data || []
+      } catch (e) {
+        this.list = []
+      } finally {
+        this.loading = false
+      }
+    },
+    handleSearch() {
+      this.loadList()
+    },
+    categoryName(id) {
+      if (!id) return '-'
+      const found = this.categories.find(c => c.id === id)
+      return found ? found.name : '-'
+    },
+    brandName(id) {
+      if (!id) return '-'
+      const found = this.brands.find(b => b.id === id)
+      return found ? found.name : '-'
+    },
+    async showDetail(productId) {
+      this.detailVisible = true
+      this.detailLoading = true
+      this.detailData = null
+      try {
+        const res = await getProductDetail(productId)
+        this.detailData = res.data
+      } catch (e) {
+        this.$message.error('获取详情失败')
+      } finally {
+        this.detailLoading = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.nutrition-products {
+  padding: 16px;
+}
+.admin-page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.admin-page-title {
+  font-size: 16px;
+  font-weight: 600;
+}
+.header-actions {
+  display: flex;
+  align-items: center;
+}
+.no-data {
+  color: #999;
+}
+</style>

+ 117 - 10
scripts/migrate-to-jdcloud.py

@@ -17,15 +17,21 @@
 
     # 指定本地目录
     python3 scripts/migrate-to-jdcloud.py --local-dir /data/cfc-uploads
+
+    # 迁移后回填 file_record 表(用于去重)
+    python3 scripts/migrate-to-jdcloud.py --backfill
 """
 
 import os
 import sys
 import boto3
+import hashlib
+import pymysql
 from botocore.config import Config
 from pathlib import Path
 import argparse
 import logging
+from datetime import datetime
 
 logging.basicConfig(
     level=logging.INFO,
@@ -41,6 +47,13 @@ SECRET_KEY = "970C0C6985A6BF29D0C79AE3693B9899"
 BUCKET = "cfc"
 REGION = "cn-north-1"
 
+# MySQL 配置(用于回填 file_record 表)
+DB_HOST = "mysql-internet-cn-north-1-23feae22680e4dfa.rds.jdcloud.com"
+DB_PORT = 3306
+DB_USER = "cfc"
+DB_PASS = "cfc@1314"
+DB_NAME = "zxyj"
+
 # 公网URL前缀
 PUBLIC_URL_PREFIX = f"https://{BUCKET}.{ENDPOINT}/"
 
@@ -56,6 +69,13 @@ def get_client():
     )
 
 
+def get_db():
+    return pymysql.connect(
+        host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASS,
+        database=DB_NAME, charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor,
+    )
+
+
 def list_cloud_keys(client):
     """列出云端已存在的所有 key"""
     keys = set()
@@ -76,26 +96,26 @@ def list_cloud_keys(client):
 
 
 def upload_file(client, local_path, key, dry_run=False, overwrite=False, cloud_keys=None):
-    """上传单个文件"""
+    """上传单个文件,返回 (成功与否, target_url)"""
     target_url = PUBLIC_URL_PREFIX + key
 
     if cloud_keys and key in cloud_keys and not overwrite:
         log.info("  [SKIP] 已在云端存在: %s", target_url)
-        return False
+        return False, target_url
 
     if dry_run:
         log.info("  [DRY-RUN] 将上传: %s -> %s", local_path, target_url)
-        return False
+        return False, target_url
 
     try:
         content_type = guess_content_type(local_path)
         extra_args = {"ContentType": content_type} if content_type else {}
         client.upload_file(str(local_path), BUCKET, key, ExtraArgs=extra_args)
         log.info("  [OK] %s", target_url)
-        return True
+        return True, target_url
     except Exception as e:
         log.error("  [FAIL] %s: %s", local_path, e)
-        return False
+        return False, target_url
 
 
 def guess_content_type(path):
@@ -127,11 +147,56 @@ def guess_content_type(path):
     return mapping.get(ext, "application/octet-stream")
 
 
+def guess_category(local_path, local_dir):
+    """根据目录结构猜测文件分类"""
+    relative = local_path.relative_to(local_dir)
+    parts = relative.parts
+    if len(parts) >= 2:
+        return parts[0]
+    return "general"
+
+
+def backfill_file_record(db, local_path, url, local_dir):
+    """回填 file_record 表"""
+    category = guess_category(local_path, local_dir)
+    file_size = local_path.stat().st_size
+    content_type = guess_content_type(local_path)
+    original_name = local_path.name
+
+    # 计算 SHA-256
+    sha256_hash = hashlib.sha256()
+    with open(local_path, "rb") as f:
+        sha256_hash.update(f.read())
+    file_hash = sha256_hash.hexdigest()
+
+    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+    with db.cursor() as cursor:
+        # 检查是否已存在
+        cursor.execute("SELECT id FROM file_record WHERE hash = %s", (file_hash,))
+        if cursor.fetchone():
+            log.debug("  [SKIP-BACKFILL] hash 已存在: %s", file_hash)
+            return False
+        sql = (
+            "INSERT INTO file_record (hash, url, file_size, content_type, "
+            "original_filename, category, created_at, updated_at) "
+            "VALUES (%s, %s, %s, %s, %s, %s, %s, %s)"
+        )
+        cursor.execute(sql, (
+            file_hash, url, file_size, content_type,
+            original_name, category, now, now,
+        ))
+    db.commit()
+    log.info("  [BACKFILL] %s -> %s", file_hash, url)
+    return True
+
+
 def main():
     parser = argparse.ArgumentParser(description="迁移本地文件到京东云对象存储")
     parser.add_argument("--local-dir", default="/data/cfc-uploads", help="本地文件目录")
     parser.add_argument("--dry-run", action="store_true", help="仅预览,不实际上传")
     parser.add_argument("--overwrite", action="store_true", help="覆盖云端已存在的文件")
+    parser.add_argument("--backfill", action="store_true", help="上传后回填 file_record 表(用于去重)")
     args = parser.parse_args()
 
     local_dir = Path(args.local_dir)
@@ -157,12 +222,13 @@ def main():
         log.info("模式: 覆盖已存在文件")
     else:
         log.info("模式: 跳过已存在文件")
+    if args.backfill:
+        log.info("回填: 启用(写入 file_record 表)")
 
     # 连接京东云
     if not args.dry_run:
         log.info("正在连接京东云 OSS...")
         client = get_client()
-        # 验证连通性
         try:
             client.head_bucket(Bucket=BUCKET)
             log.info("Bucket %s 连接成功", BUCKET)
@@ -176,31 +242,72 @@ def main():
         client = None
         cloud_keys = set()
 
+    # 连接 MySQL(回填模式)
+    db = None
+    if args.backfill and not args.dry_run:
+        try:
+            db = get_db()
+            log.info("MySQL 连接成功")
+        except Exception as e:
+            log.error("MySQL 连接失败: %s", e)
+            sys.exit(1)
+
     # 上传文件
     success = 0
     skipped = 0
     failed = 0
+    backfill_ok = 0
+    backfill_skip = 0
+    backfill_fail = 0
 
     for file_path in files:
-        # 计算相对路径作为 key(保持目录结构)
         relative = file_path.relative_to(local_dir)
         key = "uploads/" + relative.as_posix()
 
         if client and key in cloud_keys and not args.overwrite:
             skipped += 1
+            # 即使跳过上传,也回填 file_record(如果启用)
+            if db:
+                try:
+                    if backfill_file_record(db, file_path, PUBLIC_URL_PREFIX + key, local_dir):
+                        backfill_ok += 1
+                    else:
+                        backfill_skip += 1
+                except Exception as e:
+                    log.error("  [BACKFILL-FAIL] %s: %s", file_path, e)
+                    backfill_fail += 1
             continue
 
-        if upload_file(client, file_path, key, args.dry_run, args.overwrite, cloud_keys):
+        ok, url = upload_file(client, file_path, key, args.dry_run, args.overwrite, cloud_keys)
+        if ok:
             success += 1
+            if db:
+                try:
+                    if backfill_file_record(db, file_path, url, local_dir):
+                        backfill_ok += 1
+                    else:
+                        backfill_skip += 1
+                except Exception as e:
+                    log.error("  [BACKFILL-FAIL] %s: %s", file_path, e)
+                    backfill_fail += 1
         else:
-            failed += 1
+            if not args.dry_run:
+                failed += 1
 
     # 总结
     if not args.dry_run:
         log.info("=" * 50)
-        log.info("迁移完成: 成功=%d, 跳过=%d, 失败=%d", success, skipped, failed)
+        parts = [f"成功={success}", f"跳过={skipped}", f"失败={failed}"]
+        if db:
+            parts.append(f"回填={backfill_ok}")
+            parts.append(f"回填跳过={backfill_skip}")
+            if backfill_fail:
+                parts.append(f"回填失败={backfill_fail}")
+        log.info("迁移完成: " + ", ".join(parts))
         if failed > 0:
             log.warning("有 %d 个文件上传失败,请检查日志", failed)
+        if db:
+            db.close()
     else:
         log.info("=" * 50)
         log.info("DRY-RUN 完成: 将上传 %d 个文件,跳过 %d 个(已存在)", success, skipped)