ArticleManage.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. <template>
  2. <div class="admin-page">
  3. <div class="article-manage">
  4. <el-card>
  5. <div slot="header">
  6. <span>文章管理</span>
  7. <el-button style="float: right" type="primary" size="small" @click="$router.push('/article-edit')">+ 新建文章</el-button>
  8. </div>
  9. <!-- 状态标签栏 -->
  10. <div class="status-tabs">
  11. <el-button
  12. v-for="tab in statusTabs"
  13. :key="tab.value"
  14. :type="filters.status === tab.value ? 'primary' : 'default'"
  15. size="small"
  16. @click="filters.status = tab.value; loadList()"
  17. >{{ tab.label }}</el-button>
  18. </div>
  19. <!-- 搜索筛选栏 -->
  20. <div class="filter-bar">
  21. <el-select v-model="filters.categoryId" placeholder="分类" clearable @change="loadList" style="width: 150px;">
  22. <el-option label="全部分类" value="" />
  23. <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
  24. </el-select>
  25. <el-select v-model="filters.contentType" placeholder="内容类型" clearable @change="loadList" style="width: 130px;">
  26. <el-option label="全部" value="" />
  27. <el-option label="文章" value="article" />
  28. <el-option label="知识点" value="knowledge" />
  29. <el-option label="课程" value="course" />
  30. <el-option label="贴士" value="tip" />
  31. </el-select>
  32. <el-select v-model="filters.difficultyLevel" placeholder="难度" clearable @change="loadList" style="width: 100px;">
  33. <el-option label="全部" value="" />
  34. <el-option label="1星" value="1" />
  35. <el-option label="2星" value="2" />
  36. <el-option label="3星" value="3" />
  37. <el-option label="4星" value="4" />
  38. <el-option label="5星" value="5" />
  39. </el-select>
  40. <el-select v-model="filters.dimensionCode" placeholder="维度" clearable @change="loadList" style="width: 100px;">
  41. <el-option label="全部" value="" />
  42. <el-option label="身" value="body" />
  43. <el-option label="心" value="mind" />
  44. <el-option label="智" value="wisdom" />
  45. <el-option label="行" value="action" />
  46. <el-option label="富" value="wealth" />
  47. </el-select>
  48. <el-select v-model="filters.tagId" placeholder="知识标签" clearable @change="loadList" style="width: 150px;" filterable>
  49. <el-option label="全部" value="" />
  50. <el-option v-for="tag in tagOptions" :key="tag.id" :label="tag.name" :value="tag.id" />
  51. </el-select>
  52. <el-input v-model="filters.keyword" placeholder="搜索文章标题..." clearable style="width: 220px;" @keyup.enter.native="loadList" />
  53. <el-button type="primary" icon="el-icon-search" @click="loadList">搜索</el-button>
  54. </div>
  55. <el-table :data="list" v-loading="loading" border stripe>
  56. <el-table-column prop="id" label="ID" width="70" />
  57. <el-table-column label="标题" min-width="220">
  58. <template slot-scope="{ row }">
  59. <span>{{ row.title }}</span>
  60. <el-tag v-if="row.isFeatured === 1 || row.isFeatured === true" size="mini" type="warning" style="margin-left: 6px;">精选</el-tag>
  61. </template>
  62. </el-table-column>
  63. <el-table-column label="分类" width="100">
  64. <template slot-scope="{ row }">
  65. {{ row.categoryName || '-' }}
  66. </template>
  67. </el-table-column>
  68. <el-table-column label="可见性" width="90">
  69. <template slot-scope="{ row }">
  70. <el-tag v-if="row.visibility === 'public'" size="small" type="success">公开</el-tag>
  71. <el-tag v-else-if="row.visibility === 'login'" size="small" type="primary">登录</el-tag>
  72. <el-tag v-else size="small" type="info">私密</el-tag>
  73. </template>
  74. </el-table-column>
  75. <el-table-column label="内容类型" width="90">
  76. <template slot-scope="{ row }">
  77. <el-tag v-if="row.contentType === 'article'" size="small" type="primary">文章</el-tag>
  78. <el-tag v-else-if="row.contentType === 'knowledge'" size="small" type="success">知识点</el-tag>
  79. <el-tag v-else-if="row.contentType === 'course'" size="small" type="warning">课程</el-tag>
  80. <el-tag v-else-if="row.contentType === 'tip'" size="small" type="info">贴士</el-tag>
  81. <span v-else>-</span>
  82. </template>
  83. </el-table-column>
  84. <el-table-column label="难度" width="70">
  85. <template slot-scope="{ row }">
  86. <span v-if="row.difficultyLevel && row.difficultyLevel > 0">{{ row.difficultyLevel }}星</span>
  87. <span v-else>-</span>
  88. </template>
  89. </el-table-column>
  90. <el-table-column label="维度" width="100">
  91. <template slot-scope="{ row }">
  92. <span v-if="row.dimensionIds">{{ row.dimensionIds }}</span>
  93. <span v-else>-</span>
  94. </template>
  95. </el-table-column>
  96. <el-table-column prop="author" label="作者" width="100" />
  97. <el-table-column label="状态" width="100">
  98. <template slot-scope="{ row }">
  99. <el-tag v-if="row.status === 'draft' && row.auditStatus === 'pending'" size="small" type="warning">待审核</el-tag>
  100. <el-tag v-else-if="row.status === 'draft'" size="small" type="info">草稿箱</el-tag>
  101. <el-tag v-else-if="row.status === 'rejected'" size="small" type="danger">已驳回</el-tag>
  102. <el-tag v-else-if="row.status === 'published'" size="small" type="success">发布中</el-tag>
  103. <el-tag v-else-if="row.status === 'withdrawn'" size="small">已撤回</el-tag>
  104. <el-tag v-else size="small" type="info">{{ row.status }}</el-tag>
  105. </template>
  106. </el-table-column>
  107. <el-table-column label="审核状态" width="120">
  108. <template slot-scope="{ row }">
  109. <el-tag v-if="row.auditStatus === 'approved'" size="small" type="success">已通过</el-tag>
  110. <el-tag v-else-if="row.auditStatus === 'pending'" size="small" type="warning">待审核</el-tag>
  111. <el-tag v-else-if="row.auditStatus === 'rejected'" size="small" type="danger" :title="row.auditReason || ''">已驳回</el-tag>
  112. <span v-else>-</span>
  113. </template>
  114. </el-table-column>
  115. <el-table-column prop="viewCount" label="阅读" width="70" />
  116. <el-table-column label="发布时间" width="160">
  117. <template slot-scope="{ row }">
  118. {{ row.publishedAt || '-' }}
  119. </template>
  120. </el-table-column>
  121. <el-table-column label="操作" width="200" fixed="right">
  122. <template slot-scope="{ row }">
  123. <!-- 草稿箱:可编辑 -->
  124. <template v-if="row.status === 'draft' && !row.auditStatus">
  125. <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id)">编辑</el-button>
  126. <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
  127. <el-button size="mini">
  128. 更多<i class="el-icon-arrow-down el-icon--right"></i>
  129. </el-button>
  130. <el-dropdown-menu slot="dropdown">
  131. <el-dropdown-item command="submitReview" icon="el-icon-upload">提交审核</el-dropdown-item>
  132. <el-dropdown-item command="publishDirect" icon="el-icon-s-promotion">直接发布</el-dropdown-item>
  133. <el-dropdown-item command="copy" icon="el-icon-document-copy">复制</el-dropdown-item>
  134. <el-dropdown-item command="delete" icon="el-icon-delete">删除</el-dropdown-item>
  135. </el-dropdown-menu>
  136. </el-dropdown>
  137. </template>
  138. <!-- 待审核:审核操作 -->
  139. <template v-else-if="row.status === 'draft' && row.auditStatus === 'pending'">
  140. <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id + '&readonly=1')">查看</el-button>
  141. <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
  142. <el-button size="mini">
  143. 更多<i class="el-icon-arrow-down el-icon--right"></i>
  144. </el-button>
  145. <el-dropdown-menu slot="dropdown">
  146. <el-dropdown-item command="approve" icon="el-icon-circle-check">通过</el-dropdown-item>
  147. <el-dropdown-item command="reject" icon="el-icon-circle-close">驳回</el-dropdown-item>
  148. <el-dropdown-item command="copy" icon="el-icon-document-copy">复制</el-dropdown-item>
  149. </el-dropdown-menu>
  150. </el-dropdown>
  151. </template>
  152. <!-- 已驳回:查看 + 重新编辑 -->
  153. <template v-else-if="row.status === 'rejected'">
  154. <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id + '&readonly=1')">查看</el-button>
  155. <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
  156. <el-button size="mini">
  157. 更多<i class="el-icon-arrow-down el-icon--right"></i>
  158. </el-button>
  159. <el-dropdown-menu slot="dropdown">
  160. <el-dropdown-item command="reDraft" icon="el-icon-edit-outline">重新编辑</el-dropdown-item>
  161. <el-dropdown-item command="copy" icon="el-icon-document-copy">复制</el-dropdown-item>
  162. </el-dropdown-menu>
  163. </el-dropdown>
  164. </template>
  165. <!-- 发布中:查看 + 下架 + 精选 -->
  166. <template v-else-if="row.status === 'published'">
  167. <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id + '&readonly=1')">查看</el-button>
  168. <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
  169. <el-button size="mini">
  170. 更多<i class="el-icon-arrow-down el-icon--right"></i>
  171. </el-button>
  172. <el-dropdown-menu slot="dropdown">
  173. <el-dropdown-item command="withdraw" icon="el-icon-download">下架</el-dropdown-item>
  174. <el-dropdown-item command="toggleFeatured" icon="el-icon-star-on">精选/取消精选</el-dropdown-item>
  175. <el-dropdown-item command="copy" icon="el-icon-document-copy">复制</el-dropdown-item>
  176. </el-dropdown-menu>
  177. </el-dropdown>
  178. </template>
  179. <!-- 已撤回:查看 + 重新发布 -->
  180. <template v-else-if="row.status === 'withdrawn'">
  181. <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id + '&readonly=1')">查看</el-button>
  182. <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
  183. <el-button size="mini">
  184. 更多<i class="el-icon-arrow-down el-icon--right"></i>
  185. </el-button>
  186. <el-dropdown-menu slot="dropdown">
  187. <el-dropdown-item command="rePublish" icon="el-icon-s-promotion">重新发布</el-dropdown-item>
  188. <el-dropdown-item command="copy" icon="el-icon-document-copy">复制</el-dropdown-item>
  189. </el-dropdown-menu>
  190. </el-dropdown>
  191. </template>
  192. <!-- 其他 -->
  193. <template v-else>
  194. <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id + '&readonly=1')">查看</el-button>
  195. <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
  196. <el-button size="mini">
  197. 更多<i class="el-icon-arrow-down el-icon--right"></i>
  198. </el-button>
  199. <el-dropdown-menu slot="dropdown">
  200. <el-dropdown-item command="copy" icon="el-icon-document-copy">复制</el-dropdown-item>
  201. </el-dropdown-menu>
  202. </el-dropdown>
  203. </template>
  204. </template>
  205. </el-table-column>
  206. </el-table>
  207. <el-pagination
  208. @size-change="onPageChange"
  209. @current-change="onPageChange"
  210. :current-page="page"
  211. :page-size="size"
  212. :total="total"
  213. layout="total, prev, pager, next"
  214. style="margin-top: 20px; text-align: right;"
  215. />
  216. </el-card>
  217. </div>
  218. <!-- 驳回弹窗 -->
  219. <el-dialog title="驳回原因" :visible.sync="rejectDialogVisible" width="400px">
  220. <el-input
  221. type="textarea"
  222. :rows="4"
  223. v-model="rejectReason"
  224. placeholder="请输入驳回原因..."
  225. ></el-input>
  226. <span slot="footer">
  227. <el-button @click="rejectDialogVisible = false">取消</el-button>
  228. <el-button type="primary" @click="confirmReject">确认驳回</el-button>
  229. </span>
  230. </el-dialog>
  231. </div>
  232. </template>
  233. <script>
  234. import {
  235. adminArticleList,
  236. adminArticlePublish,
  237. adminArticleToggleFeatured,
  238. adminArticleDelete,
  239. adminArticleCategoriesList,
  240. adminArticleAudit,
  241. adminArticleDetail,
  242. adminArticleCreate,
  243. adminArticleSubmitReview,
  244. adminArticleWithdraw,
  245. adminArticleReDraft
  246. } from '@/api/article.js'
  247. import { getTagList } from '@/api/dimension.js'
  248. export default {
  249. name: 'ArticleManage',
  250. data() {
  251. return {
  252. loading: false,
  253. list: [],
  254. total: 0,
  255. page: 1,
  256. size: 20,
  257. statusTabs: [
  258. { value: '', label: '全部' },
  259. { value: 'draft', label: '草稿箱' },
  260. { value: 'pending', label: '待审核' },
  261. { value: 'rejected', label: '已驳回' },
  262. { value: 'published', label: '发布中' },
  263. { value: 'withdrawn', label: '已撤回' }
  264. ],
  265. filters: {
  266. status: '',
  267. categoryId: '',
  268. contentType: '',
  269. difficultyLevel: '',
  270. dimensionCode: '',
  271. tagId: '',
  272. keyword: ''
  273. },
  274. categories: [],
  275. tagOptions: [],
  276. rejectDialogVisible: false,
  277. rejectReason: '',
  278. currentAuditRow: null
  279. }
  280. },
  281. created() {
  282. Promise.all([
  283. this.loadCategories().catch(e => console.error('加载分类失败', e)),
  284. this.loadTagOptions().catch(e => console.error('加载标签失败', e)),
  285. this.loadList().catch(e => this.$message.error('加载文章列表失败'))
  286. ])
  287. },
  288. methods: {
  289. async loadCategories() {
  290. try {
  291. const res = await adminArticleCategoriesList()
  292. if (res.data) {
  293. this.categories = res.data
  294. }
  295. } catch (e) {
  296. console.error('加载分类失败', e)
  297. }
  298. },
  299. async loadTagOptions() {
  300. try {
  301. const res = await getTagList()
  302. if (res.data) {
  303. this.tagOptions = res.data
  304. }
  305. } catch (e) {
  306. console.error('加载知识标签失败', e)
  307. }
  308. },
  309. async loadList() {
  310. this.loading = true
  311. try {
  312. const res = await adminArticleList({
  313. page: this.page,
  314. size: this.size,
  315. status: this.filters.status || undefined,
  316. categoryId: this.filters.categoryId || undefined,
  317. contentType: this.filters.contentType || undefined,
  318. difficultyLevel: this.filters.difficultyLevel || undefined,
  319. dimensionCode: this.filters.dimensionCode || undefined,
  320. tagId: this.filters.tagId || undefined,
  321. keyword: this.filters.keyword || undefined
  322. })
  323. if (res.data) {
  324. this.list = res.data.records || res.data.list || res.data
  325. this.total = res.data.total || 0
  326. }
  327. } catch (e) {
  328. this.$message.error('加载文章列表失败')
  329. } finally {
  330. this.loading = false
  331. }
  332. },
  333. onPageChange() {
  334. this.loadList()
  335. },
  336. async handlePublishDirect(row) {
  337. try {
  338. await adminArticlePublish({ id: row.id, status: 'published' })
  339. this.$message.success('文章已发布')
  340. this.loadList()
  341. } catch (e) {
  342. this.$message.error(e.message || '发布失败')
  343. }
  344. },
  345. async handleSubmitReview(row) {
  346. try {
  347. await adminArticleSubmitReview({ id: row.id })
  348. this.$message.success('已提交审核')
  349. this.loadList()
  350. } catch (e) {
  351. this.$message.error(e.message || '提交审核失败')
  352. }
  353. },
  354. async handleWithdraw(row) {
  355. try {
  356. await adminArticleWithdraw({ id: row.id })
  357. this.$message.success('文章已下架')
  358. this.loadList()
  359. } catch (e) {
  360. this.$message.error(e.message || '下架失败')
  361. }
  362. },
  363. async handleReDraft(row) {
  364. try {
  365. await adminArticleReDraft({ id: row.id })
  366. this.$message.success('已保存到草稿箱')
  367. this.loadList()
  368. } catch (e) {
  369. this.$message.error(e.message || '操作失败')
  370. }
  371. },
  372. async handleToggleFeatured(row) {
  373. try {
  374. const newVal = row.isFeatured === 1 || row.isFeatured === true ? 0 : 1
  375. await adminArticleToggleFeatured({ id: row.id, isFeatured: newVal })
  376. this.$message.success(newVal ? '已设为精选' : '已取消精选')
  377. this.loadList()
  378. } catch (e) {
  379. this.$message.error(e.message || '操作失败')
  380. }
  381. },
  382. handleDelete(row) {
  383. this.$confirm(`确定删除文章「${row.title}」吗?`, '提示', {
  384. confirmButtonText: '确定',
  385. cancelButtonText: '取消',
  386. type: 'warning'
  387. }).then(async () => {
  388. try {
  389. await adminArticleDelete({ id: row.id })
  390. this.$message.success('删除成功')
  391. this.loadList()
  392. } catch (e) {
  393. this.$message.error(e.message || '删除失败')
  394. }
  395. }).catch(() => {})
  396. },
  397. handleAudit(row, auditStatus) {
  398. var self = this
  399. this.$confirm('确定' + (auditStatus === 'approved' ? '通过' : '驳回') + '该文章吗?', '提示', {
  400. confirmButtonText: '确定',
  401. cancelButtonText: '取消',
  402. type: 'warning'
  403. }).then(function() {
  404. return self.$http.post('/api/admin/articles/audit', {
  405. id: row.id,
  406. auditStatus: auditStatus,
  407. auditReason: auditStatus === 'rejected' ? self.rejectReason : ''
  408. })
  409. }).then(function(res) {
  410. if (res.data && res.data.code === 200) {
  411. self.$message.success('审核完成')
  412. self.loadList()
  413. } else {
  414. self.$message.error(res.data && res.data.message || '审核失败')
  415. }
  416. }).catch(function(e) {
  417. if (e !== 'cancel') {
  418. self.$message.error('审核失败')
  419. }
  420. })
  421. },
  422. showRejectDialog(row) {
  423. this.currentAuditRow = row
  424. this.rejectReason = ''
  425. this.rejectDialogVisible = true
  426. },
  427. confirmReject() {
  428. if (!this.rejectReason.trim()) {
  429. this.$message.warning('请填写驳回原因')
  430. return
  431. }
  432. this.rejectDialogVisible = false
  433. this.handleAudit(this.currentAuditRow, 'rejected')
  434. },
  435. async handleCopy(row) {
  436. try {
  437. const res = await adminArticleDetail({ id: row.id })
  438. if (!res.data) {
  439. this.$message.error('文章不存在')
  440. return
  441. }
  442. const d = res.data
  443. const copyData = {
  444. categoryId: d.categoryId,
  445. title: d.title ? d.title + '(副本)' : '',
  446. summary: d.summary || '',
  447. coverImage: d.coverImage || '',
  448. content: d.content || '',
  449. tags: d.tags || '',
  450. author: d.author || '',
  451. readTime: d.readTime || 3,
  452. visibility: d.visibility || 'public',
  453. visibleTo: d.visibleTo || [],
  454. status: 'draft'
  455. }
  456. const createRes = await adminArticleCreate(copyData)
  457. if (createRes.data && createRes.data.id) {
  458. this.$message.success('已复制为草稿')
  459. this.loadList()
  460. } else {
  461. this.$message.error('复制失败')
  462. }
  463. } catch (e) {
  464. this.$message.error(e.message || '复制失败')
  465. }
  466. },
  467. handleActionCmd(row, cmd) {
  468. switch (cmd) {
  469. case 'submitReview': this.handleSubmitReview(row); break;
  470. case 'publishDirect': this.handlePublishDirect(row); break;
  471. case 'copy': this.handleCopy(row); break;
  472. case 'delete': this.handleDelete(row); break;
  473. case 'approve': this.handleAudit(row, 'approved'); break;
  474. case 'reject': this.showRejectDialog(row); break;
  475. case 'reDraft': this.handleReDraft(row); break;
  476. case 'withdraw': this.handleWithdraw(row); break;
  477. case 'toggleFeatured': this.handleToggleFeatured(row); break;
  478. case 'rePublish': this.handlePublishDirect(row); break;
  479. }
  480. }
  481. }
  482. }
  483. </script>
  484. <style scoped>
  485. .article-manage {
  486. padding: 20px;
  487. }
  488. .filter-bar {
  489. margin-bottom: 18px;
  490. display: flex;
  491. align-items: center;
  492. gap: 10px;
  493. flex-wrap: wrap;
  494. }
  495. .status-tabs {
  496. margin-bottom: 18px;
  497. display: flex;
  498. align-items: center;
  499. gap: 6px;
  500. flex-wrap: wrap;
  501. }
  502. </style>