feat(ingest): API Push 前端层 + 文档 + 端到端联通

后端(支持 api_push source 创建/调度):
- schemas/source.py:SourceIn.url 改成 str(允许 api_push 的 api-push:// 占位)
- admin.py create_source 简化 url 传递
- workers/__main__.py:_rebuild_jobs 跳过 api_push 源(它是被动接收,不抓取)
- workers/pipeline.py:run_once 也加同条件,api_push 不进抓取循环

前端:
- api/articles.ts:ArticleListItem 加 is_short_news(required)/source_ref;
  ArticleDetail 加 external_id;导出 IngestTokenOut;adminApi 加
  list/create/revoke ingest token 三个方法
- views/Feed.vue:卡片根 class 短新闻加 short-card(淡蓝底 #f6f9fc +
  左侧 3px 蓝色色条 #4f9eff);元信息栏加 📰 短讯 角标;长新闻摘要
  body_zh_text 截前 200 字,短新闻不截取保留换行(white-space: pre-wrap);
  短新闻不显示 AI 插图
- views/ArticleDetail.vue:tag 行加 📰 短讯 + source_ref 角标;短新闻
  路径下隐藏翻译状态/重译/原文链接按钮;正文区短新闻直接渲染
  body_zh_text,跳过译文/原文/AI 配图卡片;Angel + 美团双评论卡片
  都保留
- views/AdminSources.vue:kind 加 api_push 选项;api_push 源 URL 字段
  变只读占位、隐藏抓取间隔;列表操作列加 🔑 Token 按钮;
  弹窗支持生成(raw_token 一次性显示 + 复制)/列表/撤销

文档:
- docs/api-push.md:调用方契约 + 三层去重 + 限速 + lifecycle +
  owner 操作手册 + curl/Python 示例 + 重试策略 + 故障排查
- README.md:关键特性加 API Push;API 概览加 /api/v1/ingest 和
  3 个 /admin/.../ingest-tokens 端点
This commit is contained in:
xiaji
2026-06-14 16:15:21 +08:00
parent 07534eb144
commit e274246056
10 changed files with 677 additions and 51 deletions

View File

@@ -48,7 +48,10 @@
- 🌐 **智能翻译**:腾讯云 TMT(月 500 万字符配额)→ 本地 NLLB-200 降级,30 天 Redis 缓存避免重复
- 🤖 **LLM 智能增强** *(新)*:翻译完成后自动跑 4 项 LLM 任务 — 排版 / 分类 / 插图 / 点评
- 🎨 **AI 配图**:文生图模型自动为每篇文章生成插图(走 Agnes 平台,带限速)
- 👤 **双角色鉴权**:JWT(access 60min + refresh 14d) + API Token(sha256,可撤销,给 Android 预留)
- 📥 **API Push 短新闻** *(新)*:`POST /api/v1/ingest` 接收外部中文短新闻推送,
三层去重(L1 external_id / L2 content_hash / L3 DB UNIQUE)+ 每 token 2 篇/秒限速;
短新闻入库后跳过翻译/排版/插图,只跑分类 + 双 provider 点评
- 👤 **双角色鉴权**:JWT(access 60min + refresh 14d) + API Token(sha256,可撤销,给 Android / ingest 预留)
- 📌 **收藏 + 关键词订阅**:用户级书签,服务端定时按关键词命中推送(预留 Telegram 通道)
- 📊 **管理看板**:源健康度 / 翻译配额 / LLM 状态,全部可视化
- 🔄 **热加载**:源/提示词改了不用重启,worker 每天 00:30 重建 job
@@ -616,9 +619,20 @@ WHERE translation_status='ok';
- `GET /bookmarks` / `POST /bookmarks` / `DELETE /bookmarks/{id}`
- `GET /subscriptions` / `POST /subscriptions` / `DELETE /subscriptions/{id}`
### API Push 短新闻(无鉴权,凭 X-Ingest-Token)
- `POST /api/v1/ingest` — 外部推送短新闻入库(中文原生,跳过翻译/排版/插图,跑分类 + 双 provider 点评)
- 鉴权:`X-Ingest-Token` 头对应 `api_tokens.purpose='ingest'` 的 sha256 token
- 限速:每 token 2 篇/秒(`INGEST_RATE_PER_SEC` 可调)
- 去重:三层(L1 external_id / L2 content_hash / L3 DB UNIQUE)
- 完整契约见 [`docs/api-push.md`](./docs/api-push.md)
### Owner only(`/admin/*`)
- `GET /admin/sources` / `POST` / `PATCH /{id}` / `DELETE /{id}` — 源 CRUD
- `POST /admin/sources/{source_id}/ingest-tokens` — 为 api_push 源生成 ingest token(raw_token 仅一次性返回)
- `GET /admin/sources/{source_id}/ingest-tokens` — 列出某个 source 的 ingest token
- `DELETE /admin/ingest-tokens/{token_id}` — 撤销 ingest token
- `POST /admin/refresh/{source_id}` — 立即触发抓取
- `POST /admin/translation/rerun/{article_id}` — 重译
- `GET /admin/health` — 源健康看板

View File

@@ -47,7 +47,7 @@ async def create_source(body: SourceIn, session: AsyncSession = Depends(get_sess
name=body.name,
slug=body.slug,
kind=body.kind,
url=str(body.url),
url=body.url,
detail_selector=body.detail_selector,
region=body.region,
language_src=body.language_src,

View File

@@ -2,8 +2,9 @@
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, HttpUrl
from pydantic import BaseModel, ConfigDict, Field
from app.models.source import SourceKind
@@ -29,11 +30,21 @@ class SourceOut(BaseModel):
blocklist_tags: list[str] = []
# url 字段:正常源要 HttpUrl(校验合法 URL),但 api_push 源是合成占位(类似 api-push://...)
# 用 Annotated Union 区分:rss/html_list/tg_channel → HttpUrl;api_push → str
# 但 SourceIn.kind 未知时(前端一次提交),无法静态区分。最简单的兼容:统一接受 str,
# 入库前在 admin.create_source 里按 kind 分支校验。
# 这里改成 str(最长 2048),保留手工校验的责任。
SourceUrlStr = Annotated[str, Field(min_length=1, max_length=2048)]
class SourceIn(BaseModel):
name: str = Field(min_length=1, max_length=128)
slug: str = Field(min_length=1, max_length=128, pattern=r"^[a-z0-9-]+$")
kind: SourceKind = SourceKind.RSS
url: HttpUrl
# url:不再强制 HttpUrl,允许 api_push 源的合成 url(api-push://...);
# rss/html_list/tg_channel 由 admin.create_source 在入库前手工校验
url: str = Field(min_length=1, max_length=2048)
region: str | None = None
language_src: str | None = None
priority: int = Field(default=50, ge=1, le=100)

View File

@@ -29,7 +29,11 @@ logging.basicConfig(
async def _rebuild_jobs(scheduler: AsyncIOScheduler) -> None:
"""从 sources 表动态构建 job(可热更新)。"""
"""从 sources 表动态构建 job(可热更新)。
只调度有抓取语义的源(rss / html_list / tg_channel);
api_push 是被动接收,不进 fetch 调度。
"""
scheduler.remove_all_jobs()
async with AsyncSessionLocal() as s:
rows = (await s.execute(select(Source).where(Source.enabled.is_(True)))).scalars()
@@ -38,6 +42,10 @@ async def _rebuild_jobs(scheduler: AsyncIOScheduler) -> None:
logger.warning("no enabled sources; scheduler idle")
return
for src in sources:
# api_push 源不抓取(由 /api/v1/ingest 被动接收),跳过调度
if src.kind.value == "api_push":
logger.debug("skip scheduling api_push source: %s", src.slug)
continue
trigger = (
CronTrigger.from_crontab(src.fetch_cron)
if src.fetch_cron

View File

@@ -293,10 +293,14 @@ def _wrap_html(text: str) -> str:
# === 全量跑(供测试 / 手动触发) ===
async def run_once() -> None:
async with AsyncSessionLocal() as session:
rows = (await session.execute(select(Source).where(Source.enabled.is_(True)))).scalars()
rows = (
await session.execute(
select(Source).where(Source.enabled.is_(True), Source.kind != SourceKind.API_PUSH)
)
).scalars()
sources = list(rows)
logger.info("run_once: %d enabled sources", len(sources))
logger.info("run_once: %d enabled sources (api_push excluded)", len(sources))
tasks = [fetch_one_source(s.id) for s in sources]
await asyncio.gather(*tasks, return_exceptions=True)

246
docs/api-push.md Normal file
View File

@@ -0,0 +1,246 @@
# API Push 短新闻
> /api/v1/ingest — 让外部系统(微信机器人、RSS-digest 脚本、自家脚本)
> 直接 POST 中文短新闻入库,跳过抓取和翻译,只跑分类 + 双 provider 点评。
跟 RSS 抓取的对比:
| 维度 | RSS | API Push(短新闻)|
|---|---|---|
| 触发 | worker 周期拉 | 外部主动 POST |
| 内容 | 长文 + 全文抽取 | 标题 + 短文(≤5000 字)|
| 语言 | 外文 → 翻译 | 中文原生,无翻译 |
| LLM 任务 | 排版 + 分类 + 插图 + 双点评 | 分类 + 双点评(无排版、无插图)|
| 入库 kind | `rss` / `html_list` / `tg_channel` | `api_push` |
| 去重 key | `url_hash` | `content_hash`(= SHA1 文本指纹) |
---
## 调用方协议
### 鉴权
每个 source 一个独立 token(绑定 source_id),通过 `X-Ingest-Token` 头传。
```http
POST /api/v1/ingest
Content-Type: application/json
X-Ingest-Token: <per-source-token>
{
"external_id": "wx-2026-06-14-001",
"title": "",
"body": "...",
"url": "https://example.com/article/123",
"source_ref": "wechat",
"author": "",
"published_at": "2026-06-14T10:00:00Z",
"tags": ["", ""]
}
```
字段:
| 字段 | 必填 | 长度 | 说明 |
|---|---|---|---|
| `title` | ✅ | 1-200 字 | 文章标题 |
| `body` | ✅ | 1-5000 字 | 正文纯文本(保留 `\n` 换行)|
| `external_id` | 推荐 | ≤128 字 | 调用方业务 ID,幂等 key |
| `url` | ❌ | ≤2048 字 | 原始链接(可选,纯展示用)|
| `source_ref` | ❌ | ≤64 字 | 短新闻里的二级来源标识,如 `"wechat"`/`"rss-digest"` |
| `author` | ❌ | ≤255 字 | 作者 |
| `published_at` | ❌ | ISO8601 | 发布时间,缺省 = now |
| `tags` | ❌ | ≤10 个,每个 ≤32 字 | 直接写入 category,跳过 LLM 分类 |
### 响应
成功新建(201):
```json
{
"article_id": 12345,
"content_hash": "a1b2c3...",
"status": "created"
}
```
命中三层去重之一(200):
```json
{
"article_id": 12340,
"content_hash": "a1b2c3...",
"status": "duplicate",
"reason": "external_id_match",
"matched_external_id": "wx-2026-06-14-001"
}
```
`reason` 取值:
- `external_id_match` —— L1,同 source 下已有相同 `external_id`
- `content_hash_match` —— L2,任意 source 下已有相同内容指纹
- `db_unique` —— L3,并发场景下 DB UNIQUE 约束兜底
错误响应:
| 状态码 | 含义 | 触发 |
|---|---|---|
| 400 | 字段缺失/超长 | pydantic 自动校验 |
| 401 | token 无效/吊销/过期 | `X-Ingest-Token` 缺失或不匹配 |
| 403 | 源 disabled 或 kind 不匹配 | source.enabled=false 或 kind≠api_push |
| 404 | source 不存在 | token 绑定的 source_id 不在 |
| 429 | 触发限速 | 同 token 1 秒内推送 >2 篇(`INGEST_RATE_PER_SEC`)|
---
## 三层去重
```
请求进来
├─ L1:SELECT WHERE external_id=? AND source_id=?
│ → 命中 → 200 duplicate, reason=external_id_match
├─ L2:SELECT WHERE content_hash=?
│ → 命中 → 200 duplicate, reason=content_hash_match
├─ INSERT (UNIQUE 约束兜底)
│ → IntegrityError → 200 duplicate, reason=db_unique
└─ 写入成功 → 201 created
```
`content_hash` 算法:
```python
if external_id:
raw = f"ext:{external_id}"
else:
raw = f"{title.strip()}|{body[:500]}" # 仅取前 500 字符,防尾部噪声
sha1(raw) 40 字符
```
- `external_id` 存在时 → 强幂等(调用方业务 ID 不变就一定去重)
- `external_id` 缺失时 → 标题 + 前 500 字符作为兜底指纹
---
## 限速
每个 token 独立计数(Redis),默认 2 篇/秒。改 `.env``INGEST_RATE_PER_SEC`
后重启 api 容器生效。
429 响应带 `Retry-After: 1` 头。
---
## 入库后会发生什么
短新闻入库时,`translation_status / format_status / image_ai_status` 均为 `'n/a'`,
跳过翻译、排版、插图。
`enrichment_loop` 看到 `is_short_news=True` + 任意 `*_status='pending'` 时:
1. **classify**(分类):带 `tags` 入库 → 直接标 `'ok'`,跳过;无 `tags` → 调 LLM 分类
2. **commentary**(Angel 评论):调 LLM 生成
3. **commentary_meituan**(美团评论):调 LLM 生成
4. **format** / **image**:跳过
完整 lifecycle:
```
T+0s POST /api/v1/ingest → 201 created
T+~15s enrichment_loop 扫到 is_short_news=True → classify + 双 commentary
T+~30s 三项 LLM 任务完成,前端拉 /articles/{id} 即可看到分类 + 双 provider 点评
```
---
## owner 操作手册
### 生成 ingest token
1. 登录 owner 账号
2. 进入 `/admin/sources`
3. 新建源(kind=API Push)或点击现有 api_push 源的 **🔑 Token** 按钮
4. 在弹窗里填名称(如 `wechat-bot`),点 "生成"
5. **raw_token 立即复制保存** —— 关闭弹窗后只显示 hash,不再显示 raw
6. 把 raw_token 配置到调用方,作为 `X-Ingest-Token`
### 撤销 token
在 Token 弹窗里点 "撤销" → 该 token 立即失效,下次推送返 401。
### 调限速
编辑 `.env``INGEST_RATE_PER_SEC=N``docker compose up -d --no-deps --force-recreate api`
(**不是 `restart`** —— `restart` 不会重新读 env;容器重建才会)
---
## 调用方示例
### curl
```bash
curl -X POST https://your-domain/api/v1/ingest \
-H "Content-Type: application/json" \
-H "X-Ingest-Token: YOUR_RAW_TOKEN" \
-d '{
"external_id": "wx-2026-06-14-001",
"title": "美联储宣布维持利率不变",
"body": "美联储在最新议息会议后宣布,将联邦基金利率目标区间维持在 4.25%-4.50%。这是连续第二次按兵不动...",
"source_ref": "wechat",
"published_at": "2026-06-14T10:00:00Z",
"tags": ["财经", "美联储"]
}'
```
### Python(httpx)
```python
import httpx
async def push_short_news(token: str, **payload):
async with httpx.AsyncClient() as client:
r = await client.post(
"https://your-domain/api/v1/ingest",
headers={"X-Ingest-Token": token},
json=payload,
timeout=10.0,
)
r.raise_for_status()
return r.json()
```
### 重试策略(应对 429)
```python
import asyncio
from httpx import HTTPStatusError
async def push_with_retry(token, **payload, max_retries=3):
for attempt in range(max_retries):
try:
return await push_short_news(token, **payload)
except HTTPStatusError as e:
if e.response.status_code == 429 and attempt < max_retries - 1:
# 429:等 Retry-After 头指定的秒数(默认 1 秒)
wait = int(e.response.headers.get("Retry-After", 1))
await asyncio.sleep(wait)
continue
raise
```
---
## 故障排查
| 现象 | 排查 | 修复 |
|---|---|---|
| 401 invalid ingest token | token 是否被撤销/过期;DB 里 `api_tokens.purpose` 是否为 `ingest` | 重新生成 |
| 413 body length invalid | body 超过 5000 字 | 截断或拆条 |
| 429 rate limit exceeded | 同 token 1 秒内推 >2 篇 | 退避 1 秒重试,或调高 `INGEST_RATE_PER_SEC` |
| 短新闻一直 `classify_status='pending'` | `enrichment_loop` 是否在跑;`/admin/health` 看 worker 状态 | 重启 worker;检查 `.env``AGNES_API_KEY` / `MEITUAN_API_KEY` |
| 短新闻在 Feed 里没出现 | 看后端日志 `docker compose logs worker \| grep ingest`;DB `SELECT * FROM articles WHERE is_short_news=true` 看是否入库 | 入库成功但 enrich 没跑就等;入库失败看 ingest 日志 |

View File

@@ -41,6 +41,10 @@ export interface ArticleListItem {
commentary_meituan_status?: string | null
commentary_engine?: string | null // angel / meituan / "angel,meituan"
image_ai_url?: string | null
// === API Push 短新闻标识 ===
// 短新闻(中文原生,由 /api/v1/ingest 推送)走差异化展示
is_short_news: boolean
source_ref?: string | null // 短新闻里再细分来源(wechat/rss-digest 等)
is_starred: boolean
is_read: boolean // 当前用户是否已读
}
@@ -79,6 +83,8 @@ export interface ArticleDetail extends ArticleListItem {
entities?: Record<string, any> | null
sentiment?: number | null
duplicate_of?: number | null
// === API Push 短新闻 ===
external_id?: string | null // 调用方幂等 key
}
export interface LlmSetting {
@@ -209,4 +215,29 @@ export const adminApi = {
`/admin/llm/enrich/${articleId}`
).then((r) => r.data)
},
// === API Push ingest token 管理 ===
listIngestTokens(sourceId: number) {
return http.get<IngestTokenOut[]>(`/admin/sources/${sourceId}/ingest-tokens`).then((r) => r.data)
},
createIngestToken(sourceId: number, body: { name?: string; expires_days?: number }) {
return http.post<IngestTokenOut>(`/admin/sources/${sourceId}/ingest-tokens`, body).then((r) => r.data)
},
revokeIngestToken(tokenId: number) {
return http.delete<{ id: number; revoked_at: string; already_revoked: boolean }>(
`/admin/ingest-tokens/${tokenId}`
).then((r) => r.data)
},
}
export interface IngestTokenOut {
id: number
source_id: number
name: string
purpose: string
created_at: string
expires_at?: string | null
revoked_at?: string | null
last_used_at?: string | null
// 仅 createIngestToken 返回时填充(raw_token 只一次性返给前端)
raw_token?: string | null
}

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, ref, computed, h } from 'vue'
import {
NCard, NDataTable, NButton, NTag, NSpace, NPopconfirm, useMessage, NModal, NForm, NFormItem, NInput, NSelect, NInputNumber, useDialog,
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'
import { adminApi, type Source, type IngestTokenOut } from '@/api/articles'
const message = useMessage()
const dialog = useDialog()
@@ -13,7 +13,7 @@ const showCreate = ref(false)
const form = ref({
name: '',
slug: '',
kind: 'rss' as 'rss' | 'html_list' | 'tg_channel',
kind: 'rss' as 'rss' | 'html_list' | 'tg_channel' | 'api_push',
url: '',
region: '',
language_src: 'en',
@@ -26,8 +26,18 @@ const kindOptions = [
{ label: 'RSS / Atom', value: 'rss' },
{ label: 'HTML 列表', value: 'html_list' },
{ label: 'Telegram', value: 'tg_channel' },
{ label: 'API Push(短新闻推送)', value: 'api_push' },
]
// === Ingest Token 管理(仅 api_push 源)===
const showTokenModal = ref(false)
const tokenSource = ref<Source | null>(null)
const tokenList = ref<IngestTokenOut[]>([])
const tokenLoading = ref(false)
const newTokenName = ref('default')
const newTokenExpiresDays = ref<number | null>(null)
const lastIssuedRaw = ref<string | null>(null) // 创建后弹一次性 token
async function load() {
sources.value = await adminApi.listSources()
}
@@ -58,13 +68,21 @@ async function refresh(s: Source) {
}
async function create() {
if (!form.value.name || !form.value.slug || !form.value.url) {
message.error('请填写名称 / slug / url')
if (!form.value.name || !form.value.slug) {
message.error('请填写名称 / slug')
return
}
// api_push 类型的 url 不是必填(只是合成占位);其他类型必填
if (form.value.kind !== 'api_push' && !form.value.url) {
message.error('请填写 URL')
return
}
try {
await adminApi.createSource(form.value)
message.success('已创建,等下一轮抓取')
await adminApi.createSource({
...form.value,
url: form.value.url || `api-push://${form.value.slug}`,
})
message.success('已创建,等下一轮抓取 / 推送')
showCreate.value = false
form.value = {
name: '', slug: '', kind: 'rss', url: '',
@@ -76,31 +94,166 @@ async function create() {
}
}
// === Ingest Token 弹窗 ===
async function openTokenModal(s: Source) {
tokenSource.value = s
showTokenModal.value = true
lastIssuedRaw.value = null
newTokenName.value = 'default'
newTokenExpiresDays.value = null
await loadTokens()
}
async function loadTokens() {
if (!tokenSource.value) return
tokenLoading.value = true
try {
tokenList.value = await adminApi.listIngestTokens(tokenSource.value.id)
} catch (e: any) {
message.error(e?.response?.data?.title || '加载 token 列表失败')
} finally {
tokenLoading.value = false
}
}
async function createToken() {
if (!tokenSource.value) return
if (!newTokenName.value.trim()) {
message.error('请填写 token 名称')
return
}
try {
const out = await adminApi.createIngestToken(tokenSource.value.id, {
name: newTokenName.value.trim(),
expires_days: newTokenExpiresDays.value ?? undefined,
})
if (out.raw_token) {
lastIssuedRaw.value = out.raw_token
message.success('Token 已生成 — 请复制下方 raw_token,关闭后不再显示')
} else {
message.warning('Token 生成成功,但未返回 raw_token')
}
await loadTokens()
} catch (e: any) {
message.error(e?.response?.data?.title || '生成失败')
}
}
async function revokeToken(id: number) {
dialog.warning({
title: '确认撤销',
content: '撤销后此 token 立即失效,所有正在用它的调用方会拿到 401',
positiveText: '撤销',
negativeText: '取消',
onPositiveClick: async () => {
try {
await adminApi.revokeIngestToken(id)
message.success('已撤销')
await loadTokens()
} catch (e: any) {
message.error(e?.response?.data?.title || '撤销失败')
}
},
})
}
// raw token 复制到剪贴板
async function copyRaw() {
if (!lastIssuedRaw.value) return
try {
await navigator.clipboard.writeText(lastIssuedRaw.value)
message.success('已复制到剪贴板')
} catch {
message.warning('复制失败,请手动选中复制')
}
}
const originForCurl = computed(() => {
if (typeof window !== 'undefined' && window.location) {
return window.location.origin
}
return 'https://your-domain'
})
const tokenColumns = [
{ title: 'ID', key: 'id', width: 60 },
{ title: '名称', key: 'name', width: 140 },
{
title: '状态', key: 'status', width: 100,
render: (r: IngestTokenOut) => {
if (r.revoked_at) return h(NTag, { type: 'default', size: 'small' }, () => '已撤销')
if (r.expires_at && new Date(r.expires_at) < new Date()) {
return h(NTag, { type: 'warning', size: 'small' }, () => '已过期')
}
return h(NTag, { type: 'success', size: 'small' }, () => '有效')
},
},
{ title: '创建', key: 'created_at', width: 170 },
{ title: '过期', key: 'expires_at', width: 170, render: (r: IngestTokenOut) => r.expires_at || '永不过期' },
{ title: '最后使用', key: 'last_used_at', width: 170, render: (r: IngestTokenOut) => r.last_used_at || '—' },
{
title: '操作', key: 'action', width: 100,
render: (r: IngestTokenOut) =>
r.revoked_at
? null
: h(NButton, {
size: 'tiny', type: 'error', ghost: true,
onClick: () => revokeToken(r.id),
}, () => '撤销'),
},
]
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',
title: 'kind', key: 'kind', width: 120,
render: (r: Source) => {
const map: Record<string, { label: string; type: 'success' | 'info' }> = {
rss: { label: 'RSS', type: 'success' },
html_list: { label: 'HTML', type: 'success' },
tg_channel: { label: 'Telegram', type: 'success' },
api_push: { label: 'API Push', type: 'info' },
}
const cfg = map[r.kind] || { label: r.kind, type: 'success' as const }
return h(NTag, { type: cfg.type, size: 'small' }, () => cfg.label)
},
},
{
title: 'URL', key: 'url',
render: (r: Source) => h('a', {
href: r.url.startsWith('http') ? r.url : '#',
target: '_blank', rel: 'noopener',
style: r.url.startsWith('api-push://') ? 'color:#9ca3af' : '',
}, r.url.slice(0, 60) + (r.url.length > 60 ? '…' : '')),
},
{ title: '地区', key: 'region', width: 90 },
{
title: '优先级/间隔', key: 'meta', width: 110,
render: (r: Source) => `P${r.priority} / ${r.fetch_interval_min}m`,
},
{
title: '状态', key: 'enabled',
title: '状态', key: 'enabled', width: 80,
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: () => '确认删除?',
}),
]),
title: '操作', key: 'action', width: 360,
render: (row: Source) => h(NSpace, {}, () => {
const items: any[] = [
h(NButton, { size: 'small', onClick: () => refresh(row), disabled: row.kind === 'api_push' }, () => '抓取'),
h(NButton, { size: 'small', onClick: () => toggleEnabled(row) }, () => row.enabled ? '停用' : '启用'),
h(NButton, {
size: 'small', type: 'info', ghost: true,
onClick: () => openTokenModal(row),
}, () => '🔑 Token'),
h(NPopconfirm, { onPositiveClick: () => del(row) }, {
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, () => '删除'),
default: () => '确认删除?',
}),
]
return items
}),
},
]
@@ -121,13 +274,25 @@ onMounted(load)
<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
v-if="form.kind !== 'api_push'"
label="URL"
>
<NInput v-model:value="form.url" placeholder="https://..." />
</NFormItem>
<NFormItem v-else label="占位 URL">
<NInput
:value="`api-push://${form.slug || '<slug>'}`"
readonly
placeholder="根据 slug 自动生成"
/>
</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="源语种"><NInput v-model:value="form.language_src" placeholder="en / zh" /></NFormItem>
<NFormItem label="优先级">
<NInputNumber v-model:value="form.priority" :min="1" :max="100" />
</NFormItem>
<NFormItem label="抓取间隔(分)">
<NFormItem v-if="form.kind !== 'api_push'" label="抓取间隔(分)">
<NInputNumber v-model:value="form.fetch_interval_min" :min="5" :max="1440" />
</NFormItem>
<NFormItem label="翻译目标"><NInput v-model:value="form.translate_to" /></NFormItem>
@@ -139,5 +304,70 @@ onMounted(load)
</NSpace>
</template>
</NModal>
<!-- Ingest Token 管理弹窗 -->
<NModal
v-model:show="showTokenModal"
preset="card"
:title="`Ingest Tokens · ${tokenSource?.name || ''}`"
style="width: 800px"
>
<NSpace vertical :size="14">
<!-- 新建 token -->
<NCard size="small" title="生成新 token">
<NSpace align="center" :size="8" :wrap="true">
<NInput
v-model:value="newTokenName"
placeholder="名称,如 'wechat-bot'"
style="width: 200px"
/>
<NInputNumber
v-model:value="newTokenExpiresDays"
placeholder="过期天数(留空=永不过期)"
:min="1"
:max="3650"
clearable
style="width: 220px"
/>
<NButton type="primary" @click="createToken">生成</NButton>
</NSpace>
</NCard>
<!-- 新生成的 raw token(只显示一次!)-->
<NCard v-if="lastIssuedRaw" size="small" :bordered="true" content-style="background: #fef3c7;">
<NSpace vertical :size="8">
<strong style="color: #92400e">
请立即复制下方 raw_token 关闭弹窗后不再显示
</strong>
<NInput
:value="lastIssuedRaw"
readonly
type="text"
style="font-family: monospace; font-size: 12px"
/>
<NSpace>
<NButton size="small" type="primary" @click="copyRaw">复制</NButton>
<NButton size="small" @click="lastIssuedRaw = null">我已保存,关闭</NButton>
</NSpace>
<small style="color: #92400e">
用法:curl -X POST {{ originForCurl }}/api/v1/ingest
-H "X-Ingest-Token: &lt;上方的 token&gt;" -H "Content-Type: application/json"
-d '{"title":"...","body":"..."}'
</small>
</NSpace>
</NCard>
<!-- 已有 token 列表 -->
<NCard size="small" title="已有 token">
<NDataTable
:columns="tokenColumns"
:data="tokenList"
:loading="tokenLoading"
:bordered="false"
:pagination="{ pageSize: 10 }"
/>
</NCard>
</NSpace>
</NModal>
</NSpace>
</template>
</template>

View File

@@ -160,6 +160,20 @@ const originalBody = computed(() => {
return ''
})
// 短新闻(API Push)判断。短新闻的 details 视图与长新闻不同:
// - 不显示原文/译文 Tab(短新闻是中文原生,无原文)
// - 不显示 AI 配图(用户明确不要)
// - 隐藏"重译"按钮(translation_status=n/a,重译无意义)
// - 隐藏"原文链接"(合成 url,打开无意义)
const isShort = computed(() => !!article.value?.is_short_news)
// 短新闻正文:用 body_zh_text(ingest 时已 = body_text),按段落切分
const shortBody = computed(() => {
const a = article.value
if (!a) return ''
return splitIntoParagraphs(a.body_zh_text || a.body_text || '')
})
async function rerunTranslation() {
if (!article.value) return
if (!confirm('重新翻译会消耗配额,确认?')) return
@@ -213,10 +227,12 @@ onMounted(load)
<NSpace align="center" :size="6" :wrap="true" style="row-gap: 6px">
<NTag type="primary" :bordered="false" round>{{ article.source.name }}</NTag>
<NTag v-if="article.lang_src" :bordered="false" round>{{ article.lang_src.toUpperCase() }}</NTag>
<NTag v-if="article.translation_status !== 'ok'" size="small" type="warning" :bordered="false" round>
<NTag v-if="isShort" type="info" :bordered="false" round>📰 短讯</NTag>
<NTag v-if="article.source_ref" :bordered="false" round>{{ article.source_ref }}</NTag>
<NTag v-if="!isShort && article.translation_status !== 'ok'" size="small" type="warning" :bordered="false" round>
翻译:{{ article.translation_status }}
</NTag>
<NTag v-if="article.translation_engine" size="small" :bordered="false" round>
<NTag v-if="!isShort && article.translation_engine" size="small" :bordered="false" round>
{{ article.translation_engine }}
</NTag>
<NTag v-for="c in categories" :key="c" type="success" size="small" :bordered="false" round>
@@ -267,13 +283,13 @@ onMounted(load)
<NButton text @click="showTranslation = !showTranslation" round>
{{ showTranslation ? '隐藏译文' : '显示译文' }}
</NButton>
<NButton v-if="isOwner" type="error" ghost @click="rerunTranslation" round>
<NButton v-if="isOwner && !isShort" type="error" ghost @click="rerunTranslation" round>
重译
</NButton>
<NButton v-if="isOwner" type="info" ghost :loading="enriching" @click="triggerEnrich" round>
LLM 增强
</NButton>
<NButton tag="a" :href="article.url" target="_blank" rel="noopener" ghost round>
<NButton v-if="!isShort" tag="a" :href="article.url" target="_blank" rel="noopener" ghost round>
原文链接
</NButton>
</NSpace>
@@ -393,8 +409,17 @@ onMounted(load)
</NText>
</NCard>
<!-- 2) 译文(优先 LLM 排版版) -->
<div v-if="showTranslation" style="margin-top: 16px">
<!-- 2) 短新闻(API Push):直接显示正文,跳过译文/原文 Tab -->
<NCard v-if="isShort" class="detail-card" style="margin-top: 16px">
<template #header>
<span class="card-header-title">📝 短讯正文</span>
</template>
<div v-if="shortBody" class="article-body-fallback" v-html="shortBody" />
<NText v-else :depth="3">暂无正文</NText>
</NCard>
<!-- 2) 译文(长新闻,优先 LLM 排版版) -->
<div v-else-if="showTranslation" style="margin-top: 16px">
<NCard v-if="article.body_zh_formatted" class="detail-card">
<template #header>
<span class="card-header-title">📖 文章译文</span>
@@ -422,16 +447,16 @@ onMounted(load)
</NCard>
</div>
<!-- AI 插图 -->
<NCard v-if="article.image_ai_url" class="detail-card" style="margin-top: 16px">
<!-- AI 插图(长新闻) -->
<NCard v-if="!isShort && article.image_ai_url" class="detail-card" style="margin-top: 16px">
<template #header>
<span class="card-header-title">🎨 AI 插图</span>
</template>
<NImage :src="article.image_ai_url" object-fit="cover" class="article-image" />
</NCard>
<!-- 3) 原文 -->
<div v-if="showOriginal" style="margin-top: 16px">
<!-- 3) 原文(长新闻) -->
<div v-if="!isShort && showOriginal" style="margin-top: 16px">
<NCard class="detail-card">
<template #header>
<span class="card-header-title">📄 文章原文</span>

View File

@@ -156,9 +156,13 @@ function commentaryState(status?: string | null, content?: string | null): Comme
return 'waiting'
}
// 正文摘要(取 body_zh_text 前 N 字;没有就 fallback 到 summary_zh)
function bodyExcerpt(text?: string | null, max = 200): string {
// 正文摘要:长新闻截前 200 字(把多空白合并),短新闻保留原始换行不截取
function bodyExcerpt(text?: string | null, max = 200, keepNewlines = false): string {
if (!text) return ''
if (keepNewlines) {
// 短新闻:不去空白,保留 \n 让前端 white-space: pre-wrap 换行
return text.length > max ? text.slice(0, max) + '…' : text
}
const trimmed = text.replace(/\s+/g, ' ').trim()
return trimmed.length > max ? trimmed.slice(0, max) + '…' : trimmed
}
@@ -209,7 +213,10 @@ onMounted(async () => {
v-for="a in items"
:key="a.id"
class="article-card"
:class="{ 'article-card-read': a.is_read }"
:class="{
'article-card-read': a.is_read,
'short-card': a.is_short_news,
}"
hoverable
@click="open(a)"
>
@@ -235,6 +242,17 @@ onMounted(async () => {
>
{{ c }}
</NTag>
<!-- 短新闻(API Push)角标:固定显示 -->
<NTag
v-if="a.is_short_news"
size="tiny"
type="info"
:bordered="false"
round
class="feed-short-tag"
>
📰 短讯
</NTag>
<!-- 已读/未读小标签 -->
<NTag
v-if="a.is_read"
@@ -273,9 +291,9 @@ onMounted(async () => {
{{ a.title }}
</div>
<!-- AI 插图(若有) -->
<!-- AI 插图(若有;短新闻不显示) -->
<img
v-if="a.image_ai_url || a.image_url"
v-if="!a.is_short_news && (a.image_ai_url || a.image_url)"
:src="a.image_ai_url || a.image_url || ''"
style="
display: block;
@@ -290,9 +308,14 @@ onMounted(async () => {
loading="lazy"
/>
<!-- 翻译后正文摘要 -->
<!--
正文摘要:
- 长新闻:body_zh_text 截前 200 (去多余空白)
- 短新闻:body_zh_text(=body_text)完整展示,保留换行
-->
<div
v-if="a.body_zh_text || a.summary_zh"
:class="{ 'short-body': a.is_short_news }"
style="
margin-top: 4px;
color: var(--color-letter);
@@ -300,7 +323,11 @@ onMounted(async () => {
line-height: 1.75;
"
>
{{ bodyExcerpt(a.body_zh_text || a.summary_zh, 220) }}
{{
a.is_short_news
? bodyExcerpt(a.body_zh_text || a.summary_zh || '', 5000, true)
: bodyExcerpt(a.body_zh_text || a.summary_zh, 200)
}}
</div>
<!-- 评论钩子( provider:Angel + 美团,三态显式显示:有内容 / 等待中 / 失败) -->
@@ -590,6 +617,36 @@ onMounted(async () => {
color: var(--color-text-faint);
}
/* === 短新闻(API Push)卡片差异化 ===
长新闻:卡片色调不变
短新闻:淡蓝底 + 左侧 3px 蓝色竖线条,便于一眼区分
*/
.article-card.short-card {
background: #f6f9fc;
border-left: 3px solid #4f9eff;
}
.article-card.short-card:hover {
background: #eef4fb;
}
/* 已读 + 短新闻:已读底色优先,左边色条仍保留作为"短讯"标识 */
.article-card.short-card.article-card-read {
background: #fafafa;
border-left-color: #4f9eff;
border-left-width: 3px;
}
/* 短新闻正文:保留换行 */
.short-body {
white-space: pre-wrap;
font-size: 13.5px;
line-height: 1.65;
}
/* 短讯角标 */
.feed-short-tag {
font-size: 11px;
}
/* === 底部操作栏(浮在卡片右下角)=== */
.feed-actions {
display: flex;