初始化2
This commit is contained in:
859
app/pages/admin/articles.vue
Normal file
859
app/pages/admin/articles.vue
Normal file
@@ -0,0 +1,859 @@
|
||||
<template>
|
||||
<div class="articles-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">📝 文章管理</h2>
|
||||
<p class="page-desc">管理平台文章内容,支持分类、封面、推荐与状态流转</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button @click="navigateTo('/admin/article-categories')">
|
||||
分类管理
|
||||
</a-button>
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增文章
|
||||
</a-button>
|
||||
<a-button @click="loadArticles" :loading="loading">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="[16, 16]" class="mb-6">
|
||||
<a-col :xs="12" :md="6" v-for="stat in statCards" :key="stat.key">
|
||||
<div
|
||||
class="stat-card"
|
||||
:class="[
|
||||
stat.color,
|
||||
{
|
||||
active:
|
||||
(filterStatus === undefined && stat.key === -1) ||
|
||||
filterStatus === stat.key,
|
||||
},
|
||||
]"
|
||||
@click="handleStatFilter(stat.key)"
|
||||
>
|
||||
<div class="stat-icon">{{ stat.icon }}</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
<div class="stat-label">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">📋 文章列表</span>
|
||||
<a-space wrap>
|
||||
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
|
||||
<a-select-option :value="undefined">全部状态</a-select-option>
|
||||
<a-select-option :value="0">已发布</a-select-option>
|
||||
<a-select-option :value="1">待审核</a-select-option>
|
||||
<a-select-option :value="2">已驳回</a-select-option>
|
||||
<a-select-option :value="3">违规</a-select-option>
|
||||
</a-select>
|
||||
<a-select
|
||||
v-model:value="filterCategoryId"
|
||||
allow-clear
|
||||
placeholder="全部分类"
|
||||
style="width: 180px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="item in categoryOptions"
|
||||
:key="item.categoryId"
|
||||
:value="item.categoryId"
|
||||
>
|
||||
{{ item.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索标题 / 摘要 / 作者"
|
||||
style="width: 240px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="pagedArticles"
|
||||
:loading="loading"
|
||||
:pagination="tablePagination"
|
||||
row-key="articleId"
|
||||
@change="handleTableChange"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'info'">
|
||||
<div class="article-info-cell">
|
||||
<img v-if="record.image" :src="record.image" class="article-thumb" />
|
||||
<div v-else class="article-thumb-empty">📄</div>
|
||||
<div class="article-info-text">
|
||||
<div class="article-title">{{ record.title }}</div>
|
||||
<div class="article-meta">
|
||||
<span v-if="record.author">✍️ {{ record.author }}</span>
|
||||
<span v-if="resolveCategoryName(record)" class="meta-item">📁 {{ resolveCategoryName(record) }}</span>
|
||||
</div>
|
||||
<div class="article-overview">{{ record.overview || '暂无摘要' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'metrics'">
|
||||
<div class="metrics-cell">
|
||||
<div>👁 {{ record.actualViews || 0 }} 次阅读</div>
|
||||
<div>❤️ {{ record.likes || 0 }} 点赞</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'recommend'">
|
||||
<a-switch
|
||||
:checked="!!record.recommend"
|
||||
size="small"
|
||||
@change="(val: boolean) => handleToggleRecommend(record, val)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'createTime'">
|
||||
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">预览</a-button>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-popconfirm title="确认删除此文章?" @confirm="handleDelete(record)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
v-model:open="showFormModal"
|
||||
:title="editingArticle?.articleId ? '编辑文章' : '新增文章'"
|
||||
width="760px"
|
||||
:confirm-loading="saving"
|
||||
@ok="handleSave"
|
||||
@cancel="showFormModal = false"
|
||||
>
|
||||
<a-form :model="formData" layout="vertical">
|
||||
<a-form-item label="文章标题" required>
|
||||
<a-input
|
||||
v-model:value="formData.title"
|
||||
placeholder="请输入文章标题"
|
||||
:maxlength="200"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="作者">
|
||||
<a-input v-model:value="formData.author" placeholder="文章作者" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="来源">
|
||||
<a-input v-model:value="formData.source" placeholder="文章来源" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="文章分类">
|
||||
<a-select
|
||||
v-model:value="formData.categoryId"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
placeholder="请选择文章分类"
|
||||
:options="categorySelectOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="formData.status">
|
||||
<a-select-option :value="0">已发布</a-select-option>
|
||||
<a-select-option :value="1">待审核</a-select-option>
|
||||
<a-select-option :value="2">已驳回</a-select-option>
|
||||
<a-select-option :value="3">违规</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="封面图">
|
||||
<div class="cover-upload-wrap">
|
||||
<div v-if="formData.image" class="cover-preview-card">
|
||||
<img :src="formData.image" class="cover-preview-image" />
|
||||
<div class="cover-preview-actions">
|
||||
<a-button size="small" @click="handlePreviewImage(formData.image)">预览</a-button>
|
||||
<a-button size="small" danger @click="handleRemoveCover">移除</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<a-upload
|
||||
accept="image/*"
|
||||
:show-upload-list="false"
|
||||
:before-upload="beforeImageUpload"
|
||||
:custom-request="handleCoverUpload"
|
||||
>
|
||||
<a-button :loading="imageUploading">上传封面</a-button>
|
||||
</a-upload>
|
||||
<div class="field-hint">支持 jpg/png/webp,建议横版封面,单张不超过 5MB</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="文章摘要">
|
||||
<a-textarea
|
||||
v-model:value="formData.overview"
|
||||
:rows="3"
|
||||
placeholder="文章简短描述"
|
||||
:maxlength="500"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="文章内容" required>
|
||||
<div class="content-editor-wrap">
|
||||
<div class="editor-tabs">
|
||||
<a-radio-group v-model:value="editorMode" button-style="solid" size="small">
|
||||
<a-radio-button value="edit">编辑</a-radio-button>
|
||||
<a-radio-button value="preview">预览</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
<div v-show="editorMode === 'edit'">
|
||||
<MarkdownEditor
|
||||
v-model="formData.content"
|
||||
placeholder="请输入 Markdown 内容,支持 # 标题、**加粗**、*斜体*、[链接](url)、、代码块等语法"
|
||||
:show-preview="false"
|
||||
min-height="320px"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="editorMode === 'preview'" class="preview-only-mode">
|
||||
<MarkdownRenderer v-if="formData.content" :content="formData.content" />
|
||||
<div v-else class="empty-preview">暂无内容</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="内容格式">
|
||||
<a-tag :color="isMarkdown ? 'blue' : 'default'">
|
||||
{{ isMarkdown ? 'Markdown' : '纯文本/HTML' }}
|
||||
</a-tag>
|
||||
<span class="format-hint">
|
||||
当前编辑器支持 Markdown 语法编写
|
||||
</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="是否推荐">
|
||||
<a-switch v-model:checked="formRecommend" />
|
||||
<span class="switch-tip">推荐文章将优先出现在列表与前台推荐位</span>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="showPreviewModal"
|
||||
:title="previewData?.title || '文章预览'"
|
||||
width="760px"
|
||||
:footer="null"
|
||||
>
|
||||
<template v-if="previewData">
|
||||
<div class="preview-meta">
|
||||
<a-tag :color="statusColor(previewData.status)">{{ statusText(previewData.status) }}</a-tag>
|
||||
<a-tag v-if="previewData.recommend" color="gold">推荐</a-tag>
|
||||
<a-tag v-if="previewData.categoryName" color="blue">{{ previewData.categoryName }}</a-tag>
|
||||
<span class="preview-meta-text">{{ previewData.createTime?.substring(0, 16) || '-' }}</span>
|
||||
</div>
|
||||
<div v-if="previewData.image" class="preview-cover-wrap">
|
||||
<img :src="previewData.image" class="preview-cover" />
|
||||
</div>
|
||||
<div class="preview-summary" v-if="previewData.overview">{{ previewData.overview }}</div>
|
||||
<a-divider />
|
||||
<div class="preview-content">
|
||||
<MarkdownRenderer v-if="isPreviewMarkdown" :content="previewData.content" />
|
||||
<div v-else v-html="previewData.content || previewData.overview || '暂无内容'"></div>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<a-modal v-model:open="showImagePreview" title="封面预览" :footer="null" width="640px">
|
||||
<img v-if="previewImageUrl" :src="previewImageUrl" class="image-preview-modal" />
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
listAppArticle as listCmsArticle,
|
||||
addAppArticle as addCmsArticle,
|
||||
updateAppArticle as updateCmsArticle,
|
||||
removeAppArticle as removeCmsArticle,
|
||||
} from '@/api/app/article'
|
||||
import { listAppArticleCategory as listCmsArticleCategory } from '@/api/app/articleCategory'
|
||||
import { uploadFile } from '@/api/system/file'
|
||||
import type { AppArticle as CmsArticle } from '@/api/app/article/model'
|
||||
import type { AppArticleCategory as CmsArticleCategory } from '@/api/app/articleCategory/model'
|
||||
import MarkdownEditor from '@/components/admin/MarkdownEditor.vue'
|
||||
import MarkdownRenderer from '@/components/admin/MarkdownRenderer.vue'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
useHead({ title: '文章管理 - 平台管理' })
|
||||
|
||||
type UploadRequestOption = {
|
||||
file?: File
|
||||
onSuccess?: (body: unknown, file: File) => void
|
||||
onError?: (err: unknown) => void
|
||||
}
|
||||
|
||||
const ANNOUNCE_MODEL = 'announcement'
|
||||
|
||||
const loading = ref(false)
|
||||
const imageUploading = ref(false)
|
||||
const allArticles = ref<CmsArticle[]>([])
|
||||
const categoryOptions = ref<CmsArticleCategory[]>([])
|
||||
const filterStatus = ref<number | undefined>(undefined)
|
||||
const filterCategoryId = ref<number | undefined>(undefined)
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
const statCards = reactive([
|
||||
{ key: 0, icon: '✅', label: '已发布', value: 0, color: 'green' },
|
||||
{ key: 1, icon: '⏳', label: '待审核', value: 0, color: 'orange' },
|
||||
{ key: 2, icon: '❌', label: '已驳回', value: 0, color: 'red' },
|
||||
{ key: -1, icon: '📝', label: '全部文章', value: 0, color: 'blue' },
|
||||
])
|
||||
|
||||
const columns = [
|
||||
{ title: '文章信息', key: 'info', width: 360 },
|
||||
{ title: '状态', key: 'status', width: 110 },
|
||||
{ title: '数据', key: 'metrics', width: 120 },
|
||||
{ title: '推荐', key: 'recommend', width: 80 },
|
||||
{ title: '创建时间', key: 'createTime', width: 120 },
|
||||
{ title: '操作', key: 'action', width: 170 },
|
||||
]
|
||||
|
||||
const showFormModal = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingArticle = ref<CmsArticle | null>(null)
|
||||
const formData = reactive<CmsArticle>({
|
||||
title: '',
|
||||
author: '',
|
||||
source: '',
|
||||
overview: '',
|
||||
content: '',
|
||||
status: 0,
|
||||
categoryId: undefined,
|
||||
image: '',
|
||||
})
|
||||
const formRecommend = ref(false)
|
||||
const editorMode = ref<'edit' | 'preview'>('edit')
|
||||
|
||||
const isMarkdown = computed(() => {
|
||||
return formData.content && (
|
||||
/[#*`_\[\]()!>-]/.test(formData.content) ||
|
||||
/^(#{1,6}\s|[-*]\s|\d+\.\s|>)/m.test(formData.content)
|
||||
)
|
||||
})
|
||||
|
||||
const isPreviewMarkdown = computed(() => {
|
||||
if (!previewData.value?.content) return false
|
||||
return (
|
||||
/[#*`_\[\]()!>-]/.test(previewData.value.content) ||
|
||||
/^(#{1,6}\s|[-*]\s|\d+\.\s|>)/m.test(previewData.value.content)
|
||||
)
|
||||
})
|
||||
|
||||
const showPreviewModal = ref(false)
|
||||
const previewData = ref<CmsArticle | null>(null)
|
||||
const showImagePreview = ref(false)
|
||||
const previewImageUrl = ref('')
|
||||
|
||||
const categorySelectOptions = computed(() =>
|
||||
categoryOptions.value.map(item => ({
|
||||
value: item.categoryId,
|
||||
label: item.title || `分类 ${item.categoryId}`,
|
||||
}))
|
||||
)
|
||||
|
||||
const standardArticles = computed(() => allArticles.value.filter(isStandardArticle))
|
||||
|
||||
const filteredArticles = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
|
||||
return [...standardArticles.value]
|
||||
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
|
||||
.filter(item => filterCategoryId.value === undefined || item.categoryId === filterCategoryId.value)
|
||||
.filter(item => {
|
||||
if (!keyword) return true
|
||||
return [item.title, item.overview, item.author, item.source]
|
||||
.some(value => String(value || '').toLowerCase().includes(keyword))
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const timeA = a.createTime || ''
|
||||
const timeB = b.createTime || ''
|
||||
if (timeA && timeB && timeA !== timeB) {
|
||||
return timeB.localeCompare(timeA)
|
||||
}
|
||||
return (b.articleId || 0) - (a.articleId || 0)
|
||||
})
|
||||
})
|
||||
|
||||
const pagedArticles = computed(() => {
|
||||
const start = (pagination.current - 1) * pagination.pageSize
|
||||
return filteredArticles.value.slice(start, start + pagination.pageSize)
|
||||
})
|
||||
|
||||
const tablePagination = computed(() => ({
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: filteredArticles.value.length,
|
||||
showSizeChanger: pagination.showSizeChanger,
|
||||
showQuickJumper: pagination.showQuickJumper,
|
||||
}))
|
||||
|
||||
async function loadCategories(silent = false) {
|
||||
try {
|
||||
const list = await listCmsArticleCategory({ status: 0 })
|
||||
categoryOptions.value = (list || [])
|
||||
.filter(item => item.categoryId)
|
||||
.sort((a, b) => (a.sortNumber || 0) - (b.sortNumber || 0))
|
||||
} catch (e: any) {
|
||||
if (!silent) {
|
||||
message.error(e?.message || '加载文章分类失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArticles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await listCmsArticle()
|
||||
allArticles.value = list || []
|
||||
ensurePaginationInRange()
|
||||
updateStats()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载文章列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const list = standardArticles.value
|
||||
statCards[0].value = list.filter(item => item.status === 0).length
|
||||
statCards[1].value = list.filter(item => item.status === 1).length
|
||||
statCards[2].value = list.filter(item => item.status === 2).length
|
||||
statCards[3].value = list.length
|
||||
}
|
||||
|
||||
function ensurePaginationInRange() {
|
||||
const total = filteredArticles.value.length
|
||||
const maxPage = Math.max(1, Math.ceil(total / pagination.pageSize))
|
||||
if (pagination.current > maxPage) {
|
||||
pagination.current = maxPage
|
||||
}
|
||||
}
|
||||
|
||||
function isStandardArticle(item: CmsArticle) {
|
||||
return (item.model || '').trim() !== ANNOUNCE_MODEL
|
||||
}
|
||||
|
||||
function handleStatFilter(key: number) {
|
||||
filterStatus.value = key === -1 ? undefined : key
|
||||
pagination.current = 1
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
}
|
||||
|
||||
function handleTableChange(pag: { current: number; pageSize: number }) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
ensurePaginationInRange()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(formData, {
|
||||
articleId: undefined,
|
||||
title: '',
|
||||
author: '',
|
||||
source: '',
|
||||
overview: '',
|
||||
content: '',
|
||||
status: 0,
|
||||
categoryId: undefined,
|
||||
image: '',
|
||||
})
|
||||
formRecommend.value = false
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
editingArticle.value = null
|
||||
resetForm()
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: CmsArticle) {
|
||||
editingArticle.value = record
|
||||
Object.assign(formData, {
|
||||
articleId: record.articleId,
|
||||
title: record.title || '',
|
||||
author: record.author || '',
|
||||
source: record.source || '',
|
||||
overview: record.overview || '',
|
||||
content: record.content || '',
|
||||
status: record.status ?? 0,
|
||||
categoryId: record.categoryId,
|
||||
image: record.image || '',
|
||||
})
|
||||
formRecommend.value = !!record.recommend
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
function handleView(record: CmsArticle) {
|
||||
previewData.value = {
|
||||
...record,
|
||||
categoryName: resolveCategoryName(record),
|
||||
}
|
||||
showPreviewModal.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!formData.title?.trim()) {
|
||||
message.warning('请输入文章标题')
|
||||
return
|
||||
}
|
||||
if (!formData.content?.trim()) {
|
||||
message.warning('请输入文章内容')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const data: CmsArticle = {
|
||||
...formData,
|
||||
model: undefined,
|
||||
categoryName: resolveCategoryNameById(formData.categoryId),
|
||||
recommend: formRecommend.value ? 1 : 0,
|
||||
}
|
||||
if (editingArticle.value?.articleId) {
|
||||
await updateCmsArticle(data)
|
||||
message.success('文章已更新')
|
||||
} else {
|
||||
await addCmsArticle(data)
|
||||
message.success('文章已创建')
|
||||
}
|
||||
showFormModal.value = false
|
||||
await loadArticles()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(record: CmsArticle) {
|
||||
try {
|
||||
await removeCmsArticle(record.articleId)
|
||||
message.success('文章已删除')
|
||||
await loadArticles()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleRecommend(record: CmsArticle, val: boolean) {
|
||||
try {
|
||||
await updateCmsArticle({ articleId: record.articleId, recommend: val ? 1 : 0 })
|
||||
message.success(val ? '已加入推荐' : '已取消推荐')
|
||||
await loadArticles()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function beforeImageUpload(file: File) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error('只能上传图片文件')
|
||||
return false
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
message.error('图片大小不能超过 5MB')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleCoverUpload(option: UploadRequestOption) {
|
||||
const rawFile = option.file
|
||||
if (!rawFile) return
|
||||
|
||||
imageUploading.value = true
|
||||
try {
|
||||
const record = await uploadFile(rawFile)
|
||||
const url = (record?.url || record?.downloadUrl || '').trim()
|
||||
if (!url) throw new Error('上传成功但未返回图片地址')
|
||||
formData.image = url
|
||||
option.onSuccess?.(record, rawFile)
|
||||
message.success('封面上传成功')
|
||||
} catch (e) {
|
||||
option.onError?.(e)
|
||||
message.error(e instanceof Error ? e.message : '封面上传失败')
|
||||
} finally {
|
||||
imageUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveCover() {
|
||||
formData.image = ''
|
||||
}
|
||||
|
||||
function handlePreviewImage(url?: string) {
|
||||
if (!url) return
|
||||
previewImageUrl.value = url
|
||||
showImagePreview.value = true
|
||||
}
|
||||
|
||||
function resolveCategoryName(record: CmsArticle) {
|
||||
return record.categoryName || resolveCategoryNameById(record.categoryId)
|
||||
}
|
||||
|
||||
function resolveCategoryNameById(categoryId?: number) {
|
||||
if (!categoryId) return ''
|
||||
return categoryOptions.value.find(item => item.categoryId === categoryId)?.title || ''
|
||||
}
|
||||
|
||||
function statusText(status?: number) {
|
||||
const map: Record<number, string> = {
|
||||
0: '已发布',
|
||||
1: '待审核',
|
||||
2: '已驳回',
|
||||
3: '违规',
|
||||
}
|
||||
return map[status ?? -1] || '-'
|
||||
}
|
||||
|
||||
function statusColor(status?: number) {
|
||||
const map: Record<number, string> = {
|
||||
0: 'success',
|
||||
1: 'orange',
|
||||
2: 'error',
|
||||
3: 'volcano',
|
||||
}
|
||||
return map[status ?? -1] || 'default'
|
||||
}
|
||||
|
||||
watch([filteredArticles, () => pagination.pageSize], () => {
|
||||
ensurePaginationInRange()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories(true)
|
||||
await loadArticles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.articles-page { min-height: 100%; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
|
||||
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
|
||||
.stat-card.active { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); }
|
||||
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
|
||||
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
|
||||
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
|
||||
.stat-card.active.blue { border-color: #3b82f6; }
|
||||
.stat-card.active.green { border-color: #22c55e; }
|
||||
.stat-card.active.orange { border-color: #f97316; }
|
||||
.stat-card.active.red { border-color: #ef4444; }
|
||||
.stat-icon { font-size: 28px; flex-shrink: 0; }
|
||||
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0, 0, 0, 0.85); line-height: 1.2; }
|
||||
.stat-label { font-size: 12px; color: rgba(0, 0, 0, 0.45); margin-top: 2px; }
|
||||
|
||||
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.85); }
|
||||
|
||||
.article-info-cell { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.article-thumb {
|
||||
width: 72px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
.article-thumb-empty {
|
||||
width: 72px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.article-info-text { flex: 1; min-width: 0; }
|
||||
.article-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.article-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.meta-item { margin-left: 8px; }
|
||||
.article-overview {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.metrics-cell { font-size: 12px; color: rgba(0, 0, 0, 0.45); line-height: 1.7; }
|
||||
|
||||
.cover-upload-wrap { display: flex; flex-direction: column; gap: 10px; }
|
||||
.cover-preview-card {
|
||||
width: 220px;
|
||||
padding: 8px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.cover-preview-image {
|
||||
width: 100%;
|
||||
height: 124px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
.cover-preview-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.field-hint { font-size: 12px; color: rgba(0, 0, 0, 0.45); }
|
||||
.switch-tip { margin-left: 8px; font-size: 12px; color: rgba(0, 0, 0, 0.45); }
|
||||
|
||||
.content-editor-wrap {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
padding: 8px 12px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.preview-only-mode {
|
||||
padding: 16px;
|
||||
min-height: 320px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.empty-preview {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.format-hint {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.preview-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.preview-meta-text { font-size: 12px; color: rgba(0, 0, 0, 0.45); }
|
||||
.preview-cover-wrap { margin: 16px 0 12px; }
|
||||
.preview-cover {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
.preview-summary {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
background: #fafafa;
|
||||
border-radius: 10px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.preview-content {
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
.image-preview-modal {
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-gray { color: rgba(0, 0, 0, 0.45); }
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user