ArticleManage.vue 21 KB

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