ArticleEdit.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. <template>
  2. <div class="article-edit admin-page">
  3. <el-card>
  4. <div slot="header">
  5. <span>{{ readonly && articleStatus !== 'rejected' ? '文章详情' : (isEdit ? '编辑文章' : '新建文章') }}</span>
  6. <div style="float: right;">
  7. <el-button @click="$router.push('/article-manage')">返回列表</el-button>
  8. <template v-if="!readonly">
  9. <el-button type="primary" @click="handleSave(false)" :loading="saving">保存草稿</el-button>
  10. <el-button type="success" @click="handleSubmitReview" :loading="reviewLoading" v-if="isEdit && articleStatus === 'draft' && !articleWasPublished">提交审核</el-button>
  11. <el-button type="success" @click="handleSave(true)" :loading="publishing" v-if="!isEdit || articleStatus === 'draft'">直接发布</el-button>
  12. </template>
  13. <template v-if="articleStatus === 'rejected'">
  14. <el-button type="primary" @click="handleReDraft" :loading="reDraftLoading">保存到草稿箱重新编辑</el-button>
  15. </template>
  16. <template v-if="readonly && articleStatus !== 'rejected'">
  17. <el-button type="default" @click="handleCopyAsNew">复制并新建</el-button>
  18. </template>
  19. </div>
  20. </div>
  21. <!-- 状态横幅 -->
  22. <el-alert
  23. v-if="statusLabel"
  24. :title="statusLabel"
  25. :type="statusType"
  26. :description="statusDescription"
  27. show-icon
  28. :closable="false"
  29. style="margin-bottom: 16px;"
  30. />
  31. <el-form :model="form" label-width="120px" v-loading="loading">
  32. <el-form-item label="标题">
  33. <el-input v-model="form.title" placeholder="请输入文章标题" maxlength="200" show-word-limit :disabled="readonly" />
  34. </el-form-item>
  35. <el-form-item label="分类">
  36. <el-select v-model="form.categoryId" placeholder="选择分类" style="width: 300px;" :disabled="readonly">
  37. <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
  38. </el-select>
  39. </el-form-item>
  40. <el-form-item label="内容类型">
  41. <el-select v-model="form.contentType" placeholder="选择类型" style="width: 200px;" :disabled="readonly">
  42. <el-option label="文章" value="article" />
  43. <el-option label="知识点" value="knowledge" />
  44. <el-option label="课程" value="course" />
  45. <el-option label="贴士" value="tip" />
  46. </el-select>
  47. <span style="color: #999; font-size: 12px; margin-left: 10px;">标识内容属于哪类知识</span>
  48. </el-form-item>
  49. <el-form-item label="难度等级">
  50. <el-rate v-model="form.difficultyLevel" :max="5" :disabled="readonly" show-text :texts="['入门', '简单', '中等', '较难', '深入']" style="margin-top: 6px;" />
  51. </el-form-item>
  52. <el-form-item label="知识标签">
  53. <el-select v-model="form.tagIds" multiple placeholder="选择知识标签" style="width: 400px;" :disabled="readonly" clearable>
  54. <el-option v-for="tag in tagOptions" :key="tag.id" :label="tag.name" :value="tag.id" />
  55. </el-select>
  56. </el-form-item>
  57. <el-form-item label="摘要">
  58. <el-input v-model="form.summary" type="textarea" :rows="3" placeholder="请输入文章摘要" maxlength="500" show-word-limit :disabled="readonly" />
  59. </el-form-item>
  60. <el-form-item label="封面图">
  61. <template v-if="!readonly">
  62. <el-upload
  63. :action="uploadActionUrl"
  64. :headers="uploadHeaders"
  65. :on-success="handleUploadSuccess"
  66. :on-error="handleUploadError"
  67. :before-upload="beforeUpload"
  68. :show-file-list="false"
  69. accept="image/jpeg,image/png,image/gif,image/webp"
  70. >
  71. <img v-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
  72. <el-button v-else type="primary" plain>+ 上传封面</el-button>
  73. </el-upload>
  74. <el-button v-if="form.coverImage" size="mini" type="text" style="margin-left: 10px;" @click="form.coverImage = ''">清除</el-button>
  75. </template>
  76. <img v-else-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
  77. <span v-else class="text-muted">无封面图</span>
  78. </el-form-item>
  79. <el-form-item label="内容">
  80. <div class="rich-editor" :class="{ 'is-disabled': readonly }">
  81. <Toolbar :editor="editorInstance" :defaultConfig="toolbarConfig" />
  82. <Editor :defaultConfig="editorConfig" v-model="form.content" @onCreated="handleEditorCreated" :disabled="readonly" style="min-height: 400px;" />
  83. </div>
  84. </el-form-item>
  85. <el-form-item label="标签">
  86. <el-input v-model="form.tags" placeholder="逗号分隔,如: 育儿,亲子,心理" :disabled="readonly" />
  87. </el-form-item>
  88. <el-row :gutter="20">
  89. <el-col :span="12">
  90. <el-form-item label="作者">
  91. <el-input v-model="form.author" placeholder="文章作者" :disabled="readonly" />
  92. </el-form-item>
  93. </el-col>
  94. <el-col :span="12">
  95. <el-form-item label="阅读时间(分)">
  96. <el-input-number v-model="form.readTime" :min="1" :max="999" :disabled="readonly" />
  97. </el-form-item>
  98. </el-col>
  99. </el-row>
  100. <el-form-item label="五维权重">
  101. <dimension-weight-picker v-model="form.dimensionWeights" :show-presets="true" :readonly="readonly" />
  102. </el-form-item>
  103. <el-form-item label="浏览权限">
  104. <el-radio-group v-model="form.visibility" :disabled="readonly">
  105. <el-radio label="public">公开</el-radio>
  106. <el-radio label="login">登录可见</el-radio>
  107. <el-radio label="private">私密</el-radio>
  108. </el-radio-group>
  109. </el-form-item>
  110. </el-form>
  111. </el-card>
  112. </div>
  113. </template>
  114. <script>
  115. import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
  116. import {
  117. adminArticleCreate,
  118. adminArticleUpdate,
  119. adminArticlePublish,
  120. adminArticleCategoriesList,
  121. adminArticleDetail,
  122. adminArticleSubmitReview,
  123. adminArticleReDraft
  124. } from '@/api/article.js'
  125. import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight.js'
  126. import { getTagList } from '@/api/dimension.js'
  127. import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
  128. import '@wangeditor/editor/dist/css/style.css'
  129. export default {
  130. name: 'ArticleEdit',
  131. components: { DimensionWeightPicker, Editor, Toolbar },
  132. data() {
  133. return {
  134. isEdit: false,
  135. loading: false,
  136. saving: false,
  137. publishing: false,
  138. reviewLoading: false,
  139. reDraftLoading: false,
  140. readonly: false,
  141. articleWasPublished: false,
  142. articleStatus: '',
  143. rejectReason: '',
  144. categories: [],
  145. tagOptions: [],
  146. editorConfig: null,
  147. toolbarConfig: {},
  148. editorInstance: null,
  149. form: {
  150. id: null,
  151. categoryId: '',
  152. title: '',
  153. summary: '',
  154. coverImage: '',
  155. content: '',
  156. tags: '',
  157. contentType: 'article',
  158. difficultyLevel: 0,
  159. tagIds: [],
  160. author: '',
  161. readTime: 3,
  162. relatedDimensions: [],
  163. dimensionWeights: [],
  164. visibility: 'public',
  165. visibleTo: []
  166. }
  167. }
  168. },
  169. computed: {
  170. statusLabel() {
  171. const map = { draft: '当前状态:草稿箱', pending: '当前状态:待审核(等待管理员审核)', rejected: '当前状态:已驳回', published: '当前状态:发布中', withdrawn: '当前状态:已撤回' }
  172. return map[this.articleStatus] || ''
  173. },
  174. statusType() {
  175. const map = { draft: 'info', pending: 'warning', rejected: 'error', published: 'success', withdrawn: 'info' }
  176. return map[this.articleStatus] || 'info'
  177. },
  178. statusDescription() {
  179. if (this.articleStatus === 'rejected' && this.rejectReason) return '原因:' + this.rejectReason
  180. return ''
  181. },
  182. uploadActionUrl() {
  183. const baseURL = process.env.VUE_APP_BASE_API || ''
  184. return baseURL + '/api/admin/articles/upload/image'
  185. },
  186. uploadHeaders() {
  187. const token = localStorage.getItem('token')
  188. return token ? { Authorization: 'Bearer ' + token } : {}
  189. }
  190. },
  191. created() {
  192. this.initEditorConfig()
  193. this.loadCategories()
  194. this.loadTagOptions()
  195. const id = this.$route.query.id
  196. const copyId = this.$route.query.copyId
  197. this.readonly = this.$route.query.readonly === '1'
  198. if (copyId) {
  199. this.isEdit = false
  200. this.loadForCopy(copyId)
  201. } else if (id) {
  202. this.isEdit = true
  203. this.loadDetail(id)
  204. }
  205. },
  206. beforeRouteLeave(to, from, next) {
  207. // 离开前清空表单数据,避免浏览器弹出"离开页面?未保存更改"原生提示
  208. this.form = {
  209. id: null,
  210. categoryId: '',
  211. title: '',
  212. summary: '',
  213. coverImage: '',
  214. content: '',
  215. tags: '',
  216. contentType: 'article',
  217. difficultyLevel: 0,
  218. tagIds: [],
  219. author: '',
  220. readTime: 3,
  221. relatedDimensions: [],
  222. dimensionWeights: [],
  223. visibility: 'public',
  224. visibleTo: []
  225. }
  226. this.isEdit = false
  227. this.loading = false
  228. this.saving = false
  229. this.publishing = false
  230. next()
  231. },
  232. methods: {
  233. initEditorConfig() {
  234. const baseURL = process.env.VUE_APP_BASE_API || ''
  235. const token = localStorage.getItem('token')
  236. this.editorConfig = {
  237. MENU_CONF: {
  238. uploadImage: {
  239. server: baseURL + '/api/admin/articles/upload/image',
  240. headers: token ? { Authorization: 'Bearer ' + token } : {},
  241. maxNumberOfFiles: 1,
  242. fieldName: 'file',
  243. customAlert: (msg, type) => {
  244. this.$message && this.$message({ message: msg, type: type || 'error' })
  245. },
  246. onSuccess(res, insertFn) {
  247. if (res.code === 200) {
  248. insertFn(res.data.url || res.data)
  249. }
  250. }
  251. }
  252. }
  253. }
  254. },
  255. handleEditorCreated(editor) {
  256. this.editorInstance = editor
  257. },
  258. async loadCategories() {
  259. try {
  260. const res = await adminArticleCategoriesList()
  261. if (res.data) {
  262. this.categories = res.data
  263. }
  264. } catch (e) {
  265. console.error('加载分类失败', e)
  266. }
  267. },
  268. async loadTagOptions() {
  269. try {
  270. const res = await getTagList()
  271. if (res.data) {
  272. this.tagOptions = res.data
  273. }
  274. } catch (e) {
  275. console.error('加载标签失败', e)
  276. }
  277. },
  278. async loadDetail(id) {
  279. this.loading = true
  280. try {
  281. const res = await adminArticleDetail({ id })
  282. if (res.data) {
  283. const d = res.data
  284. this.articleStatus = d.status || ''
  285. this.rejectReason = d.auditReason || ''
  286. this.articleWasPublished = !!(d.publishedAt || d.status === 'published')
  287. if (d.auditStatus === 'pending') {
  288. this.readonly = true
  289. } else if (d.status === 'rejected') {
  290. this.readonly = true
  291. } else if (d.status === 'published' || d.status === 'withdrawn') {
  292. this.readonly = true
  293. } else {
  294. this.readonly = this.$route.query.readonly === '1'
  295. }
  296. this.form = {
  297. id: d.id,
  298. categoryId: d.categoryId,
  299. title: d.title || '',
  300. summary: d.summary || '',
  301. coverImage: d.coverImage || '',
  302. content: d.content || '',
  303. tags: d.tags || '',
  304. contentType: d.contentType || 'article',
  305. difficultyLevel: d.difficultyLevel || 0,
  306. tagIds: d.tagIds || [],
  307. author: d.author || '',
  308. readTime: d.readTime || 3,
  309. relatedDimensions: d.relatedDimensions || [],
  310. dimensionWeights: [],
  311. visibility: d.visibility || 'public',
  312. visibleTo: d.visibleTo || []
  313. }
  314. try {
  315. const dwRes = await getDimensionWeights('article', d.id)
  316. if (dwRes.data) {
  317. this.form.dimensionWeights = dwRes.data
  318. }
  319. } catch (e) {
  320. }
  321. }
  322. } catch (e) {
  323. this.$message.error('加载文章详情失败')
  324. } finally {
  325. this.loading = false
  326. }
  327. },
  328. async loadForCopy(id) {
  329. this.loading = true
  330. try {
  331. const res = await adminArticleDetail({ id })
  332. if (res.data) {
  333. const d = res.data
  334. this.form = {
  335. id: null,
  336. categoryId: d.categoryId,
  337. title: (d.title || '') + '(副本)',
  338. summary: d.summary || '',
  339. coverImage: d.coverImage || '',
  340. content: d.content || '',
  341. tags: d.tags || '',
  342. contentType: d.contentType || 'article',
  343. difficultyLevel: d.difficultyLevel || 0,
  344. tagIds: [],
  345. author: d.author || '',
  346. readTime: d.readTime || 3,
  347. relatedDimensions: d.relatedDimensions || [],
  348. dimensionWeights: [],
  349. visibility: d.visibility || 'public',
  350. visibleTo: d.visibleTo || []
  351. }
  352. try {
  353. const dwRes = await getDimensionWeights('article', d.id)
  354. if (dwRes.data && dwRes.data.length) {
  355. await saveDimensionWeights('article', null, dwRes.data)
  356. this.form.dimensionWeights = dwRes.data
  357. }
  358. } catch (e) {
  359. }
  360. }
  361. } catch (e) {
  362. this.$message.error('加载文章失败')
  363. } finally {
  364. this.loading = false
  365. }
  366. },
  367. beforeUpload(file) {
  368. const isImage = file.type === 'image/jpeg' || file.type === 'image/png' || file.type === 'image/gif' || file.type === 'image/webp'
  369. if (!isImage) {
  370. this.$message.error('只能上传 JPG/PNG/GIF/WebP 格式图片')
  371. return false
  372. }
  373. const isLt5M = file.size / 1024 / 1024 < 5
  374. if (!isLt5M) {
  375. this.$message.error('图片大小不能超过 5MB')
  376. return false
  377. }
  378. return true
  379. },
  380. handleUploadSuccess(res) {
  381. if (res.code === 200 && res.data) {
  382. this.form.coverImage = res.data.url || res.data
  383. this.$message.success('封面上传成功')
  384. } else {
  385. this.$message.error(res.message || '封面上传失败')
  386. }
  387. },
  388. handleUploadError() {
  389. this.$message.error('封面上传失败,请重试')
  390. },
  391. buildSubmitData() {
  392. return {
  393. id: this.form.id,
  394. categoryId: this.form.categoryId,
  395. title: this.form.title,
  396. summary: this.form.summary,
  397. coverImage: this.form.coverImage,
  398. content: this.form.content,
  399. tags: this.form.tags,
  400. contentType: this.form.contentType,
  401. difficultyLevel: this.form.difficultyLevel || 0,
  402. tagIds: this.form.tagIds,
  403. author: this.form.author,
  404. readTime: this.form.readTime,
  405. relatedDimensions: this.form.relatedDimensions,
  406. visibility: this.form.visibility,
  407. visibleTo: this.form.visibleTo
  408. }
  409. },
  410. async handleSave(publishNow) {
  411. if (publishNow) {
  412. this.publishing = true
  413. } else {
  414. this.saving = true
  415. }
  416. try {
  417. const data = this.buildSubmitData()
  418. let articleId = this.form.id
  419. if (this.isEdit) {
  420. await adminArticleUpdate(data)
  421. if (publishNow) {
  422. await adminArticlePublish({ id: this.form.id, status: 'published' })
  423. }
  424. } else {
  425. if (publishNow) {
  426. data.status = 'published'
  427. }
  428. const res = await adminArticleCreate(data)
  429. if (res.data && res.data.id) {
  430. articleId = res.data.id
  431. this.form.id = articleId
  432. this.isEdit = true
  433. }
  434. }
  435. if (articleId && this.form.dimensionWeights && this.form.dimensionWeights.length) {
  436. try {
  437. await saveDimensionWeights('article', articleId, this.form.dimensionWeights)
  438. } catch (e) {
  439. console.error('保存维度权重失败', e)
  440. }
  441. }
  442. this.$message.success(publishNow ? '文章已发布' : '草稿已保存')
  443. this.$router.push('/article-manage')
  444. } catch (e) {
  445. this.$message.error(e.message || '保存失败')
  446. } finally {
  447. this.saving = false
  448. this.publishing = false
  449. }
  450. },
  451. async handleSubmitReview() {
  452. this.reviewLoading = true
  453. try {
  454. await this.handleSave(false)
  455. if (this.form.id) {
  456. await adminArticleSubmitReview({ id: this.form.id })
  457. this.$message.success('已提交审核')
  458. this.$router.push('/article-manage')
  459. }
  460. } catch (e) {
  461. this.$message.error(e.message || '提交审核失败')
  462. } finally {
  463. this.reviewLoading = false
  464. }
  465. },
  466. async handleReDraft() {
  467. this.reDraftLoading = true
  468. try {
  469. await adminArticleReDraft({ id: this.form.id })
  470. this.$message.success('已保存到草稿箱')
  471. this.articleStatus = 'draft'
  472. this.readonly = false
  473. } catch (e) {
  474. this.$message.error(e.message || '操作失败')
  475. } finally {
  476. this.reDraftLoading = false
  477. }
  478. },
  479. handleCopyAsNew() {
  480. const data = this.buildSubmitData()
  481. delete data.id
  482. data.title = (data.title || '').replace('(副本)', '') + '(副本)'
  483. data.status = 'draft'
  484. this.saving = true
  485. adminArticleCreate(data).then(res => {
  486. if (res.data && res.data.id) {
  487. this.$message.success('已复制为草稿')
  488. this.$router.push('/article-edit?id=' + res.data.id)
  489. } else {
  490. this.$message.error('复制失败')
  491. }
  492. }).catch(e => {
  493. this.$message.error(e.message || '复制失败')
  494. }).finally(() => {
  495. this.saving = false
  496. })
  497. }
  498. }
  499. }
  500. </script>
  501. <style scoped>
  502. .article-edit {
  503. padding: 20px;
  504. overflow-y: auto;
  505. }
  506. .cover-preview {
  507. width: 240px;
  508. max-height: 160px;
  509. object-fit: cover;
  510. border-radius: 6px;
  511. border: 1px solid #dcdfe6;
  512. cursor: pointer;
  513. }
  514. .rich-editor {
  515. border: 1px solid #dcdfe6;
  516. border-radius: 4px;
  517. }
  518. .rich-editor /deep/ .w-e-toolbar {
  519. flex-wrap: wrap;
  520. }
  521. .rich-editor /deep/ .w-e-text-container {
  522. min-height: 400px;
  523. }
  524. </style>