ArticleEdit.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  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. <el-input v-model="form.content" type="textarea" :rows="15" placeholder="请输入文章内容(支持 HTML)&#10;&#10;提示:此为纯文本编辑器,后续将升级为富文本编辑器" :disabled="readonly" />
  81. <div style="color: #999; font-size: 12px; margin-top: 4px;">当前为纯文本输入,后续将升级为富文本编辑器</div>
  82. </el-form-item>
  83. <el-form-item label="标签">
  84. <el-input v-model="form.tags" placeholder="逗号分隔,如: 育儿,亲子,心理" :disabled="readonly" />
  85. </el-form-item>
  86. <el-row :gutter="20">
  87. <el-col :span="12">
  88. <el-form-item label="作者">
  89. <el-input v-model="form.author" placeholder="文章作者" :disabled="readonly" />
  90. </el-form-item>
  91. </el-col>
  92. <el-col :span="12">
  93. <el-form-item label="阅读时间(分)">
  94. <el-input-number v-model="form.readTime" :min="1" :max="999" :disabled="readonly" />
  95. </el-form-item>
  96. </el-col>
  97. </el-row>
  98. <el-form-item label="五维权重">
  99. <dimension-weight-picker v-model="form.dimensionWeights" :show-presets="true" :readonly="readonly" />
  100. </el-form-item>
  101. <el-form-item label="浏览权限">
  102. <el-radio-group v-model="form.visibility" :disabled="readonly">
  103. <el-radio label="public">公开</el-radio>
  104. <el-radio label="login">登录可见</el-radio>
  105. <el-radio label="private">私密</el-radio>
  106. </el-radio-group>
  107. </el-form-item>
  108. </el-form>
  109. </el-card>
  110. </div>
  111. </template>
  112. <script>
  113. import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
  114. import {
  115. adminArticleCreate,
  116. adminArticleUpdate,
  117. adminArticlePublish,
  118. adminArticleCategoriesList,
  119. adminArticleDetail,
  120. adminArticleSubmitReview,
  121. adminArticleReDraft
  122. } from '@/api/article.js'
  123. import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight.js'
  124. import { getTagList } from '@/api/dimension.js'
  125. export default {
  126. name: 'ArticleEdit',
  127. components: { DimensionWeightPicker },
  128. data() {
  129. return {
  130. isEdit: false,
  131. loading: false,
  132. saving: false,
  133. publishing: false,
  134. reviewLoading: false,
  135. reDraftLoading: false,
  136. readonly: false,
  137. articleWasPublished: false,
  138. articleStatus: '',
  139. rejectReason: '',
  140. categories: [],
  141. tagOptions: [],
  142. form: {
  143. id: null,
  144. categoryId: '',
  145. title: '',
  146. summary: '',
  147. coverImage: '',
  148. content: '',
  149. tags: '',
  150. contentType: 'article',
  151. difficultyLevel: 0,
  152. tagIds: [],
  153. author: '',
  154. readTime: 3,
  155. relatedDimensions: [],
  156. dimensionWeights: [],
  157. visibility: 'public',
  158. visibleTo: []
  159. }
  160. }
  161. },
  162. computed: {
  163. statusLabel() {
  164. const map = { draft: '当前状态:草稿箱', pending: '当前状态:待审核(等待管理员审核)', rejected: '当前状态:已驳回', published: '当前状态:发布中', withdrawn: '当前状态:已撤回' }
  165. return map[this.articleStatus] || ''
  166. },
  167. statusType() {
  168. const map = { draft: 'info', pending: 'warning', rejected: 'error', published: 'success', withdrawn: 'info' }
  169. return map[this.articleStatus] || 'info'
  170. },
  171. statusDescription() {
  172. if (this.articleStatus === 'rejected' && this.rejectReason) return '原因:' + this.rejectReason
  173. return ''
  174. },
  175. uploadActionUrl() {
  176. const baseURL = process.env.VUE_APP_BASE_API || ''
  177. return baseURL + '/api/admin/articles/upload/image'
  178. },
  179. uploadHeaders() {
  180. const token = localStorage.getItem('token')
  181. return token ? { Authorization: 'Bearer ' + token } : {}
  182. }
  183. },
  184. created() {
  185. this.loadCategories()
  186. this.loadTagOptions()
  187. const id = this.$route.query.id
  188. const copyId = this.$route.query.copyId
  189. this.readonly = this.$route.query.readonly === '1'
  190. if (copyId) {
  191. this.isEdit = false
  192. this.loadForCopy(copyId)
  193. } else if (id) {
  194. this.isEdit = true
  195. this.loadDetail(id)
  196. }
  197. },
  198. beforeRouteLeave(to, from, next) {
  199. // 离开前清空表单数据,避免浏览器弹出"离开页面?未保存更改"原生提示
  200. this.form = {
  201. id: null,
  202. categoryId: '',
  203. title: '',
  204. summary: '',
  205. coverImage: '',
  206. content: '',
  207. tags: '',
  208. contentType: 'article',
  209. difficultyLevel: 0,
  210. tagIds: [],
  211. author: '',
  212. readTime: 3,
  213. relatedDimensions: [],
  214. dimensionWeights: [],
  215. visibility: 'public',
  216. visibleTo: []
  217. }
  218. this.isEdit = false
  219. this.loading = false
  220. this.saving = false
  221. this.publishing = false
  222. next()
  223. },
  224. methods: {
  225. async loadCategories() {
  226. try {
  227. const res = await adminArticleCategoriesList()
  228. if (res.data) {
  229. this.categories = res.data
  230. }
  231. } catch (e) {
  232. console.error('加载分类失败', e)
  233. }
  234. },
  235. async loadTagOptions() {
  236. try {
  237. const res = await getTagList()
  238. if (res.data) {
  239. this.tagOptions = res.data
  240. }
  241. } catch (e) {
  242. console.error('加载标签失败', e)
  243. }
  244. },
  245. async loadDetail(id) {
  246. this.loading = true
  247. try {
  248. const res = await adminArticleDetail({ id })
  249. if (res.data) {
  250. const d = res.data
  251. this.articleStatus = d.status || ''
  252. this.rejectReason = d.auditReason || ''
  253. this.articleWasPublished = !!(d.publishedAt || d.status === 'published')
  254. if (d.auditStatus === 'pending') {
  255. this.readonly = true
  256. } else if (d.status === 'rejected') {
  257. this.readonly = true
  258. } else if (d.status === 'published' || d.status === 'withdrawn') {
  259. this.readonly = true
  260. } else {
  261. this.readonly = this.$route.query.readonly === '1'
  262. }
  263. this.form = {
  264. id: d.id,
  265. categoryId: d.categoryId,
  266. title: d.title || '',
  267. summary: d.summary || '',
  268. coverImage: d.coverImage || '',
  269. content: d.content || '',
  270. tags: d.tags || '',
  271. contentType: d.contentType || 'article',
  272. difficultyLevel: d.difficultyLevel || 0,
  273. tagIds: d.tagIds || [],
  274. author: d.author || '',
  275. readTime: d.readTime || 3,
  276. relatedDimensions: d.relatedDimensions || [],
  277. dimensionWeights: [],
  278. visibility: d.visibility || 'public',
  279. visibleTo: d.visibleTo || []
  280. }
  281. try {
  282. const dwRes = await getDimensionWeights('article', d.id)
  283. if (dwRes.data) {
  284. this.form.dimensionWeights = dwRes.data
  285. }
  286. } catch (e) {
  287. }
  288. }
  289. } catch (e) {
  290. this.$message.error('加载文章详情失败')
  291. } finally {
  292. this.loading = false
  293. }
  294. },
  295. async loadForCopy(id) {
  296. this.loading = true
  297. try {
  298. const res = await adminArticleDetail({ id })
  299. if (res.data) {
  300. const d = res.data
  301. this.form = {
  302. id: null,
  303. categoryId: d.categoryId,
  304. title: (d.title || '') + '(副本)',
  305. summary: d.summary || '',
  306. coverImage: d.coverImage || '',
  307. content: d.content || '',
  308. tags: d.tags || '',
  309. contentType: d.contentType || 'article',
  310. difficultyLevel: d.difficultyLevel || 0,
  311. tagIds: [],
  312. author: d.author || '',
  313. readTime: d.readTime || 3,
  314. relatedDimensions: d.relatedDimensions || [],
  315. dimensionWeights: [],
  316. visibility: d.visibility || 'public',
  317. visibleTo: d.visibleTo || []
  318. }
  319. try {
  320. const dwRes = await getDimensionWeights('article', d.id)
  321. if (dwRes.data && dwRes.data.length) {
  322. await saveDimensionWeights('article', null, dwRes.data)
  323. this.form.dimensionWeights = dwRes.data
  324. }
  325. } catch (e) {
  326. }
  327. }
  328. } catch (e) {
  329. this.$message.error('加载文章失败')
  330. } finally {
  331. this.loading = false
  332. }
  333. },
  334. beforeUpload(file) {
  335. const isImage = file.type === 'image/jpeg' || file.type === 'image/png' || file.type === 'image/gif' || file.type === 'image/webp'
  336. if (!isImage) {
  337. this.$message.error('只能上传 JPG/PNG/GIF/WebP 格式图片')
  338. return false
  339. }
  340. const isLt5M = file.size / 1024 / 1024 < 5
  341. if (!isLt5M) {
  342. this.$message.error('图片大小不能超过 5MB')
  343. return false
  344. }
  345. return true
  346. },
  347. handleUploadSuccess(res) {
  348. if (res.code === 200 && res.data) {
  349. this.form.coverImage = res.data.url || res.data
  350. this.$message.success('封面上传成功')
  351. } else {
  352. this.$message.error(res.message || '封面上传失败')
  353. }
  354. },
  355. handleUploadError() {
  356. this.$message.error('封面上传失败,请重试')
  357. },
  358. buildSubmitData() {
  359. return {
  360. id: this.form.id,
  361. categoryId: this.form.categoryId,
  362. title: this.form.title,
  363. summary: this.form.summary,
  364. coverImage: this.form.coverImage,
  365. content: this.form.content,
  366. tags: this.form.tags,
  367. contentType: this.form.contentType,
  368. difficultyLevel: this.form.difficultyLevel || 0,
  369. tagIds: this.form.tagIds,
  370. author: this.form.author,
  371. readTime: this.form.readTime,
  372. relatedDimensions: this.form.relatedDimensions,
  373. visibility: this.form.visibility,
  374. visibleTo: this.form.visibleTo
  375. }
  376. },
  377. async handleSave(publishNow) {
  378. if (publishNow) {
  379. this.publishing = true
  380. } else {
  381. this.saving = true
  382. }
  383. try {
  384. const data = this.buildSubmitData()
  385. let articleId = this.form.id
  386. if (this.isEdit) {
  387. await adminArticleUpdate(data)
  388. if (publishNow) {
  389. await adminArticlePublish({ id: this.form.id, status: 'published' })
  390. }
  391. } else {
  392. if (publishNow) {
  393. data.status = 'published'
  394. }
  395. const res = await adminArticleCreate(data)
  396. if (res.data && res.data.id) {
  397. articleId = res.data.id
  398. this.form.id = articleId
  399. this.isEdit = true
  400. }
  401. }
  402. if (articleId && this.form.dimensionWeights && this.form.dimensionWeights.length) {
  403. try {
  404. await saveDimensionWeights('article', articleId, this.form.dimensionWeights)
  405. } catch (e) {
  406. console.error('保存维度权重失败', e)
  407. }
  408. }
  409. this.$message.success(publishNow ? '文章已发布' : '草稿已保存')
  410. this.$router.push('/article-manage')
  411. } catch (e) {
  412. this.$message.error(e.message || '保存失败')
  413. } finally {
  414. this.saving = false
  415. this.publishing = false
  416. }
  417. },
  418. async handleSubmitReview() {
  419. this.reviewLoading = true
  420. try {
  421. await this.handleSave(false)
  422. if (this.form.id) {
  423. await adminArticleSubmitReview({ id: this.form.id })
  424. this.$message.success('已提交审核')
  425. this.$router.push('/article-manage')
  426. }
  427. } catch (e) {
  428. this.$message.error(e.message || '提交审核失败')
  429. } finally {
  430. this.reviewLoading = false
  431. }
  432. },
  433. async handleReDraft() {
  434. this.reDraftLoading = true
  435. try {
  436. await adminArticleReDraft({ id: this.form.id })
  437. this.$message.success('已保存到草稿箱')
  438. this.articleStatus = 'draft'
  439. this.readonly = false
  440. } catch (e) {
  441. this.$message.error(e.message || '操作失败')
  442. } finally {
  443. this.reDraftLoading = false
  444. }
  445. },
  446. handleCopyAsNew() {
  447. const data = this.buildSubmitData()
  448. delete data.id
  449. data.title = (data.title || '').replace('(副本)', '') + '(副本)'
  450. data.status = 'draft'
  451. this.saving = true
  452. adminArticleCreate(data).then(res => {
  453. if (res.data && res.data.id) {
  454. this.$message.success('已复制为草稿')
  455. this.$router.push('/article-edit?id=' + res.data.id)
  456. } else {
  457. this.$message.error('复制失败')
  458. }
  459. }).catch(e => {
  460. this.$message.error(e.message || '复制失败')
  461. }).finally(() => {
  462. this.saving = false
  463. })
  464. }
  465. }
  466. }
  467. </script>
  468. <style scoped>
  469. .article-edit {
  470. padding: 20px;
  471. }
  472. .cover-preview {
  473. width: 240px;
  474. max-height: 160px;
  475. object-fit: cover;
  476. border-radius: 6px;
  477. border: 1px solid #dcdfe6;
  478. cursor: pointer;
  479. }
  480. </style>