feat: initial MVP - FastAPI backend + Vue3 frontend + docker-compose
- backend: FastAPI + SQLAlchemy 2.0(async) + asyncpg + Alembic - 7 API routes: auth/me/articles/sources/bookmarks/subscriptions/admin - models: User/Source/Article/Bookmark/Subscription/ApiToken - services: RSS fetcher (feedparser) + Tencent TMT translator with quota + cache + local NLLB fallback - workers: APScheduler + asyncio pipeline (fetch -> dedupe -> insert -> translate) - seed scripts: create_user, seed_sources (5 RSS: Reuters/BBC/Al Jazeera/NHK/DW) - frontend: Vue 3 + Vite + Naive UI + Pinia + vue-router - pages: Login, Feed (24h), ArticleDetail, Sources, Bookmarks, AdminSources - deploy: docker-compose (postgres/redis/api/worker/frontend/caddy) - docs: README, DEPLOY, architecture, acceptance
This commit is contained in:
143
frontend/src/views/AdminSources.vue
Normal file
143
frontend/src/views/AdminSources.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import {
|
||||
NCard, NDataTable, NButton, NTag, NSpace, NPopconfirm, useMessage, NModal, NForm, NFormItem, NInput, NSelect, NInputNumber, useDialog,
|
||||
} from 'naive-ui'
|
||||
import { adminApi, type Source } from '@/api/articles'
|
||||
import { h } from 'vue'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const sources = ref<Source[]>([])
|
||||
const showCreate = ref(false)
|
||||
const form = ref({
|
||||
name: '',
|
||||
slug: '',
|
||||
kind: 'rss' as 'rss' | 'html_list' | 'tg_channel',
|
||||
url: '',
|
||||
region: '',
|
||||
language_src: 'en',
|
||||
priority: 50,
|
||||
fetch_interval_min: 60,
|
||||
translate_to: 'zh',
|
||||
})
|
||||
|
||||
const kindOptions = [
|
||||
{ label: 'RSS / Atom', value: 'rss' },
|
||||
{ label: 'HTML 列表', value: 'html_list' },
|
||||
{ label: 'Telegram', value: 'tg_channel' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
sources.value = await adminApi.listSources()
|
||||
}
|
||||
|
||||
async function toggleEnabled(s: Source) {
|
||||
await adminApi.updateSource(s.id, { enabled: !s.enabled })
|
||||
await load()
|
||||
message.success(s.enabled ? '已停用' : '已启用')
|
||||
}
|
||||
|
||||
async function del(s: Source) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: `删除源 "${s.name}" 及其全部文章?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await adminApi.deleteSource(s.id)
|
||||
message.success('已删除')
|
||||
await load()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function refresh(s: Source) {
|
||||
await adminApi.refresh(s.id)
|
||||
message.success('已加入抓取队列')
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!form.value.name || !form.value.slug || !form.value.url) {
|
||||
message.error('请填写名称 / slug / url')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await adminApi.createSource(form.value)
|
||||
message.success('已创建,等下一轮抓取')
|
||||
showCreate.value = false
|
||||
form.value = {
|
||||
name: '', slug: '', kind: 'rss', url: '',
|
||||
region: '', language_src: 'en', priority: 50, fetch_interval_min: 60, translate_to: 'zh',
|
||||
}
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 50 },
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: 'slug', key: 'slug' },
|
||||
{ title: 'kind', key: 'kind' },
|
||||
{ title: 'URL', key: 'url', render: (r: Source) => h('a', { href: r.url, target: '_blank', rel: 'noopener' }, r.url.slice(0, 60) + (r.url.length > 60 ? '…' : '')) },
|
||||
{ title: '地区', key: 'region' },
|
||||
{
|
||||
title: '优先级/间隔', key: 'meta',
|
||||
render: (r: Source) => `P${r.priority} / ${r.fetch_interval_min}m`,
|
||||
},
|
||||
{
|
||||
title: '状态', key: 'enabled',
|
||||
render: (r: Source) => h(NTag, { type: r.enabled ? 'success' : 'default', size: 'small' }, () => r.enabled ? '启用' : '停用'),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 280,
|
||||
render: (row: Source) => h(NSpace, {}, () => [
|
||||
h(NButton, { size: 'small', onClick: () => refresh(row) }, () => '抓取'),
|
||||
h(NButton, { size: 'small', onClick: () => toggleEnabled(row) }, () => row.enabled ? '停用' : '启用'),
|
||||
h(NPopconfirm, { onPositiveClick: () => del(row) }, {
|
||||
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, () => '删除'),
|
||||
default: () => '确认删除?',
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical>
|
||||
<NSpace justify="end">
|
||||
<NButton type="primary" @click="showCreate = true">+ 新增源</NButton>
|
||||
</NSpace>
|
||||
<NCard title="源管理">
|
||||
<NDataTable :columns="columns" :data="sources" :bordered="false" :pagination="{ pageSize: 20 }" />
|
||||
</NCard>
|
||||
|
||||
<NModal v-model:show="showCreate" preset="card" title="新增采集源" style="width: 600px">
|
||||
<NForm label-placement="left" label-width="100">
|
||||
<NFormItem label="名称"><NInput v-model:value="form.name" /></NFormItem>
|
||||
<NFormItem label="slug"><NInput v-model:value="form.slug" placeholder="小写字母+连字符" /></NFormItem>
|
||||
<NFormItem label="类型"><NSelect v-model:value="form.kind" :options="kindOptions" /></NFormItem>
|
||||
<NFormItem label="URL"><NInput v-model:value="form.url" placeholder="https://..." /></NFormItem>
|
||||
<NFormItem label="地区"><NInput v-model:value="form.region" placeholder="global / eu / asia / mena" /></NFormItem>
|
||||
<NFormItem label="源语种"><NInput v-model:value="form.language_src" placeholder="en" /></NFormItem>
|
||||
<NFormItem label="优先级">
|
||||
<NInputNumber v-model:value="form.priority" :min="1" :max="100" />
|
||||
</NFormItem>
|
||||
<NFormItem label="抓取间隔(分)">
|
||||
<NInputNumber v-model:value="form.fetch_interval_min" :min="5" :max="1440" />
|
||||
</NFormItem>
|
||||
<NFormItem label="翻译目标"><NInput v-model:value="form.translate_to" /></NFormItem>
|
||||
</NForm>
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="showCreate = false">取消</NButton>
|
||||
<NButton type="primary" @click="create">创建</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</NSpace>
|
||||
</template>
|
||||
151
frontend/src/views/ArticleDetail.vue
Normal file
151
frontend/src/views/ArticleDetail.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
NCard, NSpace, NTag, NText, NButton, NSpin, NEmpty, NDivider, NAlert, NSkeleton, useMessage,
|
||||
} from 'naive-ui'
|
||||
import { articlesApi, type ArticleDetail } from '@/api/articles'
|
||||
import { http } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const article = ref<ArticleDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
const starred = ref(false)
|
||||
const showOriginal = ref(true)
|
||||
const showTranslation = ref(true)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const id = Number(route.params.id)
|
||||
article.value = await articlesApi.get(id)
|
||||
starred.value = article.value.is_starred
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStar() {
|
||||
if (!article.value) return
|
||||
const { bookmarksApi } = await import('@/api/articles')
|
||||
try {
|
||||
if (starred.value) {
|
||||
await bookmarksApi.remove(article.value.id)
|
||||
starred.value = false
|
||||
message.info('已取消收藏')
|
||||
} else {
|
||||
await bookmarksApi.add(article.value.id)
|
||||
starred.value = true
|
||||
message.success('已收藏')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(s?: string | null) {
|
||||
if (!s) return '—'
|
||||
return dayjs(s).format('YYYY-MM-DD HH:mm [UTC]')
|
||||
}
|
||||
|
||||
const publishedAt = computed(() => article.value?.published_at || article.value?.fetched_at)
|
||||
const isOwner = computed(() => auth.isOwner)
|
||||
|
||||
async function rerunTranslation() {
|
||||
if (!article.value) return
|
||||
if (!confirm('重新翻译会消耗配额,确认?')) return
|
||||
try {
|
||||
await http.post(`/admin/translation/rerun/${article.value.id}`)
|
||||
message.success('已加入翻译队列,稍后刷新')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '触发失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical>
|
||||
<NButton text @click="router.back()">← 返回</NButton>
|
||||
<NSpin :show="loading">
|
||||
<NSkeleton v-if="loading" :repeat="6" />
|
||||
<NEmpty v-else-if="!article" description="文章不存在" />
|
||||
<div v-else>
|
||||
<NCard>
|
||||
<NSpace vertical :size="8">
|
||||
<NSpace align="center">
|
||||
<NTag type="info">{{ article.source.name }}</NTag>
|
||||
<NText depth="3" style="font-size: 12px">{{ fmtTime(publishedAt) }}</NText>
|
||||
<NTag v-if="article.translation_status !== 'ok'" size="small" type="warning">
|
||||
翻译: {{ article.translation_status }}
|
||||
</NTag>
|
||||
<NTag v-if="article.translation_engine" size="small">
|
||||
{{ article.translation_engine }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
<h1 style="margin: 0">{{ article.title }}</h1>
|
||||
<h2 v-if="article.title_zh" style="margin: 0; color: #2080f0">{{ article.title_zh }}</h2>
|
||||
<NSpace>
|
||||
<NButton :type="starred ? 'warning' : 'default'" @click="toggleStar">
|
||||
{{ starred ? '★ 已收藏' : '☆ 收藏' }}
|
||||
</NButton>
|
||||
<NButton text @click="showOriginal = !showOriginal">
|
||||
{{ showOriginal ? '隐藏原文' : '显示原文' }}
|
||||
</NButton>
|
||||
<NButton text @click="showTranslation = !showTranslation">
|
||||
{{ showTranslation ? '隐藏译文' : '显示译文' }}
|
||||
</NButton>
|
||||
<NButton v-if="isOwner" type="error" ghost @click="rerunTranslation">
|
||||
重译
|
||||
</NButton>
|
||||
<NButton tag="a" :href="article.url" target="_blank" rel="noopener">原文链接 ↗</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<NAlert v-if="article.translation_status === 'failed'" type="warning" style="margin: 16px 0">
|
||||
本条翻译失败,可点 "重译" 重试,或查看后端日志。
|
||||
</NAlert>
|
||||
|
||||
<div v-if="showOriginal" style="margin-top: 16px">
|
||||
<NCard title="原文">
|
||||
<div v-if="article.body_html" v-html="article.body_html" style="line-height: 1.8" />
|
||||
<div v-else style="white-space: pre-wrap; line-height: 1.8">{{ article.body_text }}</div>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<div v-if="showTranslation" style="margin-top: 16px">
|
||||
<NCard title="译文">
|
||||
<div v-if="article.body_zh_html" v-html="article.body_zh_html" style="line-height: 1.8" />
|
||||
<div v-else-if="article.body_zh_text" style="white-space: pre-wrap; line-height: 1.8">
|
||||
{{ article.body_zh_text }}
|
||||
</div>
|
||||
<NText v-else depth="3">暂无译文</NText>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<NCard v-if="article.commentary || article.entities" style="margin-top: 16px" title="智能增强 (预留)">
|
||||
<div v-if="article.commentary">
|
||||
<NText strong>点评: </NText>
|
||||
<span>{{ article.commentary }}</span>
|
||||
</div>
|
||||
<div v-if="article.entities" style="margin-top: 8px">
|
||||
<NText strong>实体: </NText>
|
||||
<code>{{ JSON.stringify(article.entities) }}</code>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
</NSpin>
|
||||
</NSpace>
|
||||
</template>
|
||||
67
frontend/src/views/Bookmarks.vue
Normal file
67
frontend/src/views/Bookmarks.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { NCard, NDataTable, NTag, NSpace, NButton, useMessage } from 'naive-ui'
|
||||
import { bookmarksApi, articlesApi } from '@/api/articles'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const items = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await bookmarksApi.list()
|
||||
// 拉详情拿标题
|
||||
const detailed = await Promise.all(
|
||||
list.map(async (b) => {
|
||||
try {
|
||||
const a = await articlesApi.get(b.article_id)
|
||||
return { bookmark: b, article: a }
|
||||
} catch {
|
||||
return { bookmark: b, article: null }
|
||||
}
|
||||
})
|
||||
)
|
||||
items.value = detailed
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(article_id: number) {
|
||||
try {
|
||||
await bookmarksApi.remove(article_id)
|
||||
message.success('已移除')
|
||||
items.value = items.value.filter((x) => x.bookmark.article_id !== article_id)
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '标题', key: 'title', render: (row: any) => row.article?.title || '(已删除)' },
|
||||
{ title: '译文', key: 'title_zh', render: (row: any) => row.article?.title_zh || '—' },
|
||||
{ title: '源', key: 'source', render: (row: any) => row.article?.source?.name || '—' },
|
||||
{ title: '收藏时间', key: 'created_at', render: (row: any) => dayjs(row.bookmark.created_at).format('YYYY-MM-DD HH:mm') },
|
||||
{
|
||||
title: '操作', key: 'action', width: 200,
|
||||
render: (row: any) => h(NSpace, {}, () => [
|
||||
h(NButton, { size: 'small', onClick: () => row.article && router.push(`/article/${row.article.id}`) }, () => '查看'),
|
||||
h(NButton, { size: 'small', type: 'error', ghost: true, onClick: () => remove(row.bookmark.article_id) }, () => '移除'),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
import { h } from 'vue'
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard title="我的收藏">
|
||||
<NDataTable :columns="columns" :data="items" :bordered="false" :loading="loading" />
|
||||
</NCard>
|
||||
</template>
|
||||
133
frontend/src/views/Feed.vue
Normal file
133
frontend/src/views/Feed.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
NCard, NSpace, NTag, NText, NSelect, NInput, NButton, NEmpty, NSkeleton, NSpin,
|
||||
} from 'naive-ui'
|
||||
import { articlesApi, sourcesApi, type ArticleListItem, type Source } from '@/api/articles'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const items = ref<ArticleListItem[]>([])
|
||||
const sources = ref<Source[]>([])
|
||||
const loading = ref(false)
|
||||
const cursor = ref<string | null>(null)
|
||||
const exhausted = ref(false)
|
||||
const sourceFilter = ref<string[]>([])
|
||||
const q = ref('')
|
||||
|
||||
const sourceOptions = ref<{ label: string; value: string }[]>([])
|
||||
|
||||
async function load() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await articlesApi.list({
|
||||
source: sourceFilter.value.join(',') || undefined,
|
||||
q: q.value || undefined,
|
||||
cursor: cursor.value || undefined,
|
||||
limit: 50,
|
||||
})
|
||||
items.value.push(...resp.items)
|
||||
cursor.value = resp.next_cursor
|
||||
if (!cursor.value) exhausted.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
sources.value = await sourcesApi.list()
|
||||
sourceOptions.value = sources.value.map((s) => ({ label: s.name, value: s.slug }))
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
items.value = []
|
||||
cursor.value = null
|
||||
exhausted.value = false
|
||||
load()
|
||||
}
|
||||
|
||||
function open(a: ArticleListItem) {
|
||||
router.push(`/article/${a.id}`)
|
||||
}
|
||||
|
||||
function star(a: ArticleListItem) {
|
||||
// 简单:登录的 star 接口后续
|
||||
a.is_starred = !a.is_starred
|
||||
}
|
||||
|
||||
function fmtTime(s?: string | null) {
|
||||
if (!s) return '—'
|
||||
return dayjs(s).fromNow()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSources()
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical>
|
||||
<NSpace align="center" justify="space-between">
|
||||
<NSpace>
|
||||
<NSelect
|
||||
v-model:value="sourceFilter"
|
||||
multiple
|
||||
clearable
|
||||
placeholder="按源筛选"
|
||||
:options="sourceOptions"
|
||||
style="min-width: 240px"
|
||||
@update:value="refresh"
|
||||
/>
|
||||
<NInput v-model:value="q" placeholder="关键词搜索" clearable style="width: 200px"
|
||||
@keyup.enter="refresh" @clear="refresh" />
|
||||
<NButton @click="refresh">刷新</NButton>
|
||||
</NSpace>
|
||||
<NText depth="3">{{ items.length }} 条</NText>
|
||||
</NSpace>
|
||||
|
||||
<NSpin :show="loading && items.length === 0">
|
||||
<NSkeleton v-if="loading && items.length === 0" :repeat="4" />
|
||||
<NEmpty v-else-if="items.length === 0 && !loading" description="暂无新闻" />
|
||||
<div v-else>
|
||||
<NCard
|
||||
v-for="a in items"
|
||||
:key="a.id"
|
||||
class="article-card"
|
||||
hoverable
|
||||
@click="open(a)"
|
||||
>
|
||||
<NSpace vertical :size="4">
|
||||
<NSpace align="center" :size="8">
|
||||
<NTag size="small" type="info">{{ a.source.name }}</NTag>
|
||||
<NTag v-if="a.lang_src" size="small">{{ a.lang_src }}</NTag>
|
||||
<NTag v-if="a.translation_status !== 'ok'" size="small" type="warning">
|
||||
{{ a.translation_status }}
|
||||
</NTag>
|
||||
<NText depth="3" style="font-size: 12px">{{ fmtTime(a.published_at || a.fetched_at) }}</NText>
|
||||
</NSpace>
|
||||
<div style="font-size: 16px; font-weight: 600; color: #333">{{ a.title }}</div>
|
||||
<div v-if="a.title_zh" style="font-size: 15px; color: #2080f0; font-weight: 500;">
|
||||
{{ a.title_zh }}
|
||||
</div>
|
||||
<div v-if="a.summary_zh" style="color: #666; font-size: 13px; margin-top: 4px">
|
||||
{{ a.summary_zh.slice(0, 200) }}{{ a.summary_zh.length > 200 ? '…' : '' }}
|
||||
</div>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
<NSpace v-if="!exhausted" justify="center" style="margin: 16px 0">
|
||||
<NButton :loading="loading" @click="load">加载更多</NButton>
|
||||
</NSpace>
|
||||
<NText v-else depth="3" style="display:block; text-align:center; padding: 16px">— 到底了 —</NText>
|
||||
</div>
|
||||
</NSpin>
|
||||
</NSpace>
|
||||
</template>
|
||||
59
frontend/src/views/Login.vue
Normal file
59
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { NCard, NForm, NFormItem, NInput, NButton, NSpace, NAlert, useMessage } from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
|
||||
const username = ref('owner')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
if (!username.value || !password.value) {
|
||||
error.value = '请填写账号和密码'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
message.success('登录成功')
|
||||
const next = (route.query.next as string) || '/'
|
||||
router.push(next)
|
||||
} catch (e: any) {
|
||||
error.value = e?.response?.data?.title || '登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);">
|
||||
<NCard title="📚 Diary News" style="width: 380px;">
|
||||
<NForm @submit.prevent="submit">
|
||||
<NFormItem label="账号">
|
||||
<NInput v-model:value="username" placeholder="owner" autofocus />
|
||||
</NFormItem>
|
||||
<NFormItem label="密码">
|
||||
<NInput v-model:value="password" type="password" show-password-on="click" @keyup.enter="submit" />
|
||||
</NFormItem>
|
||||
<NAlert v-if="error" type="error" :show-icon="false" style="margin-bottom: 12px">
|
||||
{{ error }}
|
||||
</NAlert>
|
||||
<NSpace vertical>
|
||||
<NButton type="primary" block :loading="loading" @click="submit">登录</NButton>
|
||||
<NText depth="3" style="font-size: 12px">
|
||||
私人系统 · 仅授权用户
|
||||
</NText>
|
||||
</NSpace>
|
||||
</NForm>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
80
frontend/src/views/Sources.vue
Normal file
80
frontend/src/views/Sources.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { NCard, NDataTable, NTag, NSpace, NText, NButton, useMessage } from 'naive-ui'
|
||||
import { sourcesApi, adminApi, type Source } from '@/api/articles'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const message = useMessage()
|
||||
const sources = ref<Source[]>([])
|
||||
const health = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
sources.value = await sourcesApi.list()
|
||||
if (auth.isOwner) {
|
||||
try { health.value = await adminApi.health() } catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerRefresh(id: number) {
|
||||
try {
|
||||
await adminApi.refresh(id)
|
||||
message.success('已加入抓取队列')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '触发失败')
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(s?: string | null) {
|
||||
if (!s) return '—'
|
||||
return dayjs(s).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: '源', key: 'slug' },
|
||||
{ title: '地区', key: 'region' },
|
||||
{ title: '优先级', key: 'priority' },
|
||||
{
|
||||
title: '状态', key: 'enabled',
|
||||
render: (row: Source) => h(NTag, { type: row.enabled ? 'success' : 'default', size: 'small' }, () => row.enabled ? '启用' : '停用'),
|
||||
},
|
||||
{ title: '抓取间隔', key: 'fetch_interval_min', render: (r: Source) => `${r.fetch_interval_min}m` },
|
||||
{ title: '上次抓取', key: 'last_fetched_at', render: (r: Source) => fmtTime(r.last_fetched_at) },
|
||||
{ title: '上次状态', key: 'last_status' },
|
||||
{
|
||||
title: '操作', key: 'action', width: 120,
|
||||
render: (row: Source) => h(NSpace, {}, () => [
|
||||
auth.isOwner && row.enabled
|
||||
? h(NButton, { size: 'small', onClick: () => triggerRefresh(row.id) }, () => '立即抓取')
|
||||
: null,
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
import { h } from 'vue'
|
||||
|
||||
const healthColumns = [
|
||||
{ title: '源', key: 'slug' },
|
||||
{ title: '24h 新增', key: 'article_count_24h' },
|
||||
{ title: '连续失败', key: 'consecutive_failures' },
|
||||
{ title: '当前间隔', key: 'fetch_interval_min', render: (r: any) => `${r.fetch_interval_min}m` },
|
||||
{ title: '上次', key: 'last_fetched_at', render: (r: any) => fmtTime(r.last_fetched_at) },
|
||||
{ title: '状态', key: 'last_status' },
|
||||
]
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical>
|
||||
<NCard title="采集源(只读)">
|
||||
<NDataTable :columns="columns" :data="sources" :bordered="false" :pagination="false" />
|
||||
</NCard>
|
||||
<NCard v-if="auth.isOwner" title="源健康(Owner)">
|
||||
<NDataTable :columns="healthColumns" :data="health" :bordered="false" :pagination="false" />
|
||||
</NCard>
|
||||
</NSpace>
|
||||
</template>
|
||||
Reference in New Issue
Block a user