面向 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 接口文档 |
文件:
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 行,} 后)追加:
// 迁移293: articles 表加 sort_order 列(精选文章推荐排序,越小越靠前)
ensureColumn("articles", "sort_order", "INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)'");
log.info("已添加 articles.sort_order 列(迁移293)");
打开 cfc-backend/src/main/resources/schema.sql,找到 CREATE TABLE IF NOT EXISTS articles ((约第 1395 行),在 is_featured 列之后、published_at 之前插入:
sort_order INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)',
当前 schema.sql 原文(需要修改的位置):
is_featured TINYINT DEFAULT 0 COMMENT '是否精选:1 精选/0 普通',
published_at DATETIME 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 '发布时间',
[ ] 步骤 3:编译验证
cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -5
预期:无 ERROR 行,退出码 0。
[ ] 步骤 4:Commit
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 列"
文件:
修改:cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java
[ ] 步骤 1:在 Article.java 的 isFeatured 字段后加 sortOrder
找到:
private Integer isFeatured;
private Date publishedAt;
改为:
private Integer isFeatured;
/** 推荐排序值(越小越靠前,仅精选文章参与排序) */
private Integer sortOrder;
private Date publishedAt;
[ ] 步骤 2:编译验证
cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -5
预期:无 ERROR 行,退出码 0。
[ ] 步骤 3:Commit
cd /sc-data/cfc
git add cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java
git commit -m "feat(articles): Article 实体加 sortOrder 字段"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java找到 ArticleService.java 第 125 行 getFeatured 方法,将:
LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
.eq(Article::getStatus, "published")
.eq(Article::getIsFeatured, 1)
.orderByDesc(Article::getPublishedAt)
.last("LIMIT " + Math.max(size, 50));
改为:
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));
找到 ArticleService.java 第 86 行 getPublicList 方法,将:
LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
.eq(Article::getStatus, "published")
.orderByDesc(Article::getIsFeatured)
.orderByDesc(Article::getPublishedAt);
改为:
LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
.eq(Article::getStatus, "published")
.orderByDesc(Article::getIsFeatured)
.orderByAsc(Article::getSortOrder)
.orderByDesc(Article::getPublishedAt);
找到第 541-548 行 toggleFeatured 方法,将:
@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);
}
改为:
@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);
}
在 toggleFeatured() 方法之后(约第 548 行后)插入以下两个方法:
/**
* 查询已发布且精选的文章中最大的 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:编译验证
cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -10
预期:无 ERROR 行,退出码 0。
[ ] 步骤 6:Commit
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"
文件:
修改:cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
[ ] 步骤 1:在 toggleFeatured 方法后插入 move 接口
找到 AdminArticleController.java 第 265-271 行 toggleFeatured 方法之后,插入:
@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:编译验证
cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -10
预期:无 ERROR 行,退出码 0。
[ ] 步骤 3:Commit
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 接口"
文件:
修改:cfc-web/src/api/article.js
[ ] 步骤 1:在 adminArticleToggleFeatured 后新增 adminArticleMove
找到 cfc-web/src/api/article.js 中 adminArticleToggleFeatured 函数(约第 25-27 行),在其后插入:
export function adminArticleMove(data) {
return request({ url: '/api/admin/articles/move', method: 'post', data })
}
完整 api/article.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
cd /sc-data/cfc
git add cfc-web/src/api/article.js
git commit -m "feat(web): article API 新增 adminArticleMove"
文件:
cfc-web/src/views/admin/ArticleManage.vue在 <script> 的 import 块末尾(第 309-310 行之后)追加:
import {
adminArticleList,
adminArticlePublish,
adminArticleToggleFeatured,
adminArticleDelete,
adminArticleCategoriesList,
adminArticleAudit,
adminArticleDetail,
adminArticleCreate,
adminArticleSubmitReview,
adminArticleWithdraw,
adminArticleReDraft,
adminArticleReadingRecords,
adminArticleMove
} from '@/api/article.js'
在 ArticleManage.vue 的 methods 对象内,紧接 handleToggleFeatured 方法(约第 465-475 行)之后插入:
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 || '调整顺序失败')
}
}
},
在 handleActionCmd 方法的 switch 块中,case 'toggleFeatured' 之后追加:
case 'moveUp': this.handleMove(row, 'up'); break;
case 'moveDown': this.handleMove(row, 'down'); break;
找到 ArticleManage.vue 第 182-191 行("发布中"模板)的 <el-dropdown-menu> 内,在"精选/取消精选"菜单项之后追加两个项:
<el-dropdown-item command="moveUp" icon="el-icon-top">上移</el-dropdown-item>
<el-dropdown-item command="moveDown" icon="el-icon-bottom">下移</el-dropdown-item>
完整发布中下拉菜单最终代码:
<!-- 发布中:编辑 + 下架 + 精选 -->
<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>
找到 ArticleManage.vue 第 61-66 行(标题列):
<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 时展示):
<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>
在 methods 对象末尾(handleActionCmd 之前)插入:
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])
},
cd /sc-data/cfc
git add cfc-web/src/views/admin/ArticleManage.vue
git commit -m "feat(web): ArticleManage 精选行增加上移/下移 + 序号徽章"
文件:
修改:docs/superpowers/api/API_REFERENCE.md
[ ] 步骤 1:在 /api/admin/articles/* 表格后追加一行
找到第 592 行附近:
| `/api/admin/articles/*` | 文章管理 |
改为:
| `/api/admin/articles/*` | 文章管理 |
| `POST /api/admin/articles/move` | 调整精选文章的推荐顺序(`{ id, direction: 'up'|'down' }`,已边界时返回 400) |
打开 docs/superpowers/PROJECT-OVERVIEW.md,在"文档索引"或相应章节追加一行:
- `specs/2026-09-04-article-sort-order-design.md` — 文章推荐排序(精选顺序可定义)设计规格
(如 PROJECT-OVERVIEW.md 无专门规格章节,追加到文档索引表格末尾即可)
[ ] 步骤 3:Commit
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)"
[ ] 步骤 1:后端编译
cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | grep -E "ERROR|BUILD"
预期:无 ERROR 行,或仅 [WARNING],BUILD SUCCESS。
[ ] 步骤 2:小程序前端编译(无实际改动,语法检查)
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 检查
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 中判断 ✓ |
全部规格要求已覆盖,无遗漏。