ArticleEdit.vue 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041
  1. <template>
  2. <div class="article-edit admin-page">
  3. <el-card>
  4. <PageHeader slot="header" :title="readonly && articleStatus !== 'rejected' ? '文章详情' : (isEdit ? '编辑文章' : '新建文章')">
  5. <div class="admin-page-actions">
  6. <el-button @click="$router.push('/article-manage')">返回列表</el-button>
  7. <template v-if="!readonly">
  8. <el-button type="primary" @click="handleSave(false)" :loading="saving">{{ articleStatus === 'published' ? '保存修改' : '保存草稿' }}</el-button>
  9. <el-button type="success" @click="handleSubmitReview" :loading="reviewLoading" v-if="isEdit && articleStatus === 'draft' && !articleWasPublished">提交审核</el-button>
  10. <el-button type="success" @click="handleSave(true)" :loading="publishing" v-if="!isEdit || articleStatus === 'draft'">直接发布</el-button>
  11. </template>
  12. <template v-if="articleStatus === 'rejected' || articleStatus === 'withdrawn'">
  13. <el-button type="primary" @click="handleReDraft" :loading="reDraftLoading">保存到草稿箱重新编辑</el-button>
  14. </template>
  15. <template v-if="readonly && articleStatus !== 'rejected'">
  16. <el-button type="default" @click="handleCopyAsNew" :loading="saving">复制并新建</el-button>
  17. </template>
  18. </div>
  19. </PageHeader>
  20. <!-- 状态横幅 -->
  21. <el-alert
  22. v-if="statusLabel"
  23. :title="statusLabel"
  24. :type="statusType"
  25. :description="statusDescription"
  26. show-icon
  27. :closable="false"
  28. style="margin-bottom: 16px;"
  29. />
  30. <div class="article-edit-form-wrap">
  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="选择分类" class="form-select" :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="选择类型" class="form-select form-select--short" :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 class="form-hint">标识内容属于哪类知识</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="选择知识标签" class="form-select form-select--wide" :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
  82. style="border-bottom: 1px solid #ccc"
  83. :editor="editorInstance"
  84. :defaultConfig="toolbarConfig"
  85. :mode="editorMode"
  86. />
  87. <Editor
  88. :defaultConfig="editorConfig"
  89. :mode="editorMode"
  90. v-model="form.content"
  91. @onCreated="handleEditorCreated"
  92. :disabled="readonly"
  93. style="height: 400px; overflow-y: hidden;"
  94. />
  95. </div>
  96. </el-form-item>
  97. <el-form-item label="标签">
  98. <el-input v-model="form.tags" placeholder="逗号分隔,如: 育儿,亲子,心理" :disabled="readonly" />
  99. </el-form-item>
  100. <el-row :gutter="20" class="form-row">
  101. <el-col :xs="24" :sm="24" :md="12" :lg="12">
  102. <el-form-item label="作者">
  103. <el-input v-model="form.author" placeholder="文章作者" :disabled="readonly" />
  104. </el-form-item>
  105. </el-col>
  106. <el-col :xs="24" :sm="24" :md="12" :lg="12">
  107. <el-form-item label="阅读时间(分)">
  108. <el-input-number v-model="form.readTime" :min="1" :max="999" :disabled="readonly" />
  109. </el-form-item>
  110. </el-col>
  111. </el-row>
  112. <el-form-item label="五维权重">
  113. <dimension-weight-picker v-model="form.dimensionWeights" :show-presets="true" :readonly="readonly" />
  114. </el-form-item>
  115. <el-form-item label="浏览权限">
  116. <el-radio-group v-model="form.visibility" :disabled="readonly">
  117. <el-radio label="public">公开</el-radio>
  118. <el-radio label="login">登录可见</el-radio>
  119. <el-radio label="private">私密</el-radio>
  120. </el-radio-group>
  121. </el-form-item>
  122. <el-form-item label="推荐排序">
  123. <el-input-number v-model="form.sortOrder" :min="0" :max="9999" :disabled="readonly" controls-position="right" />
  124. <span class="form-hint">越小越靠前,仅精选文章生效(列表上移/下移会自动调整)</span>
  125. </el-form-item>
  126. </el-form>
  127. </div>
  128. </el-card>
  129. </div>
  130. </template>
  131. <script>
  132. import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
  133. import {
  134. adminArticleCreate,
  135. adminArticleUpdate,
  136. adminArticlePublish,
  137. adminArticleCategoriesList,
  138. adminArticleDetail,
  139. adminArticleSubmitReview,
  140. adminArticleReDraft
  141. } from '@/api/article.js'
  142. import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight.js'
  143. import { getTagList } from '@/api/dimension.js'
  144. import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
  145. import { DomEditor, SlateTransforms, SlatePath } from '@wangeditor/editor'
  146. import '@wangeditor/editor/dist/css/style.css'
  147. export default {
  148. name: 'ArticleEdit',
  149. components: { DimensionWeightPicker, Editor, Toolbar },
  150. data() {
  151. return {
  152. isEdit: false,
  153. loading: false,
  154. saving: false,
  155. publishing: false,
  156. reviewLoading: false,
  157. reDraftLoading: false,
  158. readonly: false,
  159. articleWasPublished: false,
  160. articleStatus: '',
  161. rejectReason: '',
  162. categories: [],
  163. tagOptions: [],
  164. editorConfig: null,
  165. toolbarConfig: {},
  166. editorMode: 'default',
  167. editorInstance: null,
  168. form: {
  169. id: null,
  170. categoryId: '',
  171. title: '',
  172. summary: '',
  173. coverImage: '',
  174. content: '',
  175. tags: '',
  176. contentType: 'article',
  177. difficultyLevel: 0,
  178. tagIds: [],
  179. author: '',
  180. readTime: 3,
  181. sortOrder: 0,
  182. relatedDimensions: [],
  183. dimensionWeights: [],
  184. visibility: 'public',
  185. visibleTo: []
  186. }
  187. }
  188. },
  189. computed: {
  190. statusLabel() {
  191. const map = { draft: '当前状态:草稿箱', pending: '当前状态:待审核(等待管理员审核)', rejected: '当前状态:已驳回', published: '当前状态:发布中', withdrawn: '当前状态:已撤回' }
  192. return map[this.articleStatus] || ''
  193. },
  194. statusType() {
  195. const map = { draft: 'info', pending: 'warning', rejected: 'error', published: 'success', withdrawn: 'info' }
  196. return map[this.articleStatus] || 'info'
  197. },
  198. statusDescription() {
  199. if (this.articleStatus === 'rejected' && this.rejectReason) return '原因:' + this.rejectReason
  200. return ''
  201. },
  202. uploadActionUrl() {
  203. const baseURL = process.env.VUE_APP_BASE_API || ''
  204. return baseURL + '/api/admin/articles/upload/image'
  205. },
  206. uploadHeaders() {
  207. const token = localStorage.getItem('token')
  208. return token ? { Authorization: 'Bearer ' + token } : {}
  209. }
  210. },
  211. created() {
  212. this.initEditorConfig()
  213. this.loadCategories()
  214. this.loadTagOptions()
  215. const id = this.$route.query.id
  216. const copyId = this.$route.query.copyId
  217. this.readonly = this.$route.query.readonly === '1'
  218. if (copyId) {
  219. this.isEdit = false
  220. this.loadForCopy(copyId)
  221. } else if (id) {
  222. this.isEdit = true
  223. this.loadDetail(id)
  224. }
  225. },
  226. beforeRouteLeave(to, from, next) {
  227. this.destroyEditor()
  228. next()
  229. },
  230. beforeDestroy() {
  231. this.destroyEditor()
  232. },
  233. methods: {
  234. initEditorConfig() {
  235. const baseURL = process.env.VUE_APP_BASE_API || ''
  236. const token = localStorage.getItem('token')
  237. this.editorConfig = {
  238. autoFocus: false,
  239. hoverbarKeys: {
  240. video: {
  241. menuKeys: []
  242. }
  243. },
  244. MENU_CONF: {
  245. uploadImage: {
  246. server: baseURL + '/api/admin/articles/upload/image',
  247. headers: token ? { Authorization: 'Bearer ' + token } : {},
  248. maxNumberOfFiles: 1,
  249. maxFileSize: 5 * 1024 * 1024,
  250. fieldName: 'file',
  251. customAlert: (msg, type) => {
  252. this.$message && this.$message({ message: msg, type: type || 'error' })
  253. },
  254. onError: (file, err, res) => {
  255. const msg = (file && file.name || '图片') + ' 上传失败,图片不能超过 5MB'
  256. this.$message && this.$message({ message: msg, type: 'error' })
  257. },
  258. customInsert(res, insertFn) {
  259. if (res.code === 200) {
  260. const url = res.data && res.data.url ? res.data.url : res.data
  261. insertFn(baseURL + url)
  262. }
  263. }
  264. },
  265. uploadVideo: {
  266. server: baseURL + '/api/admin/articles/upload/video',
  267. headers: token ? { Authorization: 'Bearer ' + token } : {},
  268. maxNumberOfFiles: 1,
  269. maxFileSize: 50 * 1024 * 1024,
  270. fieldName: 'file',
  271. timeout: 5 * 60 * 1000,
  272. allowedFileTypes: ['video/*'],
  273. customAlert: (msg, type) => {
  274. this.$message && this.$message({ message: msg, type: type || 'error' })
  275. },
  276. onError: (file, err, res) => {
  277. const msg = (file && file.name || '视频') + ' 上传失败,视频不能超过 50MB'
  278. this.$message && this.$message({ message: msg, type: 'error' })
  279. },
  280. customInsert(res, insertFn) {
  281. if (res.code === 200) {
  282. const url = res.data && res.data.url ? res.data.url : res.data
  283. if (!url) return
  284. if (url.indexOf('http://') === 0 || url.indexOf('https://') === 0) {
  285. insertFn(url)
  286. } else {
  287. insertFn(baseURL + url)
  288. }
  289. }
  290. }
  291. }
  292. }
  293. }
  294. },
  295. destroyEditor() {
  296. this.unbindVideoFullscreen()
  297. const editor = this.editorInstance
  298. this.editorInstance = null
  299. if (!editor) return
  300. try {
  301. if (typeof editor.blur === 'function') editor.blur()
  302. } catch (e) {}
  303. try {
  304. if (typeof editor.destroy === 'function') editor.destroy()
  305. } catch (e) {}
  306. },
  307. handleEditorCreated(editor) {
  308. // Vue 2 必须 seal,否则会把 editor 做成响应式,内部 WeakMap 取不到实例
  309. this.editorInstance = Object.seal(editor)
  310. this.bindVideoInteractions(editor)
  311. },
  312. bindVideoInteractions(editor) {
  313. const root = (editor && editor.getEditableContainer && editor.getEditableContainer())
  314. || (this.$el && this.$el.querySelector('.w-e-text-container'))
  315. if (!root || this._videoFsBound) return
  316. this._videoFsBound = true
  317. this._videoEditorRoot = root
  318. this._onVideoDblClick = (e) => {
  319. const video = this.findEventVideo(e)
  320. if (!video) return
  321. e.preventDefault()
  322. this.enterVideoFullscreen(video)
  323. }
  324. this._onVideoFsClick = (e) => {
  325. if (this._justDraggedVideo) {
  326. this._justDraggedVideo = false
  327. return
  328. }
  329. const box = this.findVideoBox(e.target)
  330. if (!box) return
  331. const rect = box.getBoundingClientRect()
  332. const x = e.clientX - rect.left
  333. const y = e.clientY - rect.top
  334. if (x < rect.width - 36 || y > 36) return
  335. const video = box.querySelector('video')
  336. if (!video) return
  337. e.preventDefault()
  338. e.stopPropagation()
  339. this.enterVideoFullscreen(video)
  340. }
  341. this._onVideoMouseDown = (e) => {
  342. this.onVideoDragMouseDown(editor, e)
  343. }
  344. this._onVideoMouseMove = (e) => {
  345. this.onVideoDragMouseMove(editor, e)
  346. }
  347. this._onVideoMouseUp = (e) => {
  348. this.onVideoDragMouseUp(editor, e)
  349. }
  350. root.addEventListener('dblclick', this._onVideoDblClick)
  351. root.addEventListener('click', this._onVideoFsClick, true)
  352. root.addEventListener('mousedown', this._onVideoMouseDown, true)
  353. document.addEventListener('mousemove', this._onVideoMouseMove)
  354. document.addEventListener('mouseup', this._onVideoMouseUp)
  355. },
  356. findVideoBox(el) {
  357. if (!el || !el.closest) return null
  358. return el.closest('.w-e-textarea-video-container') || el.closest('[data-w-e-type="video"]')
  359. },
  360. findEventVideo(e) {
  361. const t = e && e.target
  362. if (!t) return null
  363. if (t.tagName === 'VIDEO') return t
  364. return t.closest ? t.closest('video') : null
  365. },
  366. isVideoDragIgnorePoint(box, clientX, clientY) {
  367. const rect = box.getBoundingClientRect()
  368. const x = clientX - rect.left
  369. const y = clientY - rect.top
  370. if (x >= rect.width - 36 && y <= 36) return true
  371. if (y > rect.height - 44) return true
  372. return false
  373. },
  374. onVideoDragMouseDown(editor, e) {
  375. if (this.readonly || (editor && editor.isDisabled && editor.isDisabled())) return
  376. if (e.button !== 0) return
  377. const box = this.findVideoBox(e.target)
  378. if (!box) return
  379. if (this.isVideoDragIgnorePoint(box, e.clientX, e.clientY)) return
  380. this._videoDragState = {
  381. startX: e.clientX,
  382. startY: e.clientY,
  383. box: box,
  384. dragging: false
  385. }
  386. },
  387. onVideoDragMouseMove(editor, e) {
  388. if (!this.editorInstance || !editor) return
  389. const state = this._videoDragState
  390. if (!state) return
  391. const dx = e.clientX - state.startX
  392. const dy = e.clientY - state.startY
  393. if (!state.dragging && (dx * dx + dy * dy) < 36) return
  394. if (!state.dragging) {
  395. state.dragging = true
  396. state.box.classList.add('is-video-dragging')
  397. const wrap = this.$el && this.$el.querySelector('.rich-editor')
  398. if (wrap) wrap.classList.add('is-dragging-video')
  399. }
  400. e.preventDefault()
  401. this.updateVideoDropIndicator(editor, e)
  402. },
  403. onVideoDragMouseUp(editor, e) {
  404. if (!this.editorInstance || !editor) {
  405. this._videoDragState = null
  406. return
  407. }
  408. const state = this._videoDragState
  409. this._videoDragState = null
  410. const wrap = this.$el && this.$el.querySelector('.rich-editor')
  411. if (wrap) wrap.classList.remove('is-dragging-video')
  412. this.clearVideoDropIndicator()
  413. if (!state) return
  414. if (state.box) state.box.classList.remove('is-video-dragging')
  415. if (!state.dragging) return
  416. this._justDraggedVideo = true
  417. this.moveVideoToEvent(editor, state.box, e)
  418. },
  419. updateVideoDropIndicator(editor, e) {
  420. const root = this.getVideoScrollRoot()
  421. if (!root) return
  422. const info = this.getVideoDropInfo(editor, e)
  423. if (!info) {
  424. this.clearVideoDropIndicator()
  425. return
  426. }
  427. let line = this._videoDropLine
  428. if (!line) {
  429. line = document.createElement('div')
  430. line.className = 'article-video-drop-line'
  431. root.appendChild(line)
  432. this._videoDropLine = line
  433. } else if (line.parentNode !== root) {
  434. root.appendChild(line)
  435. }
  436. const rootRect = root.getBoundingClientRect()
  437. line.style.top = (info.y - rootRect.top + root.scrollTop) + 'px'
  438. line.style.display = 'block'
  439. },
  440. getVideoScrollRoot() {
  441. const root = this._videoEditorRoot
  442. if (!root) return null
  443. return root.querySelector('.w-e-scroll') || root
  444. },
  445. clearVideoDropIndicator() {
  446. if (this._videoDropLine) {
  447. this._videoDropLine.style.display = 'none'
  448. }
  449. },
  450. getVideoDropInfo(editor, e) {
  451. if (!editor) return null
  452. const root = this.getVideoScrollRoot()
  453. if (!root) return null
  454. const rootRect = root.getBoundingClientRect()
  455. if (e.clientY < rootRect.top || e.clientY > rootRect.bottom) return null
  456. let path = null
  457. let targetDom = null
  458. try {
  459. const range = DomEditor.findEventRange(editor, e)
  460. if (range && range.anchor && range.anchor.path) {
  461. path = range.anchor.path
  462. }
  463. } catch (err) {}
  464. if (!path) {
  465. const hit = document.elementFromPoint(e.clientX, e.clientY)
  466. let cur = hit
  467. while (cur && cur !== root) {
  468. try {
  469. const node = DomEditor.toSlateNode(editor, cur)
  470. if (node && node.type) {
  471. path = DomEditor.findPath(editor, node)
  472. targetDom = cur
  473. break
  474. }
  475. } catch (err) {}
  476. cur = cur.parentNode
  477. }
  478. }
  479. if (!path || !path.length) {
  480. const last = editor.children && editor.children.length ? editor.children[editor.children.length - 1] : null
  481. if (!last) return { toPath: [0], y: rootRect.top + 8 }
  482. try {
  483. targetDom = editor.toDOMNode(last)
  484. } catch (err) {
  485. return { toPath: [editor.children.length], y: rootRect.bottom - 8 }
  486. }
  487. const rect = targetDom.getBoundingClientRect()
  488. return { toPath: [editor.children.length], y: rect.bottom }
  489. }
  490. const topIndex = path[0]
  491. const block = editor.children[topIndex]
  492. if (!targetDom && block && editor.toDOMNode) {
  493. try {
  494. targetDom = editor.toDOMNode(block)
  495. } catch (err) {}
  496. }
  497. if (!targetDom) return { toPath: [topIndex], y: e.clientY }
  498. const rect = targetDom.getBoundingClientRect()
  499. const insertAfter = e.clientY > rect.top + rect.height / 2
  500. return {
  501. toPath: [insertAfter ? topIndex + 1 : topIndex],
  502. y: insertAfter ? rect.bottom : rect.top
  503. }
  504. },
  505. moveVideoToEvent(editor, box, e) {
  506. if (!editor || !box) return
  507. let videoNode = null
  508. try {
  509. videoNode = DomEditor.toSlateNode(editor, box)
  510. } catch (err) {
  511. return
  512. }
  513. if (!videoNode || videoNode.type !== 'video') return
  514. let fromPath = null
  515. try {
  516. fromPath = DomEditor.findPath(editor, videoNode)
  517. } catch (err) {
  518. return
  519. }
  520. const info = this.getVideoDropInfo(editor, e)
  521. if (!info || !info.toPath) return
  522. let toPath = info.toPath.slice()
  523. const max = editor.children ? editor.children.length : 0
  524. if (toPath[0] > max) toPath[0] = max
  525. if (SlatePath.equals(fromPath, toPath)) return
  526. if (fromPath.length === 1 && toPath.length === 1 && toPath[0] === fromPath[0] + 1) return
  527. try {
  528. SlateTransforms.moveNodes(editor, { at: fromPath, to: toPath })
  529. } catch (err) {
  530. console.error('移动视频失败', err)
  531. }
  532. },
  533. enterVideoFullscreen(video) {
  534. if (!video) return
  535. const req = video.requestFullscreen || video.webkitRequestFullscreen || video.msRequestFullscreen
  536. if (req) req.call(video)
  537. },
  538. unbindVideoFullscreen() {
  539. const root = this._videoEditorRoot
  540. || (this.$el && this.$el.querySelector('.w-e-text-container'))
  541. if (root && this._onVideoDblClick) root.removeEventListener('dblclick', this._onVideoDblClick)
  542. if (root && this._onVideoFsClick) root.removeEventListener('click', this._onVideoFsClick, true)
  543. if (root && this._onVideoMouseDown) root.removeEventListener('mousedown', this._onVideoMouseDown, true)
  544. if (this._onVideoMouseMove) document.removeEventListener('mousemove', this._onVideoMouseMove)
  545. if (this._onVideoMouseUp) document.removeEventListener('mouseup', this._onVideoMouseUp)
  546. this.clearVideoDropIndicator()
  547. if (this._videoDropLine && this._videoDropLine.parentNode) {
  548. this._videoDropLine.parentNode.removeChild(this._videoDropLine)
  549. }
  550. this._videoDropLine = null
  551. this._videoEditorRoot = null
  552. this._videoDragState = null
  553. this._videoFsBound = false
  554. },
  555. async loadCategories() {
  556. try {
  557. const res = await adminArticleCategoriesList()
  558. if (res.data) {
  559. this.categories = res.data
  560. }
  561. } catch (e) {
  562. console.error('加载分类失败', e)
  563. }
  564. },
  565. async loadTagOptions() {
  566. try {
  567. const res = await getTagList()
  568. if (res.data) {
  569. this.tagOptions = res.data
  570. }
  571. } catch (e) {
  572. console.error('加载标签失败', e)
  573. }
  574. },
  575. async loadDetail(id) {
  576. this.loading = true
  577. try {
  578. const res = await adminArticleDetail({ id })
  579. if (res.data) {
  580. const d = res.data
  581. this.articleStatus = d.status || ''
  582. this.rejectReason = d.auditReason || ''
  583. this.articleWasPublished = !!(d.publishedAt || d.status === 'published')
  584. if (d.auditStatus === 'pending') {
  585. this.readonly = true
  586. } else if (d.status === 'rejected') {
  587. this.readonly = true
  588. } else if (d.status === 'withdrawn') {
  589. this.readonly = true
  590. } else {
  591. this.readonly = this.$route.query.readonly === '1'
  592. }
  593. this.form = {
  594. id: d.id,
  595. categoryId: d.categoryId,
  596. title: d.title || '',
  597. summary: d.summary || '',
  598. coverImage: d.coverImage || '',
  599. content: d.content || '',
  600. tags: d.tags || '',
  601. contentType: d.contentType || 'article',
  602. difficultyLevel: d.difficultyLevel || 0,
  603. tagIds: d.tagIds || [],
  604. author: d.author || '',
  605. readTime: d.readTime || 3,
  606. sortOrder: d.sortOrder || 0,
  607. relatedDimensions: d.relatedDimensions || [],
  608. dimensionWeights: [],
  609. visibility: d.visibility || 'public',
  610. visibleTo: d.visibleTo || []
  611. }
  612. try {
  613. const dwRes = await getDimensionWeights('article', d.id)
  614. if (dwRes.data) {
  615. this.form.dimensionWeights = dwRes.data
  616. }
  617. } catch (e) {
  618. }
  619. }
  620. } catch (e) {
  621. this.$message.error('加载文章详情失败')
  622. } finally {
  623. this.loading = false
  624. }
  625. },
  626. async loadForCopy(id) {
  627. this.loading = true
  628. try {
  629. const res = await adminArticleDetail({ id })
  630. if (res.data) {
  631. const d = res.data
  632. this.form = {
  633. id: null,
  634. categoryId: d.categoryId,
  635. title: (d.title || '') + '(副本)',
  636. summary: d.summary || '',
  637. coverImage: d.coverImage || '',
  638. content: d.content || '',
  639. tags: d.tags || '',
  640. contentType: d.contentType || 'article',
  641. difficultyLevel: d.difficultyLevel || 0,
  642. tagIds: [],
  643. author: d.author || '',
  644. readTime: d.readTime || 3,
  645. sortOrder: 0,
  646. relatedDimensions: d.relatedDimensions || [],
  647. dimensionWeights: [],
  648. visibility: d.visibility || 'public',
  649. visibleTo: d.visibleTo || []
  650. }
  651. try {
  652. const dwRes = await getDimensionWeights('article', d.id)
  653. if (dwRes.data && dwRes.data.length) {
  654. await saveDimensionWeights('article', null, dwRes.data)
  655. this.form.dimensionWeights = dwRes.data
  656. }
  657. } catch (e) {
  658. }
  659. }
  660. } catch (e) {
  661. this.$message.error('加载文章失败')
  662. } finally {
  663. this.loading = false
  664. }
  665. },
  666. beforeUpload(file) {
  667. const isImage = file.type === 'image/jpeg' || file.type === 'image/png' || file.type === 'image/gif' || file.type === 'image/webp'
  668. if (!isImage) {
  669. this.$message.error('只能上传 JPG/PNG/GIF/WebP 格式图片')
  670. return false
  671. }
  672. const isLt5M = file.size / 1024 / 1024 < 5
  673. if (!isLt5M) {
  674. this.$message.error('图片大小不能超过 5MB')
  675. return false
  676. }
  677. return true
  678. },
  679. handleUploadSuccess(res) {
  680. if (res.code === 200 && res.data) {
  681. this.form.coverImage = res.data.url || res.data
  682. this.$message.success('封面上传成功')
  683. } else {
  684. this.$message.error(res.message || '封面上传失败')
  685. }
  686. },
  687. handleUploadError() {
  688. this.$message.error('封面上传失败,请重试')
  689. },
  690. buildSubmitData() {
  691. return {
  692. id: this.form.id,
  693. categoryId: this.form.categoryId,
  694. title: this.form.title,
  695. summary: this.form.summary,
  696. coverImage: this.form.coverImage,
  697. content: this.form.content,
  698. tags: this.form.tags,
  699. contentType: this.form.contentType,
  700. difficultyLevel: this.form.difficultyLevel || 0,
  701. tagIds: this.form.tagIds,
  702. author: this.form.author,
  703. readTime: this.form.readTime,
  704. sortOrder: this.form.sortOrder,
  705. relatedDimensions: this.form.relatedDimensions,
  706. visibility: this.form.visibility,
  707. visibleTo: this.form.visibleTo
  708. }
  709. },
  710. async handleSave(publishNow) {
  711. if (publishNow) {
  712. this.publishing = true
  713. } else {
  714. this.saving = true
  715. }
  716. try {
  717. const data = this.buildSubmitData()
  718. let articleId = this.form.id
  719. if (this.isEdit) {
  720. await adminArticleUpdate(data)
  721. if (publishNow) {
  722. await adminArticlePublish({ id: this.form.id, status: 'published' })
  723. }
  724. } else {
  725. if (publishNow) {
  726. data.status = 'published'
  727. }
  728. const res = await adminArticleCreate(data)
  729. if (res.data && res.data.id) {
  730. articleId = res.data.id
  731. this.form.id = articleId
  732. this.isEdit = true
  733. }
  734. }
  735. if (articleId && this.form.dimensionWeights && this.form.dimensionWeights.length) {
  736. try {
  737. await saveDimensionWeights('article', articleId, this.form.dimensionWeights)
  738. } catch (e) {
  739. console.error('保存维度权重失败', e)
  740. }
  741. }
  742. this.$message.success(publishNow ? '文章已发布' : (this.articleStatus === 'published' ? '修改已保存' : '草稿已保存'))
  743. this.destroyEditor()
  744. this.$router.push('/article-manage')
  745. } catch (e) {
  746. this.$message.error(e.message || '保存失败')
  747. } finally {
  748. this.saving = false
  749. this.publishing = false
  750. }
  751. },
  752. async handleSubmitReview() {
  753. this.reviewLoading = true
  754. try {
  755. await this.handleSave(false)
  756. if (this.form.id) {
  757. await adminArticleSubmitReview({ id: this.form.id })
  758. this.$message.success('已提交审核')
  759. this.$router.push('/article-manage')
  760. }
  761. } catch (e) {
  762. this.$message.error(e.message || '提交审核失败')
  763. } finally {
  764. this.reviewLoading = false
  765. }
  766. },
  767. async handleReDraft() {
  768. this.reDraftLoading = true
  769. try {
  770. await adminArticleReDraft({ id: this.form.id })
  771. this.$message.success('已保存到草稿箱')
  772. this.articleStatus = 'draft'
  773. this.readonly = false
  774. } catch (e) {
  775. this.$message.error(e.message || '操作失败')
  776. } finally {
  777. this.reDraftLoading = false
  778. }
  779. },
  780. handleCopyAsNew() {
  781. const data = this.buildSubmitData()
  782. delete data.id
  783. data.title = (data.title || '').replace('(副本)', '') + '(副本)'
  784. data.status = 'draft'
  785. this.saving = true
  786. adminArticleCreate(data).then(res => {
  787. if (res.data && res.data.id) {
  788. this.$message.success('已复制为草稿')
  789. this.$router.push('/article-edit?id=' + res.data.id)
  790. } else {
  791. this.$message.error('复制失败')
  792. }
  793. }).catch(e => {
  794. this.$message.error(e.message || '复制失败')
  795. }).finally(() => {
  796. this.saving = false
  797. })
  798. }
  799. }
  800. }
  801. </script>
  802. <style scoped>
  803. .article-edit {
  804. padding: 20px;
  805. flex: 1;
  806. display: flex;
  807. flex-direction: column;
  808. min-height: 0;
  809. }
  810. .article-edit > .el-card {
  811. flex: 1;
  812. display: flex;
  813. flex-direction: column;
  814. min-height: 0;
  815. width: 100%;
  816. }
  817. .article-edit > .el-card /deep/ .el-card__body {
  818. flex: 1;
  819. display: flex;
  820. flex-direction: column;
  821. min-height: 0;
  822. overflow: hidden;
  823. padding: 24px;
  824. width: 100%;
  825. box-sizing: border-box;
  826. }
  827. .article-edit /deep/ .el-form {
  828. width: 100%;
  829. }
  830. .article-edit /deep/ .admin-page-header {
  831. display: flex;
  832. align-items: flex-start;
  833. justify-content: space-between;
  834. gap: 12px;
  835. flex-wrap: wrap;
  836. }
  837. .article-edit /deep/ .admin-page-title {
  838. font-size: 18px;
  839. font-weight: 600;
  840. line-height: 32px;
  841. }
  842. .article-edit /deep/ .admin-page-actions {
  843. display: flex;
  844. align-items: center;
  845. justify-content: flex-end;
  846. gap: 8px;
  847. flex-wrap: wrap;
  848. margin-left: auto;
  849. }
  850. .cover-preview {
  851. width: 240px;
  852. max-height: 160px;
  853. object-fit: cover;
  854. border-radius: 6px;
  855. border: 1px solid #dcdfe6;
  856. cursor: pointer;
  857. }
  858. .rich-editor {
  859. border: 1px solid #dcdfe6;
  860. border-radius: 4px;
  861. }
  862. .rich-editor /deep/ .w-e-toolbar {
  863. flex-wrap: wrap;
  864. }
  865. .rich-editor /deep/ .w-e-text-container {
  866. position: relative;
  867. height: 400px;
  868. }
  869. .rich-editor /deep/ .w-e-scroll {
  870. position: relative;
  871. }
  872. .rich-editor /deep/ .w-e-textarea-video-container,
  873. .rich-editor /deep/ [data-w-e-type="video"] {
  874. position: relative;
  875. background-image: none !important;
  876. background-color: #111;
  877. border: 1px solid #dcdfe6;
  878. border-radius: 6px;
  879. margin: 10px 0;
  880. padding: 0;
  881. max-width: 360px;
  882. width: 360px;
  883. overflow: hidden;
  884. line-height: 0;
  885. text-align: left;
  886. cursor: grab;
  887. }
  888. .rich-editor /deep/ .w-e-textarea-video-container.is-video-dragging,
  889. .rich-editor /deep/ [data-w-e-type="video"].is-video-dragging {
  890. opacity: 0.45;
  891. cursor: grabbing;
  892. }
  893. .rich-editor.is-dragging-video {
  894. cursor: grabbing;
  895. }
  896. .rich-editor /deep/ .w-e-textarea-video-container video,
  897. .rich-editor /deep/ [data-w-e-type="video"] video {
  898. display: block;
  899. width: 100%;
  900. max-height: 202px;
  901. height: auto;
  902. background: #000;
  903. object-fit: contain;
  904. cursor: grab;
  905. }
  906. .rich-editor /deep/ .article-video-drop-line {
  907. position: absolute;
  908. left: 10px;
  909. right: 10px;
  910. height: 3px;
  911. background: #409eff;
  912. border-radius: 2px;
  913. pointer-events: none;
  914. z-index: 60;
  915. display: none;
  916. }
  917. .rich-editor /deep/ .w-e-textarea-video-container::after,
  918. .rich-editor /deep/ [data-w-e-type="video"]::after {
  919. content: '';
  920. position: absolute;
  921. top: 6px;
  922. right: 6px;
  923. width: 28px;
  924. height: 28px;
  925. border-radius: 4px;
  926. background-color: rgba(0, 0, 0, 0.55);
  927. background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23fff'%3E%3Cpath d='M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z'/%3E%3C/svg%3E");
  928. background-repeat: no-repeat;
  929. background-position: center;
  930. background-size: 14px;
  931. z-index: 3;
  932. pointer-events: none;
  933. }
  934. @media (max-width: 768px) {
  935. .rich-editor /deep/ .w-e-textarea-video-container,
  936. .rich-editor /deep/ [data-w-e-type="video"] {
  937. width: 100%;
  938. max-width: 100%;
  939. }
  940. }
  941. /* ========== 响应式表单 ========== */
  942. /* 统一选择器宽度自适应 */
  943. .form-select {
  944. width: 300px;
  945. }
  946. .form-select--short {
  947. width: 200px;
  948. }
  949. .form-select--wide {
  950. width: 400px;
  951. }
  952. .form-hint {
  953. color: #999;
  954. font-size: 12px;
  955. margin-left: 10px;
  956. white-space: nowrap;
  957. }
  958. @media (max-width: 768px) {
  959. .article-edit /deep/ .el-form-item {
  960. display: block !important;
  961. }
  962. .article-edit /deep/ .el-form-item__content {
  963. display: block !important;
  964. width: 100% !important;
  965. margin-left: 0 !important;
  966. }
  967. .article-edit /deep/ .el-form-item__label {
  968. text-align: left !important;
  969. display: block !important;
  970. float: none !important;
  971. }
  972. .form-select,
  973. .form-select--short,
  974. .form-select--wide {
  975. width: 100% !important;
  976. }
  977. .form-row {
  978. display: block;
  979. }
  980. .form-row /deep/ .el-col {
  981. width: 100% !important;
  982. }
  983. .form-hint {
  984. display: block;
  985. margin-left: 0;
  986. margin-top: 4px;
  987. white-space: normal;
  988. }
  989. .cover-preview {
  990. width: 100%;
  991. max-width: 240px;
  992. }
  993. .article-edit /deep/ .admin-page-actions {
  994. margin-left: 0;
  995. width: 100%;
  996. }
  997. .article-edit /deep/ .el-rate {
  998. display: inline-flex;
  999. flex-wrap: wrap;
  1000. }
  1001. }
  1002. /* 内滚动容器:利用 el-card__body flex column 让表单区域独立滚动 */
  1003. .article-edit-form-wrap {
  1004. flex: 1;
  1005. min-height: 0;
  1006. overflow-y: auto;
  1007. overflow-x: hidden;
  1008. padding-right: 8px;
  1009. margin-right: -8px;
  1010. }
  1011. </style>