commit 2ece5174a7ecf9f5062da2a2a060273d571f95cd Author: chenwu Date: Sun May 24 01:16:07 2026 +0800 inital diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..eae135d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:aihot.virxact.com)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d82d419 --- /dev/null +++ b/.env.example @@ -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 diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..64341f1 --- /dev/null +++ b/DESIGN.md @@ -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 个工作日** | diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..85a5a09 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/ai/__init__.py b/backend/app/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/ai/llm_client.py b/backend/app/ai/llm_client.py new file mode 100644 index 0000000..9b206c9 --- /dev/null +++ b/backend/app/ai/llm_client.py @@ -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"] diff --git a/backend/app/ai/processor.py b/backend/app/ai/processor.py new file mode 100644 index 0000000..eeb6993 --- /dev/null +++ b/backend/app/ai/processor.py @@ -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") diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..1491adc --- /dev/null +++ b/backend/app/api/admin.py @@ -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 + ] diff --git a/backend/app/api/news.py b/backend/app/api/news.py new file mode 100644 index 0000000..09803ed --- /dev/null +++ b/backend/app/api/news.py @@ -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) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..bab9ac9 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/crawler/__init__.py b/backend/app/crawler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/crawler/rss_fetcher.py b/backend/app/crawler/rss_fetcher.py new file mode 100644 index 0000000..addab2e --- /dev/null +++ b/backend/app/crawler/rss_fetcher.py @@ -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": "药品监管"}, +] diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..5898883 --- /dev/null +++ b/backend/app/database.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..29b5452 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/news.py b/backend/app/models/news.py new file mode 100644 index 0000000..268484e --- /dev/null +++ b/backend/app/models/news.py @@ -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) diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py new file mode 100644 index 0000000..424cd2e --- /dev/null +++ b/backend/app/scheduler.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..971bbaf --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d445539 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d0ea522 --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3c36b0d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + 医药情报 + + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..1ff83ed --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4dad425 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..1fb773c --- /dev/null +++ b/frontend/src/api/index.js @@ -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}`) diff --git a/frontend/src/components/NewsCard.vue b/frontend/src/components/NewsCard.vue new file mode 100644 index 0000000..a458dda --- /dev/null +++ b/frontend/src/components/NewsCard.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/frontend/src/components/TopTenPanel.vue b/frontend/src/components/TopTenPanel.vue new file mode 100644 index 0000000..9d3c3b2 --- /dev/null +++ b/frontend/src/components/TopTenPanel.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..8c32100 --- /dev/null +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..06465cf --- /dev/null +++ b/frontend/src/router/index.js @@ -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, +}) diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css new file mode 100644 index 0000000..0c5fc66 --- /dev/null +++ b/frontend/src/styles/theme.css @@ -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); } diff --git a/frontend/src/views/Admin.vue b/frontend/src/views/Admin.vue new file mode 100644 index 0000000..15b6fed --- /dev/null +++ b/frontend/src/views/Admin.vue @@ -0,0 +1,434 @@ + + + + + diff --git a/frontend/src/views/NewsReader.vue b/frontend/src/views/NewsReader.vue new file mode 100644 index 0000000..9a17e74 --- /dev/null +++ b/frontend/src/views/NewsReader.vue @@ -0,0 +1,398 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..3f9a9ef --- /dev/null +++ b/frontend/vite.config.js @@ -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 }, + }, + }, +})