gwy-exam/client/src/views/news/NewsView.vue

408 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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