ArticleManage.vue 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  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. <template slot="header">
  113. <span class="sort-header" @click.stop="onSortClick('viewCount', $event)" @dblclick.stop="onSortToggle('viewCount')">阅读{{ sortIcon('viewCount') }}</span>
  114. </template>
  115. </el-table-column>
  116. <el-table-column label="发布时间" width="160">
  117. <template slot-scope="{ row }">
  118. {{ formatTime(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)">编辑</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. </div>
  208. <div class="pagination-wrap">
  209. <el-pagination
  210. @size-change="onSizeChange"
  211. @current-change="onPageChange"
  212. :current-page="page"
  213. :page-sizes="[10, 20, 50, 100]"
  214. :page-size="size"
  215. :total="total"
  216. layout="total, sizes, prev, pager, next"
  217. />
  218. </div>
  219. </el-card>
  220. </div>
  221. <!-- 驳回弹窗 -->
  222. <el-dialog title="驳回原因" :visible.sync="rejectDialogVisible" width="400px">
  223. <el-input
  224. type="textarea"
  225. :rows="4"
  226. v-model="rejectReason"
  227. placeholder="请输入驳回原因..."
  228. ></el-input>
  229. <span slot="footer">
  230. <el-button @click="rejectDialogVisible = false">取消</el-button>
  231. <el-button type="primary" @click="confirmReject">确认驳回</el-button>
  232. </span>
  233. </el-dialog>
  234. </div>
  235. </template>
  236. <script>
  237. import SortMixin from '@/mixins/SortMixin'
  238. import {
  239. adminArticleList,
  240. adminArticlePublish,
  241. adminArticleToggleFeatured,
  242. adminArticleDelete,
  243. adminArticleCategoriesList,
  244. adminArticleAudit,
  245. adminArticleDetail,
  246. adminArticleCreate,
  247. adminArticleSubmitReview,
  248. adminArticleWithdraw,
  249. adminArticleReDraft
  250. } from '@/api/article.js'
  251. import { getTagList } from '@/api/dimension.js'
  252. export default {
  253. mixins: [SortMixin],
  254. name: 'ArticleManage',
  255. computed: {
  256. tableHeight() {
  257. return window.innerHeight - 340
  258. }
  259. },
  260. data() {
  261. return {
  262. loading: false,
  263. list: [],
  264. total: 0,
  265. page: 1,
  266. size: 20,
  267. statusTabs: [
  268. { value: '', label: '全部' },
  269. { value: 'draft', label: '草稿箱' },
  270. { value: 'pending', label: '待审核' },
  271. { value: 'rejected', label: '已驳回' },
  272. { value: 'published', label: '发布中' },
  273. { value: 'withdrawn', label: '已撤回' }
  274. ],
  275. filters: {
  276. status: '',
  277. categoryId: '',
  278. contentType: '',
  279. difficultyLevel: '',
  280. dimensionCode: '',
  281. tagId: '',
  282. keyword: ''
  283. },
  284. sortableColumns: { viewCount: '阅读' },
  285. categories: [],
  286. tagOptions: [],
  287. rejectDialogVisible: false,
  288. rejectReason: '',
  289. currentAuditRow: null
  290. }
  291. },
  292. created() {
  293. Promise.all([
  294. this.loadCategories().catch(e => console.error('加载分类失败', e)),
  295. this.loadTagOptions().catch(e => console.error('加载标签失败', e)),
  296. this.loadList().catch(e => this.$message.error('加载文章列表失败'))
  297. ])
  298. },
  299. methods: {
  300. async loadCategories() {
  301. try {
  302. const res = await adminArticleCategoriesList()
  303. if (res.data) {
  304. this.categories = res.data
  305. }
  306. } catch (e) {
  307. console.error('加载分类失败', e)
  308. }
  309. },
  310. async loadTagOptions() {
  311. try {
  312. const res = await getTagList()
  313. if (res.data) {
  314. this.tagOptions = res.data
  315. }
  316. } catch (e) {
  317. console.error('加载知识标签失败', e)
  318. }
  319. },
  320. async loadList() {
  321. this.loading = true
  322. try {
  323. const res = await adminArticleList({
  324. page: this.page,
  325. size: this.size,
  326. status: this.filters.status || undefined,
  327. categoryId: this.filters.categoryId || undefined,
  328. contentType: this.filters.contentType || undefined,
  329. difficultyLevel: this.filters.difficultyLevel || undefined,
  330. dimensionCode: this.filters.dimensionCode || undefined,
  331. tagId: this.filters.tagId || undefined,
  332. keyword: this.filters.keyword || undefined,
  333. sort: this.sortState.length > 0 ? this.sortState : undefined
  334. })
  335. if (res.data) {
  336. this.list = res.data.records || res.data.list || res.data
  337. this.total = res.data.total || 0
  338. }
  339. } catch (e) {
  340. this.$message.error('加载文章列表失败')
  341. } finally {
  342. this.loading = false
  343. }
  344. },
  345. onPageChange(page) {
  346. this.page = page
  347. this.loadList()
  348. },
  349. onSizeChange(size) {
  350. this.size = size
  351. this.page = 1
  352. this.loadList()
  353. },
  354. async handlePublishDirect(row) {
  355. try {
  356. await this.$confirm('确认发布该文章?', '提示')
  357. await adminArticlePublish({ id: row.id, status: 'published' })
  358. this.$message.success('文章已发布')
  359. this.loadList()
  360. } catch (e) {
  361. if (e !== 'cancel') this.$message.error(e.message || '发布失败')
  362. }
  363. },
  364. async handleSubmitReview(row) {
  365. try {
  366. await this.$confirm('确认提交审核?', '提示')
  367. await adminArticleSubmitReview({ id: row.id })
  368. this.$message.success('已提交审核')
  369. this.loadList()
  370. } catch (e) {
  371. if (e !== 'cancel') this.$message.error(e.message || '提交审核失败')
  372. }
  373. },
  374. async handleWithdraw(row) {
  375. try {
  376. await this.$confirm('确认下架该文章?', '提示')
  377. await adminArticleWithdraw({ id: row.id })
  378. this.$message.success('文章已下架')
  379. this.loadList()
  380. } catch (e) {
  381. if (e !== 'cancel') this.$message.error(e.message || '下架失败')
  382. }
  383. },
  384. async handleReDraft(row) {
  385. try {
  386. await this.$confirm('确认移回草稿箱?', '提示')
  387. await adminArticleReDraft({ id: row.id })
  388. this.$message.success('已保存到草稿箱')
  389. this.loadList()
  390. } catch (e) {
  391. if (e !== 'cancel') this.$message.error(e.message || '操作失败')
  392. }
  393. },
  394. async handleToggleFeatured(row) {
  395. try {
  396. const newVal = row.isFeatured === 1 || row.isFeatured === true ? 0 : 1
  397. await this.$confirm(newVal ? '确认设为精选?' : '确认取消精选?', '提示')
  398. await adminArticleToggleFeatured({ id: row.id, isFeatured: newVal })
  399. this.$message.success(newVal ? '已设为精选' : '已取消精选')
  400. this.loadList()
  401. } catch (e) {
  402. if (e !== 'cancel') this.$message.error(e.message || '操作失败')
  403. }
  404. },
  405. handleDelete(row) {
  406. this.$confirm(`确定删除文章「${row.title}」吗?`, '提示', {
  407. confirmButtonText: '确定',
  408. cancelButtonText: '取消',
  409. type: 'warning'
  410. }).then(async () => {
  411. try {
  412. await adminArticleDelete({ id: row.id })
  413. this.$message.success('删除成功')
  414. this.loadList()
  415. } catch (e) {
  416. this.$message.error(e.message || '删除失败')
  417. }
  418. }).catch(() => {})
  419. },
  420. handleAudit(row, auditStatus) {
  421. var self = this
  422. this.$confirm('确定' + (auditStatus === 'approved' ? '通过' : '驳回') + '该文章吗?', '提示', {
  423. confirmButtonText: '确定',
  424. cancelButtonText: '取消',
  425. type: 'warning'
  426. }).then(function() {
  427. return self.$http.post('/api/admin/articles/audit', {
  428. id: row.id,
  429. auditStatus: auditStatus,
  430. auditReason: auditStatus === 'rejected' ? self.rejectReason : ''
  431. })
  432. }).then(function(res) {
  433. if (res.data && res.data.code === 200) {
  434. self.$message.success('审核完成')
  435. self.loadList()
  436. } else {
  437. self.$message.error(res.data && res.data.message || '审核失败')
  438. }
  439. }).catch(function(e) {
  440. if (e !== 'cancel') {
  441. self.$message.error('审核失败')
  442. }
  443. })
  444. },
  445. showRejectDialog(row) {
  446. this.currentAuditRow = row
  447. this.rejectReason = ''
  448. this.rejectDialogVisible = true
  449. },
  450. confirmReject() {
  451. if (!this.rejectReason.trim()) {
  452. this.$message.warning('请填写驳回原因')
  453. return
  454. }
  455. this.rejectDialogVisible = false
  456. this.handleAudit(this.currentAuditRow, 'rejected')
  457. },
  458. async handleCopy(row) {
  459. try {
  460. const res = await adminArticleDetail({ id: row.id })
  461. if (!res.data) {
  462. this.$message.error('文章不存在')
  463. return
  464. }
  465. const d = res.data
  466. const copyData = {
  467. categoryId: d.categoryId,
  468. title: d.title ? d.title + '(副本)' : '',
  469. summary: d.summary || '',
  470. coverImage: d.coverImage || '',
  471. content: d.content || '',
  472. tags: d.tags || '',
  473. author: d.author || '',
  474. readTime: d.readTime || 3,
  475. visibility: d.visibility || 'public',
  476. visibleTo: d.visibleTo || [],
  477. status: 'draft'
  478. }
  479. const createRes = await adminArticleCreate(copyData)
  480. if (createRes.data && createRes.data.id) {
  481. this.$message.success('已复制为草稿')
  482. this.loadList()
  483. } else {
  484. this.$message.error('复制失败')
  485. }
  486. } catch (e) {
  487. this.$message.error(e.message || '复制失败')
  488. }
  489. },
  490. formatTime(t) {
  491. if (!t) return '-'
  492. return t.replace ? t.replace('T', ' ').substring(0, 19) : t
  493. },
  494. handleActionCmd(row, cmd) {
  495. switch (cmd) {
  496. case 'submitReview': this.handleSubmitReview(row); break;
  497. case 'publishDirect': this.handlePublishDirect(row); break;
  498. case 'copy': this.handleCopy(row); break;
  499. case 'delete': this.handleDelete(row); break;
  500. case 'approve': this.handleAudit(row, 'approved'); break;
  501. case 'reject': this.showRejectDialog(row); break;
  502. case 'reDraft': this.handleReDraft(row); break;
  503. case 'withdraw': this.handleWithdraw(row); break;
  504. case 'toggleFeatured': this.handleToggleFeatured(row); break;
  505. case 'rePublish': this.handlePublishDirect(row); break;
  506. }
  507. }
  508. }
  509. }
  510. </script>
  511. <style scoped>
  512. .article-manage {
  513. padding: 0;
  514. }
  515. .sort-header {
  516. cursor: pointer;
  517. user-select: none;
  518. font-weight: 500;
  519. font-size: 12px;
  520. }
  521. .sort-header:hover {
  522. color: #409eff;
  523. }
  524. .sort-tag {
  525. margin-left: 8px;
  526. cursor: pointer;
  527. white-space: nowrap;
  528. }
  529. </style>