|
|
<script setup lang="ts">
|
|
|
import PageTitle from '@/components/PageTitle.vue'
|
|
|
import RichTextEditor from '@/components/RichTextEditor.vue'
|
|
|
import { ref, watch } from 'vue'
|
|
|
import { usePageLoad } from '@/composables/usePageLoad'
|
|
|
import { useRoute } from 'vue-router'
|
|
|
import {
|
|
|
fetchNewsList,
|
|
|
fetchNews,
|
|
|
createNews,
|
|
|
updateNews,
|
|
|
deleteNews,
|
|
|
batchPublishNews,
|
|
|
type NewsRow,
|
|
|
} from '@/api/admin/news'
|
|
|
import { fetchDictByCode } from '@/api/admin/dict'
|
|
|
import { uploadNewsCover } from '@/api/admin/upload'
|
|
|
import { publishedStatusClass } from '@/utils/admin-list'
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
import type { UploadRequestOptions } from 'element-plus'
|
|
|
|
|
|
type DictOpt = { id: number; label: string; value: string; sort: number }
|
|
|
|
|
|
const route = useRoute()
|
|
|
const crawlJobFilter = ref<number | null>(null)
|
|
|
const loading = ref(false)
|
|
|
const saving = ref(false)
|
|
|
const batchPublishing = ref(false)
|
|
|
const selectedRows = ref<NewsRow[]>([])
|
|
|
const items = ref<NewsRow[]>([])
|
|
|
const meta = ref({ current_page: 1, per_page: 20, total: 0 })
|
|
|
const page = ref(1)
|
|
|
const keyword = ref('')
|
|
|
const filterCategoryItemId = ref<number | ''>('')
|
|
|
const filterStatus = ref<number | ''>('')
|
|
|
const filterHasCover = ref<number | ''>('')
|
|
|
|
|
|
const categoryOptions = ref<DictOpt[]>([])
|
|
|
|
|
|
const dialog = ref(false)
|
|
|
const editing = ref<NewsRow | null>(null)
|
|
|
const form = ref({
|
|
|
title: '',
|
|
|
category_dict_item_id: undefined as number | undefined,
|
|
|
source: '',
|
|
|
cover_url: '',
|
|
|
published_at: '',
|
|
|
content_html: '',
|
|
|
})
|
|
|
|
|
|
function formatPublishedDate(iso?: string | null) {
|
|
|
if (!iso) return '—'
|
|
|
const d = new Date(iso)
|
|
|
if (Number.isNaN(d.getTime())) return '—'
|
|
|
const pad = (n: number) => String(n).padStart(2, '0')
|
|
|
return `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())}`
|
|
|
}
|
|
|
|
|
|
async function loadDictOptions() {
|
|
|
try {
|
|
|
const res = await fetchDictByCode('news_category')
|
|
|
categoryOptions.value = res.items ?? []
|
|
|
} catch {
|
|
|
ElMessage.warning('资讯分类字典加载失败,请执行 NewsDictionarySeeder 或在字典中维护 news_category')
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function load() {
|
|
|
loading.value = true
|
|
|
try {
|
|
|
const params: Record<string, unknown> = {
|
|
|
page: page.value,
|
|
|
page_size: meta.value.per_page,
|
|
|
}
|
|
|
if (keyword.value) params.keyword = keyword.value
|
|
|
if (filterCategoryItemId.value !== '') params.category_dict_item_id = filterCategoryItemId.value
|
|
|
if (crawlJobFilter.value) params.crawl_job_id = crawlJobFilter.value
|
|
|
if (filterStatus.value !== '') params.status = filterStatus.value
|
|
|
if (filterHasCover.value !== '') params.has_cover = filterHasCover.value
|
|
|
const res = await fetchNewsList(params)
|
|
|
items.value = res.items ?? []
|
|
|
meta.value = res.meta ?? { current_page: 1, per_page: 20, total: 0 }
|
|
|
} finally {
|
|
|
loading.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function searchNewsList() {
|
|
|
page.value = 1
|
|
|
void load()
|
|
|
}
|
|
|
|
|
|
function resetFilters() {
|
|
|
keyword.value = ''
|
|
|
filterCategoryItemId.value = ''
|
|
|
filterStatus.value = ''
|
|
|
filterHasCover.value = ''
|
|
|
page.value = 1
|
|
|
void load()
|
|
|
}
|
|
|
|
|
|
function openCreate() {
|
|
|
editing.value = null
|
|
|
form.value = {
|
|
|
title: '',
|
|
|
category_dict_item_id: categoryOptions.value[0]?.id,
|
|
|
source: '',
|
|
|
cover_url: '',
|
|
|
published_at: new Date().toISOString().slice(0, 10),
|
|
|
content_html: '',
|
|
|
}
|
|
|
dialog.value = true
|
|
|
}
|
|
|
|
|
|
async function openEdit(row: NewsRow) {
|
|
|
editing.value = row
|
|
|
const detail = await fetchNews(row.id)
|
|
|
form.value = {
|
|
|
title: detail.title,
|
|
|
category_dict_item_id: detail.category_dict_item_id ?? undefined,
|
|
|
source: detail.source || '',
|
|
|
cover_url: detail.cover_url || '',
|
|
|
published_at: detail.published_at ? detail.published_at.slice(0, 10) : '',
|
|
|
content_html: detail.content_html || '',
|
|
|
}
|
|
|
dialog.value = true
|
|
|
}
|
|
|
|
|
|
function validateForm(): boolean {
|
|
|
if (!form.value.title?.trim()) {
|
|
|
ElMessage.warning('请填写资讯标题')
|
|
|
return false
|
|
|
}
|
|
|
if (!form.value.category_dict_item_id) {
|
|
|
ElMessage.warning('请选择资讯分类')
|
|
|
return false
|
|
|
}
|
|
|
if (!form.value.published_at) {
|
|
|
ElMessage.warning('请填写发布时间')
|
|
|
return false
|
|
|
}
|
|
|
const content = form.value.content_html?.replace(/<[^>]+>/g, '').trim()
|
|
|
if (!content) {
|
|
|
ElMessage.warning('请填写资讯正文')
|
|
|
return false
|
|
|
}
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
async function runSave(status: 0 | 1) {
|
|
|
if (!validateForm()) return
|
|
|
|
|
|
const payload: Record<string, unknown> = {
|
|
|
title: form.value.title.trim(),
|
|
|
category_dict_item_id: form.value.category_dict_item_id,
|
|
|
source: form.value.source || null,
|
|
|
cover_url: form.value.cover_url || null,
|
|
|
published_at: form.value.published_at,
|
|
|
content_html: form.value.content_html || null,
|
|
|
status,
|
|
|
}
|
|
|
|
|
|
saving.value = true
|
|
|
try {
|
|
|
if (editing.value) {
|
|
|
await updateNews(editing.value.id, payload)
|
|
|
} else {
|
|
|
await createNews(payload)
|
|
|
}
|
|
|
ElMessage.success(status === 1 ? '已保存资讯' : '已暂存草稿')
|
|
|
dialog.value = false
|
|
|
await load()
|
|
|
} finally {
|
|
|
saving.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function handleCoverUpload(opt: UploadRequestOptions) {
|
|
|
const raw = opt.file
|
|
|
const file = raw instanceof File ? raw : (raw as { raw?: File }).raw
|
|
|
if (!file) {
|
|
|
opt.onError?.(new Error('no file') as never)
|
|
|
return
|
|
|
}
|
|
|
try {
|
|
|
const res = await uploadNewsCover(file)
|
|
|
form.value.cover_url = res.url
|
|
|
ElMessage.success('封面上传成功')
|
|
|
opt.onSuccess?.({} as never)
|
|
|
} catch {
|
|
|
ElMessage.error('封面上传失败')
|
|
|
opt.onError?.(new Error('upload failed') as never)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function clearCover() {
|
|
|
form.value.cover_url = ''
|
|
|
}
|
|
|
|
|
|
async function remove(row: NewsRow) {
|
|
|
await ElMessageBox.confirm(`确定删除资讯「${row.title}」?`, '提示', { type: 'warning' })
|
|
|
await deleteNews(row.id)
|
|
|
ElMessage.success('已删除')
|
|
|
await load()
|
|
|
}
|
|
|
|
|
|
function onSelectionChange(rows: NewsRow[]) {
|
|
|
selectedRows.value = rows
|
|
|
}
|
|
|
|
|
|
async function runBatchPublish() {
|
|
|
const drafts = selectedRows.value.filter((r) => r.status === 0)
|
|
|
if (drafts.length === 0) {
|
|
|
ElMessage.warning('请勾选未发布的资讯')
|
|
|
return
|
|
|
}
|
|
|
|
|
|
await ElMessageBox.confirm(
|
|
|
`确定发布已勾选的 ${drafts.length} 条资讯?`,
|
|
|
'批量发布',
|
|
|
{ type: 'info' },
|
|
|
)
|
|
|
|
|
|
batchPublishing.value = true
|
|
|
try {
|
|
|
const res = await batchPublishNews(drafts.map((r) => r.id))
|
|
|
const skipHint = res.skipped > 0 ? `,跳过 ${res.skipped} 条` : ''
|
|
|
ElMessage.success(`已发布 ${res.published} 条${skipHint}`)
|
|
|
selectedRows.value = []
|
|
|
await load()
|
|
|
} finally {
|
|
|
batchPublishing.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function clearCrawlJobFilter() {
|
|
|
crawlJobFilter.value = null
|
|
|
page.value = 1
|
|
|
load()
|
|
|
}
|
|
|
|
|
|
async function initPage() {
|
|
|
const q = route.query.crawl_job_id
|
|
|
crawlJobFilter.value = q ? Number(q) || null : null
|
|
|
await loadDictOptions()
|
|
|
await load()
|
|
|
}
|
|
|
|
|
|
usePageLoad(initPage)
|
|
|
|
|
|
watch(
|
|
|
() => route.query.crawl_job_id,
|
|
|
() => {
|
|
|
void initPage()
|
|
|
},
|
|
|
)
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
<div class="list-page">
|
|
|
<div class="page-header">
|
|
|
<PageTitle />
|
|
|
<el-button type="primary" size="small" class="btn-create" @click="openCreate">新建资讯</el-button>
|
|
|
</div>
|
|
|
|
|
|
<el-card shadow="never" class="admin-list-card">
|
|
|
<el-alert
|
|
|
v-if="crawlJobFilter"
|
|
|
type="info"
|
|
|
:closable="false"
|
|
|
show-icon
|
|
|
class="crawl-filter-tip"
|
|
|
>
|
|
|
当前仅显示爬虫任务 #{{ crawlJobFilter }} 已入库资讯
|
|
|
<el-button link type="primary" @click="clearCrawlJobFilter">查看全部</el-button>
|
|
|
</el-alert>
|
|
|
<div class="list-filter-bar">
|
|
|
<el-input
|
|
|
v-model="keyword"
|
|
|
placeholder="搜索标题、来源…"
|
|
|
clearable
|
|
|
class="filter-search"
|
|
|
@keyup.enter="searchNewsList"
|
|
|
/>
|
|
|
<el-select
|
|
|
v-model="filterCategoryItemId"
|
|
|
clearable
|
|
|
placeholder="资讯分类"
|
|
|
class="filter-select"
|
|
|
filterable
|
|
|
>
|
|
|
<el-option v-for="o in categoryOptions" :key="o.id" :label="o.label" :value="o.id" />
|
|
|
</el-select>
|
|
|
<el-select v-model="filterHasCover" clearable placeholder="封面状态" class="filter-select-wide">
|
|
|
<el-option label="已上传封面" :value="1" />
|
|
|
<el-option label="未上传封面" :value="0" />
|
|
|
</el-select>
|
|
|
<el-select v-model="filterStatus" clearable placeholder="资讯状态" class="filter-select">
|
|
|
<el-option label="已发布" :value="1" />
|
|
|
<el-option label="未发布" :value="0" />
|
|
|
</el-select>
|
|
|
<el-button type="primary" @click="searchNewsList">搜索</el-button>
|
|
|
<el-button @click="resetFilters">重置</el-button>
|
|
|
<el-button
|
|
|
type="success"
|
|
|
:loading="batchPublishing"
|
|
|
:disabled="selectedRows.length === 0"
|
|
|
@click="runBatchPublish"
|
|
|
>
|
|
|
批量发布{{ selectedRows.length > 0 ? `(${selectedRows.length})` : '' }}
|
|
|
</el-button>
|
|
|
</div>
|
|
|
|
|
|
<el-table
|
|
|
v-loading="loading"
|
|
|
:data="items"
|
|
|
row-key="id"
|
|
|
@selection-change="onSelectionChange"
|
|
|
>
|
|
|
<el-table-column
|
|
|
type="selection"
|
|
|
width="48"
|
|
|
:selectable="(row: NewsRow) => row.status === 0"
|
|
|
/>
|
|
|
<el-table-column prop="title" label="资讯标题" min-width="220" show-overflow-tooltip />
|
|
|
<el-table-column label="资讯分类" width="110">
|
|
|
<template #default="{ row }">
|
|
|
{{ row.category_item?.label ?? '—' }}
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
<el-table-column label="发布时间" width="120">
|
|
|
<template #default="{ row }">
|
|
|
{{ formatPublishedDate(row.published_at) }}
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
<el-table-column prop="source" label="来源" width="120" show-overflow-tooltip />
|
|
|
<el-table-column label="链接" width="80" align="center">
|
|
|
<template #default="{ row }">
|
|
|
<a v-if="row.source_url" :href="row.source_url" target="_blank" rel="noopener">查看</a>
|
|
|
<span v-else class="text-mute">—</span>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
<el-table-column label="封面图" width="100" align="center">
|
|
|
<template #default="{ row }">
|
|
|
<el-image
|
|
|
v-if="row.cover_url"
|
|
|
:src="row.cover_url"
|
|
|
:preview-src-list="[row.cover_url]"
|
|
|
fit="cover"
|
|
|
class="list-cover-thumb"
|
|
|
preview-teleported
|
|
|
/>
|
|
|
<span v-else class="text-mute">—</span>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
<el-table-column label="资讯状态" width="90" align="center">
|
|
|
<template #default="{ row }">
|
|
|
<span class="status-badge" :class="publishedStatusClass(row.status)">
|
|
|
{{ row.status === 1 ? '已发布' : '未发布' }}
|
|
|
</span>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
<el-table-column label="操作" width="160" fixed="right">
|
|
|
<template #default="{ row }">
|
|
|
<div class="table-row-actions">
|
|
|
<el-button class="btn-action-primary" @click="openEdit(row)">编辑</el-button>
|
|
|
<el-button class="btn-action-brand" @click="remove(row)">删除</el-button>
|
|
|
</div>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
</el-table>
|
|
|
|
|
|
<div class="list-pager">
|
|
|
<el-pagination
|
|
|
v-model:current-page="page"
|
|
|
layout="total, prev, pager, next"
|
|
|
:total="meta.total"
|
|
|
:page-size="meta.per_page"
|
|
|
@current-change="load"
|
|
|
/>
|
|
|
</div>
|
|
|
</el-card>
|
|
|
</div>
|
|
|
|
|
|
<el-dialog
|
|
|
v-model="dialog"
|
|
|
:title="editing ? '编辑资讯' : '新建资讯'"
|
|
|
width="960px"
|
|
|
top="4vh"
|
|
|
destroy-on-close
|
|
|
class="news-dialog"
|
|
|
>
|
|
|
<el-form label-position="top" class="news-form">
|
|
|
<el-row :gutter="16">
|
|
|
<el-col :span="12">
|
|
|
<el-form-item label="资讯标题" required>
|
|
|
<el-input v-model="form.title" placeholder="请输入资讯标题" />
|
|
|
</el-form-item>
|
|
|
</el-col>
|
|
|
<el-col :span="6">
|
|
|
<el-form-item label="资讯分类" required>
|
|
|
<el-select v-model="form.category_dict_item_id" placeholder="请选择" filterable style="width: 100%">
|
|
|
<el-option v-for="o in categoryOptions" :key="o.id" :label="o.label" :value="o.id" />
|
|
|
</el-select>
|
|
|
</el-form-item>
|
|
|
</el-col>
|
|
|
<el-col :span="6">
|
|
|
<el-form-item label="发布时间" required>
|
|
|
<el-date-picker
|
|
|
v-model="form.published_at"
|
|
|
type="date"
|
|
|
value-format="YYYY-MM-DD"
|
|
|
placeholder="选择日期"
|
|
|
style="width: 100%"
|
|
|
/>
|
|
|
</el-form-item>
|
|
|
</el-col>
|
|
|
<el-col :span="12">
|
|
|
<el-form-item label="来源">
|
|
|
<el-input v-model="form.source" placeholder="如:高校雷达网" />
|
|
|
</el-form-item>
|
|
|
</el-col>
|
|
|
<el-col :span="12">
|
|
|
<el-form-item label="封面图">
|
|
|
<div class="upload-row">
|
|
|
<el-upload :show-file-list="false" accept="image/*" :http-request="handleCoverUpload">
|
|
|
<el-button type="primary" plain size="small">上传图片</el-button>
|
|
|
</el-upload>
|
|
|
<el-button v-if="form.cover_url" size="small" @click="clearCover">移除</el-button>
|
|
|
</div>
|
|
|
<div v-if="form.cover_url" class="thumb-preview">
|
|
|
<img :src="form.cover_url" alt="封面" />
|
|
|
</div>
|
|
|
</el-form-item>
|
|
|
</el-col>
|
|
|
<el-col :span="24">
|
|
|
<el-form-item label="资讯正文" required class="intro-form-item">
|
|
|
<RichTextEditor v-model="form.content_html" scope="news" :height="320" />
|
|
|
</el-form-item>
|
|
|
</el-col>
|
|
|
</el-row>
|
|
|
</el-form>
|
|
|
|
|
|
<template #footer>
|
|
|
<div class="dialog-footer-inner">
|
|
|
<el-button @click="dialog = false">取消</el-button>
|
|
|
<el-button :loading="saving" @click="runSave(0)">暂存草稿</el-button>
|
|
|
<el-button type="primary" :loading="saving" @click="runSave(1)">保存资讯</el-button>
|
|
|
</div>
|
|
|
</template>
|
|
|
</el-dialog>
|
|
|
</template>
|
|
|
|
|
|
<style scoped>
|
|
|
.crawl-filter-tip {
|
|
|
margin-bottom: 12px;
|
|
|
}
|
|
|
.list-cover-thumb {
|
|
|
width: 56px;
|
|
|
height: 36px;
|
|
|
border-radius: 4px;
|
|
|
border: 1px solid var(--el-border-color-lighter);
|
|
|
}
|
|
|
.text-mute {
|
|
|
color: var(--el-text-color-placeholder);
|
|
|
font-size: 13px;
|
|
|
}
|
|
|
.upload-row {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
gap: 8px;
|
|
|
width: 100%;
|
|
|
}
|
|
|
.thumb-preview {
|
|
|
margin-top: 8px;
|
|
|
max-width: 200px;
|
|
|
}
|
|
|
.thumb-preview img {
|
|
|
max-width: 100%;
|
|
|
border-radius: 4px;
|
|
|
border: 1px solid var(--el-border-color);
|
|
|
}
|
|
|
.intro-form-item {
|
|
|
width: 100%;
|
|
|
}
|
|
|
.intro-form-item :deep(.el-form-item__content) {
|
|
|
width: 100%;
|
|
|
}
|
|
|
.dialog-footer-inner {
|
|
|
display: flex;
|
|
|
justify-content: flex-end;
|
|
|
flex-wrap: wrap;
|
|
|
gap: 8px;
|
|
|
}
|
|
|
</style>
|