You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

496 lines
15 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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>