inital
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"WebFetch(domain:aihot.virxact.com)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 数据库
|
||||||
|
DATABASE_URL=postgresql+asyncpg://pharma:pharma123@db:5432/pharma_news
|
||||||
|
|
||||||
|
# 管理员令牌(用于后台登录)
|
||||||
|
ADMIN_TOKEN=change-me-admin-token
|
||||||
|
|
||||||
|
# 初始 LLM 配置(可在管理页面覆盖)
|
||||||
|
# 推荐国内服务器使用 DeepSeek(可直连)
|
||||||
|
INITIAL_LLM_PROVIDER=deepseek
|
||||||
|
INITIAL_LLM_API_KEY=sk-xxxxxxxx
|
||||||
|
INITIAL_LLM_BASE_URL=https://api.deepseek.com
|
||||||
|
INITIAL_LLM_MODEL=deepseek-chat
|
||||||
|
|
||||||
|
# 若使用 Anthropic(需配置代理)
|
||||||
|
# INITIAL_LLM_PROVIDER=anthropic
|
||||||
|
# INITIAL_LLM_API_KEY=sk-ant-xxxxxxxx
|
||||||
|
# INITIAL_LLM_BASE_URL=https://api.anthropic.com
|
||||||
|
# INITIAL_LLM_MODEL=claude-sonnet-4-6
|
||||||
335
DESIGN.md
Normal file
335
DESIGN.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# 医药新闻聚合系统 — 设计方案
|
||||||
|
|
||||||
|
> 版本:v1.0 | 日期:2026-05-24
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、项目目标
|
||||||
|
|
||||||
|
每日自动抓取全网中英文医药新闻(约 100 条),经 AI 深度加工后精选 10 条,以网页形式展示,未来对接微信小程序。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、整体架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 定时调度器 │
|
||||||
|
│ APScheduler (每日 06:00) │
|
||||||
|
└───────────────────────┬─────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 抓取层 Crawler │
|
||||||
|
│ RSS Fetcher │ HTTP Scraper │ NewsAPI Client │
|
||||||
|
│ (中文源) │ (备用) │ (英文源) │
|
||||||
|
└───────────────────────┬─────────────────────────────┘
|
||||||
|
↓ 原始新闻 (~100条/天)
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 数据库 PostgreSQL │
|
||||||
|
│ raw_news │ processed_news │ llm_config │
|
||||||
|
│ │ news_sources │ system_logs │
|
||||||
|
└───────────────────────┬─────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ AI 处理流水线 │
|
||||||
|
│ 1. 相关性过滤(去除非医药内容) │
|
||||||
|
│ 2. 去重(相似度检测) │
|
||||||
|
│ 3. 摘要生成(中文,150字) │
|
||||||
|
│ 4. 观点提炼(核心结论/影响) │
|
||||||
|
│ 5. 关键词提取(5-8个标签) │
|
||||||
|
│ 6. 重要性评级(1-10分 + 理由) │
|
||||||
|
│ 7. 分类标注(4个类别) │
|
||||||
|
│ 8. 精选 TOP 10 │
|
||||||
|
└───────────────────────┬─────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ FastAPI 后端 │
|
||||||
|
│ 读者 API │ 管理员 API │
|
||||||
|
│ GET /news │ POST /admin/llm-config │
|
||||||
|
│ GET /news/{id} │ GET /admin/sources │
|
||||||
|
│ GET /news/featured │ POST /admin/trigger-crawl │
|
||||||
|
└───────────────────────┬─────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌───────────────┴───────────────┐
|
||||||
|
↓ ↓
|
||||||
|
┌──────────────┐ ┌──────────────────┐
|
||||||
|
│ Vue 3 前端 │ │ 未来:微信小程序 │
|
||||||
|
│ 读者页 + 管理 │ │ (复用同一 API) │
|
||||||
|
└──────────────┘ └──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 | 版本 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 任务调度 | APScheduler | 3.x | 每日定时触发,支持手动触发 |
|
||||||
|
| 爬虫 | httpx + BeautifulSoup4 | — | 异步 HTTP,HTML 解析 |
|
||||||
|
| RSS 解析 | feedparser | — | 解析 RSS/Atom 订阅源 |
|
||||||
|
| AI 调用 | 统一适配层(见下) | — | 支持多 LLM 提供商切换 |
|
||||||
|
| 后端框架 | FastAPI | 0.11x | 异步,自动生成 OpenAPI 文档 |
|
||||||
|
| 数据库 | PostgreSQL 16 | — | 主存储,支持全文搜索 |
|
||||||
|
| ORM | SQLAlchemy 2 + Alembic | — | 数据模型 + 迁移 |
|
||||||
|
| 前端框架 | Vue 3 + Vite | — | 组合式 API |
|
||||||
|
| UI 组件库 | Element Plus | — | 中文生态好,适配小程序迁移 |
|
||||||
|
| 部署 | Docker Compose | — | 容器化,国内服务器一键启动 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、LLM 适配层设计
|
||||||
|
|
||||||
|
> 国内服务器无法直连 Claude API,需要支持可切换的 LLM 提供商。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 统一接口,后端只调用这一层
|
||||||
|
class LLMClient:
|
||||||
|
provider: str # "openai" | "anthropic" | "qwen" | "deepseek" | "custom"
|
||||||
|
api_key: str
|
||||||
|
base_url: str # 支持自定义 endpoint(代理/国内镜像)
|
||||||
|
model: str
|
||||||
|
|
||||||
|
async def complete(self, messages: list, response_format="json") -> str:
|
||||||
|
# 内部适配不同 provider 的 SDK/HTTP 接口
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**支持的 LLM 提供商(管理页可配置):**
|
||||||
|
|
||||||
|
| 提供商 | 说明 | 推荐模型 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| DeepSeek | 国内可直连,性价比高 | deepseek-chat |
|
||||||
|
| 通义千问 (Qwen) | 阿里云,国内服务器友好 | qwen-plus |
|
||||||
|
| OpenAI 兼容 | 走代理或第三方中转 | gpt-4o-mini |
|
||||||
|
| Anthropic Claude | 走代理 | claude-sonnet-4-6 |
|
||||||
|
| 自定义 Endpoint | 私有部署模型 | 自填 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、数据库模型
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 新闻来源配置
|
||||||
|
news_sources (
|
||||||
|
id, name, url, type ENUM('rss','scrape'),
|
||||||
|
language ENUM('zh','en'), category, is_active,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
|
||||||
|
-- 原始抓取数据
|
||||||
|
raw_news (
|
||||||
|
id, source_id, title, url, raw_content,
|
||||||
|
published_at, crawled_at,
|
||||||
|
status ENUM('pending','processed','skipped','error')
|
||||||
|
)
|
||||||
|
|
||||||
|
-- AI 处理后数据
|
||||||
|
processed_news (
|
||||||
|
id, raw_news_id,
|
||||||
|
title_zh, -- 中文标题(英文原文翻译或原标题)
|
||||||
|
summary, -- 中文摘要(150字)
|
||||||
|
opinion, -- 核心观点/影响(100字)
|
||||||
|
keywords TEXT[], -- 关键词数组
|
||||||
|
importance_score, -- 重要性 1-10
|
||||||
|
importance_reason, -- 评分理由
|
||||||
|
category ENUM('药品监管','临床研究','行业动态','政策法规'),
|
||||||
|
is_featured BOOL, -- 是否入选当日 TOP 10
|
||||||
|
featured_rank, -- TOP 10 排名
|
||||||
|
processed_at
|
||||||
|
)
|
||||||
|
|
||||||
|
-- LLM 配置(管理页维护)
|
||||||
|
llm_config (
|
||||||
|
id, name, provider, api_key, base_url,
|
||||||
|
model_name, is_active, created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
-- 系统运行日志
|
||||||
|
system_logs (
|
||||||
|
id, event_type, message, details JSONB, created_at
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、AI 处理 Prompt 设计
|
||||||
|
|
||||||
|
每条新闻用一次 LLM 调用,返回结构化 JSON:
|
||||||
|
|
||||||
|
```
|
||||||
|
你是医药行业资深分析师。请分析以下新闻,返回 JSON 格式结果。
|
||||||
|
|
||||||
|
新闻标题:{title}
|
||||||
|
新闻内容:{content}
|
||||||
|
新闻语言:{zh|en}
|
||||||
|
|
||||||
|
请返回:
|
||||||
|
{
|
||||||
|
"is_medical_related": true/false, // 是否与医药相关
|
||||||
|
"title_zh": "中文标题",
|
||||||
|
"summary": "中文摘要(100-150字)",
|
||||||
|
"opinion": "核心观点或行业影响(50-100字)",
|
||||||
|
"keywords": ["关键词1", ..., "关键词5"],
|
||||||
|
"importance_score": 1-10,
|
||||||
|
"importance_reason": "评分理由(30字内)",
|
||||||
|
"category": "药品监管|临床研究|行业动态|政策法规"
|
||||||
|
}
|
||||||
|
|
||||||
|
评分标准:
|
||||||
|
9-10: 重大政策/突破性研究/重要监管决定
|
||||||
|
7-8: 行业重要动态,影响范围广
|
||||||
|
5-6: 常规行业新闻,有一定参考价值
|
||||||
|
1-4: 普通资讯,信息价值有限
|
||||||
|
```
|
||||||
|
|
||||||
|
**TOP 10 精选策略:**
|
||||||
|
1. 过滤掉 `is_medical_related = false`
|
||||||
|
2. 按 `importance_score` 降序排列
|
||||||
|
3. 每个 category 至少保留 1 条(保证多样性)
|
||||||
|
4. 最终取前 10 条
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、新闻数据来源(初始列表)
|
||||||
|
|
||||||
|
### 中文 RSS 源
|
||||||
|
| 来源 | URL | 类别 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 国家药监局 | https://www.nmpa.gov.cn/rss | 药品监管 |
|
||||||
|
| 丁香园 | https://www.dxy.cn/rss | 临床研究 |
|
||||||
|
| 医学界 | https://www.yxj.org.cn/rss | 行业动态 |
|
||||||
|
| 健康时报 | http://www.jksb.com.cn/rss | 行业动态 |
|
||||||
|
|
||||||
|
### 英文 RSS 源
|
||||||
|
| 来源 | URL | 类别 |
|
||||||
|
|------|-----|------|
|
||||||
|
| STAT News | https://www.statnews.com/feed | 临床研究 |
|
||||||
|
| FiercePharma | https://www.fiercepharma.com/rss/xml | 行业动态 |
|
||||||
|
| FDA News | https://www.fda.gov/about-fda/contact-fda/stay-informed/rss-feeds/fda-news-feed/rss.xml | 药品监管 |
|
||||||
|
| PubMed (医药) | https://pubmed.ncbi.nlm.nih.gov/rss/... | 临床研究 |
|
||||||
|
|
||||||
|
> 注:以上 URL 需在开发时验证可用性,部分需要爬虫替代 RSS。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ai_news_v1/
|
||||||
|
├── backend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── main.py # FastAPI 入口
|
||||||
|
│ │ ├── config.py # 环境变量配置
|
||||||
|
│ │ ├── database.py # DB 连接
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ │ ├── news.py
|
||||||
|
│ │ │ ├── source.py
|
||||||
|
│ │ │ └── llm_config.py
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ │ ├── news.py # 读者 API
|
||||||
|
│ │ │ └── admin.py # 管理 API
|
||||||
|
│ │ ├── crawler/
|
||||||
|
│ │ │ ├── rss_fetcher.py # RSS 抓取
|
||||||
|
│ │ │ └── http_fetcher.py # HTTP 爬取(备用)
|
||||||
|
│ │ ├── ai/
|
||||||
|
│ │ │ ├── llm_client.py # 统一 LLM 适配层
|
||||||
|
│ │ │ ├── processor.py # AI 处理流水线
|
||||||
|
│ │ │ └── selector.py # TOP 10 精选逻辑
|
||||||
|
│ │ └── scheduler.py # APScheduler 定时任务
|
||||||
|
│ ├── alembic/ # 数据库迁移
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── views/
|
||||||
|
│ │ │ ├── NewsReader.vue # 读者主页
|
||||||
|
│ │ │ ├── NewsDetail.vue # 新闻详情
|
||||||
|
│ │ │ └── Admin.vue # 管理后台
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── NewsCard.vue
|
||||||
|
│ │ │ └── FeaturedBadge.vue
|
||||||
|
│ │ └── api/
|
||||||
|
│ │ └── index.ts # API 调用封装
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── docker-compose.prod.yml
|
||||||
|
├── .env.example
|
||||||
|
└── DESIGN.md # 本文档
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、API 接口设计
|
||||||
|
|
||||||
|
### 读者端
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/news/featured` | 当日精选 TOP 10 |
|
||||||
|
| GET | `/api/news?date=&category=&page=` | 新闻列表(分页) |
|
||||||
|
| GET | `/api/news/{id}` | 新闻详情 |
|
||||||
|
| GET | `/api/news/dates` | 有数据的日期列表 |
|
||||||
|
|
||||||
|
### 管理端(需 Bearer Token 鉴权)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET/POST | `/api/admin/llm-config` | 查看/更新 LLM 配置 |
|
||||||
|
| GET | `/api/admin/sources` | 新闻源列表 |
|
||||||
|
| POST | `/api/admin/sources` | 添加新闻源 |
|
||||||
|
| PUT | `/api/admin/sources/{id}` | 启用/禁用新闻源 |
|
||||||
|
| POST | `/api/admin/crawl/trigger` | 手动触发抓取 |
|
||||||
|
| GET | `/api/admin/logs` | 查看运行日志 |
|
||||||
|
| GET | `/api/admin/stats` | 今日抓取统计 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、前端页面设计
|
||||||
|
|
||||||
|
### 读者主页
|
||||||
|
- 顶部:Logo + 今日日期 + 上次更新时间
|
||||||
|
- 主区:精选 TOP 10 卡片列表,每张卡片含:
|
||||||
|
- 重要性评分角标(红/橙/蓝)
|
||||||
|
- 中文标题
|
||||||
|
- AI 摘要(折叠展开)
|
||||||
|
- 核心观点
|
||||||
|
- 关键词标签
|
||||||
|
- 来源 + 原文链接
|
||||||
|
- 侧边栏(或 Tab):按分类筛选、历史日期切换
|
||||||
|
|
||||||
|
### 管理后台
|
||||||
|
- LLM 配置面板:provider 选择、API Key、Base URL、模型名、连接测试按钮
|
||||||
|
- 新闻源管理:表格 + 启用/禁用开关 + 添加
|
||||||
|
- 手动触发:一键触发今日抓取 + 进度显示
|
||||||
|
- 运行日志:最近 100 条日志,带时间戳和级别
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、部署方案
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml 核心服务
|
||||||
|
services:
|
||||||
|
db: # PostgreSQL
|
||||||
|
backend: # FastAPI
|
||||||
|
frontend: # Vue (Nginx 静态托管)
|
||||||
|
scheduler: # APScheduler (可合并入 backend)
|
||||||
|
```
|
||||||
|
|
||||||
|
**国内服务器注意事项:**
|
||||||
|
- LLM API 如需访问境外服务,在服务器配置出口代理,或选用 DeepSeek/Qwen 等国内可直连的模型
|
||||||
|
- pip/npm 镜像切换为阿里云/清华源
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、开发阶段划分
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 预估工作量 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| Phase 1 | DB 模型 + FastAPI 骨架 + LLM 适配层 | 2天 |
|
||||||
|
| Phase 2 | RSS 抓取 + AI 处理流水线 + 定时任务 | 3天 |
|
||||||
|
| Phase 3 | 读者前端(主页 + 详情) | 2天 |
|
||||||
|
| Phase 4 | 管理后台前端 | 2天 |
|
||||||
|
| Phase 5 | Docker 部署 + 联调测试 | 1天 |
|
||||||
|
| 合计 | | **约 10 个工作日** |
|
||||||
9
backend/Dockerfile
Normal file
9
backend/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
# 使用清华镜像加速(国内服务器部署)
|
||||||
|
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/ai/__init__.py
Normal file
0
backend/app/ai/__init__.py
Normal file
54
backend/app/ai/llm_client.py
Normal file
54
backend/app/ai/llm_client.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClient:
|
||||||
|
"""统一 LLM 接口,支持 OpenAI 兼容接口和 Anthropic 原生接口。"""
|
||||||
|
|
||||||
|
def __init__(self, provider: str, api_key: str, base_url: str, model: str):
|
||||||
|
self.provider = provider.lower()
|
||||||
|
self.api_key = api_key
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
async def complete(self, system_prompt: str, user_prompt: str) -> str:
|
||||||
|
if self.provider == "anthropic":
|
||||||
|
return await self._call_anthropic(system_prompt, user_prompt)
|
||||||
|
return await self._call_openai_compat(system_prompt, user_prompt)
|
||||||
|
|
||||||
|
async def _call_openai_compat(self, system_prompt: str, user_prompt: str) -> str:
|
||||||
|
"""适配 DeepSeek / 通义千问 / OpenAI 等兼容 /v1/chat/completions 的接口。"""
|
||||||
|
async with httpx.AsyncClient(timeout=90) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self.base_url}/v1/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
async def _call_anthropic(self, system_prompt: str, user_prompt: str) -> str:
|
||||||
|
async with httpx.AsyncClient(timeout=90) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self.base_url}/v1/messages",
|
||||||
|
headers={
|
||||||
|
"x-api-key": self.api_key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"max_tokens": 2048,
|
||||||
|
"system": system_prompt,
|
||||||
|
"messages": [{"role": "user", "content": user_prompt}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()["content"][0]["text"]
|
||||||
189
backend/app/ai/processor.py
Normal file
189
backend/app/ai/processor.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, date
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models.news import RawNews, ProcessedNews, LLMConfig, NewsSource, SystemLog
|
||||||
|
from ..crawler.rss_fetcher import fetch_rss
|
||||||
|
from .llm_client import LLMClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = "你是医药行业资深分析师,擅长解读全球医药政策、临床研究、行业动态。"
|
||||||
|
|
||||||
|
ANALYSIS_PROMPT = """分析以下新闻,返回严格的 JSON 格式结果,不要包含任何其他文字。
|
||||||
|
|
||||||
|
新闻标题:{title}
|
||||||
|
新闻内容:{content}
|
||||||
|
新闻语言:{language}
|
||||||
|
|
||||||
|
返回格式:
|
||||||
|
{{
|
||||||
|
"is_medical_related": true,
|
||||||
|
"title_zh": "中文标题(英文原文请翻译成简洁中文)",
|
||||||
|
"summary": "中文摘要(100-150字,客观陈述核心内容)",
|
||||||
|
"opinion": "核心观点或行业影响(50-100字,分析性语言,点明实际意义)",
|
||||||
|
"keywords": ["关键词1", "关键词2", "关键词3", "关键词4", "关键词5"],
|
||||||
|
"importance_score": 8.5,
|
||||||
|
"importance_reason": "评分理由(30字内)",
|
||||||
|
"category": "药品监管"
|
||||||
|
}}
|
||||||
|
|
||||||
|
category 只能是以下四个之一:药品监管 / 临床研究 / 行业动态 / 政策法规
|
||||||
|
|
||||||
|
importance_score 评分标准(1-10):
|
||||||
|
9-10:重大监管决定 / 突破性研究 / 影响整个行业的政策
|
||||||
|
7-8 :行业重要动态,有明显商业或学术价值
|
||||||
|
5-6 :常规行业新闻,有一定参考价值
|
||||||
|
1-4 :普通资讯,信息价值有限
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def _log(db: AsyncSession, level: str, event_type: str, message: str):
|
||||||
|
db.add(SystemLog(level=level, event_type=event_type, message=message))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_active_llm(db: AsyncSession) -> LLMConfig | None:
|
||||||
|
result = await db.execute(select(LLMConfig).where(LLMConfig.is_active == True).limit(1))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _analyze_article(client: LLMClient, title: str, content: str, language: str) -> dict | None:
|
||||||
|
prompt = ANALYSIS_PROMPT.format(
|
||||||
|
title=title,
|
||||||
|
content=content[:2000] if content else "(无正文)",
|
||||||
|
language="中文" if language == "zh" else "英文",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
raw = await client.complete(SYSTEM_PROMPT, prompt)
|
||||||
|
raw = raw.strip()
|
||||||
|
if raw.startswith("```"):
|
||||||
|
raw = raw.split("```")[1]
|
||||||
|
if raw.startswith("json"):
|
||||||
|
raw = raw[4:]
|
||||||
|
return json.loads(raw)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"LLM parse error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _select_top_10(db: AsyncSession, target: date):
|
||||||
|
"""Reset featured flags and elect TOP 10 with category diversity."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(ProcessedNews)
|
||||||
|
.where(func.date(ProcessedNews.processed_at) == target)
|
||||||
|
.order_by(ProcessedNews.importance_score.desc())
|
||||||
|
)
|
||||||
|
all_news = result.scalars().all()
|
||||||
|
|
||||||
|
# Reset
|
||||||
|
for n in all_news:
|
||||||
|
n.is_featured = False
|
||||||
|
n.featured_rank = None
|
||||||
|
|
||||||
|
categories = ["药品监管", "临床研究", "行业动态", "政策法规"]
|
||||||
|
selected: list[ProcessedNews] = []
|
||||||
|
seen_cats: set[str] = set()
|
||||||
|
|
||||||
|
# First pass: one guaranteed per category
|
||||||
|
for cat in categories:
|
||||||
|
for n in all_news:
|
||||||
|
if n.category == cat and cat not in seen_cats and n not in selected:
|
||||||
|
selected.append(n)
|
||||||
|
seen_cats.add(cat)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Second pass: fill up to 10 by score
|
||||||
|
for n in all_news:
|
||||||
|
if len(selected) >= 10:
|
||||||
|
break
|
||||||
|
if n not in selected:
|
||||||
|
selected.append(n)
|
||||||
|
|
||||||
|
for rank, n in enumerate(selected, start=1):
|
||||||
|
n.is_featured = True
|
||||||
|
n.featured_rank = rank
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return len(selected)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_daily_pipeline(db: AsyncSession):
|
||||||
|
await _log(db, "INFO", "pipeline_start", "每日流水线启动")
|
||||||
|
|
||||||
|
llm_cfg = await _get_active_llm(db)
|
||||||
|
if not llm_cfg:
|
||||||
|
await _log(db, "ERROR", "pipeline_error", "未找到激活的 LLM 配置,请在管理后台配置")
|
||||||
|
return
|
||||||
|
|
||||||
|
client = LLMClient(
|
||||||
|
provider=llm_cfg.provider,
|
||||||
|
api_key=llm_cfg.api_key,
|
||||||
|
base_url=llm_cfg.base_url,
|
||||||
|
model=llm_cfg.model_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 1. 抓取 ──────────────────────────────────────────────────────────────
|
||||||
|
sources_result = await db.execute(select(NewsSource).where(NewsSource.is_active == True))
|
||||||
|
sources = sources_result.scalars().all()
|
||||||
|
raw_added = 0
|
||||||
|
|
||||||
|
for src in sources:
|
||||||
|
items = await fetch_rss(src.url)
|
||||||
|
for item in items:
|
||||||
|
exists = await db.execute(select(RawNews.id).where(RawNews.url == item["url"]))
|
||||||
|
if exists.scalar_one_or_none():
|
||||||
|
continue
|
||||||
|
db.add(RawNews(
|
||||||
|
source_id=src.id,
|
||||||
|
title=item["title"],
|
||||||
|
url=item["url"],
|
||||||
|
raw_content=item["content"],
|
||||||
|
published_at=item["published_at"],
|
||||||
|
))
|
||||||
|
raw_added += 1
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await _log(db, "INFO", "crawl_done", f"抓取完成,新增 {raw_added} 条原始新闻")
|
||||||
|
|
||||||
|
# ── 2. AI 处理 ────────────────────────────────────────────────────────────
|
||||||
|
pending_result = await db.execute(
|
||||||
|
select(RawNews).join(RawNews.source).where(RawNews.status == "pending").limit(120)
|
||||||
|
)
|
||||||
|
pending = pending_result.scalars().all()
|
||||||
|
processed_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
for raw in pending:
|
||||||
|
language = raw.source.language if raw.source else "zh"
|
||||||
|
analysis = await _analyze_article(client, raw.title, raw.raw_content or "", language)
|
||||||
|
|
||||||
|
if not analysis or not analysis.get("is_medical_related"):
|
||||||
|
raw.status = "skipped"
|
||||||
|
skipped_count += 1
|
||||||
|
else:
|
||||||
|
db.add(ProcessedNews(
|
||||||
|
raw_news_id=raw.id,
|
||||||
|
title_zh=analysis.get("title_zh", raw.title),
|
||||||
|
summary=analysis.get("summary", ""),
|
||||||
|
opinion=analysis.get("opinion"),
|
||||||
|
keywords=analysis.get("keywords", []),
|
||||||
|
importance_score=float(analysis.get("importance_score", 5.0)),
|
||||||
|
importance_reason=analysis.get("importance_reason"),
|
||||||
|
category=analysis.get("category", "行业动态"),
|
||||||
|
source_name=raw.source.name if raw.source else "",
|
||||||
|
source_url=raw.url,
|
||||||
|
published_at=raw.published_at,
|
||||||
|
))
|
||||||
|
raw.status = "processed"
|
||||||
|
processed_count += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await _log(db, "INFO", "process_done", f"AI 处理完成:{processed_count} 条入库,{skipped_count} 条跳过")
|
||||||
|
|
||||||
|
# ── 3. 精选 TOP 10 ────────────────────────────────────────────────────────
|
||||||
|
featured = await _select_top_10(db, date.today())
|
||||||
|
await _log(db, "INFO", "pipeline_done", f"流水线完成,精选 {featured} 条入今日 TOP 10")
|
||||||
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
182
backend/app/api/admin.py
Normal file
182
backend/app/api/admin.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models.news import LLMConfig, NewsSource, SystemLog, RawNews, ProcessedNews
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_admin(authorization: str = Header(...)):
|
||||||
|
token = authorization.removeprefix("Bearer ").strip()
|
||||||
|
if token != settings.admin_token:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid admin token")
|
||||||
|
|
||||||
|
|
||||||
|
# ── LLM Config ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class LLMConfigIn(BaseModel):
|
||||||
|
name: str
|
||||||
|
provider: str
|
||||||
|
api_key: str
|
||||||
|
base_url: str
|
||||||
|
model_name: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/llm-config", dependencies=[Depends(verify_admin)])
|
||||||
|
async def get_llm_config(db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(LLMConfig).where(LLMConfig.is_active == True).limit(1))
|
||||||
|
cfg = result.scalar_one_or_none()
|
||||||
|
if not cfg:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": cfg.id, "name": cfg.name, "provider": cfg.provider,
|
||||||
|
"api_key": "***" + cfg.api_key[-4:] if len(cfg.api_key) > 4 else "****",
|
||||||
|
"base_url": cfg.base_url, "model_name": cfg.model_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/llm-config", dependencies=[Depends(verify_admin)])
|
||||||
|
async def save_llm_config(body: LLMConfigIn, db: AsyncSession = Depends(get_db)):
|
||||||
|
await db.execute(
|
||||||
|
LLMConfig.__table__.update().values(is_active=False)
|
||||||
|
)
|
||||||
|
cfg = LLMConfig(**body.model_dump(), is_active=True)
|
||||||
|
db.add(cfg)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "id": cfg.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/llm-config/test", dependencies=[Depends(verify_admin)])
|
||||||
|
async def test_llm_config(body: LLMConfigIn):
|
||||||
|
from ..ai.llm_client import LLMClient
|
||||||
|
client = LLMClient(
|
||||||
|
provider=body.provider,
|
||||||
|
api_key=body.api_key,
|
||||||
|
base_url=body.base_url,
|
||||||
|
model=body.model_name,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
reply = await client.complete(
|
||||||
|
system_prompt="你是一个助手。",
|
||||||
|
user_prompt="请回复'连接正常',不要说其他内容。",
|
||||||
|
)
|
||||||
|
return {"ok": True, "reply": reply}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── News Sources ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SourceIn(BaseModel):
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
source_type: str = "rss"
|
||||||
|
language: str = "zh"
|
||||||
|
category: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sources", dependencies=[Depends(verify_admin)])
|
||||||
|
async def get_sources(db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(NewsSource).order_by(NewsSource.id))
|
||||||
|
sources = result.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": s.id, "name": s.name, "url": s.url, "source_type": s.source_type,
|
||||||
|
"language": s.language, "category": s.category, "is_active": s.is_active}
|
||||||
|
for s in sources
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sources", dependencies=[Depends(verify_admin)])
|
||||||
|
async def add_source(body: SourceIn, db: AsyncSession = Depends(get_db)):
|
||||||
|
src = NewsSource(**body.model_dump())
|
||||||
|
db.add(src)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "id": src.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/sources/{source_id}", dependencies=[Depends(verify_admin)])
|
||||||
|
async def toggle_source(source_id: int, body: dict, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(NewsSource).where(NewsSource.id == source_id))
|
||||||
|
src = result.scalar_one_or_none()
|
||||||
|
if not src:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
if "is_active" in body:
|
||||||
|
src.is_active = body["is_active"]
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sources/{source_id}", dependencies=[Depends(verify_admin)])
|
||||||
|
async def delete_source(source_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(NewsSource).where(NewsSource.id == source_id))
|
||||||
|
src = result.scalar_one_or_none()
|
||||||
|
if src:
|
||||||
|
await db.delete(src)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Trigger & Stats ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_pipeline_running = False
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/crawl/trigger", dependencies=[Depends(verify_admin)])
|
||||||
|
async def trigger_crawl():
|
||||||
|
global _pipeline_running
|
||||||
|
if _pipeline_running:
|
||||||
|
return {"ok": False, "message": "Pipeline already running"}
|
||||||
|
_pipeline_running = True
|
||||||
|
asyncio.create_task(_run_pipeline())
|
||||||
|
return {"ok": True, "message": "Pipeline started"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_pipeline():
|
||||||
|
global _pipeline_running
|
||||||
|
from ..scheduler import trigger_now
|
||||||
|
try:
|
||||||
|
await trigger_now()
|
||||||
|
finally:
|
||||||
|
_pipeline_running = False
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", dependencies=[Depends(verify_admin)])
|
||||||
|
async def get_stats(db: AsyncSession = Depends(get_db)):
|
||||||
|
from datetime import date
|
||||||
|
today = date.today()
|
||||||
|
raw_today = (await db.execute(
|
||||||
|
select(func.count(RawNews.id)).where(func.date(RawNews.crawled_at) == today)
|
||||||
|
)).scalar_one()
|
||||||
|
processed_today = (await db.execute(
|
||||||
|
select(func.count(ProcessedNews.id)).where(func.date(ProcessedNews.processed_at) == today)
|
||||||
|
)).scalar_one()
|
||||||
|
featured_today = (await db.execute(
|
||||||
|
select(func.count(ProcessedNews.id))
|
||||||
|
.where(func.date(ProcessedNews.processed_at) == today)
|
||||||
|
.where(ProcessedNews.is_featured == True)
|
||||||
|
)).scalar_one()
|
||||||
|
return {
|
||||||
|
"raw_today": raw_today,
|
||||||
|
"processed_today": processed_today,
|
||||||
|
"featured_today": featured_today,
|
||||||
|
"pipeline_running": _pipeline_running,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs", dependencies=[Depends(verify_admin)])
|
||||||
|
async def get_logs(limit: int = 100, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(SystemLog).order_by(SystemLog.created_at.desc()).limit(limit)
|
||||||
|
)
|
||||||
|
logs = result.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": l.id, "level": l.level, "event_type": l.event_type,
|
||||||
|
"message": l.message, "created_at": l.created_at.isoformat()}
|
||||||
|
for l in logs
|
||||||
|
]
|
||||||
96
backend/app/api/news.py
Normal file
96
backend/app/api/news.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select, func, distinct
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models.news import ProcessedNews, RawNews
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(n: ProcessedNews) -> dict:
|
||||||
|
raw = n.raw_news
|
||||||
|
return {
|
||||||
|
"id": n.id,
|
||||||
|
"title_zh": n.title_zh,
|
||||||
|
"summary": n.summary,
|
||||||
|
"opinion": n.opinion,
|
||||||
|
"keywords": n.keywords or [],
|
||||||
|
"importance_score": n.importance_score,
|
||||||
|
"importance_reason": n.importance_reason,
|
||||||
|
"category": n.category,
|
||||||
|
"is_featured": n.is_featured,
|
||||||
|
"featured_rank": n.featured_rank,
|
||||||
|
"source_name": n.source_name or (raw.source.name if raw and raw.source else ""),
|
||||||
|
"source_url": n.source_url or (raw.url if raw else ""),
|
||||||
|
"published_at": n.published_at.isoformat() if n.published_at else None,
|
||||||
|
"processed_at": n.processed_at.isoformat() if n.processed_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/featured")
|
||||||
|
async def get_featured(
|
||||||
|
news_date: Optional[str] = Query(default=None, alias="date"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
target = date.fromisoformat(news_date) if news_date else date.today()
|
||||||
|
stmt = (
|
||||||
|
select(ProcessedNews)
|
||||||
|
.join(ProcessedNews.raw_news)
|
||||||
|
.where(ProcessedNews.is_featured == True)
|
||||||
|
.where(func.date(ProcessedNews.processed_at) == target)
|
||||||
|
.order_by(ProcessedNews.featured_rank)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
items = result.scalars().all()
|
||||||
|
return {"date": str(target), "items": [_serialize(n) for n in items]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_news(
|
||||||
|
news_date: Optional[str] = Query(default=None, alias="date"),
|
||||||
|
category: Optional[str] = Query(default=None),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
target = date.fromisoformat(news_date) if news_date else date.today()
|
||||||
|
stmt = (
|
||||||
|
select(ProcessedNews)
|
||||||
|
.join(ProcessedNews.raw_news)
|
||||||
|
.where(func.date(ProcessedNews.processed_at) == target)
|
||||||
|
)
|
||||||
|
if category:
|
||||||
|
stmt = stmt.where(ProcessedNews.category == category)
|
||||||
|
|
||||||
|
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||||
|
total = (await db.execute(count_stmt)).scalar_one()
|
||||||
|
|
||||||
|
stmt = stmt.order_by(ProcessedNews.importance_score.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
return {"date": str(target), "total": total, "page": page, "items": [_serialize(n) for n in items]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dates")
|
||||||
|
async def get_dates(db: AsyncSession = Depends(get_db)):
|
||||||
|
stmt = select(
|
||||||
|
func.date(ProcessedNews.processed_at).label("d"),
|
||||||
|
func.count(ProcessedNews.id).label("cnt"),
|
||||||
|
).group_by("d").order_by(func.date(ProcessedNews.processed_at).desc()).limit(30)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return [{"date": str(row.d), "count": row.cnt} for row in result]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{news_id}")
|
||||||
|
async def get_news_detail(news_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
stmt = select(ProcessedNews).join(ProcessedNews.raw_news).where(ProcessedNews.id == news_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
news = result.scalar_one_or_none()
|
||||||
|
if not news:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return _serialize(news)
|
||||||
17
backend/app/config.py
Normal file
17
backend/app/config.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
database_url: str = "postgresql+asyncpg://pharma:pharma123@localhost/pharma_news"
|
||||||
|
admin_token: str = "change-me-admin-token"
|
||||||
|
|
||||||
|
initial_llm_provider: str = "deepseek"
|
||||||
|
initial_llm_api_key: str = ""
|
||||||
|
initial_llm_base_url: str = "https://api.deepseek.com"
|
||||||
|
initial_llm_model: str = "deepseek-chat"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
0
backend/app/crawler/__init__.py
Normal file
0
backend/app/crawler/__init__.py
Normal file
73
backend/app/crawler/rss_fetcher.py
Normal file
73
backend/app/crawler/rss_fetcher.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
from typing import Optional
|
||||||
|
import feedparser
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (compatible; PharmaIntelBot/1.0)"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(raw: str) -> Optional[datetime]:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return parsedate_to_datetime(raw).replace(tzinfo=None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_rss(url: str, max_items: int = 30) -> list[dict]:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(headers=HEADERS, timeout=30, follow_redirects=True) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
text = resp.text
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"RSS fetch failed {url}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
feed = feedparser.parse(text)
|
||||||
|
items = []
|
||||||
|
for entry in feed.entries[:max_items]:
|
||||||
|
title = entry.get("title", "").strip()
|
||||||
|
link = entry.get("link", "").strip()
|
||||||
|
if not title or not link:
|
||||||
|
continue
|
||||||
|
|
||||||
|
content = (
|
||||||
|
entry.get("summary", "")
|
||||||
|
or entry.get("content", [{}])[0].get("value", "")
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
published_raw = entry.get("published") or entry.get("updated") or ""
|
||||||
|
items.append({
|
||||||
|
"title": title,
|
||||||
|
"url": link,
|
||||||
|
"content": content[:3000],
|
||||||
|
"published_at": _parse_date(published_raw),
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"RSS {url}: got {len(items)} items")
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
# 默认新闻源(管理页可增删)
|
||||||
|
DEFAULT_SOURCES = [
|
||||||
|
# 中文
|
||||||
|
{"name": "国家药监局", "url": "https://www.nmpa.gov.cn/rss/yaopinxinxi.xml", "language": "zh", "category": "药品监管"},
|
||||||
|
{"name": "丁香园", "url": "https://www.dxy.cn/bbs/feed.xml", "language": "zh", "category": "临床研究"},
|
||||||
|
{"name": "医学界", "url": "https://www.yxj.org.cn/rss.xml", "language": "zh", "category": "行业动态"},
|
||||||
|
# 英文
|
||||||
|
{"name": "STAT News", "url": "https://www.statnews.com/feed/", "language": "en", "category": "临床研究"},
|
||||||
|
{"name": "FiercePharma", "url": "https://www.fiercepharma.com/rss/xml", "language": "en", "category": "行业动态"},
|
||||||
|
{"name": "FDA News", "url": "https://www.fda.gov/about-fda/contact-fda/stay-informed/rss-feeds/fda-news-feed/rss.xml", "language": "en", "category": "药品监管"},
|
||||||
|
]
|
||||||
21
backend/app/database.py
Normal file
21
backend/app/database.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, echo=False)
|
||||||
|
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
async def create_tables():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
from .models import news # noqa: ensure models are registered
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
57
backend/app/main.py
Normal file
57
backend/app/main.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from .database import create_tables, AsyncSessionLocal
|
||||||
|
from .scheduler import start_scheduler, shutdown_scheduler
|
||||||
|
from .api import news, admin
|
||||||
|
from .config import settings
|
||||||
|
from .models.news import LLMConfig
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await create_tables()
|
||||||
|
await seed_initial_llm_config()
|
||||||
|
start_scheduler()
|
||||||
|
yield
|
||||||
|
shutdown_scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_initial_llm_config():
|
||||||
|
"""Insert default LLM config on first run if none exists."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(select(LLMConfig).limit(1))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
return
|
||||||
|
if not settings.initial_llm_api_key:
|
||||||
|
return
|
||||||
|
config = LLMConfig(
|
||||||
|
name="默认配置",
|
||||||
|
provider=settings.initial_llm_provider,
|
||||||
|
api_key=settings.initial_llm_api_key,
|
||||||
|
base_url=settings.initial_llm_base_url,
|
||||||
|
model_name=settings.initial_llm_model,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(config)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="医药情报 API", version="1.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(news.router, prefix="/api/news", tags=["news"])
|
||||||
|
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
81
backend/app/models/news.py
Normal file
81
backend/app/models/news.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy import String, Text, Integer, Float, Boolean, DateTime, ForeignKey, ARRAY
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from ..database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class NewsSource(Base):
|
||||||
|
__tablename__ = "news_sources"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
url: Mapped[str] = mapped_column(String(500))
|
||||||
|
source_type: Mapped[str] = mapped_column(String(20), default="rss") # rss | scrape
|
||||||
|
language: Mapped[str] = mapped_column(String(5), default="zh") # zh | en
|
||||||
|
category: Mapped[Optional[str]] = mapped_column(String(50))
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
raw_news: Mapped[List["RawNews"]] = relationship(back_populates="source")
|
||||||
|
|
||||||
|
|
||||||
|
class RawNews(Base):
|
||||||
|
__tablename__ = "raw_news"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
source_id: Mapped[Optional[int]] = mapped_column(ForeignKey("news_sources.id"))
|
||||||
|
title: Mapped[str] = mapped_column(String(500))
|
||||||
|
url: Mapped[str] = mapped_column(String(1000), unique=True)
|
||||||
|
raw_content: Mapped[Optional[str]] = mapped_column(Text)
|
||||||
|
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||||
|
crawled_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="pending") # pending|processed|skipped|error
|
||||||
|
|
||||||
|
source: Mapped[Optional["NewsSource"]] = relationship(back_populates="raw_news")
|
||||||
|
processed: Mapped[Optional["ProcessedNews"]] = relationship(back_populates="raw_news", uselist=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessedNews(Base):
|
||||||
|
__tablename__ = "processed_news"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
raw_news_id: Mapped[int] = mapped_column(ForeignKey("raw_news.id"))
|
||||||
|
title_zh: Mapped[str] = mapped_column(String(500))
|
||||||
|
summary: Mapped[str] = mapped_column(Text)
|
||||||
|
opinion: Mapped[Optional[str]] = mapped_column(Text)
|
||||||
|
keywords: Mapped[Optional[List[str]]] = mapped_column(ARRAY(String))
|
||||||
|
importance_score: Mapped[float] = mapped_column(Float, default=5.0)
|
||||||
|
importance_reason: Mapped[Optional[str]] = mapped_column(Text)
|
||||||
|
category: Mapped[str] = mapped_column(String(50), default="行业动态")
|
||||||
|
is_featured: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
featured_rank: Mapped[Optional[int]] = mapped_column(Integer)
|
||||||
|
source_name: Mapped[Optional[str]] = mapped_column(String(200))
|
||||||
|
source_url: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||||
|
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||||
|
processed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
raw_news: Mapped["RawNews"] = relationship(back_populates="processed")
|
||||||
|
|
||||||
|
|
||||||
|
class LLMConfig(Base):
|
||||||
|
__tablename__ = "llm_config"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
provider: Mapped[str] = mapped_column(String(50)) # openai | anthropic | qwen | deepseek | custom
|
||||||
|
api_key: Mapped[str] = mapped_column(String(500))
|
||||||
|
base_url: Mapped[str] = mapped_column(String(500))
|
||||||
|
model_name: Mapped[str] = mapped_column(String(200))
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemLog(Base):
|
||||||
|
__tablename__ = "system_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(50))
|
||||||
|
message: Mapped[str] = mapped_column(Text)
|
||||||
|
level: Mapped[str] = mapped_column(String(20), default="INFO")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
31
backend/app/scheduler.py
Normal file
31
backend/app/scheduler.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import logging
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
async def daily_pipeline_job():
|
||||||
|
from .database import AsyncSessionLocal
|
||||||
|
from .ai.processor import run_daily_pipeline
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
|
await run_daily_pipeline(db)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Daily pipeline failed: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler():
|
||||||
|
scheduler.add_job(daily_pipeline_job, CronTrigger(hour=6, minute=0), id="daily_pipeline", replace_existing=True)
|
||||||
|
scheduler.start()
|
||||||
|
logger.info("Scheduler started — daily pipeline runs at 06:00 Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown_scheduler():
|
||||||
|
scheduler.shutdown(wait=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def trigger_now():
|
||||||
|
"""Manually trigger the pipeline (called from admin API)."""
|
||||||
|
await daily_pipeline_job()
|
||||||
12
backend/requirements.txt
Normal file
12
backend/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.32.1
|
||||||
|
sqlalchemy[asyncio]==2.0.36
|
||||||
|
asyncpg==0.30.0
|
||||||
|
alembic==1.14.0
|
||||||
|
httpx==0.28.0
|
||||||
|
feedparser==6.0.11
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
pydantic-settings==2.6.1
|
||||||
|
apscheduler==3.10.4
|
||||||
|
beautifulsoup4==4.12.3
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
40
docker-compose.yml
Normal file
40
docker-compose.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: pharma
|
||||||
|
POSTGRES_PASSWORD: pharma123
|
||||||
|
POSTGRES_DB: pharma_news
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U pharma"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
# 使用淘宝镜像
|
||||||
|
RUN npm config set registry https://registry.npmmirror.com
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>医药情报</title>
|
||||||
|
<!-- 国内可将 Google Fonts 替换为 fonts.loli.net 镜像 -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
frontend/nginx.conf
Normal file
17
frontend/nginx.conf
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Vue Router history mode
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 反代后端 API
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
frontend/package.json
Normal file
20
frontend/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "pharma-intel-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0",
|
||||||
|
"element-plus": "^2.9.1",
|
||||||
|
"@element-plus/icons-vue": "^2.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/src/App.vue
Normal file
3
frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
64
frontend/src/api/index.js
Normal file
64
frontend/src/api/index.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
const BASE = '/api'
|
||||||
|
|
||||||
|
function adminHeaders() {
|
||||||
|
const token = localStorage.getItem('admin_token') || ''
|
||||||
|
return { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(url) {
|
||||||
|
const resp = await fetch(BASE + url)
|
||||||
|
if (!resp.ok) throw new Error(`GET ${url} failed: ${resp.status}`)
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminGet(url) {
|
||||||
|
const resp = await fetch(BASE + url, { headers: adminHeaders() })
|
||||||
|
if (resp.status === 401) throw new Error('UNAUTHORIZED')
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminPost(url, body) {
|
||||||
|
const resp = await fetch(BASE + url, {
|
||||||
|
method: 'POST', headers: adminHeaders(), body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
if (resp.status === 401) throw new Error('UNAUTHORIZED')
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminPut(url, body) {
|
||||||
|
const resp = await fetch(BASE + url, {
|
||||||
|
method: 'PUT', headers: adminHeaders(), body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminDelete(url) {
|
||||||
|
const resp = await fetch(BASE + url, { method: 'DELETE', headers: adminHeaders() })
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reader API ────────────────────────────────────────────────────────────────
|
||||||
|
export const fetchFeatured = (date) => get(`/news/featured${date ? `?date=${date}` : ''}`)
|
||||||
|
export const fetchNews = ({ date, category, page = 1 } = {}) => {
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
if (date) p.set('date', date)
|
||||||
|
if (category) p.set('category', category)
|
||||||
|
p.set('page', page)
|
||||||
|
return get(`/news?${p}`)
|
||||||
|
}
|
||||||
|
export const fetchDates = () => get('/news/dates')
|
||||||
|
export const fetchNewsDetail = (id) => get(`/news/${id}`)
|
||||||
|
|
||||||
|
// ── Admin API ─────────────────────────────────────────────────────────────────
|
||||||
|
export const getLLMConfig = () => adminGet('/admin/llm-config')
|
||||||
|
export const saveLLMConfig = (cfg) => adminPost('/admin/llm-config', cfg)
|
||||||
|
export const testLLMConfig = (cfg) => adminPost('/admin/llm-config/test', cfg)
|
||||||
|
|
||||||
|
export const getSources = () => adminGet('/admin/sources')
|
||||||
|
export const addSource = (src) => adminPost('/admin/sources', src)
|
||||||
|
export const toggleSource = (id, is_active) => adminPut(`/admin/sources/${id}`, { is_active })
|
||||||
|
export const deleteSource = (id) => adminDelete(`/admin/sources/${id}`)
|
||||||
|
|
||||||
|
export const triggerCrawl = () => adminPost('/admin/crawl/trigger', {})
|
||||||
|
export const getStats = () => adminGet('/admin/stats')
|
||||||
|
export const getLogs = (limit = 100) => adminGet(`/admin/logs?limit=${limit}`)
|
||||||
141
frontend/src/components/NewsCard.vue
Normal file
141
frontend/src/components/NewsCard.vue
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<div class="news-card" :class="{ featured: news.is_featured }" @click="$emit('open', news)">
|
||||||
|
<div class="card-top">
|
||||||
|
<span class="score-badge" :class="badgeClass">{{ news.importance_score?.toFixed(1) }}</span>
|
||||||
|
<span class="cat-label">{{ news.category }}</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span class="source-meta">{{ news.source_name }} · {{ timeAgo }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="card-title">{{ news.title_zh }}</h3>
|
||||||
|
<p class="card-summary">{{ news.summary }}</p>
|
||||||
|
|
||||||
|
<div v-if="news.opinion" class="opinion-block">
|
||||||
|
<b>核心观点</b>{{ news.opinion }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="kw-row">
|
||||||
|
<span v-for="kw in (news.keywords || []).slice(0, 5)" :key="kw" class="kw-tag">{{ kw }}</span>
|
||||||
|
</div>
|
||||||
|
<a :href="news.source_url" target="_blank" @click.stop class="orig-link">查看原文 →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({ news: { type: Object, required: true } })
|
||||||
|
defineEmits(['open'])
|
||||||
|
|
||||||
|
const badgeClass = computed(() => {
|
||||||
|
const s = props.news.importance_score
|
||||||
|
if (s >= 9) return 'badge-red'
|
||||||
|
if (s >= 7) return 'badge-amber'
|
||||||
|
if (s >= 5) return 'badge-blue'
|
||||||
|
return 'badge-gray'
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeAgo = computed(() => {
|
||||||
|
if (!props.news.published_at) return ''
|
||||||
|
const diff = Date.now() - new Date(props.news.published_at).getTime()
|
||||||
|
const h = Math.floor(diff / 3600000)
|
||||||
|
if (h < 1) return '刚刚'
|
||||||
|
if (h < 24) return `${h}小时前`
|
||||||
|
return `${Math.floor(h / 24)}天前`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.news-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--r);
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s, transform .15s;
|
||||||
|
}
|
||||||
|
.news-card:hover { border-color: var(--rule2); transform: translateY(-1px); }
|
||||||
|
.news-card.featured { border-left: 2px solid var(--blue-2); }
|
||||||
|
|
||||||
|
/* Top row */
|
||||||
|
.card-top { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||||
|
.score-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.badge-red { background: rgba(255,77,106,.2); color: var(--red); }
|
||||||
|
.badge-amber { background: rgba(255,163,54,.2); color: var(--amber); }
|
||||||
|
.badge-blue { background: rgba(46,85,245,.2); color: var(--blue-2); }
|
||||||
|
.badge-gray { background: rgba(122,128,160,.15); color: var(--t3); }
|
||||||
|
|
||||||
|
.cat-label { font-size: 11px; color: var(--t3); }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.source-meta { font-size: 11px; color: var(--t4); font-family: var(--mono); }
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--t1);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.card-summary {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--t3);
|
||||||
|
line-height: 1.65;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Opinion */
|
||||||
|
.opinion-block {
|
||||||
|
background: var(--blue-gl);
|
||||||
|
border-left: 2px solid var(--blue-bd);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #8AAAFF;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.opinion-block b {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
opacity: .7;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.card-footer { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.kw-row { display: flex; gap: 5px; flex-wrap: wrap; flex: 1; }
|
||||||
|
.kw-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(46,85,245,.15);
|
||||||
|
color: #7B9BFF;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.orig-link {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--blue-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color .15s;
|
||||||
|
}
|
||||||
|
.orig-link:hover { color: var(--t1); }
|
||||||
|
</style>
|
||||||
149
frontend/src/components/TopTenPanel.vue
Normal file
149
frontend/src/components/TopTenPanel.vue
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="top10-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="dot d-blue"></span>
|
||||||
|
今日精选 TOP 10
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="items.length === 0" class="empty-msg">暂无精选,等待今日抓取...</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(n, i) in items"
|
||||||
|
:key="n.id"
|
||||||
|
class="top10-item"
|
||||||
|
:class="{ active: activeId === n.id }"
|
||||||
|
@click="$emit('select', n)"
|
||||||
|
>
|
||||||
|
<span class="rank">{{ String(i + 1).padStart(2, '0') }}</span>
|
||||||
|
<div class="item-body">
|
||||||
|
<span class="item-score" :class="badgeClass(n.importance_score)">{{ n.importance_score?.toFixed(1) }}</span>
|
||||||
|
<span class="item-title">{{ n.title_zh }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="archive-section" v-if="dates.length > 0">
|
||||||
|
<div class="archive-head">历史归档</div>
|
||||||
|
<div
|
||||||
|
v-for="d in dates.slice(1, 8)"
|
||||||
|
:key="d.date"
|
||||||
|
class="archive-item"
|
||||||
|
:class="{ active: selectedDate === d.date }"
|
||||||
|
@click="$emit('date-change', d.date)"
|
||||||
|
>
|
||||||
|
<span class="arc-date">{{ d.date.slice(5) }}</span>
|
||||||
|
<span class="arc-count">{{ d.count }}条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
items: { type: Array, default: () => [] },
|
||||||
|
dates: { type: Array, default: () => [] },
|
||||||
|
activeId: { type: Number, default: null },
|
||||||
|
selectedDate: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
defineEmits(['select', 'date-change'])
|
||||||
|
|
||||||
|
function badgeClass(score) {
|
||||||
|
if (score >= 9) return 'badge-red'
|
||||||
|
if (score >= 7) return 'badge-amber'
|
||||||
|
return 'badge-blue'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.top10-panel {
|
||||||
|
width: 260px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 120px;
|
||||||
|
height: calc(100vh - 140px);
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.top10-panel::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
letter-spacing: .1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--t4);
|
||||||
|
padding: 0 0 12px;
|
||||||
|
}
|
||||||
|
.dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||||
|
.d-blue { background: var(--blue-2); }
|
||||||
|
|
||||||
|
.empty-msg { font-size: 12px; color: var(--t4); padding: 8px 0; }
|
||||||
|
|
||||||
|
.top10-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.top10-item:hover { background: var(--bg-hi); }
|
||||||
|
.top10-item.active { background: var(--blue-gl); }
|
||||||
|
|
||||||
|
.rank {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t4);
|
||||||
|
width: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 3px;
|
||||||
|
}
|
||||||
|
.item-body { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.item-score {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.badge-red { background: rgba(255,77,106,.2); color: var(--red); }
|
||||||
|
.badge-amber { background: rgba(255,163,54,.2); color: var(--amber); }
|
||||||
|
.badge-blue { background: rgba(46,85,245,.2); color: var(--blue-2); }
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t2);
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Archive */
|
||||||
|
.archive-section { margin-top: 20px; border-top: 1px solid var(--rule); padding-top: 12px; }
|
||||||
|
.archive-head {
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--t4);
|
||||||
|
letter-spacing: .1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.archive-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.archive-item:hover, .archive-item.active { background: var(--bg-hi); }
|
||||||
|
.arc-date { font-size: 12px; color: var(--t2); font-family: var(--mono); }
|
||||||
|
.arc-count { font-size: 11px; color: var(--t4); font-family: var(--mono); }
|
||||||
|
</style>
|
||||||
20
frontend/src/main.js
Normal file
20
frontend/src/main.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router/index.js'
|
||||||
|
import './styles/theme.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(ElementPlus, { locale: zhCn })
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
13
frontend/src/router/index.js
Normal file
13
frontend/src/router/index.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import NewsReader from '../views/NewsReader.vue'
|
||||||
|
import Admin from '../views/Admin.vue'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', component: NewsReader },
|
||||||
|
{ path: '/admin', component: Admin },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
59
frontend/src/styles/theme.css
Normal file
59
frontend/src/styles/theme.css
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/* 设计令牌——与 HTML 参考文件保持一致 */
|
||||||
|
:root {
|
||||||
|
--bg: #07091A;
|
||||||
|
--bg-1: #0B0E24;
|
||||||
|
--bg-2: #10142E;
|
||||||
|
--bg-card: #0D1028;
|
||||||
|
--bg-hi: #141838;
|
||||||
|
|
||||||
|
--blue: #2E55F5;
|
||||||
|
--blue-2: #5578FF;
|
||||||
|
--blue-gl: rgba(46, 85, 245, 0.15);
|
||||||
|
--blue-bd: rgba(46, 85, 245, 0.3);
|
||||||
|
|
||||||
|
--violet: #7B3FE4;
|
||||||
|
--violet-2: #9A68EE;
|
||||||
|
--cyan: #19C3E6;
|
||||||
|
--mint: #25D6A3;
|
||||||
|
--amber: #FFA336;
|
||||||
|
--red: #FF4D6A;
|
||||||
|
|
||||||
|
--t1: #F0F2FF;
|
||||||
|
--t2: #B8BEDD;
|
||||||
|
--t3: #7A80A0;
|
||||||
|
--t4: #4A506A;
|
||||||
|
|
||||||
|
--rule: rgba(255, 255, 255, 0.07);
|
||||||
|
--rule2: rgba(255, 255, 255, 0.13);
|
||||||
|
|
||||||
|
--sans: 'Noto Sans SC', system-ui, sans-serif;
|
||||||
|
--mono: 'JetBrains Mono', 'Courier New', monospace;
|
||||||
|
|
||||||
|
--r: 12px;
|
||||||
|
--r-sm: 8px;
|
||||||
|
--r-pill: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--t1);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
button { cursor: pointer; font-family: inherit; }
|
||||||
|
input, select, textarea { font-family: inherit; }
|
||||||
|
|
||||||
|
/* Element Plus 暗色覆盖 */
|
||||||
|
.el-table { --el-table-bg-color: var(--bg-card); --el-table-header-bg-color: var(--bg-2); }
|
||||||
|
.el-input__wrapper { background: var(--bg-2) !important; box-shadow: 0 0 0 1px var(--rule2) !important; }
|
||||||
|
.el-input__inner { color: var(--t1) !important; }
|
||||||
|
.el-select-dropdown { background: var(--bg-2); border-color: var(--rule2); }
|
||||||
|
.el-select-dropdown__item { color: var(--t2); }
|
||||||
|
.el-select-dropdown__item.hover, .el-select-dropdown__item:hover { background: var(--bg-hi); }
|
||||||
434
frontend/src/views/Admin.vue
Normal file
434
frontend/src/views/Admin.vue
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
<template>
|
||||||
|
<div class="admin-layout">
|
||||||
|
<!-- ── 登录 ──────────────────────────────────────────────── -->
|
||||||
|
<div v-if="!authed" class="login-screen">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-logo">PI</div>
|
||||||
|
<div class="login-title">管理后台</div>
|
||||||
|
<input
|
||||||
|
v-model="tokenInput"
|
||||||
|
type="password"
|
||||||
|
placeholder="输入管理员令牌"
|
||||||
|
class="token-input"
|
||||||
|
@keyup.enter="login"
|
||||||
|
/>
|
||||||
|
<div v-if="loginError" class="login-error">{{ loginError }}</div>
|
||||||
|
<button class="login-btn" @click="login">进入</button>
|
||||||
|
<router-link to="/" class="back-link">← 返回首页</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 管理内容 ────────────────────────────────────────────── -->
|
||||||
|
<template v-else>
|
||||||
|
<header class="admin-topbar">
|
||||||
|
<div class="admin-logo">
|
||||||
|
<div class="logo-chip">PI</div>
|
||||||
|
<span>医药情报 · 管理后台</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-nav">
|
||||||
|
<button v-for="t in TABS" :key="t.key" :class="['tab-btn', { active: activeTab === t.key }]" @click="activeTab = t.key">
|
||||||
|
{{ t.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-right">
|
||||||
|
<router-link to="/" class="exit-link">← 返回首页</router-link>
|
||||||
|
<button class="logout-btn" @click="logout">退出</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="admin-body">
|
||||||
|
|
||||||
|
<!-- Tab: LLM 配置 -->
|
||||||
|
<section v-if="activeTab === 'llm'" class="admin-section">
|
||||||
|
<div class="sec-title">LLM 配置</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="form-label">提供商</label>
|
||||||
|
<el-select v-model="llmForm.provider" class="dark-input">
|
||||||
|
<el-option v-for="p in PROVIDERS" :key="p.value" :label="p.label" :value="p.value" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<label class="form-label">配置名称</label>
|
||||||
|
<el-input v-model="llmForm.name" placeholder="如「DeepSeek 默认」" class="dark-input" />
|
||||||
|
|
||||||
|
<label class="form-label">API Key</label>
|
||||||
|
<el-input v-model="llmForm.api_key" type="password" placeholder="sk-..." show-password class="dark-input" />
|
||||||
|
|
||||||
|
<label class="form-label">Base URL</label>
|
||||||
|
<el-input v-model="llmForm.base_url" placeholder="https://api.deepseek.com" class="dark-input" />
|
||||||
|
|
||||||
|
<label class="form-label">模型名称</label>
|
||||||
|
<el-input v-model="llmForm.model_name" placeholder="deepseek-chat" class="dark-input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn-primary" @click="saveLLM" :disabled="llmSaving">
|
||||||
|
{{ llmSaving ? '保存中...' : '保存配置' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary" @click="testLLM" :disabled="llmTesting">
|
||||||
|
{{ llmTesting ? '测试中...' : '连接测试' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="llmTestResult" class="test-result" :class="llmTestResult.ok ? 'ok' : 'err'">
|
||||||
|
{{ llmTestResult.ok ? '✓ ' + llmTestResult.reply : '✗ ' + llmTestResult.error }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Tab: 新闻源 -->
|
||||||
|
<section v-if="activeTab === 'sources'" class="admin-section">
|
||||||
|
<div class="sec-title">新闻源管理</div>
|
||||||
|
<el-table :data="sources" class="dark-table">
|
||||||
|
<el-table-column prop="name" label="名称" width="140" />
|
||||||
|
<el-table-column prop="url" label="URL" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="source_type" label="类型" width="80" />
|
||||||
|
<el-table-column prop="language" label="语言" width="70" />
|
||||||
|
<el-table-column prop="category" label="分类" width="100" />
|
||||||
|
<el-table-column label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch :model-value="row.is_active" @change="toggleSrc(row)" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<button class="del-btn" @click="deleteSrc(row.id)">删除</button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="add-source-form">
|
||||||
|
<div class="add-title">添加新闻源</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<el-input v-model="newSrc.name" placeholder="名称" style="width:130px" />
|
||||||
|
<el-input v-model="newSrc.url" placeholder="RSS URL" style="flex:1" />
|
||||||
|
<el-select v-model="newSrc.source_type" style="width:90px">
|
||||||
|
<el-option label="RSS" value="rss" />
|
||||||
|
<el-option label="Scrape" value="scrape" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="newSrc.language" style="width:80px">
|
||||||
|
<el-option label="中文" value="zh" />
|
||||||
|
<el-option label="English" value="en" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="newSrc.category" placeholder="分类" style="width:110px">
|
||||||
|
<el-option v-for="c in CAT_OPTIONS" :key="c" :label="c" :value="c" />
|
||||||
|
</el-select>
|
||||||
|
<button class="btn-primary" style="height:32px;padding:0 14px" @click="addSrc">添加</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Tab: 系统操作 -->
|
||||||
|
<section v-if="activeTab === 'ops'" class="admin-section">
|
||||||
|
<div class="sec-title">系统操作</div>
|
||||||
|
<div class="stats-row" v-if="stats">
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-val">{{ stats.raw_today }}</div>
|
||||||
|
<div class="stat-lbl">今日抓取原始</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-val">{{ stats.processed_today }}</div>
|
||||||
|
<div class="stat-lbl">今日 AI 处理</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box sc-m">
|
||||||
|
<div class="stat-val">{{ stats.featured_today }}</div>
|
||||||
|
<div class="stat-lbl">精选 TOP</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ops-card">
|
||||||
|
<div class="ops-desc">立即触发今日抓取 + AI 处理 + 精选 TOP 10(约需 3-10 分钟)</div>
|
||||||
|
<button
|
||||||
|
class="btn-primary trigger-btn"
|
||||||
|
@click="triggerCrawl"
|
||||||
|
:disabled="stats?.pipeline_running || triggering"
|
||||||
|
>
|
||||||
|
{{ stats?.pipeline_running || triggering ? '⏳ 运行中...' : '▶ 立即触发' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Tab: 日志 -->
|
||||||
|
<section v-if="activeTab === 'logs'" class="admin-section">
|
||||||
|
<div class="sec-title-row">
|
||||||
|
<div class="sec-title">运行日志</div>
|
||||||
|
<button class="btn-secondary" style="height:30px;padding:0 12px;font-size:12px" @click="loadLogs">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div class="log-list">
|
||||||
|
<div v-for="l in logs" :key="l.id" class="log-row" :class="'log-' + l.level.toLowerCase()">
|
||||||
|
<span class="log-time">{{ l.created_at.slice(11, 19) }}</span>
|
||||||
|
<span class="log-level">{{ l.level }}</span>
|
||||||
|
<span class="log-msg">{{ l.message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import {
|
||||||
|
getLLMConfig, saveLLMConfig, testLLMConfig,
|
||||||
|
getSources, addSource, toggleSource, deleteSource,
|
||||||
|
triggerCrawl as apiTrigger, getStats, getLogs,
|
||||||
|
} from '../api/index.js'
|
||||||
|
|
||||||
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
const authed = ref(!!localStorage.getItem('admin_token'))
|
||||||
|
const tokenInput = ref('')
|
||||||
|
const loginError = ref('')
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
if (!tokenInput.value) return
|
||||||
|
localStorage.setItem('admin_token', tokenInput.value)
|
||||||
|
authed.value = true
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
authed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||||
|
const TABS = [
|
||||||
|
{ key: 'llm', label: 'LLM 配置' },
|
||||||
|
{ key: 'sources', label: '新闻源' },
|
||||||
|
{ key: 'ops', label: '系统操作' },
|
||||||
|
{ key: 'logs', label: '日志' },
|
||||||
|
]
|
||||||
|
const activeTab = ref('llm')
|
||||||
|
|
||||||
|
// ── LLM ──────────────────────────────────────────────────────────────────────
|
||||||
|
const PROVIDERS = [
|
||||||
|
{ label: 'DeepSeek(推荐·国内可直连)', value: 'deepseek' },
|
||||||
|
{ label: '通义千问 Qwen', value: 'qwen' },
|
||||||
|
{ label: 'OpenAI 兼容', value: 'openai' },
|
||||||
|
{ label: 'Anthropic Claude', value: 'anthropic' },
|
||||||
|
{ label: '自定义 Endpoint', value: 'custom' },
|
||||||
|
]
|
||||||
|
const llmForm = ref({ name: '默认配置', provider: 'deepseek', api_key: '', base_url: 'https://api.deepseek.com', model_name: 'deepseek-chat' })
|
||||||
|
const llmSaving = ref(false)
|
||||||
|
const llmTesting = ref(false)
|
||||||
|
const llmTestResult = ref(null)
|
||||||
|
|
||||||
|
async function loadLLM() {
|
||||||
|
try {
|
||||||
|
const cfg = await getLLMConfig()
|
||||||
|
if (cfg) Object.assign(llmForm.value, { ...cfg, api_key: '' })
|
||||||
|
} catch (e) { if (e.message === 'UNAUTHORIZED') logout() }
|
||||||
|
}
|
||||||
|
async function saveLLM() {
|
||||||
|
llmSaving.value = true
|
||||||
|
try { await saveLLMConfig(llmForm.value) }
|
||||||
|
finally { llmSaving.value = false }
|
||||||
|
}
|
||||||
|
async function testLLM() {
|
||||||
|
llmTesting.value = true
|
||||||
|
llmTestResult.value = null
|
||||||
|
try { llmTestResult.value = await testLLMConfig(llmForm.value) }
|
||||||
|
finally { llmTesting.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sources ───────────────────────────────────────────────────────────────────
|
||||||
|
const CAT_OPTIONS = ['药品监管', '临床研究', '行业动态', '政策法规']
|
||||||
|
const sources = ref([])
|
||||||
|
const newSrc = ref({ name: '', url: '', source_type: 'rss', language: 'zh', category: '行业动态' })
|
||||||
|
|
||||||
|
async function loadSources() {
|
||||||
|
try { sources.value = await getSources() }
|
||||||
|
catch (e) { if (e.message === 'UNAUTHORIZED') logout() }
|
||||||
|
}
|
||||||
|
async function addSrc() {
|
||||||
|
await addSource(newSrc.value)
|
||||||
|
newSrc.value = { name: '', url: '', source_type: 'rss', language: 'zh', category: '行业动态' }
|
||||||
|
await loadSources()
|
||||||
|
}
|
||||||
|
async function toggleSrc(row) {
|
||||||
|
await toggleSource(row.id, !row.is_active)
|
||||||
|
await loadSources()
|
||||||
|
}
|
||||||
|
async function deleteSrc(id) {
|
||||||
|
await deleteSource(id)
|
||||||
|
await loadSources()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ops ───────────────────────────────────────────────────────────────────────
|
||||||
|
const stats = ref(null)
|
||||||
|
const triggering = ref(false)
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try { stats.value = await getStats() }
|
||||||
|
catch (e) { if (e.message === 'UNAUTHORIZED') logout() }
|
||||||
|
}
|
||||||
|
async function triggerCrawl() {
|
||||||
|
triggering.value = true
|
||||||
|
try {
|
||||||
|
await apiTrigger()
|
||||||
|
setTimeout(loadStats, 2000)
|
||||||
|
} finally {
|
||||||
|
triggering.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Logs ──────────────────────────────────────────────────────────────────────
|
||||||
|
const logs = ref([])
|
||||||
|
async function loadLogs() {
|
||||||
|
try { logs.value = await getLogs(100) }
|
||||||
|
catch (e) { if (e.message === 'UNAUTHORIZED') logout() }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
await Promise.all([loadLLM(), loadSources(), loadStats(), loadLogs()])
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { if (authed.value) init() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-layout { min-height: 100vh; background: var(--bg); }
|
||||||
|
|
||||||
|
/* Login */
|
||||||
|
.login-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--rule2);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px;
|
||||||
|
width: 360px;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 16px;
|
||||||
|
}
|
||||||
|
.login-logo {
|
||||||
|
width: 48px; height: 48px;
|
||||||
|
background: linear-gradient(135deg, var(--blue), var(--violet));
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: var(--mono); font-size: 16px; font-weight: 700; color: #fff;
|
||||||
|
}
|
||||||
|
.login-title { font-size: 20px; font-weight: 700; color: var(--t1); }
|
||||||
|
.token-input {
|
||||||
|
width: 100%; height: 40px; border-radius: 8px;
|
||||||
|
background: var(--bg-2); border: 1px solid var(--rule2);
|
||||||
|
color: var(--t1); padding: 0 14px; font-size: 14px;
|
||||||
|
}
|
||||||
|
.token-input:focus { outline: none; border-color: var(--blue-bd); }
|
||||||
|
.login-error { font-size: 12px; color: var(--red); }
|
||||||
|
.login-btn {
|
||||||
|
width: 100%; height: 40px; border-radius: 8px;
|
||||||
|
background: var(--blue); color: #fff; font-size: 14px; font-weight: 600; border: none;
|
||||||
|
}
|
||||||
|
.back-link { font-size: 12px; color: var(--t4); }
|
||||||
|
|
||||||
|
/* Topbar */
|
||||||
|
.admin-topbar {
|
||||||
|
height: 56px;
|
||||||
|
background: var(--bg-1);
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
display: flex; align-items: center; padding: 0 24px; gap: 20px;
|
||||||
|
}
|
||||||
|
.admin-logo {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
font-size: 14px; font-weight: 600; color: var(--t1);
|
||||||
|
}
|
||||||
|
.logo-chip {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
background: linear-gradient(135deg, var(--blue), var(--violet));
|
||||||
|
border-radius: 7px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: var(--mono); font-size: 10px; font-weight: 700; color: #fff;
|
||||||
|
}
|
||||||
|
.admin-nav { flex: 1; display: flex; gap: 4px; }
|
||||||
|
.tab-btn {
|
||||||
|
padding: 5px 14px; border-radius: 7px; font-size: 13px;
|
||||||
|
background: transparent; border: none; color: var(--t3); transition: all .15s;
|
||||||
|
}
|
||||||
|
.tab-btn:hover { color: var(--t1); background: var(--bg-hi); }
|
||||||
|
.tab-btn.active { background: var(--blue-gl); color: var(--blue-2); }
|
||||||
|
.admin-right { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.exit-link { font-size: 12px; color: var(--t4); }
|
||||||
|
.logout-btn {
|
||||||
|
padding: 4px 12px; border-radius: 6px; font-size: 12px;
|
||||||
|
background: transparent; border: 1px solid var(--rule2); color: var(--t3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.admin-body { padding: 24px; max-width: 960px; }
|
||||||
|
.admin-section { }
|
||||||
|
.sec-title { font-size: 13px; font-family: var(--mono); letter-spacing: .08em; color: var(--t4); text-transform: uppercase; margin-bottom: 20px; }
|
||||||
|
.sec-title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
||||||
|
.sec-title-row .sec-title { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* LLM form */
|
||||||
|
.form-grid {
|
||||||
|
display: grid; grid-template-columns: 120px 1fr; gap: 12px 16px;
|
||||||
|
align-items: center; max-width: 560px; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.form-label { font-size: 13px; color: var(--t3); }
|
||||||
|
.form-actions { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.test-result {
|
||||||
|
padding: 10px 14px; border-radius: var(--r-sm);
|
||||||
|
font-size: 13px; font-family: var(--mono);
|
||||||
|
}
|
||||||
|
.test-result.ok { background: rgba(37,214,163,.1); color: var(--mint); }
|
||||||
|
.test-result.err { background: rgba(255,77,106,.1); color: var(--red); }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0 18px; height: 36px; border-radius: 8px;
|
||||||
|
background: var(--blue); color: #fff; font-size: 13px; font-weight: 600;
|
||||||
|
border: none; transition: background .15s;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) { background: var(--blue-2); }
|
||||||
|
.btn-primary:disabled { opacity: .5; cursor: default; }
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0 18px; height: 36px; border-radius: 8px;
|
||||||
|
background: var(--bg-2); border: 1px solid var(--rule2); color: var(--t2);
|
||||||
|
font-size: 13px; font-weight: 600; transition: background .15s;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: var(--bg-hi); }
|
||||||
|
|
||||||
|
/* Sources table */
|
||||||
|
.dark-table { background: var(--bg-card); --el-table-border-color: var(--rule); color: var(--t2); margin-bottom: 20px; }
|
||||||
|
.del-btn {
|
||||||
|
padding: 2px 8px; border-radius: 4px; font-size: 11px;
|
||||||
|
background: rgba(255,77,106,.15); border: 1px solid rgba(255,77,106,.3); color: var(--red);
|
||||||
|
}
|
||||||
|
.add-source-form { background: var(--bg-card); border: 1px solid var(--rule); border-radius: var(--r); padding: 16px; }
|
||||||
|
.add-title { font-size: 12px; color: var(--t4); font-family: var(--mono); margin-bottom: 10px; }
|
||||||
|
.form-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats-row { display: flex; gap: 12px; margin-bottom: 20px; }
|
||||||
|
.stat-box {
|
||||||
|
flex: 1; background: var(--bg-card); border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--r); padding: 16px; text-align: center;
|
||||||
|
}
|
||||||
|
.stat-val { font-family: var(--mono); font-size: 28px; font-weight: 700; color: var(--t1); }
|
||||||
|
.stat-lbl { font-size: 11px; color: var(--t4); margin-top: 4px; font-family: var(--mono); }
|
||||||
|
|
||||||
|
.ops-card {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--r); padding: 20px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 20px;
|
||||||
|
}
|
||||||
|
.ops-desc { font-size: 13px; color: var(--t3); line-height: 1.6; }
|
||||||
|
.trigger-btn { flex-shrink: 0; padding: 0 24px; height: 42px; font-size: 14px; }
|
||||||
|
|
||||||
|
/* Logs */
|
||||||
|
.log-list { background: var(--bg-card); border: 1px solid var(--rule); border-radius: var(--r); overflow: hidden; }
|
||||||
|
.log-row {
|
||||||
|
display: flex; gap: 12px; align-items: baseline;
|
||||||
|
padding: 8px 14px; border-bottom: 1px solid var(--rule);
|
||||||
|
font-family: var(--mono); font-size: 12px;
|
||||||
|
}
|
||||||
|
.log-row:last-child { border-bottom: none; }
|
||||||
|
.log-time { color: var(--t4); flex-shrink: 0; }
|
||||||
|
.log-level { width: 50px; flex-shrink: 0; font-weight: 700; }
|
||||||
|
.log-msg { color: var(--t2); }
|
||||||
|
.log-info .log-level { color: var(--blue-2); }
|
||||||
|
.log-error .log-level { color: var(--red); }
|
||||||
|
.log-warn .log-level { color: var(--amber); }
|
||||||
|
</style>
|
||||||
398
frontend/src/views/NewsReader.vue
Normal file
398
frontend/src/views/NewsReader.vue
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
<template>
|
||||||
|
<div class="reader-layout">
|
||||||
|
<!-- ── 顶部导航 ─────────────────────────────────────────────────── -->
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<div class="logo-chip">PI</div>
|
||||||
|
<span class="app-title">医药情报</span>
|
||||||
|
</div>
|
||||||
|
<div class="live-bar">
|
||||||
|
<span class="live-dot"></span>
|
||||||
|
<span>{{ selectedDate }} · 今日 {{ totalCount }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<router-link to="/admin" class="admin-link">管理后台 →</router-link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ── 分类筛选 ─────────────────────────────────────────────────── -->
|
||||||
|
<nav class="cat-nav">
|
||||||
|
<button
|
||||||
|
v-for="c in CATEGORIES"
|
||||||
|
:key="c.value"
|
||||||
|
:class="['cat-pill', { active: activeCategory === c.value }]"
|
||||||
|
@click="setCategory(c.value)"
|
||||||
|
>{{ c.label }}</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ── 主内容区 ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="main-content">
|
||||||
|
<TopTenPanel
|
||||||
|
:items="featuredNews"
|
||||||
|
:dates="archiveDates"
|
||||||
|
:active-id="selectedNews?.id"
|
||||||
|
:selected-date="selectedDate"
|
||||||
|
@select="openDetail"
|
||||||
|
@date-change="loadDate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="news-feed">
|
||||||
|
<div v-if="loading" class="feed-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
<template v-else-if="newsList.length">
|
||||||
|
<NewsCard
|
||||||
|
v-for="n in newsList"
|
||||||
|
:key="n.id"
|
||||||
|
:news="n"
|
||||||
|
@open="openDetail"
|
||||||
|
/>
|
||||||
|
<div v-if="hasMore" class="load-more" @click="loadMore">加载更多</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="feed-state">今日暂无数据,请先在管理后台触发抓取</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 详情面板 ─────────────────────────────────────────────────── -->
|
||||||
|
<Transition name="sheet">
|
||||||
|
<div v-if="selectedNews" class="sheet-bg" @click.self="selectedNews = null">
|
||||||
|
<div class="sheet">
|
||||||
|
<div class="sheet-handle" @click="selectedNews = null"></div>
|
||||||
|
<div class="sheet-inner">
|
||||||
|
<div class="sheet-head">
|
||||||
|
<div class="sh-tags">
|
||||||
|
<span class="kw-tag cat-tag">{{ selectedNews.category }}</span>
|
||||||
|
<span class="score-badge" :class="badgeClass(selectedNews.importance_score)">
|
||||||
|
{{ selectedNews.importance_score?.toFixed(1) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="sh-title">{{ selectedNews.title_zh }}</h2>
|
||||||
|
<div class="sh-meta">
|
||||||
|
{{ selectedNews.source_name }} · {{ selectedNews.published_at?.slice(0, 10) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-body">
|
||||||
|
<p class="sh-summary">{{ selectedNews.summary }}</p>
|
||||||
|
|
||||||
|
<div v-if="selectedNews.opinion" class="sh-block sh-blue">
|
||||||
|
<b>核心观点</b>{{ selectedNews.opinion }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedNews.importance_reason" class="sh-block sh-mint">
|
||||||
|
<b>评分依据 · {{ selectedNews.importance_score?.toFixed(1) }} 分</b>
|
||||||
|
{{ selectedNews.importance_reason }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedNews.keywords?.length" class="sh-kw">
|
||||||
|
<span v-for="kw in selectedNews.keywords" :key="kw" class="kw-tag">{{ kw }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sh-actions">
|
||||||
|
<a :href="selectedNews.source_url" target="_blank" class="btn-primary">查看原文 →</a>
|
||||||
|
<button class="btn-secondary" @click="selectedNews = null">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import NewsCard from '../components/NewsCard.vue'
|
||||||
|
import TopTenPanel from '../components/TopTenPanel.vue'
|
||||||
|
import { fetchFeatured, fetchNews, fetchDates } from '../api/index.js'
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
{ label: '药品监管', value: '药品监管' },
|
||||||
|
{ label: '临床研究', value: '临床研究' },
|
||||||
|
{ label: '行业动态', value: '行业动态' },
|
||||||
|
{ label: '政策法规', value: '政策法规' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const selectedDate = ref(new Date().toISOString().slice(0, 10))
|
||||||
|
const activeCategory = ref('')
|
||||||
|
const featuredNews = ref([])
|
||||||
|
const newsList = ref([])
|
||||||
|
const archiveDates = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const hasMore = ref(false)
|
||||||
|
const selectedNews = ref(null)
|
||||||
|
|
||||||
|
function badgeClass(score) {
|
||||||
|
if (score >= 9) return 'badge-red'
|
||||||
|
if (score >= 7) return 'badge-amber'
|
||||||
|
if (score >= 5) return 'badge-blue'
|
||||||
|
return 'badge-gray'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll(date) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [feat, list, dates] = await Promise.all([
|
||||||
|
fetchFeatured(date),
|
||||||
|
fetchNews({ date, category: activeCategory.value, page: 1 }),
|
||||||
|
fetchDates(),
|
||||||
|
])
|
||||||
|
featuredNews.value = feat.items || []
|
||||||
|
newsList.value = list.items || []
|
||||||
|
totalCount.value = list.total || 0
|
||||||
|
hasMore.value = list.items?.length < list.total
|
||||||
|
archiveDates.value = dates || []
|
||||||
|
page.value = 1
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDate(date) {
|
||||||
|
selectedDate.value = date
|
||||||
|
await loadAll(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setCategory(cat) {
|
||||||
|
activeCategory.value = cat
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const list = await fetchNews({ date: selectedDate.value, category: cat, page: 1 })
|
||||||
|
newsList.value = list.items || []
|
||||||
|
totalCount.value = list.total || 0
|
||||||
|
hasMore.value = list.items?.length < list.total
|
||||||
|
page.value = 1
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
page.value++
|
||||||
|
const list = await fetchNews({ date: selectedDate.value, category: activeCategory.value, page: page.value })
|
||||||
|
newsList.value.push(...(list.items || []))
|
||||||
|
hasMore.value = newsList.value.length < list.total
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(news) {
|
||||||
|
selectedNews.value = news
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => loadAll(selectedDate.value))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reader-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Topbar ─────────────────────────────────────────────────────────────────── */
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
height: 56px;
|
||||||
|
background: rgba(7,9,26,.95);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.topbar-left { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.logo-chip {
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
background: linear-gradient(135deg, var(--blue), var(--violet));
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: var(--mono); font-size: 11px; font-weight: 700; color: #fff;
|
||||||
|
}
|
||||||
|
.app-title { font-size: 16px; font-weight: 700; letter-spacing: -.02em; }
|
||||||
|
.live-bar {
|
||||||
|
flex: 1;
|
||||||
|
display: flex; align-items: center; gap: 6px; justify-content: center;
|
||||||
|
font-size: 12px; color: var(--t4); font-family: var(--mono);
|
||||||
|
}
|
||||||
|
.live-dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%; background: var(--mint);
|
||||||
|
box-shadow: 0 0 0 0 rgba(37,214,163,.4);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(37,214,163,.4); }
|
||||||
|
70% { box-shadow: 0 0 0 6px rgba(37,214,163,0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(37,214,163,0); }
|
||||||
|
}
|
||||||
|
.topbar-right { display: flex; align-items: center; }
|
||||||
|
.admin-link {
|
||||||
|
font-size: 12px; color: var(--blue-2); font-family: var(--mono);
|
||||||
|
padding: 5px 12px; border: 1px solid var(--blue-bd); border-radius: var(--r-pill);
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.admin-link:hover { background: var(--blue-gl); }
|
||||||
|
|
||||||
|
/* ── Category nav ────────────────────────────────────────────────────────────── */
|
||||||
|
.cat-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 56px;
|
||||||
|
z-index: 99;
|
||||||
|
background: rgba(7,9,26,.92);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.cat-nav::-webkit-scrollbar { display: none; }
|
||||||
|
.cat-pill {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
border: 1px solid var(--rule2);
|
||||||
|
background: var(--bg-2);
|
||||||
|
color: var(--t3);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.cat-pill:hover { color: var(--t2); border-color: var(--rule2); }
|
||||||
|
.cat-pill.active { background: var(--blue-gl); border-color: var(--blue-bd); color: var(--blue-2); }
|
||||||
|
|
||||||
|
/* ── Main layout ─────────────────────────────────────────────────────────────── */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-feed { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.feed-state {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 40px 0; color: var(--t4); font-size: 13px;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
border: 2px solid var(--rule2); border-top-color: var(--blue-2);
|
||||||
|
animation: spin .8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
text-align: center; padding: 16px;
|
||||||
|
font-size: 13px; color: var(--blue-2);
|
||||||
|
cursor: pointer; border: 1px dashed var(--blue-bd);
|
||||||
|
border-radius: var(--r); transition: background .15s;
|
||||||
|
}
|
||||||
|
.load-more:hover { background: var(--blue-gl); }
|
||||||
|
|
||||||
|
/* ── Detail sheet ────────────────────────────────────────────────────────────── */
|
||||||
|
.sheet-bg {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex; align-items: flex-end; justify-content: center;
|
||||||
|
}
|
||||||
|
.sheet {
|
||||||
|
width: 100%; max-width: 680px;
|
||||||
|
background: var(--bg-1);
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
border-top: 1px solid var(--rule2);
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.sheet::-webkit-scrollbar { display: none; }
|
||||||
|
.sheet-handle {
|
||||||
|
width: 36px; height: 4px; background: var(--rule2); border-radius: 2px;
|
||||||
|
margin: 10px auto 0; cursor: pointer;
|
||||||
|
}
|
||||||
|
.sheet-inner { padding: 8px 0 40px; }
|
||||||
|
|
||||||
|
.sheet-head { padding: 12px 24px 16px; }
|
||||||
|
.sh-tags { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||||
|
.cat-tag { background: rgba(122,128,160,.15); color: var(--t3); }
|
||||||
|
.score-badge {
|
||||||
|
padding: 2px 8px; border-radius: 5px;
|
||||||
|
font-family: var(--mono); font-size: 11px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.badge-red { background: rgba(255,77,106,.2); color: var(--red); }
|
||||||
|
.badge-amber { background: rgba(255,163,54,.2); color: var(--amber); }
|
||||||
|
.badge-blue { background: rgba(46,85,245,.2); color: var(--blue-2); }
|
||||||
|
.badge-gray { background: rgba(122,128,160,.15); color: var(--t3); }
|
||||||
|
|
||||||
|
.sh-title { font-size: 18px; font-weight: 700; color: var(--t1); line-height: 1.4; margin-bottom: 6px; }
|
||||||
|
.sh-meta { font-size: 11px; color: var(--t4); font-family: var(--mono); }
|
||||||
|
|
||||||
|
.sheet-body { padding: 0 24px; }
|
||||||
|
.sh-summary { font-size: 14px; color: var(--t2); line-height: 1.7; margin-bottom: 14px; }
|
||||||
|
|
||||||
|
.sh-block {
|
||||||
|
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.sh-blue { background: var(--blue-gl); border-left: 2px solid var(--blue-bd); color: #8AAAFF; }
|
||||||
|
.sh-mint { background: rgba(37,214,163,.06); border-left: 2px solid rgba(37,214,163,.4); color: rgba(37,214,163,.85); }
|
||||||
|
.sh-block b {
|
||||||
|
display: block; font-family: var(--mono); font-size: 10px;
|
||||||
|
font-weight: 700; margin-bottom: 4px; opacity: .7; letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-kw { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.kw-tag {
|
||||||
|
font-size: 10px; font-family: var(--mono);
|
||||||
|
padding: 2px 7px; border-radius: 4px;
|
||||||
|
background: rgba(46,85,245,.15); color: #7B9BFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-actions { display: flex; gap: 10px; }
|
||||||
|
.btn-primary {
|
||||||
|
flex: 1; height: 42px; border-radius: 10px;
|
||||||
|
background: var(--blue); color: #fff;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 13px; font-weight: 600;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--blue-2); }
|
||||||
|
.btn-secondary {
|
||||||
|
flex: 1; height: 42px; border-radius: 10px;
|
||||||
|
background: var(--bg-2); border: 1px solid var(--rule2); color: var(--t2);
|
||||||
|
font-size: 13px; font-weight: 600;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: var(--bg-hi); }
|
||||||
|
|
||||||
|
/* ── Transitions ─────────────────────────────────────────────────────────────── */
|
||||||
|
.sheet-enter-active, .sheet-leave-active { transition: opacity .25s; }
|
||||||
|
.sheet-enter-active .sheet, .sheet-leave-active .sheet { transition: transform .3s cubic-bezier(.32,.72,0,1); }
|
||||||
|
.sheet-enter-from, .sheet-leave-to { opacity: 0; }
|
||||||
|
.sheet-enter-from .sheet, .sheet-leave-to .sheet { transform: translateY(100%); }
|
||||||
|
|
||||||
|
/* ── Mobile ──────────────────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main-content { padding: 12px 16px; gap: 0; }
|
||||||
|
/* TopTenPanel becomes horizontally scrollable on mobile — handled in component */
|
||||||
|
.topbar { padding: 0 16px; }
|
||||||
|
.cat-nav { padding: 8px 16px; }
|
||||||
|
.live-bar { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:8000', changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user