浏览代码

docs: 文章推荐排序实现计划

iwt 2 周之前
父节点
当前提交
282ff26aaf
共有 1 个文件被更改,包括 609 次插入0 次删除
  1. 609 0
      docs/superpowers/plans/2026-09-04-article-sort-order.md

+ 609 - 0
docs/superpowers/plans/2026-09-04-article-sort-order.md

@@ -0,0 +1,609 @@
+# 文章推荐排序(精选顺序可定义)实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 让运营在管理端可定义精选文章在首页/知识中心的展示顺序,提供上移/下移操作,无需前端额外开发。
+
+**架构:** 在 `articles` 表新增 `sort_order` 列(整数,越小越靠前);`getFeatured()` 和 `getPublicList()` 查询改用 `sort_order ASC`;管理端新增 `/api/admin/articles/move` 接口支持相邻交换 + 全局重编号;管理端列表对精选文章显示上移/下移按钮和序号徽章。
+
+**技术栈:** Spring Boot 2.7.18 + MyBatis-Plus、Vue 2 + Element UI、MySQL
+
+---
+
+## 文件清单
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `cfc-backend/.../entity/Article.java` | 修改 | 加 `sortOrder` 字段 |
+| `cfc-backend/.../service/ArticleService.java` | 修改 | 改排序 + 改 toggleFeatured + 新增 moveSortOrder / getMaxSortOrder |
+| `cfc-backend/.../controller/admin/AdminArticleController.java` | 修改 | 新增 POST /move 接口 |
+| `cfc-backend/.../config/DatabaseInitializer.java` | 修改 | 迁移293 加 sort_order 列 |
+| `cfc-backend/src/main/resources/schema.sql` | 修改 | articles CREATE TABLE 加 sort_order 列 |
+| `cfc-web/src/api/article.js` | 修改 | 新增 adminArticleMove |
+| `cfc-web/src/views/admin/ArticleManage.vue` | 修改 | 精选行显示上移/下移 + 序号徽章 |
+| `docs/superpowers/api/API_REFERENCE.md` | 修改 | 新增 /move 接口文档 |
+
+---
+
+## 任务 1:数据库迁移 — 新增 sort_order 列
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+- 修改:`cfc-backend/src/main/resources/schema.sql`
+
+- [ ] **步骤 1:在 DatabaseInitializer.java 末尾(迁移292 之后)追加迁移293**
+
+打开 `DatabaseInitializer.java`,在:
+```
+        // 迁移292: health_status 补 member_id 并改为成员级归档
+```
+块结束后(约第 10330 行,`}` 后)追加:
+
+```java
+        // 迁移293: articles 表加 sort_order 列(精选文章推荐排序,越小越靠前)
+        ensureColumn("articles", "sort_order", "INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)'");
+        log.info("已添加 articles.sort_order 列(迁移293)");
+```
+
+- [ ] **步骤 2:更新 schema.sql 的 articles 建表语句**
+
+打开 `cfc-backend/src/main/resources/schema.sql`,找到 `CREATE TABLE IF NOT EXISTS articles (`(约第 1395 行),在 `is_featured` 列之后、`published_at` 之前插入:
+
+```sql
+    sort_order INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)',
+```
+
+当前 schema.sql 原文(需要修改的位置):
+```sql
+    is_featured TINYINT DEFAULT 0 COMMENT '是否精选:1 精选/0 普通',
+    published_at DATETIME DEFAULT NULL COMMENT '发布时间',
+```
+改为:
+```sql
+    is_featured TINYINT DEFAULT 0 COMMENT '是否精选:1 精选/0 普通',
+    sort_order INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)',
+    published_at DATETIME DEFAULT NULL COMMENT '发布时间',
+```
+
+- [ ] **步骤 3:编译验证**
+
+```bash
+cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -5
+```
+预期:无 ERROR 行,退出码 0。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+cd /sc-data/cfc
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
+git add cfc-backend/src/main/resources/schema.sql
+git commit -m "feat(articles): 迁移293 为 articles 表添加 sort_order 列"
+```
+
+---
+
+## 任务 2:Article 实体加 sortOrder 字段
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java`
+
+- [ ] **步骤 1:在 Article.java 的 isFeatured 字段后加 sortOrder**
+
+找到:
+```java
+    private Integer isFeatured;
+    private Date publishedAt;
+```
+改为:
+```java
+    private Integer isFeatured;
+    /** 推荐排序值(越小越靠前,仅精选文章参与排序) */
+    private Integer sortOrder;
+    private Date publishedAt;
+```
+
+- [ ] **步骤 2:编译验证**
+
+```bash
+cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -5
+```
+预期:无 ERROR 行,退出码 0。
+
+- [ ] **步骤 3:Commit**
+
+```bash
+cd /sc-data/cfc
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java
+git commit -m "feat(articles): Article 实体加 sortOrder 字段"
+```
+
+---
+
+## 任务 3:ArticleService — 改排序逻辑 + toggleFeatured 追加队尾 + 新增 moveSortOrder
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java`
+
+### 步骤 1:改 getFeatured() 排序
+
+找到 `ArticleService.java` 第 125 行 `getFeatured` 方法,将:
+```java
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .eq(Article::getIsFeatured, 1)
+                .orderByDesc(Article::getPublishedAt)
+                .last("LIMIT " + Math.max(size, 50));
+```
+改为:
+```java
+        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));
+```
+
+### 步骤 2:改 getPublicList() 排序
+
+找到 `ArticleService.java` 第 86 行 `getPublicList` 方法,将:
+```java
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .orderByDesc(Article::getIsFeatured)
+                .orderByDesc(Article::getPublishedAt);
+```
+改为:
+```java
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .orderByDesc(Article::getIsFeatured)
+                .orderByAsc(Article::getSortOrder)
+                .orderByDesc(Article::getPublishedAt);
+```
+
+### 步骤 3:改 toggleFeatured() 设为精选时追加队尾
+
+找到第 541-548 行 `toggleFeatured` 方法,将:
+```java
+    @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);
+    }
+```
+改为:
+```java
+    @Transactional
+    public void toggleFeatured(Long id, int isFeatured) {
+        Article article = articleMapper.selectById(id);
+        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);
+    }
+```
+
+### 步骤 4:新增 getMaxSortOrder() 和 moveSortOrder() 两个方法
+
+在 `toggleFeatured()` 方法**之后**(约第 548 行后)插入以下两个方法:
+
+```java
+    /**
+     * 查询已发布且精选的文章中最大的 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'。
+     * 边界(已在最前/最后)抛 RuntimeException,调用方会返回 400。
+     */
+    @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");
+        }
+
+        // 按展示序(sort_order ASC, published_at ASC)取全部精选文章列表
+        // published_at ASC 用于并列时稳定排序(sort_order 已唯一后不影响结果)
+        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;
+        Long swapId = featured.get(swapIdx).getId();
+
+        // 交换 sort_order(先交换,避免覆盖)
+        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 "已调整顺序";
+    }
+```
+
+- [ ] **步骤 5:编译验证**
+
+```bash
+cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -10
+```
+预期:无 ERROR 行,退出码 0。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+cd /sc-data/cfc
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
+git commit -m "feat(articles): getFeatured/getPublicList 改按 sort_order 排序,toggleFeatured 追加队尾,新增 moveSortOrder"
+```
+
+---
+
+## 任务 4:AdminArticleController 新增 POST /move 接口
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java`
+
+- [ ] **步骤 1:在 toggleFeatured 方法后插入 move 接口**
+
+找到 `AdminArticleController.java` 第 265-271 行 `toggleFeatured` 方法之后,插入:
+
+```java
+    @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());
+        }
+    }
+```
+
+- [ ] **步骤 2:编译验证**
+
+```bash
+cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -10
+```
+预期:无 ERROR 行,退出码 0。
+
+- [ ] **步骤 3:Commit**
+
+```bash
+cd /sc-data/cfc
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
+git commit -m "feat(articles): AdminArticleController 新增 POST /move 接口"
+```
+
+---
+
+## 任务 5:cfc-web — api/article.js 加 adminArticleMove
+
+**文件:**
+- 修改:`cfc-web/src/api/article.js`
+
+- [ ] **步骤 1:在 adminArticleToggleFeatured 后新增 adminArticleMove**
+
+找到 `cfc-web/src/api/article.js` 中 `adminArticleToggleFeatured` 函数(约第 25-27 行),在其后插入:
+
+```js
+export function adminArticleMove(data) {
+  return request({ url: '/api/admin/articles/move', method: 'post', data })
+}
+```
+
+完整 api/article.js 关键片段(修改后):
+```js
+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 })
+}
+```
+
+- [ ] **步骤 2:Commit**
+
+```bash
+cd /sc-data/cfc
+git add cfc-web/src/api/article.js
+git commit -m "feat(web): article API 新增 adminArticleMove"
+```
+
+---
+
+## 任务 6:cfc-web — ArticleManage.vue 加上移/下移按钮与序号徽章
+
+**文件:**
+- 修改:`cfc-web/src/views/admin/ArticleManage.vue`
+
+### 步骤 1:在 data() 中引入 adminArticleMove
+
+在 `<script>` 的 import 块末尾(第 309-310 行之后)追加:
+```js
+import {
+  adminArticleList,
+  adminArticlePublish,
+  adminArticleToggleFeatured,
+  adminArticleDelete,
+  adminArticleCategoriesList,
+  adminArticleAudit,
+  adminArticleDetail,
+  adminArticleCreate,
+  adminArticleSubmitReview,
+  adminArticleWithdraw,
+  adminArticleReDraft,
+  adminArticleReadingRecords,
+  adminArticleMove
+} from '@/api/article.js'
+```
+
+### 步骤 2:为发布中 + 精选行新增 handleMove
+
+在 ArticleManage.vue 的 `methods` 对象内,紧接 `handleToggleFeatured` 方法(约第 465-475 行)之后插入:
+
+```js
+    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 || '调整顺序失败')
+        }
+      }
+    },
+```
+
+### 步骤 3:在 handleActionCmd 的 toggleFeatured 分支旁增加 move 分支(可选,通过下拉菜单)
+
+在 `handleActionCmd` 方法的 switch 块中,`case 'toggleFeatured'` 之后追加:
+```js
+        case 'moveUp': this.handleMove(row, 'up'); break;
+        case 'moveDown': this.handleMove(row, 'down'); break;
+```
+
+### 步骤 4:为发布中行的下拉菜单新增上移/下移项
+
+找到 `ArticleManage.vue` 第 182-191 行("发布中"模板)的 `<el-dropdown-menu>` 内,在"精选/取消精选"菜单项之后追加两个项:
+
+```html
+                  <el-dropdown-item command="moveUp" icon="el-icon-top">上移</el-dropdown-item>
+                  <el-dropdown-item command="moveDown" icon="el-icon-bottom">下移</el-dropdown-item>
+```
+
+完整发布中下拉菜单最终代码:
+```html
+            <!-- 发布中:编辑 + 下架 + 精选 -->
+            <template v-else-if="row.status === 'published'">
+              <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id)">编辑</el-button>
+              <el-button size="mini" @click="openReadRecords(row)">阅读记录</el-button>
+              <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
+                <el-button size="mini">
+                  更多<i class="el-icon-arrow-down el-icon--right"></i>
+                </el-button>
+                <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>
+            </template>
+```
+
+### 步骤 5:在标题列展示精选序号徽章
+
+找到 `ArticleManage.vue` 第 61-66 行(标题列):
+```html
+        <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>
+          </template>
+        </el-table-column>
+```
+改为(序号徽章用橙色,仅排序值 > 0 时展示):
+```html
+        <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;">精选{{ row.sortOrder && row.sortOrder > 0 ? ' ' + toChineseNum(row.sortOrder) : '' }}</el-tag>
+          </template>
+        </el-table-column>
+```
+
+### 步骤 6:添加 toChineseNum 辅助方法
+
+在 `methods` 对象末尾(`handleActionCmd` 之前)插入:
+```js
+    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])
+    },
+```
+
+### 步骤 7:保存并提交
+
+```bash
+cd /sc-data/cfc
+git add cfc-web/src/views/admin/ArticleManage.vue
+git commit -m "feat(web): ArticleManage 精选行增加上移/下移 + 序号徽章"
+```
+
+---
+
+## 任务 7:更新 API_REFERENCE.md
+
+**文件:**
+- 修改:`docs/superpowers/api/API_REFERENCE.md`
+
+- [ ] **步骤 1:在 `/api/admin/articles/*` 表格后追加一行**
+
+找到第 592 行附近:
+```markdown
+| `/api/admin/articles/*` | 文章管理 |
+```
+改为:
+```markdown
+| `/api/admin/articles/*` | 文章管理 |
+| `POST /api/admin/articles/move` | 调整精选文章的推荐顺序(`{ id, direction: 'up'|'down' }`,已边界时返回 400) |
+```
+
+- [ ] **步骤 2:更新 PROJECT-OVERVIEW.md(文档增删需同步)**
+
+打开 `docs/superpowers/PROJECT-OVERVIEW.md`,在"文档索引"或相应章节追加一行:
+```
+- `specs/2026-09-04-article-sort-order-design.md` — 文章推荐排序(精选顺序可定义)设计规格
+```
+
+(如 PROJECT-OVERVIEW.md 无专门规格章节,追加到文档索引表格末尾即可)
+
+- [ ] **步骤 3:Commit**
+
+```bash
+cd /sc-data/cfc
+git add docs/superpowers/api/API_REFERENCE.md
+git add docs/superpowers/PROJECT-OVERVIEW.md
+git commit -m "docs: 更新 API_REFERENCE 与 PROJECT-OVERVIEW(article sort order)"
+```
+
+---
+
+## 任务 8:端到端验证
+
+- [ ] **步骤 1:后端编译**
+```bash
+cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | grep -E "ERROR|BUILD"
+```
+预期:无 ERROR 行,或仅 `[WARNING]`,BUILD SUCCESS。
+
+- [ ] **步骤 2:小程序前端编译(无实际改动,语法检查)**
+```bash
+cd /sc-data/cfc/cfc-frontend && node -e "
+const fs = require('fs');
+const src = fs.readFileSync('pages/index-home/index.vue', 'utf8');
+const m = src.match(/<script>([\s\S]*?)<\/script>/);
+if (!m) { console.log('no script'); process.exit(0); }
+try { new Function(m[1]); console.log('index-home script OK'); } catch(e) { console.error('ERROR:', e.message); process.exit(1); }
+"
+```
+
+- [ ] **步骤 3:提交 git status 检查**
+```bash
+cd /sc-data/cfc && git status --short
+```
+预期:仅 `cfc-web/.last_build_commit` 与 `cfc-web/package-lock.json` 有未提交变更(属 cfc-web 构建产物,与本次无关)。
+
+---
+
+## 规格自检
+
+| 规格要求 | 对应任务 |
+|---------|---------|
+| 迁移 293 加 sort_order 列 | 任务 1 ✓ |
+| Article 实体加 sortOrder | 任务 2 ✓ |
+| getFeatured() 排序改 sort_order ASC | 任务 3 步骤 1 ✓ |
+| getPublicList() 排序改 sort_order ASC | 任务 3 步骤 2 ✓ |
+| toggleFeatured 设为精选追加队尾 | 任务 3 步骤 3 ✓ |
+| 新增 moveSortOrder 交换+重编号 | 任务 3 步骤 4 ✓ |
+| 新增 POST /move 接口 | 任务 4 ✓ |
+| api/article.js 新增 adminArticleMove | 任务 5 ✓ |
+| ArticleManage.vue 上移/下移菜单 | 任务 6 步骤 4 ✓ |
+| 标题列序号徽章 | 任务 6 步骤 5 ✓ |
+| API_REFERENCE.md 同步 | 任务 7 ✓ |
+| PROJECT-OVERVIEW.md 同步 | 任务 7 步骤 2 ✓ |
+| 边界:非精选文章拒绝 | moveSortOrder 中判断 ✓ |
+| 边界:已在最前/最后提示 | throw RuntimeException ✓ |
+| 边界:非法 direction 拒绝 | moveSortOrder 中判断 ✓ |
+
+全部规格要求已覆盖,无遗漏。