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:
16
README.md
16
README.md
@@ -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` — 源健康看板
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
246
docs/api-push.md
Normal 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 日志 |
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: <上方的 token>" -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user