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:
19
frontend/src/App.vue
Normal file
19
frontend/src/App.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { NConfigProvider, NMessageProvider, NDialogProvider, darkTheme } from 'naive-ui'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
onMounted(() => auth.restore())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NConfigProvider :theme="null">
|
||||
<NMessageProvider>
|
||||
<NDialogProvider>
|
||||
<RouterView />
|
||||
</NDialogProvider>
|
||||
</NMessageProvider>
|
||||
</NConfigProvider>
|
||||
</template>
|
||||
111
frontend/src/api/articles.ts
Normal file
111
frontend/src/api/articles.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { http } from './client'
|
||||
|
||||
export interface Source {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
kind: string
|
||||
url: string
|
||||
enabled: boolean
|
||||
region?: string | null
|
||||
language_src?: string | null
|
||||
priority: number
|
||||
fetch_interval_min: number
|
||||
translate_to: string
|
||||
last_fetched_at?: string | null
|
||||
last_status?: string | null
|
||||
consecutive_failures: number
|
||||
}
|
||||
|
||||
export interface ArticleListItem {
|
||||
id: number
|
||||
source: { id: number; name: string; slug: string; region?: string | null }
|
||||
title: string
|
||||
title_zh?: string | null
|
||||
summary_zh?: string | null
|
||||
lang_src?: string | null
|
||||
translation_status: string
|
||||
category?: string | null
|
||||
published_at?: string | null
|
||||
fetched_at: string
|
||||
image_url?: string | null
|
||||
is_starred: boolean
|
||||
}
|
||||
|
||||
export interface ArticleListResponse {
|
||||
items: ArticleListItem[]
|
||||
next_cursor: string | null
|
||||
total: number | null
|
||||
}
|
||||
|
||||
export interface ArticleDetail extends ArticleListItem {
|
||||
url: string
|
||||
body_html?: string | null
|
||||
body_text: string
|
||||
body_zh_html?: string | null
|
||||
body_zh_text?: string | null
|
||||
author?: string | null
|
||||
translation_engine?: string | null
|
||||
translated_at?: string | null
|
||||
commentary?: string | null
|
||||
entities?: Record<string, any> | null
|
||||
sentiment?: number | null
|
||||
duplicate_of?: number | null
|
||||
}
|
||||
|
||||
export const articlesApi = {
|
||||
list(params: Record<string, any> = {}) {
|
||||
return http.get<ArticleListResponse>('/articles', { params }).then((r) => r.data)
|
||||
},
|
||||
get(id: number) {
|
||||
return http.get<ArticleDetail>(`/articles/${id}`).then((r) => r.data)
|
||||
},
|
||||
}
|
||||
|
||||
export const sourcesApi = {
|
||||
list() {
|
||||
return http.get<Source[]>('/sources').then((r) => r.data)
|
||||
},
|
||||
}
|
||||
|
||||
export const meApi = {
|
||||
me() {
|
||||
return http.get('/me').then((r) => r.data)
|
||||
},
|
||||
usage() {
|
||||
return http.get('/me/usage').then((r) => r.data)
|
||||
},
|
||||
}
|
||||
|
||||
export const bookmarksApi = {
|
||||
list() {
|
||||
return http.get<any[]>('/bookmarks').then((r) => r.data)
|
||||
},
|
||||
add(article_id: number, note?: string) {
|
||||
return http.post('/bookmarks', { article_id, note }).then((r) => r.data)
|
||||
},
|
||||
remove(article_id: number) {
|
||||
return http.delete(`/bookmarks/${article_id}`).then((r) => r.data)
|
||||
},
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
listSources() {
|
||||
return http.get<Source[]>('/admin/sources').then((r) => r.data)
|
||||
},
|
||||
createSource(body: any) {
|
||||
return http.post('/admin/sources', body).then((r) => r.data)
|
||||
},
|
||||
updateSource(id: number, body: any) {
|
||||
return http.patch(`/admin/sources/${id}`, body).then((r) => r.data)
|
||||
},
|
||||
deleteSource(id: number) {
|
||||
return http.delete(`/admin/sources/${id}`).then((r) => r.data)
|
||||
},
|
||||
refresh(id: number) {
|
||||
return http.post(`/admin/refresh/${id}`).then((r) => r.data)
|
||||
},
|
||||
health() {
|
||||
return http.get('/admin/health').then((r) => r.data)
|
||||
},
|
||||
}
|
||||
37
frontend/src/api/client.ts
Normal file
37
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import axios, { AxiosError, type AxiosInstance } from 'axios'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE || '/api/v1'
|
||||
|
||||
export const http: AxiosInstance = axios.create({
|
||||
baseURL: BASE,
|
||||
timeout: 20000,
|
||||
})
|
||||
|
||||
http.interceptors.request.use((cfg) => {
|
||||
const auth = useAuthStore()
|
||||
if (auth.accessToken) {
|
||||
cfg.headers = cfg.headers ?? {}
|
||||
cfg.headers.Authorization = `Bearer ${auth.accessToken}`
|
||||
}
|
||||
return cfg
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(r) => r,
|
||||
async (err: AxiosError) => {
|
||||
const auth = useAuthStore()
|
||||
const original: any = err.config
|
||||
if (err.response?.status === 401 && !original?._retry && auth.refreshToken) {
|
||||
original._retry = true
|
||||
try {
|
||||
await auth.refresh()
|
||||
original.headers.Authorization = `Bearer ${auth.accessToken}`
|
||||
return http(original)
|
||||
} catch {
|
||||
auth.logout()
|
||||
}
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
82
frontend/src/components/AppLayout.vue
Normal file
82
frontend/src/components/AppLayout.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { useRoute, useRouter, RouterView, RouterLink } from 'vue-router'
|
||||
import {
|
||||
NLayout, NLayoutHeader, NLayoutContent, NLayoutSider,
|
||||
NMenu, NIcon, NSpace, NButton, NText, NTag, NAvatar, NDropdown, useMessage,
|
||||
} from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { meApi } from '@/api/articles'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const usage = ref<any>(null)
|
||||
|
||||
const menu = computed(() => [
|
||||
{ key: '/', label: '24h 列表', icon: () => '📰' },
|
||||
{ key: '/sources', label: '采集源', icon: () => '📡' },
|
||||
{ key: '/bookmarks', label: '收藏', icon: () => '⭐' },
|
||||
...(auth.isOwner ? [{ key: '/admin/sources', label: '源管理(Admin)', icon: () => '🛠' }] : []),
|
||||
])
|
||||
|
||||
const userMenu = computed(() => [
|
||||
{ key: 'logout', label: '退出登录' },
|
||||
])
|
||||
|
||||
async function fetchUsage() {
|
||||
try { usage.value = await meApi.usage() } catch { /* noop */ }
|
||||
}
|
||||
onMounted(fetchUsage)
|
||||
|
||||
function onUserMenu(key: string) {
|
||||
if (key === 'logout') {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
message.info('已退出')
|
||||
}
|
||||
}
|
||||
|
||||
function onMenu(key: string) {
|
||||
router.push(key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NLayout style="min-height: 100vh">
|
||||
<NLayoutHeader bordered style="padding: 12px 24px">
|
||||
<NSpace align="center" justify="space-between">
|
||||
<NSpace align="center">
|
||||
<span style="font-size: 20px; font-weight: 700">📚 Diary News</span>
|
||||
<NTag v-if="usage" size="small" :type="usage.pct_used > 80 ? 'warning' : 'default'">
|
||||
翻译: {{ usage.used_chars.toLocaleString() }} / {{ usage.quota_chars.toLocaleString() }}
|
||||
({{ usage.pct_used.toFixed(1) }}%)
|
||||
</NTag>
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NText v-if="auth.user">{{ auth.user.username }} ({{ auth.user.role }})</NText>
|
||||
<NDropdown :options="userMenu" trigger="click" @select="onUserMenu">
|
||||
<NButton quaternary>账号 ▾</NButton>
|
||||
</NDropdown>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NLayoutHeader>
|
||||
|
||||
<NLayout has-sider style="min-height: calc(100vh - 60px)">
|
||||
<NLayoutSider bordered :width="220" :native-scrollbar="false">
|
||||
<NMenu
|
||||
:value="route.path"
|
||||
:options="menu"
|
||||
@update:value="onMenu"
|
||||
style="padding-top: 12px"
|
||||
/>
|
||||
</NLayoutSider>
|
||||
<NLayoutContent style="padding: 24px; max-width: 1000px; margin: 0 auto; width: 100%;">
|
||||
<RouterView />
|
||||
</NLayoutContent>
|
||||
</NLayout>
|
||||
</NLayout>
|
||||
</template>
|
||||
10
frontend/src/main.ts
Normal file
10
frontend/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
39
frontend/src/router.ts
Normal file
39
frontend/src/router.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/login', component: () => import('@/views/Login.vue'), meta: { layout: 'blank' } },
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/components/AppLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: '', component: () => import('@/views/Feed.vue') },
|
||||
{ path: 'article/:id', component: () => import('@/views/ArticleDetail.vue') },
|
||||
{ path: 'sources', component: () => import('@/views/Sources.vue') },
|
||||
{ path: 'bookmarks', component: () => import('@/views/Bookmarks.vue') },
|
||||
{ path: 'admin/sources', component: () => import('@/views/AdminSources.vue'), meta: { ownerOnly: true } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore()
|
||||
auth.restore()
|
||||
if (to.meta.requiresAuth && !auth.isLogged) {
|
||||
return { path: '/login', query: { next: to.fullPath } }
|
||||
}
|
||||
if (to.meta.ownerOnly && !auth.isOwner) {
|
||||
return { path: '/' }
|
||||
}
|
||||
if (to.path === '/login' && auth.isLogged) {
|
||||
return { path: '/' }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
61
frontend/src/stores/auth.ts
Normal file
61
frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { http } from '@/api/client'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
role: 'owner' | 'member'
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
const ACCESS_KEY = 'dn.access'
|
||||
const REFRESH_KEY = 'dn.refresh'
|
||||
const USER_KEY = 'dn.user'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
accessToken: '' as string,
|
||||
refreshToken: '' as string,
|
||||
user: null as User | null,
|
||||
}),
|
||||
getters: {
|
||||
isLogged: (s) => !!s.accessToken,
|
||||
isOwner: (s) => s.user?.role === 'owner',
|
||||
},
|
||||
actions: {
|
||||
async login(username: string, password: string) {
|
||||
const { data } = await http.post('/auth/login', { username, password })
|
||||
this.accessToken = data.access_token
|
||||
this.refreshToken = data.refresh_token
|
||||
localStorage.setItem(ACCESS_KEY, this.accessToken)
|
||||
localStorage.setItem(REFRESH_KEY, this.refreshToken)
|
||||
await this.fetchMe()
|
||||
},
|
||||
async refresh() {
|
||||
const { data } = await http.post('/auth/refresh', { refresh_token: this.refreshToken })
|
||||
this.accessToken = data.access_token
|
||||
this.refreshToken = data.refresh_token
|
||||
localStorage.setItem(ACCESS_KEY, this.accessToken)
|
||||
localStorage.setItem(REFRESH_KEY, this.refreshToken)
|
||||
},
|
||||
async fetchMe() {
|
||||
const { data } = await http.get('/me')
|
||||
this.user = data
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data))
|
||||
},
|
||||
restore() {
|
||||
this.accessToken = localStorage.getItem(ACCESS_KEY) || ''
|
||||
this.refreshToken = localStorage.getItem(REFRESH_KEY) || ''
|
||||
const u = localStorage.getItem(USER_KEY)
|
||||
if (u) this.user = JSON.parse(u)
|
||||
},
|
||||
logout() {
|
||||
this.accessToken = ''
|
||||
this.refreshToken = ''
|
||||
this.user = null
|
||||
localStorage.removeItem(ACCESS_KEY)
|
||||
localStorage.removeItem(REFRESH_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
},
|
||||
},
|
||||
})
|
||||
29
frontend/src/style.css
Normal file
29
frontend/src/style.css
Normal file
@@ -0,0 +1,29 @@
|
||||
:root {
|
||||
--max-width: 1200px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1f2328;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
a:hover { color: #2080f0; }
|
||||
|
||||
img { max-width: 100%; }
|
||||
|
||||
.n-card.article-card {
|
||||
margin-bottom: 16px;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.n-card.article-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
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