| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541 |
- <template>
- <div class="article-edit admin-page">
- <el-card>
- <div slot="header">
- <span>{{ readonly && articleStatus !== 'rejected' ? '文章详情' : (isEdit ? '编辑文章' : '新建文章') }}</span>
- <div style="float: right;">
- <el-button @click="$router.push('/article-manage')">返回列表</el-button>
- <template v-if="!readonly">
- <el-button type="primary" @click="handleSave(false)" :loading="saving">保存草稿</el-button>
- <el-button type="success" @click="handleSubmitReview" :loading="reviewLoading" v-if="isEdit && articleStatus === 'draft' && !articleWasPublished">提交审核</el-button>
- <el-button type="success" @click="handleSave(true)" :loading="publishing" v-if="!isEdit || articleStatus === 'draft'">直接发布</el-button>
- </template>
- <template v-if="articleStatus === 'rejected'">
- <el-button type="primary" @click="handleReDraft" :loading="reDraftLoading">保存到草稿箱重新编辑</el-button>
- </template>
- <template v-if="readonly && articleStatus !== 'rejected'">
- <el-button type="default" @click="handleCopyAsNew">复制并新建</el-button>
- </template>
- </div>
- </div>
- <!-- 状态横幅 -->
- <el-alert
- v-if="statusLabel"
- :title="statusLabel"
- :type="statusType"
- :description="statusDescription"
- show-icon
- :closable="false"
- style="margin-bottom: 16px;"
- />
- <el-form :model="form" label-width="120px" v-loading="loading">
- <el-form-item label="标题">
- <el-input v-model="form.title" placeholder="请输入文章标题" maxlength="200" show-word-limit :disabled="readonly" />
- </el-form-item>
- <el-form-item label="分类">
- <el-select v-model="form.categoryId" placeholder="选择分类" style="width: 300px;" :disabled="readonly">
- <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
- </el-select>
- </el-form-item>
- <el-form-item label="内容类型">
- <el-select v-model="form.contentType" placeholder="选择类型" style="width: 200px;" :disabled="readonly">
- <el-option label="文章" value="article" />
- <el-option label="知识点" value="knowledge" />
- <el-option label="课程" value="course" />
- <el-option label="贴士" value="tip" />
- </el-select>
- <span style="color: #999; font-size: 12px; margin-left: 10px;">标识内容属于哪类知识</span>
- </el-form-item>
- <el-form-item label="难度等级">
- <el-rate v-model="form.difficultyLevel" :max="5" :disabled="readonly" show-text :texts="['入门', '简单', '中等', '较难', '深入']" style="margin-top: 6px;" />
- </el-form-item>
- <el-form-item label="知识标签">
- <el-select v-model="form.tagIds" multiple placeholder="选择知识标签" style="width: 400px;" :disabled="readonly" clearable>
- <el-option v-for="tag in tagOptions" :key="tag.id" :label="tag.name" :value="tag.id" />
- </el-select>
- </el-form-item>
- <el-form-item label="摘要">
- <el-input v-model="form.summary" type="textarea" :rows="3" placeholder="请输入文章摘要" maxlength="500" show-word-limit :disabled="readonly" />
- </el-form-item>
- <el-form-item label="封面图">
- <template v-if="!readonly">
- <el-upload
- :action="uploadActionUrl"
- :headers="uploadHeaders"
- :on-success="handleUploadSuccess"
- :on-error="handleUploadError"
- :before-upload="beforeUpload"
- :show-file-list="false"
- accept="image/jpeg,image/png,image/gif,image/webp"
- >
- <img v-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
- <el-button v-else type="primary" plain>+ 上传封面</el-button>
- </el-upload>
- <el-button v-if="form.coverImage" size="mini" type="text" style="margin-left: 10px;" @click="form.coverImage = ''">清除</el-button>
- </template>
- <img v-else-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
- <span v-else class="text-muted">无封面图</span>
- </el-form-item>
- <el-form-item label="内容">
- <div class="rich-editor" :class="{ 'is-disabled': readonly }">
- <Toolbar :editor="editorInstance" :defaultConfig="toolbarConfig" />
- <Editor :defaultConfig="editorConfig" v-model="form.content" @onCreated="handleEditorCreated" :disabled="readonly" style="min-height: 400px;" />
- </div>
- </el-form-item>
- <el-form-item label="标签">
- <el-input v-model="form.tags" placeholder="逗号分隔,如: 育儿,亲子,心理" :disabled="readonly" />
- </el-form-item>
- <el-row :gutter="20">
- <el-col :span="12">
- <el-form-item label="作者">
- <el-input v-model="form.author" placeholder="文章作者" :disabled="readonly" />
- </el-form-item>
- </el-col>
- <el-col :span="12">
- <el-form-item label="阅读时间(分)">
- <el-input-number v-model="form.readTime" :min="1" :max="999" :disabled="readonly" />
- </el-form-item>
- </el-col>
- </el-row>
- <el-form-item label="五维权重">
- <dimension-weight-picker v-model="form.dimensionWeights" :show-presets="true" :readonly="readonly" />
- </el-form-item>
- <el-form-item label="浏览权限">
- <el-radio-group v-model="form.visibility" :disabled="readonly">
- <el-radio label="public">公开</el-radio>
- <el-radio label="login">登录可见</el-radio>
- <el-radio label="private">私密</el-radio>
- </el-radio-group>
- </el-form-item>
- </el-form>
- </el-card>
- </div>
- </template>
- <script>
- import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
- import {
- adminArticleCreate,
- adminArticleUpdate,
- adminArticlePublish,
- adminArticleCategoriesList,
- adminArticleDetail,
- adminArticleSubmitReview,
- adminArticleReDraft
- } from '@/api/article.js'
- import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight.js'
- import { getTagList } from '@/api/dimension.js'
- import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
- import '@wangeditor/editor/dist/css/style.css'
- export default {
- name: 'ArticleEdit',
- components: { DimensionWeightPicker, Editor, Toolbar },
- data() {
- return {
- isEdit: false,
- loading: false,
- saving: false,
- publishing: false,
- reviewLoading: false,
- reDraftLoading: false,
- readonly: false,
- articleWasPublished: false,
- articleStatus: '',
- rejectReason: '',
- categories: [],
- tagOptions: [],
- editorConfig: null,
- toolbarConfig: {},
- editorInstance: null,
- form: {
- id: null,
- categoryId: '',
- title: '',
- summary: '',
- coverImage: '',
- content: '',
- tags: '',
- contentType: 'article',
- difficultyLevel: 0,
- tagIds: [],
- author: '',
- readTime: 3,
- relatedDimensions: [],
- dimensionWeights: [],
- visibility: 'public',
- visibleTo: []
- }
- }
- },
- computed: {
- statusLabel() {
- const map = { draft: '当前状态:草稿箱', pending: '当前状态:待审核(等待管理员审核)', rejected: '当前状态:已驳回', published: '当前状态:发布中', withdrawn: '当前状态:已撤回' }
- return map[this.articleStatus] || ''
- },
- statusType() {
- const map = { draft: 'info', pending: 'warning', rejected: 'error', published: 'success', withdrawn: 'info' }
- return map[this.articleStatus] || 'info'
- },
- statusDescription() {
- if (this.articleStatus === 'rejected' && this.rejectReason) return '原因:' + this.rejectReason
- return ''
- },
- uploadActionUrl() {
- const baseURL = process.env.VUE_APP_BASE_API || ''
- return baseURL + '/api/admin/articles/upload/image'
- },
- uploadHeaders() {
- const token = localStorage.getItem('token')
- return token ? { Authorization: 'Bearer ' + token } : {}
- }
- },
- created() {
- this.initEditorConfig()
- this.loadCategories()
- this.loadTagOptions()
- const id = this.$route.query.id
- const copyId = this.$route.query.copyId
- this.readonly = this.$route.query.readonly === '1'
- if (copyId) {
- this.isEdit = false
- this.loadForCopy(copyId)
- } else if (id) {
- this.isEdit = true
- this.loadDetail(id)
- }
- },
- beforeRouteLeave(to, from, next) {
- // 离开前清空表单数据,避免浏览器弹出"离开页面?未保存更改"原生提示
- this.form = {
- id: null,
- categoryId: '',
- title: '',
- summary: '',
- coverImage: '',
- content: '',
- tags: '',
- contentType: 'article',
- difficultyLevel: 0,
- tagIds: [],
- author: '',
- readTime: 3,
- relatedDimensions: [],
- dimensionWeights: [],
- visibility: 'public',
- visibleTo: []
- }
- this.isEdit = false
- this.loading = false
- this.saving = false
- this.publishing = false
- next()
- },
- methods: {
- initEditorConfig() {
- const baseURL = process.env.VUE_APP_BASE_API || ''
- const token = localStorage.getItem('token')
- this.editorConfig = {
- MENU_CONF: {
- uploadImage: {
- server: baseURL + '/api/admin/articles/upload/image',
- headers: token ? { Authorization: 'Bearer ' + token } : {},
- maxNumberOfFiles: 1,
- fieldName: 'file',
- customAlert: (msg, type) => {
- this.$message && this.$message({ message: msg, type: type || 'error' })
- },
- onSuccess(res, insertFn) {
- if (res.code === 200) {
- insertFn(res.data.url || res.data)
- }
- }
- }
- }
- }
- },
- handleEditorCreated(editor) {
- this.editorInstance = editor
- },
- async loadCategories() {
- try {
- const res = await adminArticleCategoriesList()
- if (res.data) {
- this.categories = res.data
- }
- } catch (e) {
- console.error('加载分类失败', e)
- }
- },
- async loadTagOptions() {
- try {
- const res = await getTagList()
- if (res.data) {
- this.tagOptions = res.data
- }
- } catch (e) {
- console.error('加载标签失败', e)
- }
- },
- async loadDetail(id) {
- this.loading = true
- try {
- const res = await adminArticleDetail({ id })
- if (res.data) {
- const d = res.data
- this.articleStatus = d.status || ''
- this.rejectReason = d.auditReason || ''
- this.articleWasPublished = !!(d.publishedAt || d.status === 'published')
- if (d.auditStatus === 'pending') {
- this.readonly = true
- } else if (d.status === 'rejected') {
- this.readonly = true
- } else if (d.status === 'published' || d.status === 'withdrawn') {
- this.readonly = true
- } else {
- this.readonly = this.$route.query.readonly === '1'
- }
- this.form = {
- id: d.id,
- categoryId: d.categoryId,
- title: d.title || '',
- summary: d.summary || '',
- coverImage: d.coverImage || '',
- content: d.content || '',
- tags: d.tags || '',
- contentType: d.contentType || 'article',
- difficultyLevel: d.difficultyLevel || 0,
- tagIds: d.tagIds || [],
- author: d.author || '',
- readTime: d.readTime || 3,
- relatedDimensions: d.relatedDimensions || [],
- dimensionWeights: [],
- visibility: d.visibility || 'public',
- visibleTo: d.visibleTo || []
- }
- try {
- const dwRes = await getDimensionWeights('article', d.id)
- if (dwRes.data) {
- this.form.dimensionWeights = dwRes.data
- }
- } catch (e) {
- }
- }
- } catch (e) {
- this.$message.error('加载文章详情失败')
- } finally {
- this.loading = false
- }
- },
- async loadForCopy(id) {
- this.loading = true
- try {
- const res = await adminArticleDetail({ id })
- if (res.data) {
- const d = res.data
- this.form = {
- id: null,
- categoryId: d.categoryId,
- title: (d.title || '') + '(副本)',
- summary: d.summary || '',
- coverImage: d.coverImage || '',
- content: d.content || '',
- tags: d.tags || '',
- contentType: d.contentType || 'article',
- difficultyLevel: d.difficultyLevel || 0,
- tagIds: [],
- author: d.author || '',
- readTime: d.readTime || 3,
- relatedDimensions: d.relatedDimensions || [],
- dimensionWeights: [],
- visibility: d.visibility || 'public',
- visibleTo: d.visibleTo || []
- }
- try {
- const dwRes = await getDimensionWeights('article', d.id)
- if (dwRes.data && dwRes.data.length) {
- await saveDimensionWeights('article', null, dwRes.data)
- this.form.dimensionWeights = dwRes.data
- }
- } catch (e) {
- }
- }
- } catch (e) {
- this.$message.error('加载文章失败')
- } finally {
- this.loading = false
- }
- },
- beforeUpload(file) {
- const isImage = file.type === 'image/jpeg' || file.type === 'image/png' || file.type === 'image/gif' || file.type === 'image/webp'
- if (!isImage) {
- this.$message.error('只能上传 JPG/PNG/GIF/WebP 格式图片')
- return false
- }
- const isLt5M = file.size / 1024 / 1024 < 5
- if (!isLt5M) {
- this.$message.error('图片大小不能超过 5MB')
- return false
- }
- return true
- },
- handleUploadSuccess(res) {
- if (res.code === 200 && res.data) {
- this.form.coverImage = res.data.url || res.data
- this.$message.success('封面上传成功')
- } else {
- this.$message.error(res.message || '封面上传失败')
- }
- },
- handleUploadError() {
- this.$message.error('封面上传失败,请重试')
- },
- buildSubmitData() {
- return {
- id: this.form.id,
- categoryId: this.form.categoryId,
- title: this.form.title,
- summary: this.form.summary,
- coverImage: this.form.coverImage,
- content: this.form.content,
- tags: this.form.tags,
- contentType: this.form.contentType,
- difficultyLevel: this.form.difficultyLevel || 0,
- tagIds: this.form.tagIds,
- author: this.form.author,
- readTime: this.form.readTime,
- relatedDimensions: this.form.relatedDimensions,
- visibility: this.form.visibility,
- visibleTo: this.form.visibleTo
- }
- },
- async handleSave(publishNow) {
- if (publishNow) {
- this.publishing = true
- } else {
- this.saving = true
- }
- try {
- const data = this.buildSubmitData()
- let articleId = this.form.id
- if (this.isEdit) {
- await adminArticleUpdate(data)
- if (publishNow) {
- await adminArticlePublish({ id: this.form.id, status: 'published' })
- }
- } else {
- if (publishNow) {
- data.status = 'published'
- }
- const res = await adminArticleCreate(data)
- if (res.data && res.data.id) {
- articleId = res.data.id
- this.form.id = articleId
- this.isEdit = true
- }
- }
- if (articleId && this.form.dimensionWeights && this.form.dimensionWeights.length) {
- try {
- await saveDimensionWeights('article', articleId, this.form.dimensionWeights)
- } catch (e) {
- console.error('保存维度权重失败', e)
- }
- }
- this.$message.success(publishNow ? '文章已发布' : '草稿已保存')
- this.$router.push('/article-manage')
- } catch (e) {
- this.$message.error(e.message || '保存失败')
- } finally {
- this.saving = false
- this.publishing = false
- }
- },
- async handleSubmitReview() {
- this.reviewLoading = true
- try {
- await this.handleSave(false)
- if (this.form.id) {
- await adminArticleSubmitReview({ id: this.form.id })
- this.$message.success('已提交审核')
- this.$router.push('/article-manage')
- }
- } catch (e) {
- this.$message.error(e.message || '提交审核失败')
- } finally {
- this.reviewLoading = false
- }
- },
- async handleReDraft() {
- this.reDraftLoading = true
- try {
- await adminArticleReDraft({ id: this.form.id })
- this.$message.success('已保存到草稿箱')
- this.articleStatus = 'draft'
- this.readonly = false
- } catch (e) {
- this.$message.error(e.message || '操作失败')
- } finally {
- this.reDraftLoading = false
- }
- },
- handleCopyAsNew() {
- const data = this.buildSubmitData()
- delete data.id
- data.title = (data.title || '').replace('(副本)', '') + '(副本)'
- data.status = 'draft'
- this.saving = true
- adminArticleCreate(data).then(res => {
- if (res.data && res.data.id) {
- this.$message.success('已复制为草稿')
- this.$router.push('/article-edit?id=' + res.data.id)
- } else {
- this.$message.error('复制失败')
- }
- }).catch(e => {
- this.$message.error(e.message || '复制失败')
- }).finally(() => {
- this.saving = false
- })
- }
- }
- }
- </script>
- <style scoped>
- .article-edit {
- padding: 20px;
- overflow-y: auto;
- }
- .cover-preview {
- width: 240px;
- max-height: 160px;
- object-fit: cover;
- border-radius: 6px;
- border: 1px solid #dcdfe6;
- cursor: pointer;
- }
- .rich-editor {
- border: 1px solid #dcdfe6;
- border-radius: 4px;
- }
- .rich-editor /deep/ .w-e-toolbar {
- flex-wrap: wrap;
- }
- .rich-editor /deep/ .w-e-text-container {
- min-height: 400px;
- }
- </style>
|