ArticleManage.vue 21 KB

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