408 lines
12 KiB
Vue
408 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import { newsApi } from '../../api'
|
||
import { useRequest } from '../../composables/useRequest'
|
||
import { useResponsive } from '../../composables/useResponsive'
|
||
import { useAppStore } from '../../stores/app'
|
||
import { formatDateShort, timeAgo } from '../../utils/format'
|
||
import AppCard from '../../components/base/AppCard.vue'
|
||
import AppButton from '../../components/base/AppButton.vue'
|
||
import AppIcon from '../../components/base/AppIcon.vue'
|
||
import AppModal from '../../components/base/AppModal.vue'
|
||
import AppPageHeader from '../../components/base/AppPageHeader.vue'
|
||
import AppLoading from '../../components/feedback/AppLoading.vue'
|
||
import AppError from '../../components/feedback/AppError.vue'
|
||
import AppEmpty from '../../components/feedback/AppEmpty.vue'
|
||
|
||
const { isMobile } = useResponsive()
|
||
const app = useAppStore()
|
||
|
||
const categories = ['全部', '公告', '联考', '政策', '时政', '申论']
|
||
const activeCategory = ref('全部')
|
||
|
||
const list = useRequest(() => newsApi.list({ page: 1, pageSize: 50 }))
|
||
|
||
const items = computed(() => list.data.value?.items ?? [])
|
||
// 「公告」分类的置顶卡放在最前(对应原型置顶深蓝卡)
|
||
const pinned = computed(() => items.value.filter((n) => n.category === '公告')[0] ?? items.value[0] ?? null)
|
||
const restItems = computed(() => items.value.filter((n) => n.id !== pinned.value?.id))
|
||
|
||
const categoryTone = (c: string) =>
|
||
c === '公告' ? 'primary' : c === '政策' ? 'warning' : c === '联考' ? 'soft' : 'success'
|
||
|
||
function filterCategory(category: string) {
|
||
activeCategory.value = category
|
||
list.refresh()
|
||
}
|
||
|
||
// 导入对话框
|
||
const showImport = ref(false)
|
||
const importType = ref<'json' | 'rss' | 'api' | 'url'>('json')
|
||
const importText = ref('')
|
||
const importUrl = ref('')
|
||
|
||
function openImport() {
|
||
showImport.value = true
|
||
importType.value = 'json'
|
||
importText.value = ''
|
||
importUrl.value = ''
|
||
}
|
||
|
||
type ImportResult = { success: number; skipped: number; failed: number; errors: { message: string }[] }
|
||
|
||
async function doImport() {
|
||
try {
|
||
let result: ImportResult
|
||
if (importType.value === 'json') {
|
||
if (!importText.value.trim()) {
|
||
app.toast('请粘贴 JSON 内容', 'error')
|
||
return
|
||
}
|
||
let parsed: { news?: unknown[] }
|
||
try {
|
||
parsed = JSON.parse(importText.value)
|
||
} catch {
|
||
app.toast('JSON 解析失败,请检查格式', 'error')
|
||
return
|
||
}
|
||
result = await newsApi.importJson({ version: '1.0', news: (parsed.news ?? []) as never })
|
||
} else if (importType.value === 'rss') {
|
||
result = await newsApi.importRss({ url: importUrl.value })
|
||
} else if (importType.value === 'api') {
|
||
result = await newsApi.importApi({ url: importUrl.value })
|
||
} else {
|
||
result = await newsApi.importUrl({ url: importUrl.value })
|
||
}
|
||
showImport.value = false
|
||
app.toast(`导入完成:成功 ${result.success} · 跳过 ${result.skipped} · 失败 ${result.failed}`, 'success')
|
||
list.refresh()
|
||
} catch (err) {
|
||
app.toast(err instanceof Error ? err.message : '导入失败', 'error')
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="news">
|
||
<AppLoading v-if="list.loading.value" fullscreen />
|
||
<AppError
|
||
v-else-if="list.error.value"
|
||
fullscreen
|
||
title="要闻加载失败"
|
||
:message="list.error.value"
|
||
retry
|
||
@retry="list.refresh"
|
||
/>
|
||
|
||
<template v-else>
|
||
<!-- 页头 -->
|
||
<AppPageHeader :title="isMobile ? '要闻公告' : '要闻'" subtitle="最新招考资讯与政策动态" sticky-mobile>
|
||
<AppButton variant="ghost" @click="openImport">订阅推送</AppButton>
|
||
<AppButton variant="primary" @click="openImport">
|
||
<AppIcon name="inbox" :size="16" />发布公告
|
||
</AppButton>
|
||
</AppPageHeader>
|
||
|
||
<!-- 分类 Tab -->
|
||
<div class="news-tabs">
|
||
<AppButton
|
||
v-for="c in categories"
|
||
:key="c"
|
||
variant="ghost"
|
||
size="md"
|
||
class="news-tabs__tab"
|
||
:class="{ 'is-active': activeCategory === c }"
|
||
@click="filterCategory(c)"
|
||
>
|
||
{{ c }}
|
||
</AppButton>
|
||
</div>
|
||
|
||
<AppEmpty
|
||
v-if="items.length === 0"
|
||
icon="news"
|
||
title="暂无要闻"
|
||
description="暂无相关分类内容,可导入或稍后再来。"
|
||
/>
|
||
|
||
<template v-else>
|
||
<!-- 置顶卡 -->
|
||
<RouterLink
|
||
v-if="pinned"
|
||
class="news-pin"
|
||
:to="`/news/${pinned.id}`"
|
||
>
|
||
<div class="news-pin__tag"><span class="news-pin__dot" />置顶</div>
|
||
<h2 class="news-pin__title">{{ pinned.title }}</h2>
|
||
<p class="news-pin__meta">{{ pinned.source }} · {{ formatDateShort(pinned.publishedAt.slice(0, 10)) }}</p>
|
||
</RouterLink>
|
||
|
||
<!-- 列表 -->
|
||
<div class="news-list">
|
||
<RouterLink
|
||
v-for="n in restItems"
|
||
:key="n.id"
|
||
class="news-item"
|
||
:to="`/news/${n.id}`"
|
||
>
|
||
<div class="news-item__body">
|
||
<h3 class="news-item__title">{{ n.title }}</h3>
|
||
<p class="news-item__summary">{{ n.summary }}</p>
|
||
<p class="news-item__meta">
|
||
{{ n.source }} · {{ timeAgo(n.publishedAt) }}
|
||
</p>
|
||
</div>
|
||
<span class="news-item__badge" :class="`is-${categoryTone(n.category)}`">{{ n.category }}</span>
|
||
</RouterLink>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
|
||
<!-- 导入弹层 -->
|
||
<AppModal v-if="showImport" title="导入要闻" :max-width="520" @close="showImport = false">
|
||
<div class="import-tabs">
|
||
<AppButton
|
||
v-for="t in (['json', 'rss', 'api', 'url'] as const)"
|
||
:key="t"
|
||
variant="ghost"
|
||
size="md"
|
||
class="import-tabs__tab"
|
||
:class="{ 'is-active': importType === t }"
|
||
@click="importType = t"
|
||
>
|
||
{{ t.toUpperCase() }}
|
||
</AppButton>
|
||
</div>
|
||
<textarea
|
||
v-if="importType === 'json'"
|
||
v-model="importText"
|
||
class="modal__textarea"
|
||
placeholder='粘贴 JSON:{"version":"1.0","news":[{...}]}'
|
||
/>
|
||
<div
|
||
v-else
|
||
class="import-hint"
|
||
>
|
||
<p class="import-hint__title">该导入来源为占位入口</p>
|
||
<p class="import-hint__text">
|
||
提交后将返回「{{ importType.toUpperCase() }} 导入未配置」的明确反馈。当前仅 JSON 为可用的维护方式。
|
||
</p>
|
||
<div class="input-row">
|
||
<input v-model="importUrl" class="modal__input" :placeholder="`请输入 ${importType.toUpperCase()} 地址`" />
|
||
</div>
|
||
</div>
|
||
<p class="import-note">JSON 导入将按「标题 + 分类 + 发布时间」指纹去重,重复条目自动跳过。</p>
|
||
<template #footer>
|
||
<AppButton variant="ghost" @click="showImport = false">取消</AppButton>
|
||
<AppButton variant="primary" @click="doImport">开始导入</AppButton>
|
||
</template>
|
||
</AppModal>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.news {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-4);
|
||
}
|
||
|
||
/* 分类 tab */
|
||
.news-tabs {
|
||
display: flex;
|
||
gap: var(--space-2);
|
||
flex-wrap: wrap;
|
||
}
|
||
/* 分类 tab(覆盖 AppButton 基样式) */
|
||
.news-tabs__tab {
|
||
height: auto !important;
|
||
padding: 6px var(--space-4) !important;
|
||
border: 1px solid var(--border-subtle) !important;
|
||
border-radius: var(--radius-pill) !important;
|
||
background: var(--bg-card) !important;
|
||
color: var(--text-secondary) !important;
|
||
font-size: var(--fs-label) !important;
|
||
font-weight: var(--fw-label-bold) !important;
|
||
cursor: pointer;
|
||
transition: all var(--motion-hover-fast) var(--ease-default);
|
||
}
|
||
.news-tabs__tab.is-active {
|
||
background: var(--primary) !important;
|
||
border-color: var(--primary) !important;
|
||
color: var(--on-accent) !important;
|
||
}
|
||
|
||
/* 置顶卡 */
|
||
.news-pin {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-3);
|
||
padding: var(--card-padding-desktop);
|
||
border-radius: var(--radius-lg);
|
||
background: var(--gradient-hero-deep);
|
||
color: var(--on-accent);
|
||
text-decoration: none;
|
||
}
|
||
.news-pin__tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
align-self: flex-start;
|
||
padding: 4px var(--space-3);
|
||
border-radius: var(--radius-pill);
|
||
background: rgba(255, 255, 255, 0.18);
|
||
font-size: var(--fs-label);
|
||
font-weight: var(--fw-label-bold);
|
||
}
|
||
.news-pin__dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
}
|
||
.news-pin__title {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: var(--fw-h1);
|
||
line-height: var(--lh-h1);
|
||
}
|
||
.news-pin__meta {
|
||
margin: 0;
|
||
font-size: var(--fs-label);
|
||
opacity: 0.8;
|
||
}
|
||
|
||
/* 列表 */
|
||
.news-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-3);
|
||
}
|
||
.news-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: var(--space-4);
|
||
padding: var(--card-padding-desktop);
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
text-decoration: none;
|
||
transition: box-shadow var(--motion-card-hover) var(--ease-default);
|
||
}
|
||
.news-item:hover {
|
||
box-shadow: var(--shadow-level-1);
|
||
}
|
||
.news-item__body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.news-item__title {
|
||
margin: 0;
|
||
font-size: var(--fs-card-title);
|
||
font-weight: var(--fw-card-title);
|
||
color: var(--text-primary);
|
||
}
|
||
.news-item__summary {
|
||
margin: var(--space-1) 0 0;
|
||
font-size: var(--fs-subtitle);
|
||
color: var(--text-secondary);
|
||
line-height: var(--lh-subtitle);
|
||
}
|
||
.news-item__meta {
|
||
margin: var(--space-2) 0 0;
|
||
font-size: var(--fs-label);
|
||
color: var(--text-muted);
|
||
}
|
||
.news-item__badge {
|
||
flex: none;
|
||
padding: 4px var(--space-3);
|
||
border-radius: var(--radius-pill);
|
||
font-size: var(--fs-label);
|
||
font-weight: var(--fw-label-bold);
|
||
}
|
||
.news-item__badge.is-primary {
|
||
background: var(--bg-soft);
|
||
color: var(--primary);
|
||
}
|
||
.news-item__badge.is-warning {
|
||
background: var(--warning-soft);
|
||
color: var(--warning);
|
||
}
|
||
.news-item__badge.is-success {
|
||
background: var(--success-soft);
|
||
color: var(--success);
|
||
}
|
||
.news-item__badge.is-soft {
|
||
background: var(--bg-soft);
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.import-tabs {
|
||
display: flex;
|
||
gap: var(--space-2);
|
||
}
|
||
.import-tabs__tab {
|
||
height: auto !important;
|
||
padding: 6px var(--space-3) !important;
|
||
border: 1px solid var(--border-subtle) !important;
|
||
border-radius: var(--radius-sm) !important;
|
||
background: var(--bg-card) !important;
|
||
color: var(--text-secondary) !important;
|
||
font-size: var(--fs-label) !important;
|
||
font-weight: var(--fw-label-bold) !important;
|
||
cursor: pointer;
|
||
}
|
||
.import-tabs__tab.is-active {
|
||
background: var(--primary) !important;
|
||
border-color: var(--primary) !important;
|
||
color: var(--on-accent) !important;
|
||
}
|
||
.modal__textarea {
|
||
min-height: 160px;
|
||
padding: var(--space-3);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-btn);
|
||
background: var(--bg-card);
|
||
color: var(--text-primary);
|
||
font-family: var(--font-num);
|
||
font-size: var(--fs-label);
|
||
resize: vertical;
|
||
}
|
||
.import-hint {
|
||
padding: var(--space-4);
|
||
border-radius: var(--radius-md);
|
||
background: var(--bg-soft);
|
||
}
|
||
.import-hint__title {
|
||
margin: 0;
|
||
font-size: var(--fs-subtitle);
|
||
font-weight: var(--fw-card-title);
|
||
color: var(--text-primary);
|
||
}
|
||
.import-hint__text {
|
||
margin: var(--space-1) 0 var(--space-3);
|
||
font-size: var(--fs-label);
|
||
color: var(--text-secondary);
|
||
line-height: var(--lh-subtitle);
|
||
}
|
||
.input-row {
|
||
display: flex;
|
||
}
|
||
.modal__input {
|
||
flex: 1;
|
||
height: 40px;
|
||
padding: 0 var(--space-3);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-btn);
|
||
background: var(--bg-card);
|
||
color: var(--text-primary);
|
||
font-size: var(--fs-subtitle);
|
||
}
|
||
.import-note {
|
||
margin: 0;
|
||
font-size: var(--fs-label);
|
||
color: var(--text-muted);
|
||
}
|
||
</style>
|