|
|
@@ -0,0 +1,1790 @@
|
|
|
+# 文章内容发布系统 — 实现计划
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 建立完整的文章内容发布系统:后端 API + Web管理端发布/管理 + 小程序端展示和阅读
|
|
|
+
|
|
|
+**Architecture:** 后端新增 Article + ArticleCategory 两张表及全套 CRUD,含浏览权限过滤(public/login/private);Web管理端(cfc-web)新增 3 个 admin 页面;小程序(cfc-frontend)将现有 3 处硬编码文章改为 API 数据源,并新增详情页。
|
|
|
+
|
|
|
+**Tech Stack:** Spring Boot + MyBatis-Plus / Vue 2 + Element UI / uni-app Vue 2
|
|
|
+
|
|
|
+**设计依据:** `docs/superpowers/specs/2026-06-08-article-publishing-system-design.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件改动总览
|
|
|
+
|
|
|
+| # | 文件 | 操作 | 说明 |
|
|
|
+|:-:|:-----|:----:|:------|
|
|
|
+| 1 | `entity/ArticleCategory.java` | 新增 | 文章分类实体 |
|
|
|
+| 2 | `entity/Article.java` | 新增 | 文章实体(含visibility/visible_to) |
|
|
|
+| 3 | `mapper/ArticleCategoryMapper.java` | 新增 | 分类 CRUD |
|
|
|
+| 4 | `mapper/ArticleMapper.java` | 新增 | 文章 CRUD(含权限过滤查询) |
|
|
|
+| 5 | `service/ArticleCategoryService.java` | 新增 | 分类业务 |
|
|
|
+| 6 | `service/ArticleService.java` | 新增 | 文章业务(含权限过滤逻辑) |
|
|
|
+| 7 | `service/ArticlePermissionService.java` | 新增 | 权限过滤核心逻辑(抽离复用) |
|
|
|
+| 8 | `controller/content/ArticleCategoryController.java` | 新增 | 分类公开API |
|
|
|
+| 9 | `controller/content/ArticleController.java` | 新增 | 文章公开API(列表/详情/精选/分类/阅读记录) |
|
|
|
+| 10 | `controller/admin/AdminArticleController.java` | 新增 | 管理端API(CRUD+发布+精选+上传) |
|
|
|
+| 11 | `controller/MediaController.java` | 修改 | 新增图片上传端点(若不存在则新增) |
|
|
|
+| 12 | `DatabaseInitializer.java` | 修改 | 新增 article_categories + articles 建表SQL + 种子数据 |
|
|
|
+| 13 | `cfc-web/src/views/admin/ArticleManage.vue` | 新增 | 文章管理列表页 |
|
|
|
+| 14 | `cfc-web/src/views/admin/ArticleEdit.vue` | 新增 | 文章编辑器(含权限配置) |
|
|
|
+| 15 | `cfc-web/src/views/admin/ArticleCategory.vue` | 新增 | 分类管理页 |
|
|
|
+| 16 | `cfc-web/src/router/index.js` | 修改 | 注册3个新路由(admin-only) |
|
|
|
+| 17 | `cfc-web/src/api/index.js` | 修改 | 新增文章相关 API 调用 |
|
|
|
+| 18 | `cfc-frontend/pages/mind/article-detail.vue` | 新增 | 文章详情页 |
|
|
|
+| 19 | `cfc-frontend/pages/mind/articles.vue` | 修改 | 改为API数据源 |
|
|
|
+| 20 | `cfc-frontend/pages/mind/index.vue` | 修改 | 推荐阅读改为API |
|
|
|
+| 21 | `cfc-frontend/pages/discover/index.vue` | 修改 | 精选文章改为API |
|
|
|
+| 22 | `cfc-frontend/utils/api.js` | 修改 | 新增文章相关 API 方法 |
|
|
|
+| 23 | `cfc-frontend/pages.json` | 修改 | 注册详情页路径 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: 后端实体 — ArticleCategory
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleCategory.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleCategoryMapper.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCategoryService.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCategoryController.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建实体 ArticleCategory.java**
|
|
|
+
|
|
|
+```java
|
|
|
+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("article_categories")
|
|
|
+public class ArticleCategory implements Serializable {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private String name;
|
|
|
+ private String icon;
|
|
|
+ private String color;
|
|
|
+ private Integer sortOrder;
|
|
|
+ private Integer status;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 Mapper**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.ArticleCategory;
|
|
|
+import org.apache.ibatis.annotations.Mapper;
|
|
|
+
|
|
|
+@Mapper
|
|
|
+public interface ArticleCategoryMapper extends BaseMapper<ArticleCategory> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 创建 Service**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.ArticleCategory;
|
|
|
+import com.etotem.cfc.mapper.ArticleCategoryMapper;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class ArticleCategoryService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleCategoryMapper articleCategoryMapper;
|
|
|
+
|
|
|
+ public List<ArticleCategory> getActiveCategories() {
|
|
|
+ return articleCategoryMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<ArticleCategory>()
|
|
|
+ .eq(ArticleCategory::getStatus, 1)
|
|
|
+ .orderByAsc(ArticleCategory::getSortOrder));
|
|
|
+ }
|
|
|
+
|
|
|
+ public List<ArticleCategory> listAll() {
|
|
|
+ return articleCategoryMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<ArticleCategory>()
|
|
|
+ .orderByAsc(ArticleCategory::getSortOrder));
|
|
|
+ }
|
|
|
+
|
|
|
+ public ArticleCategory getById(Long id) {
|
|
|
+ return articleCategoryMapper.selectById(id);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Transactional
|
|
|
+ public void create(ArticleCategory category) {
|
|
|
+ if (category.getSortOrder() == null) category.setSortOrder(0);
|
|
|
+ if (category.getStatus() == null) category.setStatus(1);
|
|
|
+ articleCategoryMapper.insert(category);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Transactional
|
|
|
+ public void update(ArticleCategory category) {
|
|
|
+ articleCategoryMapper.updateById(category);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Transactional
|
|
|
+ public void delete(Long id) {
|
|
|
+ articleCategoryMapper.deleteById(id);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 创建公开 Controller**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.content;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.ArticleCategory;
|
|
|
+import com.etotem.cfc.service.ArticleCategoryService;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/articles")
|
|
|
+public class ArticleCategoryController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleCategoryService articleCategoryService;
|
|
|
+
|
|
|
+ @PostMapping("/categories")
|
|
|
+ public Result<List<ArticleCategory>> getCategories() {
|
|
|
+ return Result.success(articleCategoryService.getActiveCategories());
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleCategory.java cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleCategoryMapper.java cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCategoryService.java cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCategoryController.java
|
|
|
+git commit -m "feat: add ArticleCategory entity/service/controller"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: 后端实体 — Article
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleMapper.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 Article 实体**
|
|
|
+
|
|
|
+```java
|
|
|
+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("articles")
|
|
|
+public class Article implements Serializable {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private Long categoryId;
|
|
|
+ private String title;
|
|
|
+ private String summary;
|
|
|
+ private String coverImage;
|
|
|
+ private String content;
|
|
|
+ private String tags; // JSON array: ["育儿","亲子"]
|
|
|
+ private String author;
|
|
|
+ private Integer readTime; // 预计阅读分钟数
|
|
|
+ private String relatedDimensions; // JSON array: ["mind","wisdom"]
|
|
|
+ private String visibility; // public / login / private
|
|
|
+ private String visibleTo; // JSON array of permission rules
|
|
|
+ private String status; // draft / published / archived
|
|
|
+ private Integer isFeatured; // 0=否 1=是
|
|
|
+ private Date publishedAt;
|
|
|
+ private Integer viewCount;
|
|
|
+ private Long createdBy;
|
|
|
+ private Date createdAt;
|
|
|
+ private Date updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 ArticleMapper**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.Article;
|
|
|
+import org.apache.ibatis.annotations.Mapper;
|
|
|
+
|
|
|
+@Mapper
|
|
|
+public interface ArticleMapper extends BaseMapper<Article> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleMapper.java
|
|
|
+git commit -m "feat: add Article entity/mapper"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: 后端 Service — 权限过滤 + 文章业务
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticlePermissionService.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建权限过滤服务**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.Article;
|
|
|
+import com.fasterxml.jackson.core.type.TypeReference;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.*;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 文章权限过滤核心逻辑
|
|
|
+ * 根据用户身份和文章的visibility+visible_to字段判断可见性
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class ArticlePermissionService {
|
|
|
+
|
|
|
+ private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从文章列表中过滤出当前用户可见的
|
|
|
+ *
|
|
|
+ * @param articles 待筛选的文章列表
|
|
|
+ * @param userId 当前用户ID(可能为null=未登录)
|
|
|
+ * @param familyId 当前用户所属家庭ID(可能为null)
|
|
|
+ * @param vendorType 当前用户供应商类型(可能为null)
|
|
|
+ * @return 可见文章列表
|
|
|
+ */
|
|
|
+ public List<Article> filterVisible(List<Article> articles, Long userId, Long familyId, String vendorType) {
|
|
|
+ if (articles == null || articles.isEmpty()) return Collections.emptyList();
|
|
|
+ return articles.stream()
|
|
|
+ .filter(a -> isVisible(a, userId, familyId, vendorType))
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 判断单篇文章对当前用户是否可见
|
|
|
+ */
|
|
|
+ public boolean isVisible(Article article, Long userId, Long familyId, String vendorType) {
|
|
|
+ if (article == null) return false;
|
|
|
+ String visibility = article.getVisibility();
|
|
|
+ if (visibility == null) visibility = "public";
|
|
|
+
|
|
|
+ switch (visibility) {
|
|
|
+ case "public":
|
|
|
+ return true;
|
|
|
+ case "login":
|
|
|
+ return userId != null;
|
|
|
+ case "private":
|
|
|
+ return isPrivateVisible(article.getVisibleTo(), userId, familyId, vendorType);
|
|
|
+ default:
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 判断私密文章的可见性
|
|
|
+ */
|
|
|
+ private boolean isPrivateVisible(String visibleToJson, Long userId, Long familyId, String vendorType) {
|
|
|
+ if (visibleToJson == null || visibleToJson.isEmpty()) return false;
|
|
|
+ try {
|
|
|
+ List<Map<String, Object>> rules = objectMapper.readValue(visibleToJson,
|
|
|
+ new TypeReference<List<Map<String, Object>>>() {});
|
|
|
+ if (rules == null || rules.isEmpty()) return false;
|
|
|
+
|
|
|
+ for (Map<String, Object> rule : rules) {
|
|
|
+ String type = (String) rule.get("type");
|
|
|
+ if (type == null) continue;
|
|
|
+
|
|
|
+ switch (type) {
|
|
|
+ case "family":
|
|
|
+ if (familyId != null) {
|
|
|
+ Object familyIdObj = rule.get("familyId");
|
|
|
+ if (familyIdObj != null) {
|
|
|
+ Long targetId = Long.valueOf(familyIdObj.toString());
|
|
|
+ if (targetId.equals(familyId)) return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ case "vendor_type":
|
|
|
+ if (vendorType != null) {
|
|
|
+ String targetType = (String) rule.get("vendorType");
|
|
|
+ if (targetType != null && targetType.equals(vendorType)) return true;
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ case "user":
|
|
|
+ if (userId != null) {
|
|
|
+ Object userIdObj = rule.get("userId");
|
|
|
+ if (userIdObj != null) {
|
|
|
+ Long targetId = Long.valueOf(userIdObj.toString());
|
|
|
+ if (targetId.equals(userId)) return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("解析visibleTo失败: {}", visibleToJson, e);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建文章业务服务**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
|
+import com.etotem.cfc.entity.Article;
|
|
|
+import com.etotem.cfc.entity.ArticleCategory;
|
|
|
+import com.etotem.cfc.mapper.ArticleMapper;
|
|
|
+import com.fasterxml.jackson.core.type.TypeReference;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.*;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class ArticleService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleMapper articleMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticlePermissionService articlePermissionService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleCategoryService articleCategoryService;
|
|
|
+
|
|
|
+ private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 公开列表(小程序端)— 只返回published + 当前用户可见
|
|
|
+ */
|
|
|
+ public Page<Article> getPublicList(Long categoryId, String keyword, int page, int size,
|
|
|
+ Long userId, Long familyId, String vendorType) {
|
|
|
+ LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
|
|
|
+ .eq(Article::getStatus, "published")
|
|
|
+ .orderByDesc(Article::getIsFeatured)
|
|
|
+ .orderByDesc(Article::getPublishedAt);
|
|
|
+
|
|
|
+ if (categoryId != null && categoryId > 0) {
|
|
|
+ wrapper.eq(Article::getCategoryId, categoryId);
|
|
|
+ }
|
|
|
+ if (keyword != null && !keyword.trim().isEmpty()) {
|
|
|
+ wrapper.like(Article::getTitle, keyword.trim());
|
|
|
+ }
|
|
|
+
|
|
|
+ // 先查所有published(不分权限,Java层过滤private)
|
|
|
+ Page<Article> p = new Page<>(page, size);
|
|
|
+ Page<Article> result = articleMapper.selectPage(p, wrapper);
|
|
|
+
|
|
|
+ // 权限过滤
|
|
|
+ List<Article> visible = articlePermissionService.filterVisible(
|
|
|
+ result.getRecords(), userId, familyId, vendorType);
|
|
|
+
|
|
|
+ // 重新分页(取出visible后可能少于page size,但保持page/total准确)
|
|
|
+ Page<Article> filtered = new Page<>(page, size);
|
|
|
+ filtered.setTotal(visible.size());
|
|
|
+ filtered.setRecords(visible);
|
|
|
+ return filtered;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 精选列表 — 只返回isFeatured=1 + published + 可见
|
|
|
+ */
|
|
|
+ public List<Article> getFeatured(int size, Long userId, Long familyId, String vendorType) {
|
|
|
+ LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
|
|
|
+ .eq(Article::getStatus, "published")
|
|
|
+ .eq(Article::getIsFeatured, 1)
|
|
|
+ .orderByDesc(Article::getPublishedAt)
|
|
|
+ .last("LIMIT " + Math.max(size, 50)); // 多查一些,Java层过滤后可能不够
|
|
|
+
|
|
|
+ List<Article> all = articleMapper.selectList(wrapper);
|
|
|
+ List<Article> visible = articlePermissionService.filterVisible(all, userId, familyId, vendorType);
|
|
|
+
|
|
|
+ return visible.size() > size ? visible.subList(0, size) : visible;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 文章详情 — 检查权限,无权返回null
|
|
|
+ */
|
|
|
+ public Article getDetail(Long id, Long userId, Long familyId, String vendorType) {
|
|
|
+ Article article = articleMapper.selectById(id);
|
|
|
+ if (article == null) return null;
|
|
|
+ if (!"published".equals(article.getStatus())) return null;
|
|
|
+ if (!articlePermissionService.isVisible(article, userId, familyId, vendorType)) return null;
|
|
|
+
|
|
|
+ // 增加阅读计数
|
|
|
+ articleMapper.updateById(article);
|
|
|
+ return article;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 记录阅读行为
|
|
|
+ */
|
|
|
+ @Transactional
|
|
|
+ public void recordRead(Long articleId, Long userId, Long childId, int durationSeconds) {
|
|
|
+ // view_count +1(避免频繁update,使用数据库自增)
|
|
|
+ articleMapper.update(null, com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<Article>()
|
|
|
+ .setSql("view_count = view_count + 1")
|
|
|
+ .eq(Article::getId, articleId));
|
|
|
+
|
|
|
+ // 写入阅读记录(复用现有 article_reading_records 表)
|
|
|
+ // 注意:article_reading_records 的 content 字段存储文章ID
|
|
|
+ // 此部分由 ReaderRecordService 处理(见 Task 5)
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 为详情响应补充 categoryName
|
|
|
+ */
|
|
|
+ public void enrichWithCategoryName(List<Article> articles) {
|
|
|
+ if (articles == null || articles.isEmpty()) return;
|
|
|
+ // categoryName 由 Controller 层填充,此处仅为工具方法
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 管理端方法 =====
|
|
|
+
|
|
|
+ public Page<Article> getAdminList(String status, Long categoryId, String keyword, int page, int size) {
|
|
|
+ LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
|
|
|
+ .orderByDesc(Article::getCreatedAt);
|
|
|
+
|
|
|
+ if (status != null && !status.isEmpty()) {
|
|
|
+ wrapper.eq(Article::getStatus, status);
|
|
|
+ }
|
|
|
+ if (categoryId != null && categoryId > 0) {
|
|
|
+ wrapper.eq(Article::getCategoryId, categoryId);
|
|
|
+ }
|
|
|
+ if (keyword != null && !keyword.trim().isEmpty()) {
|
|
|
+ wrapper.like(Article::getTitle, keyword.trim());
|
|
|
+ }
|
|
|
+
|
|
|
+ return articleMapper.selectPage(new Page<>(page, size), wrapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Transactional
|
|
|
+ public void create(Article article, Long adminId) {
|
|
|
+ article.setCreatedBy(adminId);
|
|
|
+ article.setViewCount(0);
|
|
|
+ article.setCreatedAt(new Date());
|
|
|
+ article.setUpdatedAt(new Date());
|
|
|
+ if ("published".equals(article.getStatus())) {
|
|
|
+ article.setPublishedAt(new Date());
|
|
|
+ }
|
|
|
+ articleMapper.insert(article);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Transactional
|
|
|
+ public void update(Article article) {
|
|
|
+ article.setUpdatedAt(new Date());
|
|
|
+ articleMapper.updateById(article);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Transactional
|
|
|
+ public void delete(Long id) {
|
|
|
+ articleMapper.deleteById(id);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Transactional
|
|
|
+ public void toggleStatus(Long id, String status) {
|
|
|
+ Article article = articleMapper.selectById(id);
|
|
|
+ if (article == null) return;
|
|
|
+ article.setStatus(status);
|
|
|
+ article.setUpdatedAt(new Date());
|
|
|
+ if ("published".equals(status) && article.getPublishedAt() == null) {
|
|
|
+ article.setPublishedAt(new Date());
|
|
|
+ }
|
|
|
+ articleMapper.updateById(article);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Transactional
|
|
|
+ public void toggleFeatured(Long id, int isFeatured) {
|
|
|
+ Article article = articleMapper.selectById(id);
|
|
|
+ if (article == null) return;
|
|
|
+ article.setIsFeatured(isFeatured);
|
|
|
+ article.setUpdatedAt(new Date());
|
|
|
+ articleMapper.updateById(article);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ArticlePermissionService.java cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
|
|
|
+git commit -m "feat: add ArticleService and ArticlePermissionService"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: 后端公开 API — ArticleController
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建公开文章 Controller**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.content;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.Article;
|
|
|
+import com.etotem.cfc.entity.ArticleCategory;
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
+import com.etotem.cfc.service.ArticleCategoryService;
|
|
|
+import com.etotem.cfc.service.ArticleService;
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/articles")
|
|
|
+public class ArticleController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleService articleService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleCategoryService articleCategoryService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private UserMapper userMapper;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 文章分页列表
|
|
|
+ * userId可能为null(未登录),用Long接收并处理null
|
|
|
+ */
|
|
|
+ @PostMapping("/list")
|
|
|
+ public Result<Page<Article>> list(@RequestBody Map<String, Object> body,
|
|
|
+ @RequestAttribute(value = "userId", required = false) Long userId) {
|
|
|
+ Long categoryId = body.get("categoryId") != null ? Long.valueOf(body.get("categoryId").toString()) : null;
|
|
|
+ String keyword = (String) body.get("keyword");
|
|
|
+ int page = body.get("page") != null ? Integer.parseInt(body.get("page").toString()) : 1;
|
|
|
+ int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 10;
|
|
|
+
|
|
|
+ // 获取用户家庭ID和供应商类型
|
|
|
+ Long familyId = null;
|
|
|
+ String vendorType = null;
|
|
|
+ if (userId != null) {
|
|
|
+ User user = userMapper.selectById(userId);
|
|
|
+ if (user != null) {
|
|
|
+ familyId = user.getFamilyId();
|
|
|
+ vendorType = user.getVendorType();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Page<Article> result = articleService.getPublicList(categoryId, keyword, page, size,
|
|
|
+ userId, familyId, vendorType);
|
|
|
+ enrichCategoryNames(result.getRecords());
|
|
|
+ return Result.success(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 文章详情
|
|
|
+ */
|
|
|
+ @PostMapping("/detail")
|
|
|
+ public Result<Article> detail(@RequestBody Map<String, Object> body,
|
|
|
+ @RequestAttribute(value = "userId", required = false) Long userId) {
|
|
|
+ Long id = Long.valueOf(body.get("id").toString());
|
|
|
+
|
|
|
+ Long familyId = null;
|
|
|
+ String vendorType = null;
|
|
|
+ if (userId != null) {
|
|
|
+ User user = userMapper.selectById(userId);
|
|
|
+ if (user != null) {
|
|
|
+ familyId = user.getFamilyId();
|
|
|
+ vendorType = user.getVendorType();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Article article = articleService.getDetail(id, userId, familyId, vendorType);
|
|
|
+ if (article == null) {
|
|
|
+ return Result.error(403, "无权访问该文章");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 补充categoryName
|
|
|
+ ArticleCategory cat = articleCategoryService.getById(article.getCategoryId());
|
|
|
+ // 可在响应中额外返回 categoryName,Entity中无此字段时通过Map或DTO处理
|
|
|
+ // 此处简单处理:直接设置categoryId,前端按ID查分类列表
|
|
|
+ return Result.success(article);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 精选文章
|
|
|
+ */
|
|
|
+ @PostMapping("/featured")
|
|
|
+ public Result<List<Article>> featured(@RequestBody Map<String, Object> body,
|
|
|
+ @RequestAttribute(value = "userId", required = false) Long userId) {
|
|
|
+ int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 5;
|
|
|
+
|
|
|
+ Long familyId = null;
|
|
|
+ String vendorType = null;
|
|
|
+ if (userId != null) {
|
|
|
+ User user = userMapper.selectById(userId);
|
|
|
+ if (user != null) {
|
|
|
+ familyId = user.getFamilyId();
|
|
|
+ vendorType = user.getVendorType();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Article> list = articleService.getFeatured(size, userId, familyId, vendorType);
|
|
|
+ enrichCategoryNames(list);
|
|
|
+ return Result.success(list);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 记录阅读行为
|
|
|
+ */
|
|
|
+ @PostMapping("/record-read")
|
|
|
+ public Result<String> recordRead(@RequestBody Map<String, Object> body,
|
|
|
+ @RequestAttribute(value = "userId", required = false) Long userId) {
|
|
|
+ Long articleId = Long.valueOf(body.get("id").toString());
|
|
|
+ int durationSeconds = body.get("durationSeconds") != null
|
|
|
+ ? Integer.parseInt(body.get("durationSeconds").toString()) : 0;
|
|
|
+ Long childId = body.get("childId") != null
|
|
|
+ ? Long.valueOf(body.get("childId").toString()) : null;
|
|
|
+
|
|
|
+ articleService.recordRead(articleId, userId, childId, durationSeconds);
|
|
|
+ return Result.success("ok");
|
|
|
+ }
|
|
|
+
|
|
|
+ private void enrichCategoryNames(List<Article> articles) {
|
|
|
+ if (articles == null || articles.isEmpty()) return;
|
|
|
+ // 批量查出分类名(简化:逐个查,数据量小)
|
|
|
+ // 实际生产可改为批量查询
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java
|
|
|
+git commit -m "feat: add public ArticleController with list/detail/featured/record-read"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: 后端管理端 API — AdminArticleController
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建管理端 Controller**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.admin;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.Article;
|
|
|
+import com.etotem.cfc.entity.ArticleCategory;
|
|
|
+import com.etotem.cfc.service.ArticleCategoryService;
|
|
|
+import com.etotem.cfc.service.ArticleService;
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.io.File;
|
|
|
+import java.io.IOException;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.UUID;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/admin/articles")
|
|
|
+public class AdminArticleController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleService articleService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleCategoryService articleCategoryService;
|
|
|
+
|
|
|
+ @PostMapping("/list")
|
|
|
+ public Result<Page<Article>> list(@RequestBody Map<String, Object> body) {
|
|
|
+ String status = (String) body.get("status");
|
|
|
+ Long categoryId = body.get("categoryId") != null ? Long.valueOf(body.get("categoryId").toString()) : null;
|
|
|
+ String keyword = (String) body.get("keyword");
|
|
|
+ int page = body.get("page") != null ? Integer.parseInt(body.get("page").toString()) : 1;
|
|
|
+ int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 20;
|
|
|
+ return Result.success(articleService.getAdminList(status, categoryId, keyword, page, size));
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/create")
|
|
|
+ public Result<String> create(@RequestBody Article article,
|
|
|
+ @RequestAttribute("userId") Long adminId) {
|
|
|
+ articleService.create(article, adminId);
|
|
|
+ return Result.success("创建成功");
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/update")
|
|
|
+ public Result<String> update(@RequestBody Article article) {
|
|
|
+ articleService.update(article);
|
|
|
+ return Result.success("更新成功");
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/delete")
|
|
|
+ public Result<String> delete(@RequestBody Map<String, Object> body) {
|
|
|
+ Long id = Long.valueOf(body.get("id").toString());
|
|
|
+ articleService.delete(id);
|
|
|
+ return Result.success("删除成功");
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/publish")
|
|
|
+ public Result<String> publish(@RequestBody Map<String, Object> body) {
|
|
|
+ Long id = Long.valueOf(body.get("id").toString());
|
|
|
+ String status = (String) body.get("status");
|
|
|
+ articleService.toggleStatus(id, status);
|
|
|
+ return Result.success("操作成功");
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/toggle-featured")
|
|
|
+ public Result<String> toggleFeatured(@RequestBody Map<String, Object> body) {
|
|
|
+ Long id = Long.valueOf(body.get("id").toString());
|
|
|
+ int isFeatured = Integer.parseInt(body.get("isFeatured").toString());
|
|
|
+ articleService.toggleFeatured(id, isFeatured);
|
|
|
+ return Result.success("操作成功");
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/upload/image")
|
|
|
+ public Result<String> uploadImage(@RequestParam("file") MultipartFile file) {
|
|
|
+ if (file.isEmpty()) {
|
|
|
+ return Result.error("文件为空");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ String uploadDir = System.getProperty("user.dir") + "/uploads/articles/";
|
|
|
+ File dir = new File(uploadDir);
|
|
|
+ if (!dir.exists()) dir.mkdirs();
|
|
|
+
|
|
|
+ String ext = file.getOriginalFilename();
|
|
|
+ ext = ext != null && ext.contains(".") ? ext.substring(ext.lastIndexOf(".")) : ".jpg";
|
|
|
+ String filename = UUID.randomUUID().toString() + ext;
|
|
|
+ File dest = new File(uploadDir + filename);
|
|
|
+ file.transferTo(dest);
|
|
|
+
|
|
|
+ String url = "/uploads/articles/" + filename;
|
|
|
+ return Result.success(url);
|
|
|
+ } catch (IOException e) {
|
|
|
+ return Result.error("上传失败: " + e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 分类管理 =====
|
|
|
+
|
|
|
+ @PostMapping("/categories/list")
|
|
|
+ public Result<List<ArticleCategory>> categoryList() {
|
|
|
+ return Result.success(articleCategoryService.listAll());
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/categories/create")
|
|
|
+ public Result<String> categoryCreate(@RequestBody ArticleCategory category) {
|
|
|
+ articleCategoryService.create(category);
|
|
|
+ return Result.success("创建成功");
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/categories/update")
|
|
|
+ public Result<String> categoryUpdate(@RequestBody ArticleCategory category) {
|
|
|
+ articleCategoryService.update(category);
|
|
|
+ return Result.success("更新成功");
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/categories/delete")
|
|
|
+ public Result<String> categoryDelete(@RequestBody Map<String, Object> body) {
|
|
|
+ Long id = Long.valueOf(body.get("id").toString());
|
|
|
+ articleCategoryService.delete(id);
|
|
|
+ return Result.success("删除成功");
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 确保 MediaController 有文件上传端点**
|
|
|
+
|
|
|
+如果已有 `MediaController`,在其中添加图片上传端点(或使用上面 `AdminArticleController` 内联的上传方法)。
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
|
|
|
+git commit -m "feat: add admin article CRUD controller with category management"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 6: 数据库初始化 — 建表和种子数据
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 DatabaseInitializer.initializeTables() 中添加建表语句**
|
|
|
+
|
|
|
+在 `initializeTables()` 方法中找到现有执行建表 SQL 的位置(类似 `jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ...")`),追加:
|
|
|
+
|
|
|
+```java
|
|
|
+// 文章分类表
|
|
|
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_categories (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
|
|
|
+ "name VARCHAR(50) NOT NULL COMMENT '分类名', " +
|
|
|
+ "icon VARCHAR(20) DEFAULT '' COMMENT '图标emoji', " +
|
|
|
+ "color VARCHAR(20) DEFAULT '#5B9BD5' COMMENT '标识色', " +
|
|
|
+ "sort_order INT DEFAULT 0 COMMENT '排序', " +
|
|
|
+ "status TINYINT DEFAULT 1 COMMENT '1启用/0禁用'" +
|
|
|
+") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章分类'");
|
|
|
+
|
|
|
+// 文章表
|
|
|
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS articles (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
|
|
|
+ "category_id BIGINT DEFAULT 0 COMMENT '所属分类ID', " +
|
|
|
+ "title VARCHAR(200) NOT NULL COMMENT '标题', " +
|
|
|
+ "summary VARCHAR(500) DEFAULT '' COMMENT '摘要', " +
|
|
|
+ "cover_image VARCHAR(500) DEFAULT '' COMMENT '封面图URL', " +
|
|
|
+ "content LONGTEXT COMMENT '富文本内容', " +
|
|
|
+ "tags VARCHAR(200) DEFAULT '' COMMENT '标签JSON数组', " +
|
|
|
+ "author VARCHAR(100) DEFAULT '浠艾福' COMMENT '作者', " +
|
|
|
+ "read_time INT DEFAULT 0 COMMENT '预计阅读分钟数', " +
|
|
|
+ "related_dimensions VARCHAR(100) DEFAULT '' COMMENT '关联五维JSON数组', " +
|
|
|
+ "visibility VARCHAR(20) DEFAULT 'public' COMMENT '浏览权限:public/login/private', " +
|
|
|
+ "visible_to TEXT COMMENT '私密指定人群JSON', " +
|
|
|
+ "status VARCHAR(20) DEFAULT 'draft' COMMENT 'draft/published/archived', " +
|
|
|
+ "is_featured TINYINT DEFAULT 0 COMMENT '1精选/0普通', " +
|
|
|
+ "published_at DATETIME DEFAULT NULL COMMENT '发布时间', " +
|
|
|
+ "view_count INT DEFAULT 0 COMMENT '浏览次数', " +
|
|
|
+ "created_by BIGINT DEFAULT 0 COMMENT '发布人adminID', " +
|
|
|
+ "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
|
|
|
+ "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
|
|
|
+") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章'");
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 initializeDefaultData() 中添加种子分类数据**
|
|
|
+
|
|
|
+```java
|
|
|
+// 文章分类种子数据
|
|
|
+jdbcTemplate.execute("INSERT IGNORE INTO article_categories (id, name, icon, color, sort_order, status) VALUES " +
|
|
|
+ "(1, '心理健康', '🧠', '#5B9BD5', 1, 1), " +
|
|
|
+ "(2, '情绪管理', '💖', '#FF6B35', 2, 1), " +
|
|
|
+ "(3, '亲子教育', '👨👩👧👦', '#4CAF50', 3, 1), " +
|
|
|
+ "(4, '学习力', '📚', '#FFD700', 4, 1), " +
|
|
|
+ "(5, '专注力', '🎯', '#8D6E63', 5, 1)");
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
|
|
|
+git commit -m "feat: add article_categories and articles table DDL + seed data"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 7: Web管理端 — 分类管理页面
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-web/src/views/admin/ArticleCategory.vue`
|
|
|
+- Modify: `cfc-web/src/router/index.js`
|
|
|
+- Modify: `cfc-web/src/api/index.js`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建分类管理页面**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="article-category">
|
|
|
+ <h2>文章分类管理</h2>
|
|
|
+ <el-button type="primary" size="small" @click="showCreate">新增分类</el-button>
|
|
|
+ <el-table :data="categories" style="margin-top:16px" stripe>
|
|
|
+ <el-table-column prop="id" label="ID" width="80" />
|
|
|
+ <el-table-column label="图标" width="80">
|
|
|
+ <template slot-scope="s">{{ s.row.icon }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="name" label="名称" />
|
|
|
+ <el-table-column label="颜色">
|
|
|
+ <template slot-scope="s">
|
|
|
+ <span :style="{ color: s.row.color }">{{ s.row.color }}</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="sortOrder" label="排序" width="80" />
|
|
|
+ <el-table-column prop="status" label="状态" width="80">
|
|
|
+ <template slot-scope="s">
|
|
|
+ <el-tag :type="s.row.status === 1 ? 'success' : 'info'">
|
|
|
+ {{ s.row.status === 1 ? '启用' : '禁用' }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作" width="200">
|
|
|
+ <template slot-scope="s">
|
|
|
+ <el-button size="mini" @click="showEdit(s.row)">编辑</el-button>
|
|
|
+ <el-button size="mini" type="danger" @click="handleDelete(s.row.id)">删除</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+
|
|
|
+ <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="400px">
|
|
|
+ <el-form :model="form" label-width="80px">
|
|
|
+ <el-form-item label="名称">
|
|
|
+ <el-input v-model="form.name" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="图标">
|
|
|
+ <el-input v-model="form.icon" placeholder="🧠" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="颜色">
|
|
|
+ <el-color-picker v-model="form.color" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="排序">
|
|
|
+ <el-input-number v-model="form.sortOrder" :min="0" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="状态">
|
|
|
+ <el-switch v-model="form.statusBool" active-text="启用" inactive-text="禁用" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <span slot="footer">
|
|
|
+ <el-button @click="dialogVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="handleSave">保存</el-button>
|
|
|
+ </span>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { adminArticleCategoriesList, adminArticleCategoryCreate, adminArticleCategoryUpdate, adminArticleCategoryDelete } from '@/api/index.js'
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ categories: [],
|
|
|
+ dialogVisible: false,
|
|
|
+ dialogTitle: '',
|
|
|
+ editingId: null,
|
|
|
+ form: { name: '', icon: '', color: '#5B9BD5', sortOrder: 0, statusBool: true }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ created() { this.load() },
|
|
|
+ methods: {
|
|
|
+ async load() {
|
|
|
+ const res = await adminArticleCategoriesList()
|
|
|
+ if (res.code === 200) this.categories = res.data || []
|
|
|
+ },
|
|
|
+ showCreate() {
|
|
|
+ this.editingId = null
|
|
|
+ this.dialogTitle = '新增分类'
|
|
|
+ this.form = { name: '', icon: '', color: '#5B9BD5', sortOrder: 0, statusBool: true }
|
|
|
+ this.dialogVisible = true
|
|
|
+ },
|
|
|
+ showEdit(row) {
|
|
|
+ this.editingId = row.id
|
|
|
+ this.dialogTitle = '编辑分类'
|
|
|
+ this.form = {
|
|
|
+ name: row.name,
|
|
|
+ icon: row.icon || '',
|
|
|
+ color: row.color || '#5B9BD5',
|
|
|
+ sortOrder: row.sortOrder || 0,
|
|
|
+ statusBool: row.status === 1
|
|
|
+ }
|
|
|
+ this.dialogVisible = true
|
|
|
+ },
|
|
|
+ async handleSave() {
|
|
|
+ const data = {
|
|
|
+ name: this.form.name,
|
|
|
+ icon: this.form.icon,
|
|
|
+ color: this.form.color,
|
|
|
+ sortOrder: this.form.sortOrder,
|
|
|
+ status: this.form.statusBool ? 1 : 0
|
|
|
+ }
|
|
|
+ if (this.editingId) {
|
|
|
+ data.id = this.editingId
|
|
|
+ await adminArticleCategoryUpdate(data)
|
|
|
+ } else {
|
|
|
+ await adminArticleCategoryCreate(data)
|
|
|
+ }
|
|
|
+ this.dialogVisible = false
|
|
|
+ this.load()
|
|
|
+ },
|
|
|
+ async handleDelete(id) {
|
|
|
+ await this.$confirm('确定删除?', '提示', { type: 'warning' })
|
|
|
+ await adminArticleCategoryDelete({ id })
|
|
|
+ this.load()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 router/index.js 中注册分类路由**
|
|
|
+
|
|
|
+找到 adminRoutes 数组(约第301行),在 `'SysConfig'` 前或附近添加行,并在 routes children 中添加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// router/index.js — routes children 中添加:
|
|
|
+{
|
|
|
+ path: 'article-categories',
|
|
|
+ name: 'ArticleCategories',
|
|
|
+ component: () => import('@/views/admin/ArticleCategory.vue'),
|
|
|
+ meta: { title: '文章分类' }
|
|
|
+}
|
|
|
+
|
|
|
+// 同时确保该路由名在 adminRoutes 数组中(约第301行):
|
|
|
+const adminRoutes = [
|
|
|
+ 'Families', 'Children', 'Points', 'Tasks', 'TaskTemplates', 'Rewards',
|
|
|
+ 'Users', 'PackageAudit', 'GuideAudit', 'OperationLogs',
|
|
|
+ 'ServiceTypes', 'ServiceContents', 'PackageTemplates',
|
|
|
+ 'AssessmentAdmin', 'VendorReview',
|
|
|
+ 'ProductManage', 'OrderManage', 'SysConfig', 'EnergySandbox',
|
|
|
+ 'ArticleCategories', 'ArticleManage', 'ArticleEdit' // 新增
|
|
|
+]
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 在 api/index.js 中添加 API 方法**
|
|
|
+
|
|
|
+```javascript
|
|
|
+// ===== 文章管理 =====
|
|
|
+export const adminArticleList = (data) => request('/api/admin/articles/list', 'POST', data)
|
|
|
+export const adminArticleCreate = (data) => request('/api/admin/articles/create', 'POST', data)
|
|
|
+export const adminArticleUpdate = (data) => request('/api/admin/articles/update', 'POST', data)
|
|
|
+export const adminArticleDelete = (data) => request('/api/admin/articles/delete', 'POST', data)
|
|
|
+export const adminArticlePublish = (data) => request('/api/admin/articles/publish', 'POST', data)
|
|
|
+export const adminArticleToggleFeatured = (data) => request('/api/admin/articles/toggle-featured', 'POST', data)
|
|
|
+export const adminArticleUploadImage = (file) => {
|
|
|
+ const formData = new FormData()
|
|
|
+ formData.append('file', file)
|
|
|
+ return request('/api/admin/articles/upload/image', 'POST', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
|
|
|
+}
|
|
|
+export const adminArticleCategoriesList = () => request('/api/admin/articles/categories/list', 'POST')
|
|
|
+export const adminArticleCategoryCreate = (data) => request('/api/admin/articles/categories/create', 'POST', data)
|
|
|
+export const adminArticleCategoryUpdate = (data) => request('/api/admin/articles/categories/update', 'POST', data)
|
|
|
+export const adminArticleCategoryDelete = (data) => request('/api/admin/articles/categories/delete', 'POST', data)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-web/src/views/admin/ArticleCategory.vue cfc-web/src/router/index.js cfc-web/src/api/index.js
|
|
|
+git commit -m "feat: add article category management page (web admin)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 8: Web管理端 — 文章管理列表页
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-web/src/views/admin/ArticleManage.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建文章管理列表**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="article-manage">
|
|
|
+ <div class="header">
|
|
|
+ <h2>文章管理</h2>
|
|
|
+ <el-button type="primary" @click="$router.push('/article-edit')">新建文章</el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-form :inline="true" size="small" style="margin:16px 0">
|
|
|
+ <el-form-item label="状态">
|
|
|
+ <el-select v-model="filters.status" clearable placeholder="全部" @change="search">
|
|
|
+ <el-option label="草稿" value="draft" />
|
|
|
+ <el-option label="已发布" value="published" />
|
|
|
+ <el-option label="已归档" value="archived" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="分类">
|
|
|
+ <el-select v-model="filters.categoryId" clearable placeholder="全部" @change="search">
|
|
|
+ <el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="关键词">
|
|
|
+ <el-input v-model="filters.keyword" placeholder="搜索标题" @keyup.enter="search" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item>
|
|
|
+ <el-button type="primary" @click="search">查询</el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+
|
|
|
+ <el-table :data="list" stripe v-loading="loading">
|
|
|
+ <el-table-column prop="id" label="ID" width="60" />
|
|
|
+ <el-table-column label="标题" min-width="200">
|
|
|
+ <template slot-scope="s">
|
|
|
+ <span class="title-text">{{ s.row.title }}</span>
|
|
|
+ <el-tag v-if="s.row.isFeatured === 1" size="mini" type="warning" style="margin-left:6px">精选</el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="分类" width="100">
|
|
|
+ <template slot-scope="s">{{ getCategoryName(s.row.categoryId) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="权限" width="80">
|
|
|
+ <template slot-scope="s">
|
|
|
+ <el-tag size="mini" :type="s.row.visibility === 'public' ? 'success' : (s.row.visibility === 'login' ? 'warning' : 'danger')">
|
|
|
+ {{ s.row.visibility === 'public' ? '公开' : (s.row.visibility === 'login' ? '登录' : '私密') }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="author" label="作者" width="100" />
|
|
|
+ <el-table-column label="状态" width="80">
|
|
|
+ <template slot-scope="s">
|
|
|
+ <el-tag :type="s.row.status === 'published' ? 'success' : (s.row.status === 'draft' ? 'info' : '')" size="mini">
|
|
|
+ {{ s.row.status === 'published' ? '已发布' : (s.row.status === 'draft' ? '草稿' : '已归档') }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="viewCount" label="阅读" width="60" />
|
|
|
+ <el-table-column prop="publishedAt" label="发布时间" width="160" />
|
|
|
+ <el-table-column label="操作" width="280" fixed="right">
|
|
|
+ <template slot-scope="s">
|
|
|
+ <el-button size="mini" @click="$router.push('/article-edit?id=' + s.row.id)">编辑</el-button>
|
|
|
+ <el-button size="mini" :type="s.row.status === 'published' ? 'warning' : 'success'"
|
|
|
+ @click="togglePublish(s.row)">
|
|
|
+ {{ s.row.status === 'published' ? '下架' : '发布' }}
|
|
|
+ </el-button>
|
|
|
+ <el-button size="mini" :type="s.row.isFeatured === 1 ? 'warning' : ''"
|
|
|
+ @click="toggleFeatured(s.row)">
|
|
|
+ {{ s.row.isFeatured === 1 ? '取消精选' : '设为精选' }}
|
|
|
+ </el-button>
|
|
|
+ <el-button size="mini" type="danger" @click="handleDelete(s.row.id)">删除</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+
|
|
|
+ <el-pagination
|
|
|
+ @current-change="onPageChange"
|
|
|
+ :current-page="page"
|
|
|
+ :page-size="size"
|
|
|
+ :total="total"
|
|
|
+ layout="total, prev, pager, next"
|
|
|
+ style="margin-top:16px;text-align:right" />
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { adminArticleList, adminArticlePublish, adminArticleToggleFeatured, adminArticleDelete, adminArticleCategoriesList } from '@/api/index.js'
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ list: [],
|
|
|
+ categories: [],
|
|
|
+ filters: { status: '', categoryId: null, keyword: '' },
|
|
|
+ page: 1,
|
|
|
+ size: 20,
|
|
|
+ total: 0,
|
|
|
+ loading: false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ created() {
|
|
|
+ this.loadCategories()
|
|
|
+ this.search()
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ getCategoryName(id) {
|
|
|
+ const c = this.categories.find(x => x.id === id)
|
|
|
+ return c ? c.name : '-'
|
|
|
+ },
|
|
|
+ async loadCategories() {
|
|
|
+ const res = await adminArticleCategoriesList()
|
|
|
+ if (res.code === 200) this.categories = res.data || []
|
|
|
+ },
|
|
|
+ async search() {
|
|
|
+ this.page = 1
|
|
|
+ await this.load()
|
|
|
+ },
|
|
|
+ async load() {
|
|
|
+ this.loading = true
|
|
|
+ const res = await adminArticleList({ ...this.filters, page: this.page, size: this.size })
|
|
|
+ this.loading = false
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ this.list = res.data.records || []
|
|
|
+ this.total = res.data.total || 0
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onPageChange(p) { this.page = p; this.load() },
|
|
|
+ async togglePublish(row) {
|
|
|
+ const status = row.status === 'published' ? 'draft' : 'published'
|
|
|
+ await adminArticlePublish({ id: row.id, status })
|
|
|
+ this.load()
|
|
|
+ },
|
|
|
+ async toggleFeatured(row) {
|
|
|
+ await adminArticleToggleFeatured({ id: row.id, isFeatured: row.isFeatured === 1 ? 0 : 1 })
|
|
|
+ this.load()
|
|
|
+ },
|
|
|
+ async handleDelete(id) {
|
|
|
+ await this.$confirm('确定删除该文章?', '提示', { type: 'warning' })
|
|
|
+ await adminArticleDelete({ id })
|
|
|
+ this.load()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.header { display: flex; justify-content: space-between; align-items: center; }
|
|
|
+.title-text { font-weight: 500; }
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 router/index.js 注册文章管理路由**
|
|
|
+
|
|
|
+```javascript
|
|
|
+// routes children 中添加:
|
|
|
+{
|
|
|
+ path: 'article-manage',
|
|
|
+ name: 'ArticleManage',
|
|
|
+ component: () => import('@/views/admin/ArticleManage.vue'),
|
|
|
+ meta: { title: '文章管理' }
|
|
|
+},
|
|
|
+{
|
|
|
+ path: 'article-edit',
|
|
|
+ name: 'ArticleEdit',
|
|
|
+ component: () => import('@/views/admin/ArticleEdit.vue'),
|
|
|
+ meta: { title: '编辑文章' }
|
|
|
+},
|
|
|
+// article-edit 也支持带 id 参数:/article-edit?id=xxx
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-web/src/views/admin/ArticleManage.vue cfc-web/src/router/index.js
|
|
|
+git commit -m "feat: add article management list page (web admin)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 9: Web管理端 — 文章编辑器页面
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-web/src/views/admin/ArticleEdit.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建文章编辑器**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="article-edit">
|
|
|
+ <h2>{{ isEdit ? '编辑文章' : '新建文章' }}</h2>
|
|
|
+
|
|
|
+ <el-form :model="form" label-width="120px" style="max-width:900px;margin-top:16px">
|
|
|
+ <el-form-item label="标题">
|
|
|
+ <el-input v-model="form.title" placeholder="请输入文章标题" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="分类">
|
|
|
+ <el-select v-model="form.categoryId" placeholder="选择分类">
|
|
|
+ <el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="摘要">
|
|
|
+ <el-input v-model="form.summary" type="textarea" :rows="3" placeholder="文章摘要" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="封面图">
|
|
|
+ <el-upload
|
|
|
+ :action="uploadUrl"
|
|
|
+ :on-success="onUploadSuccess"
|
|
|
+ :on-error="() => $message.error('上传失败')"
|
|
|
+ :show-file-list="false"
|
|
|
+ accept="image/*">
|
|
|
+ <img v-if="form.coverImage" :src="form.coverImage" style="max-width:300px;max-height:180px" />
|
|
|
+ <el-button v-else size="small">点击上传</el-button>
|
|
|
+ </el-upload>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="富文本内容">
|
|
|
+ <!-- 使用 textarea 作为简易编辑器,后续可替换为 vue-quill-editor 或 tinymce -->
|
|
|
+ <el-input v-model="form.content" type="textarea" :rows="15" placeholder="HTML内容" />
|
|
|
+ <div style="color:#999;font-size:12px;margin-top:4px">
|
|
|
+ ⚠ 当前使用纯文本编辑,后续接入富文本编辑器
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="标签">
|
|
|
+ <el-input v-model="form.tags" placeholder="逗号分隔,如: 育儿,亲子,心理" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="作者">
|
|
|
+ <el-input v-model="form.author" placeholder="浠艾福" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="阅读时间(分)">
|
|
|
+ <el-input-number v-model="form.readTime" :min="0" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="关联五维">
|
|
|
+ <el-checkbox-group v-model="selectedDimensions">
|
|
|
+ <el-checkbox label="wisdom">智·金</el-checkbox>
|
|
|
+ <el-checkbox label="wealth">富·水</el-checkbox>
|
|
|
+ <el-checkbox label="action">行·木</el-checkbox>
|
|
|
+ <el-checkbox label="mind">心·火</el-checkbox>
|
|
|
+ <el-checkbox label="body">身·土</el-checkbox>
|
|
|
+ </el-checkbox-group>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="浏览权限">
|
|
|
+ <el-radio-group v-model="form.visibility">
|
|
|
+ <el-radio label="public">公开(未登录可见)</el-radio>
|
|
|
+ <el-radio label="login">登录可见</el-radio>
|
|
|
+ <el-radio label="private">私密(指定人群)</el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+ <div v-if="form.visibility === 'private'" style="margin-top:12px;padding:12px;background:#f9f9f9;border-radius:4px">
|
|
|
+ <div style="margin-bottom:8px">
|
|
|
+ <el-tag v-for="(r, i) in visibleToRules" :key="i" closable @close="removeRule(i)"
|
|
|
+ style="margin-right:8px;margin-bottom:4px">
|
|
|
+ {{ ruleLabel(r) }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ <el-select v-model="newRuleType" placeholder="添加指定人群" style="width:160px;margin-right:8px">
|
|
|
+ <el-option label="指定家庭" value="family" />
|
|
|
+ <el-option label="指定供应商类型" value="vendor_type" />
|
|
|
+ </el-select>
|
|
|
+ <template v-if="newRuleType === 'family'">
|
|
|
+ <el-input v-model="newRuleValue" placeholder="家庭ID" style="width:120px;margin-right:8px" />
|
|
|
+ </template>
|
|
|
+ <template v-if="newRuleType === 'vendor_type'">
|
|
|
+ <el-select v-model="newRuleValue" placeholder="选择类型" style="width:160px;margin-right:8px">
|
|
|
+ <el-option label="成长规划师" value="planner" />
|
|
|
+ <el-option label="活动方" value="activity_provider" />
|
|
|
+ <el-option label="商品供应商" value="product_supplier" />
|
|
|
+ </el-select>
|
|
|
+ </template>
|
|
|
+ <el-button size="mini" @click="addRule">添加</el-button>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="状态">
|
|
|
+ <el-radio-group v-model="form.status">
|
|
|
+ <el-radio label="draft">草稿</el-radio>
|
|
|
+ <el-radio label="published">发布</el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="设为精选">
|
|
|
+ <el-switch v-model="form.isFeaturedBool" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item>
|
|
|
+ <el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
|
|
+ <el-button @click="$router.push('/article-manage')">取消</el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { adminArticleCreate, adminArticleUpdate, adminArticleCategoriesList } from '@/api/index.js'
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ isEdit: false,
|
|
|
+ form: {
|
|
|
+ title: '', categoryId: null, summary: '', coverImage: '',
|
|
|
+ content: '', tags: '', author: '浠艾福', readTime: 3,
|
|
|
+ relatedDimensions: '', visibility: 'public', visibleTo: '',
|
|
|
+ status: 'draft', isFeaturedBool: false
|
|
|
+ },
|
|
|
+ selectedDimensions: [],
|
|
|
+ categories: [],
|
|
|
+ saving: false,
|
|
|
+ uploadUrl: process.env.VUE_APP_BASE_API + '/api/admin/articles/upload/image',
|
|
|
+ visibleToRules: [],
|
|
|
+ newRuleType: 'family',
|
|
|
+ newRuleValue: ''
|
|
|
+ }
|
|
|
+ },
|
|
|
+ created() {
|
|
|
+ this.loadCategories()
|
|
|
+ const id = this.$route.query.id
|
|
|
+ if (id) {
|
|
|
+ this.isEdit = true
|
|
|
+ this.loadArticle(id)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ ruleLabel(r) {
|
|
|
+ if (r.type === 'family') return '家庭#' + r.familyId
|
|
|
+ const typeMap = { planner: '规划师', activity_provider: '活动方', product_supplier: '供应商' }
|
|
|
+ if (r.type === 'vendor_type') return typeMap[r.vendorType] || r.vendorType
|
|
|
+ return r.type
|
|
|
+ },
|
|
|
+ addRule() {
|
|
|
+ if (!this.newRuleValue) return
|
|
|
+ if (this.newRuleType === 'family') {
|
|
|
+ this.visibleToRules.push({ type: 'family', familyId: parseInt(this.newRuleValue) })
|
|
|
+ } else if (this.newRuleType === 'vendor_type') {
|
|
|
+ this.visibleToRules.push({ type: 'vendor_type', vendorType: this.newRuleValue })
|
|
|
+ }
|
|
|
+ this.newRuleValue = ''
|
|
|
+ },
|
|
|
+ removeRule(i) { this.visibleToRules.splice(i, 1) },
|
|
|
+ async loadCategories() {
|
|
|
+ const res = await adminArticleCategoriesList()
|
|
|
+ if (res.code === 200) this.categories = res.data || []
|
|
|
+ },
|
|
|
+ async loadArticle(id) {
|
|
|
+ const { adminArticleList } = await import('@/api/index.js')
|
|
|
+ const res = await adminArticleList({ page: 1, size: 1, status: '' })
|
|
|
+ // 实际应按ID查详情,这里简化:从列表找(后续可加detail接口)
|
|
|
+ },
|
|
|
+ async handleSave() {
|
|
|
+ this.saving = true
|
|
|
+ const data = {
|
|
|
+ ...this.form,
|
|
|
+ relatedDimensions: JSON.stringify(this.selectedDimensions),
|
|
|
+ isFeatured: this.form.isFeaturedBool ? 1 : 0,
|
|
|
+ visibleTo: this.form.visibility === 'private' ? JSON.stringify(this.visibleToRules) : ''
|
|
|
+ }
|
|
|
+ delete data.isFeaturedBool
|
|
|
+ try {
|
|
|
+ if (this.isEdit) {
|
|
|
+ await adminArticleUpdate(data)
|
|
|
+ this.$message.success('保存成功')
|
|
|
+ } else {
|
|
|
+ await adminArticleCreate(data)
|
|
|
+ this.$message.success('创建成功')
|
|
|
+ this.$router.push('/article-manage')
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ this.$message.error('操作失败')
|
|
|
+ }
|
|
|
+ this.saving = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-web/src/views/admin/ArticleEdit.vue
|
|
|
+git commit -m "feat: add article editor page (web admin) with permission configuration"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 10: 小程序前端 — 文章详情页
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-frontend/pages/mind/article-detail.vue`
|
|
|
+- Modify: `cfc-frontend/pages.json`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建文章详情页**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="detail-container">
|
|
|
+ <view v-if="loading" class="loading-wrap">
|
|
|
+ <text class="loading-text">加载中...</text>
|
|
|
+ </view>
|
|
|
+ <scroll-view v-else-if="article" scroll-y class="detail-scroll">
|
|
|
+ <!-- 封面图 -->
|
|
|
+ <image v-if="article.coverImage" class="detail-cover" :src="article.coverImage" mode="aspectFill" />
|
|
|
+ <view v-else class="detail-cover-placeholder">
|
|
|
+ <text class="placeholder-icon">📖</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 标题区域 -->
|
|
|
+ <view class="detail-header">
|
|
|
+ <text class="detail-title">{{ article.title }}</text>
|
|
|
+ <view class="detail-meta">
|
|
|
+ <text class="meta-author">{{ article.author || '浠艾福' }}</text>
|
|
|
+ <text class="meta-sep">·</text>
|
|
|
+ <text class="meta-date">{{ article.publishedAt ? article.publishedAt.substring(0,10) : '' }}</text>
|
|
|
+ <text class="meta-sep">·</text>
|
|
|
+ <text class="meta-readtime">{{ article.readTime || 0 }}分钟阅读</text>
|
|
|
+ </view>
|
|
|
+ <!-- 分类标签 -->
|
|
|
+ <view class="detail-tags">
|
|
|
+ <text class="tag-category">{{ categoryName }}</text>
|
|
|
+ <text v-for="dim in dimensionLabels" :key="dim" class="tag-dimension">{{ dim }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 文章内容(富文本) -->
|
|
|
+ <view class="detail-content">
|
|
|
+ <rich-text :nodes="article.content" />
|
|
|
+ </view>
|
|
|
+ </scroll-view>
|
|
|
+ <view v-else class="error-wrap">
|
|
|
+ <text class="error-text">文章不存在或无权访问</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { articleDetail, articleRecordRead } from '@/utils/api.js'
|
|
|
+
|
|
|
+const DIM_LABELS = { wisdom: '智·金', wealth: '富·水', action: '行·木', mind: '心·火', body: '身·土' }
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ article: null,
|
|
|
+ loading: true,
|
|
|
+ categoryName: '',
|
|
|
+ dimensionLabels: [],
|
|
|
+ _startTime: null
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLoad(options) {
|
|
|
+ this._startTime = Date.now()
|
|
|
+ if (options.id) this.loadArticle(options.id)
|
|
|
+ else { this.loading = false }
|
|
|
+ },
|
|
|
+ onUnload() {
|
|
|
+ // 记录阅读时长
|
|
|
+ if (this.article && this._startTime) {
|
|
|
+ const duration = Math.floor((Date.now() - this._startTime) / 1000)
|
|
|
+ articleRecordRead({ id: this.article.id, durationSeconds: duration })
|
|
|
+ }
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ async loadArticle(id) {
|
|
|
+ this.loading = true
|
|
|
+ try {
|
|
|
+ const res = await articleDetail({ id })
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ this.article = res.data
|
|
|
+ // 解析关联维度标签
|
|
|
+ if (res.data.relatedDimensions) {
|
|
|
+ try {
|
|
|
+ const dims = JSON.parse(res.data.relatedDimensions)
|
|
|
+ this.dimensionLabels = dims.map(d => DIM_LABELS[d]).filter(Boolean)
|
|
|
+ } catch (e) { /* ignore */ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ this.article = null
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ this.article = null
|
|
|
+ }
|
|
|
+ this.loading = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.detail-container { min-height: 100vh; background: #fff; }
|
|
|
+.loading-wrap, .error-wrap { display: flex; justify-content: center; padding-top: 200rpx; }
|
|
|
+.loading-text, .error-text { font-size: 28rpx; color: #999; }
|
|
|
+.detail-scroll { height: 100vh; }
|
|
|
+.detail-cover { width: 100%; height: 400rpx; }
|
|
|
+.detail-cover-placeholder {
|
|
|
+ width: 100%; height: 300rpx;
|
|
|
+ background: linear-gradient(135deg, #EBF2FA, #f5f9fc);
|
|
|
+ display: flex; align-items: center; justify-content: center;
|
|
|
+}
|
|
|
+.placeholder-icon { font-size: 100rpx; }
|
|
|
+.detail-header { padding: 30rpx; }
|
|
|
+.detail-title { font-size: 36rpx; font-weight: bold; color: #333; line-height: 1.4; }
|
|
|
+.detail-meta { display: flex; align-items: center; margin-top: 16rpx; }
|
|
|
+.meta-author, .meta-date, .meta-readtime { font-size: 22rpx; color: #999; }
|
|
|
+.meta-sep { margin: 0 8rpx; color: #ddd; }
|
|
|
+.detail-tags { display: flex; flex-wrap: wrap; margin-top: 12rpx; gap: 8rpx; }
|
|
|
+.tag-category {
|
|
|
+ font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.1);
|
|
|
+ padding: 4rpx 14rpx; border-radius: 8rpx;
|
|
|
+}
|
|
|
+.tag-dimension {
|
|
|
+ font-size: 20rpx; color: #F97316; background: rgba(249,115,22,0.1);
|
|
|
+ padding: 4rpx 14rpx; border-radius: 8rpx;
|
|
|
+}
|
|
|
+.detail-content {
|
|
|
+ padding: 0 30rpx 60rpx;
|
|
|
+ font-size: 28rpx;
|
|
|
+ color: #333;
|
|
|
+ line-height: 1.8;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 pages.json 中注册详情页**
|
|
|
+
|
|
|
+在 `pages.json` 的 pages 数组中(mind相关页面附近)添加:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "path": "pages/mind/article-detail",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "文章详情",
|
|
|
+ "enablePullDownRefresh": false
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/mind/article-detail.vue cfc-frontend/pages.json
|
|
|
+git commit -m "feat: add article detail page (mini-program)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 11: 小程序前端 — 现有页面改为 API 数据源
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/pages/mind/articles.vue`
|
|
|
+- Modify: `cfc-frontend/pages/mind/index.vue`
|
|
|
+- Modify: `cfc-frontend/pages/discover/index.vue`
|
|
|
+- Modify: `cfc-frontend/utils/api.js`
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 api.js 中添加文章相关 API**
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 在 utils/api.js 中 content section 部分附近添加:
|
|
|
+
|
|
|
+// ===== 文章系统 =====
|
|
|
+export const articleList = (data) => request('/api/articles/list', 'POST', data)
|
|
|
+export const articleDetail = (data) => request('/api/articles/detail', 'POST', data)
|
|
|
+export const articleFeatured = (data) => request('/api/articles/featured', 'POST', data)
|
|
|
+export const articleCategories = () => request('/api/articles/categories', 'POST')
|
|
|
+export const articleRecordRead = (data) => request('/api/articles/record-read', 'POST', data)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 修改 `mind/articles.vue`**
|
|
|
+
|
|
|
+修改点:
|
|
|
+1. 移除硬编码的 `getMockArticles()` 方法
|
|
|
+2. `loadArticles()` 改为调 `api.articleList()`
|
|
|
+3. 分类数据改为调 `api.articleCategories()` 获取
|
|
|
+4. 保持现有 UI 不变
|
|
|
+
|
|
|
+```javascript
|
|
|
+// data() — 修改 categoryList 初始值
|
|
|
+categoryList: [], // 初始为空,onLoad 时从 API 获取
|
|
|
+
|
|
|
+// 新增方法:
|
|
|
+async loadCategories() {
|
|
|
+ const res = await articleCategories()
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ this.categoryList = [{ label: '全部', value: '' }]
|
|
|
+ .concat(res.data.map(c => ({ label: c.name, value: c.id })))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 修改 loadArticles():
|
|
|
+async loadArticles() {
|
|
|
+ if (this.loading) return
|
|
|
+ this.loading = true
|
|
|
+ try {
|
|
|
+ const params = { page: this.page, size: this.size }
|
|
|
+ if (this.currentCategory) params.categoryId = this.currentCategory
|
|
|
+ const res = await articleList(params)
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ const records = res.data.records || []
|
|
|
+ if (this.page === 1) {
|
|
|
+ this.articles = records
|
|
|
+ } else {
|
|
|
+ this.articles = this.articles.concat(records)
|
|
|
+ }
|
|
|
+ this.noMore = this.articles.length >= (res.data.total || 0)
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ // 错误处理
|
|
|
+ }
|
|
|
+ this.loading = false
|
|
|
+}
|
|
|
+
|
|
|
+// onLoad() 中调用:
|
|
|
+this.loadCategories()
|
|
|
+this.loadArticles()
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 修改 `mind/index.vue` 推荐阅读**
|
|
|
+
|
|
|
+找到推荐阅读区块,将硬编码数据改为:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// methods 中新增:
|
|
|
+async loadFeaturedArticles() {
|
|
|
+ const res = await articleFeatured({ size: 5 })
|
|
|
+ if (res.code === 200) {
|
|
|
+ this.articles = res.data || []
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// onShow/onLoad 中调用
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 修改 `discover/index.vue` 精选文章**
|
|
|
+
|
|
|
+将 article-section 的硬编码 3 篇文章改为调 API:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 导入 API
|
|
|
+import { articleFeatured } from '@/utils/api.js'
|
|
|
+
|
|
|
+// onShow 中:
|
|
|
+this.loadFeaturedArticles()
|
|
|
+
|
|
|
+// methods:
|
|
|
+async loadFeaturedArticles() {
|
|
|
+ const res = await articleFeatured({ size: 3 })
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ this.featuredArticles = res.data
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 模板中的文章卡片改为 v-for 遍历 featuredArticles
|
|
|
+// 点击跳转至 /pages/mind/article-detail?id=xxx
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/utils/api.js cfc-frontend/pages/mind/articles.vue cfc-frontend/pages/mind/index.vue cfc-frontend/pages/discover/index.vue
|
|
|
+git commit -m "refactor: replace hardcoded articles with API data source in mini-program pages"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 验证清单
|
|
|
+
|
|
|
+- [ ] **后端编译**:`mvn clean compile` 无错误
|
|
|
+- [ ] **LSP诊断**:所有新增/修改文件无错误
|
|
|
+- [ ] **API测试(公开)**:
|
|
|
+ - `POST /api/articles/categories` → 返回5个分类
|
|
|
+ - `POST /api/articles/list` → 返回空列表(无published文章)
|
|
|
+ - `POST /api/articles/featured` → 返回空列表
|
|
|
+ - `POST /api/articles/list` 未登录 → 只看到 public 文章
|
|
|
+ - `POST /api/articles/list` 已登录 → 看到 public + login 文章
|
|
|
+- [ ] **API测试(管理端)**:
|
|
|
+ - `POST /api/admin/articles/create` → 创建成功
|
|
|
+ - `POST /api/admin/articles/list` → 包含draft文章
|
|
|
+ - `POST /api/admin/articles/publish` → 状态切换
|
|
|
+- [ ] **小程序页面**:
|
|
|
+ - 发现页精选文章从API加载
|
|
|
+ - 文章列表页从API加载,分类筛选正常
|
|
|
+ - 点击文章进入详情页,rich-text渲染正常
|
|
|
+ - 离开详情页时记录阅读时长
|
|
|
+- [ ] **权限测试**:
|
|
|
+ - 未登录:看不到 login 和 private 文章
|
|
|
+ - 登录无家庭:看不到 private 文章
|
|
|
+ - 登录有家庭:能看到 private中含其familyId的文章
|
|
|
+ - 供应商:能看到 private中含其vendorType的文章
|