Просмотр исходного кода

feat(articles): 文章推荐排序——精选顺序可定义(sort_order + 上移/下移)

- 迁移294: articles 表加 sort_order 列
- getFeatured/getPublicList 改按 sort_order ASC 排序
- toggleFeatured 设为精选时追加队尾,不抢占既有顺序
- 新增 POST /api/admin/articles/move 上移/下移 + 全局重编号
- 管理端 ArticleManage 精选行显示上移/下移 + 序号徽章
iwt 2 месяцев назад
Родитель
Сommit
43a334215b

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

@@ -10341,5 +10341,9 @@ public class DatabaseInitializer implements CommandLineRunner {
         } catch (Exception e) {
             log.warn("迁移293: 回填家庭创建者为管理员失败: {}", e.getMessage());
         }
+
+        // 迁移294: articles 表加 sort_order 列(精选文章推荐排序,越小越靠前)
+        ensureColumn("articles", "sort_order", "INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)'");
+        log.info("已添加 articles.sort_order 列(迁移294)");
     }
 }

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -270,6 +270,28 @@ public class AdminArticleController {
         return Result.success("操作成功");
     }
 
+    @PostMapping("/move")
+    public Result<String> move(@RequestBody Map<String, Object> body,
+                                @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long id = ParamUtils.getLong(body.get("id"));
+        String direction = (String) body.get("direction");
+        if (id == null) {
+            return Result.error("缺少文章ID");
+        }
+        if (direction == null || (!"up".equals(direction) && !"down".equals(direction))) {
+            return Result.error("direction 只能是 up 或 down");
+        }
+        try {
+            articleService.moveSortOrder(id, direction);
+            return Result.success("已调整顺序");
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
     @PostMapping("/audit")
     public Result<String> audit(@RequestBody Map<String, Object> body,
                                  @RequestAttribute("userId") Long adminId) {

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java

@@ -47,6 +47,8 @@ public class Article implements Serializable {
     /** 审核时间 */
     private Date auditedAt;
     private Integer isFeatured;
+    /** 推荐排序值(越小越靠前) */
+    private Integer sortOrder;
     private Date publishedAt;
     private Integer viewCount;
     /** 总阅读时长(秒,瞬态字段,由 article_reading_records 聚合) */

+ 90 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -88,6 +88,7 @@ public class ArticleService {
         LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
                 .eq(Article::getStatus, "published")
                 .orderByDesc(Article::getIsFeatured)
+                .orderByAsc(Article::getSortOrder)
                 .orderByDesc(Article::getPublishedAt);
 
         if (categoryId != null && categoryId > 0) {
@@ -126,6 +127,7 @@ public class ArticleService {
         LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
                 .eq(Article::getStatus, "published")
                 .eq(Article::getIsFeatured, 1)
+                .orderByAsc(Article::getSortOrder)
                 .orderByDesc(Article::getPublishedAt)
                 .last("LIMIT " + Math.max(size, 50));
 
@@ -544,9 +546,97 @@ public class ArticleService {
         if (article == null) return;
         article.setIsFeatured(isFeatured);
         article.setUpdatedAt(new Date());
+        if (isFeatured == 1) {
+            // 设为精选时追加到队尾,不抢占既有顺序
+            Integer maxSort = getMaxSortOrder();
+            article.setSortOrder((maxSort == null ? 0 : maxSort) + 1);
+        }
         articleMapper.updateById(article);
     }
 
+    /**
+     * 查询已发布且精选的文章中最大的 sort_order;若没有精选文章返回 null。
+     */
+    private Integer getMaxSortOrder() {
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .eq(Article::getIsFeatured, 1)
+                .select(Article::getSortOrder)
+                .orderByDesc(Article::getSortOrder)
+                .last("LIMIT 1");
+        Article top = articleMapper.selectOne(wrapper);
+        return top != null ? top.getSortOrder() : null;
+    }
+
+    /**
+     * 上移/下移精选文章的推荐顺序。direction: 'up' 或 'down'。
+     */
+    @Transactional
+    public String moveSortOrder(Long id, String direction) {
+        Article target = articleMapper.selectById(id);
+        if (target == null) {
+            throw new RuntimeException("文章不存在");
+        }
+        if (!"published".equals(target.getStatus()) || !Integer.valueOf(1).equals(target.getIsFeatured())) {
+            throw new RuntimeException("仅已发布且精选的文章可调整顺序");
+        }
+        if (!"up".equals(direction) && !"down".equals(direction)) {
+            throw new RuntimeException("direction 只能是 up 或 down");
+        }
+
+        LambdaQueryWrapper<Article> listWrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .eq(Article::getIsFeatured, 1)
+                .orderByAsc(Article::getSortOrder)
+                .orderByAsc(Article::getPublishedAt);
+        List<Article> featured = articleMapper.selectList(listWrapper);
+
+        int idx = -1;
+        for (int i = 0; i < featured.size(); i++) {
+            if (featured.get(i).getId().equals(id)) {
+                idx = i;
+                break;
+            }
+        }
+        if (idx == -1) {
+            throw new RuntimeException("文章不存在");
+        }
+        if ("up".equals(direction) && idx <= 0) {
+            throw new RuntimeException("已在最前面");
+        }
+        if ("down".equals(direction) && idx >= featured.size() - 1) {
+            throw new RuntimeException("已在最后面");
+        }
+
+        int swapIdx = "up".equals(direction) ? idx - 1 : idx + 1;
+
+        Article a = featured.get(idx);
+        Article b = featured.get(swapIdx);
+        Integer tmpSort = a.getSortOrder();
+        a.setSortOrder(b.getSortOrder());
+        b.setSortOrder(tmpSort);
+        a.setUpdatedAt(new Date());
+        b.setUpdatedAt(new Date());
+        articleMapper.updateById(a);
+        articleMapper.updateById(b);
+
+        // 全局重编号 1..N,消除并列与空洞
+        LambdaQueryWrapper<Article> renumWrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .eq(Article::getIsFeatured, 1)
+                .orderByAsc(Article::getSortOrder)
+                .orderByAsc(Article::getPublishedAt);
+        List<Article> sorted = articleMapper.selectList(renumWrapper);
+        Date now = new Date();
+        for (int i = 0; i < sorted.size(); i++) {
+            sorted.get(i).setSortOrder(i + 1);
+            sorted.get(i).setUpdatedAt(now);
+            articleMapper.updateById(sorted.get(i));
+        }
+
+        return "已调整顺序";
+    }
+
     public ReadingStatsDTO getReadingStats(Long memberId) {
         ReadingStatsDTO dto = new ReadingStatsDTO();
 

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

@@ -1420,6 +1420,7 @@ CREATE TABLE IF NOT EXISTS articles (
     status VARCHAR(20) DEFAULT 'draft' COMMENT '状态:draft/published/archived',
     growth_category VARCHAR(50) DEFAULT NULL COMMENT '成长分类',
     is_featured TINYINT DEFAULT 0 COMMENT '是否精选:1 精选/0 普通',
+    sort_order INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)',
     published_at DATETIME DEFAULT NULL COMMENT '发布时间',
     view_count INT DEFAULT 0 COMMENT '浏览次数',
     created_by BIGINT DEFAULT 0 COMMENT '发布人 adminID',

+ 4 - 0
cfc-web/src/api/article.js

@@ -26,6 +26,10 @@ export function adminArticleToggleFeatured(data) {
   return request({ url: '/api/admin/articles/toggle-featured', method: 'post', data })
 }
 
+export function adminArticleMove(data) {
+  return request({ url: '/api/admin/articles/move', method: 'post', data })
+}
+
 export function adminArticleUploadImage(file) {
   const formData = new FormData()
   formData.append('file', file)

+ 28 - 2
cfc-web/src/views/admin/ArticleManage.vue

@@ -61,7 +61,7 @@
         <el-table-column label="标题" min-width="220">
           <template slot-scope="{ row }">
             <span>{{ row.title }}</span>
-            <el-tag v-if="row.isFeatured === 1 || row.isFeatured === true" size="mini" type="warning" style="margin-left: 6px;">精选</el-tag>
+            <el-tag v-if="row.isFeatured === 1 || row.isFeatured === true" size="mini" type="warning" style="margin-left: 6px;">精选{{ row.sortOrder && row.sortOrder > 0 ? ' ' + toChineseNum(row.sortOrder) : '' }}</el-tag>
           </template>
         </el-table-column>
           <el-table-column label="可见性" width="90">
@@ -186,6 +186,8 @@
                 <el-dropdown-menu slot="dropdown">
                   <el-dropdown-item command="withdraw" icon="el-icon-download">下架</el-dropdown-item>
                   <el-dropdown-item command="toggleFeatured" icon="el-icon-star-on">精选/取消精选</el-dropdown-item>
+                  <el-dropdown-item command="moveUp" icon="el-icon-top">上移</el-dropdown-item>
+                  <el-dropdown-item command="moveDown" icon="el-icon-bottom">下移</el-dropdown-item>
                   <el-dropdown-item command="copy" icon="el-icon-document-copy">复制</el-dropdown-item>
                 </el-dropdown-menu>
               </el-dropdown>
@@ -306,7 +308,8 @@ import {
   adminArticleSubmitReview,
   adminArticleWithdraw,
   adminArticleReDraft,
-  adminArticleReadingRecords
+  adminArticleReadingRecords,
+  adminArticleMove
 } from '@/api/article.js'
 
 import { getTagList } from '@/api/dimension.js'
@@ -473,6 +476,19 @@ export default {
         if (e !== 'cancel') this.$message.error(e.message || '操作失败')
       }
     },
+    async handleMove(row, direction) {
+      try {
+        await this.$confirm(
+          direction === 'up' ? '确认将《' + row.title + '》上移一位?' : '确认将《' + row.title + '》下移一位?',
+          '提示'
+        )
+        await adminArticleMove({ id: row.id, direction })
+        this.$message.success('已调整顺序')
+        this.loadList()
+      } catch (e) {
+        if (e !== 'cancel') this.$message.error(e.message || '调整顺序失败')
+      }
+    },
     handleDelete(row) {
       this.$confirm(`确定删除文章「${row.title}」吗?`, '提示', {
         confirmButtonText: '确定',
@@ -611,8 +627,18 @@ export default {
         case 'reDraft': this.handleReDraft(row); break;
         case 'withdraw': this.handleWithdraw(row); break;
         case 'toggleFeatured': this.handleToggleFeatured(row); break;
+        case 'moveUp': this.handleMove(row, 'up'); break;
+        case 'moveDown': this.handleMove(row, 'down'); break;
         case 'rePublish': this.handlePublishDirect(row); break;
       }
+    },
+    toChineseNum(n) {
+      var digits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']
+      if (n <= 10) return digits[n]
+      var tens = Math.floor(n / 10)
+      var ones = n % 10
+      if (tens === 1) return '十' + (ones === 0 ? '' : digits[ones])
+      return digits[tens] + '十' + (ones === 0 ? '' : digits[ones])
     }
   }
 }

+ 1 - 0
docs/superpowers/PROJECT-OVERVIEW.md

@@ -180,6 +180,7 @@
 | 文章发布系统(运营审核流程) | ✅ 已实施(AdminArticleController + ReviewCenterController + ArticleService.auditArticle) | `plans/2026-07-22-comprehensive-implementation.md` |
 | 文章与活动发布系统(合并版) | ✅ 已实施(同上) | `specs/2026-06-09-article-activity-publishing-design.md` |
 | 文章阅读追踪增强(阅读数显示+匿名/登录区分+后台阅读记录查询) | ✅ 已实施(detail 接口递增 view_count;匿名不显示时长;`POST /api/admin/articles/reading-records`;后台弹窗) | `specs/2026-07-28-article-reading-time-tracking-design.md` + `plans/2026-08-30-article-reading-tracking-enhancement.md` |
+| 文章推荐排序(精选顺序可定义) | ✅ 已实施(articles.sort_order 迁移294 + getFeatured/getPublicList 按 sort_order 排序 + toggleFeatured 追加队尾 + `POST /api/admin/articles/move` 上移/下移 + 管理端序号徽章) | `specs/2026-09-04-article-sort-order-design.md` + `plans/2026-09-04-article-sort-order.md` |
 | 公众号内容发布(LangGraph工作流) | 🔄 已废弃(由LangGraph + Python发布脚本替代) | — |
 
 ---

+ 1 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -590,6 +590,7 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | 路径前缀 | 说明 |
 |----------|------|
 | `/api/admin/articles/*` | 文章管理 |
+| `POST /api/admin/articles/move` | 调整精选文章的推荐顺序(`{ id, direction: 'up'|'down' }`,已边界时返回 400) |
 | `/api/admin/articles/reading-records` | 按文章查询阅读记录明细(分页,含孩子昵称/时长) |
 | `/api/admin/assessment/*` | 测评材料管理 |
 | `/api/admin/bazi-config/*` | 八字配置 |