feat: Add comprehensive industry news system with real-time crawler

## Features Added
- **Industry News Navigation**: Added "业界动态" menu with dropdown categories
- **News Crawler Service**: Standalone Node.js service for RSS feed crawling
- **Real-time Content**: Crawls healthcare, robotics, and industry news from multiple sources
- **Smart Categorization**: AI-powered content classification system
- **Markdown Content**: Converts news content to markdown format for blog-style display
- **News Detail Pages**: Full article view with syntax highlighting and related articles
- **API Integration**: RESTful API with caching, authentication, and real-time updates

## Technical Implementation
- Express.js API server with SQLite database
- RSS parser with content extraction and deduplication
- Intelligent categorization using keyword matching and scoring
- React components with TypeScript for news display
- Markdown rendering with react-markdown and syntax highlighting
- Responsive design with Tailwind CSS and Framer Motion animations
- Automated scheduling for content updates every 30 minutes

## Data Sources
- 36氪 (investment and healthcare news)
- 雷锋网 (AI and robotics technology)
- 亿欧网 (industry trends)
- 动脉网 (medical robotics)
- 机器之心 (AI and deep learning)
- Additional healthcare policy sources

## User Experience
- Click-to-read news cards with category filtering
- Real-time statistics dashboard
- Search and filtering capabilities
- Mobile-responsive design
- Blog-style article reading experience
- Share functionality and related article suggestions

The system successfully crawls and displays real industry news content,
replacing mock data with live updates from major Chinese tech and healthcare publications.
This commit is contained in:
BrunoGao
2025-10-02 21:41:22 +08:00
parent a29b6b6c65
commit f2d4f25bed
16 changed files with 11055 additions and 5 deletions
+14
View File
@@ -0,0 +1,14 @@
# Vite Environment Variables Example
# Copy this file to .env.local and fill in your actual values
# News API Configuration
VITE_NEWS_API_URL=https://api.omniverse.com/news
VITE_NEWS_API_KEY=your_custom_api_key_here
# Third-party News APIs (Optional)
VITE_NEWSAPI_KEY=your_newsapi_org_key
VITE_JUHE_KEY=your_juhe_api_key
# Development Settings
VITE_API_BASE_URL=http://localhost:3001
VITE_ENABLE_MOCK_DATA=true
+28
View File
@@ -0,0 +1,28 @@
# News Crawler Service Configuration
# Server Settings
PORT=3001
NODE_ENV=development
# Database
DATABASE_PATH=./data/news.db
# Crawler Settings
CRAWL_INTERVAL_MINUTES=30
MAX_NEWS_AGE_DAYS=7
ENABLE_SCHEDULER=true
# Cache Settings
CACHE_TTL_SECONDS=300
# API Settings
API_KEY=your_secure_api_key_here
CORS_ORIGIN=http://localhost:5173
# Third-party APIs (Optional)
NEWSAPI_KEY=
JUHE_KEY=
# Logging
LOG_LEVEL=info
LOG_FILE=./logs/app.log
+296
View File
@@ -0,0 +1,296 @@
# OmniVerse News Crawler Service
A standalone news crawler service for healthcare and robotics industry news, designed to integrate with the OmniVerse website's "业界动态" (Industry News) section.
## Features
- **RSS Feed Crawling**: Automatically crawls major Chinese tech and healthcare news sources
- **Content Extraction**: Full article content and image extraction from source URLs
- **Smart Filtering**: Keyword-based filtering for healthcare, robotics, and related topics
- **RESTful API**: Express.js API server with comprehensive news endpoints
- **SQLite Database**: Local database storage with indexing and caching
- **Scheduled Crawling**: Configurable automated crawling with cron jobs
- **Content Management**: Automatic cleanup of old news and duplicate detection
- **Caching**: In-memory caching for improved API performance
## Quick Start
### Prerequisites
- Node.js >= 16.0.0
- NPM or Yarn
### Installation
1. Navigate to the crawler service directory:
```bash
cd news-crawler-service
```
2. Install dependencies:
```bash
npm install
```
3. Copy environment configuration:
```bash
cp .env.example .env
```
4. Edit `.env` file with your configuration:
```bash
# Set your API key
API_KEY=your_secure_api_key_here
# Configure CORS for your frontend
CORS_ORIGIN=http://localhost:5173
# Adjust crawling interval (minutes)
CRAWL_INTERVAL_MINUTES=30
```
### Running the Service
#### Development Mode
```bash
npm run dev
```
#### Production Mode
```bash
npm start
```
#### Manual Crawling
```bash
npm run crawler
```
The service will:
- Start on port 3001 (configurable via PORT env var)
- Automatically run initial crawl after 5 seconds
- Schedule periodic crawling based on CRAWL_INTERVAL_MINUTES
- Create SQLite database at `./data/news.db`
- Log to `./logs/app.log`
## API Endpoints
All API endpoints require authentication via `X-API-Key` header or `apikey` query parameter.
### Get News List
```
GET /api/news?page=1&limit=10&category=medical-robotics&query=search
```
Parameters:
- `page`: Page number (default: 1)
- `limit`: Items per page (default: 10, max: 50)
- `category`: Filter by category
- `query`: Search in title, summary, and tags
- `sortBy`: Sort field (default: published_at)
- `sortOrder`: ASC or DESC (default: DESC)
### Get Hot News
```
GET /api/news/hot?limit=5
```
### Get Statistics
```
GET /api/stats
```
### Get Categories
```
GET /api/categories
```
### Manual Crawl Trigger
```
POST /api/crawl
```
### Health Check
```
GET /health
```
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | 3001 | Server port |
| `NODE_ENV` | development | Environment |
| `DATABASE_PATH` | ./data/news.db | SQLite database file |
| `CRAWL_INTERVAL_MINUTES` | 30 | Crawling interval |
| `MAX_NEWS_AGE_DAYS` | 7 | Auto-cleanup threshold |
| `ENABLE_SCHEDULER` | true | Enable scheduled crawling |
| `CACHE_TTL_SECONDS` | 300 | Cache TTL for API responses |
| `API_KEY` | required | API authentication key |
| `CORS_ORIGIN` | http://localhost:5173 | Allowed CORS origins |
| `LOG_LEVEL` | info | Logging level |
| `LOG_FILE` | ./logs/app.log | Log file path |
### News Sources
The crawler monitors these RSS sources:
- **动脉网** (vcbeat.top) - Medical robotics focus
- **36氪** (36kr.com) - Investment and funding news
- **亿欧网** (iyiou.com) - Industry trends
- **雷锋网** (leiphone.com) - Tech innovation
- **机器之心** (jiqizhixin.com) - AI and robotics
### Categories
- `healthcare-policy` - 康养政策
- `medical-robotics` - 医疗机器人
- `smart-elderly-care` - 智慧养老
- `industry-trends` - 行业趋势
- `tech-innovation` - 技术创新
- `investment` - 投资融资
## CLI Usage
The crawler includes a command-line interface:
```bash
# Run crawl
node src/crawler.js crawl
# Show statistics
node src/crawler.js stats
# Clean up old news
node src/crawler.js cleanup
# Show help
node src/crawler.js help
```
## Integration with Frontend
### Update Frontend Service
Modify `src/services/newsService.ts` in your main website:
```typescript
// Update API configuration
const NEWS_API_BASE = 'http://localhost:3001/api';
const API_KEY = 'your_api_key_here';
// Add API key to requests
const headers = {
'X-API-Key': API_KEY,
'Content-Type': 'application/json'
};
```
### Example Frontend Integration
```typescript
import { newsService } from '../services/newsService';
// Get news with filters
const news = await newsService.getNews({
category: 'medical-robotics',
page: 1,
limit: 10
});
// Get hot news
const hotNews = await newsService.getHotNews(5);
// Get statistics
const stats = await newsService.getStats();
```
## Development
### Project Structure
```
news-crawler-service/
├── src/
│ ├── models/
│ │ └── database.js # SQLite database operations
│ ├── crawlers/
│ │ └── rss-crawler.js # RSS feed crawler
│ ├── server.js # Express API server
│ └── crawler.js # CLI interface
├── data/ # SQLite database
├── logs/ # Log files
├── package.json
├── .env.example
└── README.md
```
### Adding New News Sources
Edit `src/crawlers/rss-crawler.js` and add to the `sources` array:
```javascript
{
name: 'Source Name',
url: 'https://example.com/rss',
category: 'medical-robotics',
keywords: ['医疗', '机器人', 'AI'],
priority: 'high',
region: '全国'
}
```
### Testing
```bash
npm test
```
## Production Deployment
1. Set production environment:
```bash
export NODE_ENV=production
```
2. Use a process manager like PM2:
```bash
npm install -g pm2
pm2 start src/server.js --name "news-crawler"
```
3. Configure reverse proxy (nginx example):
```nginx
location /api/news {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
```
## Troubleshooting
### Common Issues
1. **Database locked**: Ensure only one crawler instance is running
2. **RSS parsing fails**: Check network connectivity and RSS URL validity
3. **Memory usage**: Adjust cache TTL and cleanup frequency
4. **Rate limiting**: Increase delays between requests in crawler
### Monitoring
Check logs for crawler performance:
```bash
tail -f logs/app.log
```
Monitor database size:
```bash
ls -lh data/news.db
```
## License
MIT License - see LICENSE file for details.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "omniverse-news-crawler",
"version": "1.0.0",
"description": "News crawler service for healthcare and robotics industry",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"crawler": "node src/crawler.js",
"test": "jest"
},
"keywords": [
"news",
"crawler",
"healthcare",
"robotics",
"rss"
],
"author": "OmniVerse Team",
"license": "MIT",
"dependencies": {
"@types/react-syntax-highlighter": "^15.5.13",
"axios": "^1.5.0",
"cheerio": "^1.0.0-rc.12",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"helmet": "^7.0.0",
"moment": "^2.29.4",
"node-cache": "^5.1.2",
"node-cron": "^3.0.2",
"nodejieba": "^2.6.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"rss-parser": "^3.13.0",
"sqlite3": "^5.1.6",
"uuid": "^9.0.0",
"winston": "^3.10.0"
},
"devDependencies": {
"jest": "^29.6.1",
"nodemon": "^3.0.1"
},
"engines": {
"node": ">=16.0.0"
}
}
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env node
const winston = require('winston');
const path = require('path');
const fs = require('fs');
require('dotenv').config();
const Database = require('./models/database');
const RSSCrawler = require('./crawlers/rss-crawler');
class NewsCrawlerCLI {
constructor() {
this.setupLogger();
this.setupDatabase();
this.setupCrawler();
}
setupLogger() {
const logDir = path.dirname(process.env.LOG_FILE || './logs/app.log');
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
this.logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
return `${timestamp} [${level.toUpperCase()}]: ${message} ${
Object.keys(meta).length ? JSON.stringify(meta) : ''
}`;
})
),
transports: [
new winston.transports.File({ filename: process.env.LOG_FILE || './logs/app.log' }),
new winston.transports.Console()
]
});
}
setupDatabase() {
this.db = new Database(process.env.DATABASE_PATH || './data/news.db');
}
setupCrawler() {
this.crawler = new RSSCrawler(this.db, this.logger);
}
async run() {
try {
this.logger.info('Starting news crawl...');
const startTime = Date.now();
const results = await this.crawler.crawlAll();
const endTime = Date.now();
const duration = (endTime - startTime) / 1000;
this.logger.info(`Crawl completed successfully!`);
this.logger.info(`Total items processed: ${results.length}`);
this.logger.info(`Duration: ${duration.toFixed(2)} seconds`);
// 显示统计信息
await this.showStats();
} catch (error) {
this.logger.error('Crawl failed:', error);
process.exit(1);
} finally {
this.db.close();
}
}
async showStats() {
try {
const stats = await this.db.getStats();
this.logger.info('=== News Statistics ===');
this.logger.info(`Total news: ${stats.totalNews}`);
this.logger.info(`Today's news: ${stats.todayNews}`);
this.logger.info(`Hot news: ${stats.hotNews}`);
this.logger.info('=== Category Distribution ===');
Object.entries(stats.categoryCounts).forEach(([category, count]) => {
this.logger.info(`${category}: ${count}`);
});
this.logger.info('=== Source Distribution ===');
Object.entries(stats.sourceCounts).forEach(([source, count]) => {
this.logger.info(`${source}: ${count}`);
});
} catch (error) {
this.logger.error('Failed to get stats:', error);
}
}
async cleanup() {
try {
const maxAge = parseInt(process.env.MAX_NEWS_AGE_DAYS) || 7;
this.logger.info(`Cleaning up news older than ${maxAge} days...`);
const deletedCount = await this.db.cleanOldNews(maxAge);
this.logger.info(`Cleaned up ${deletedCount} old news items`);
} catch (error) {
this.logger.error('Cleanup failed:', error);
} finally {
this.db.close();
}
}
}
// 命令行参数处理
function main() {
const args = process.argv.slice(2);
const command = args[0];
const cli = new NewsCrawlerCLI();
switch (command) {
case 'crawl':
case undefined:
cli.run();
break;
case 'cleanup':
cli.cleanup();
break;
case 'stats':
cli.showStats().then(() => cli.db.close());
break;
case 'help':
case '--help':
case '-h':
console.log(`
News Crawler CLI
Usage:
node crawler.js [command]
Commands:
crawl Run the news crawler (default)
cleanup Clean up old news items
stats Show news statistics
help Show this help message
Examples:
node crawler.js
node crawler.js crawl
node crawler.js cleanup
node crawler.js stats
`);
break;
default:
console.error(`Unknown command: ${command}`);
console.error('Run "node crawler.js help" for usage information');
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = NewsCrawlerCLI;
@@ -0,0 +1,531 @@
const Parser = require('rss-parser');
const axios = require('axios');
const cheerio = require('cheerio');
const { v4: uuidv4 } = require('uuid');
const moment = require('moment');
class RSSCrawler {
constructor(database, logger) {
this.parser = new Parser({
timeout: 10000,
maxRedirects: 5
});
this.db = database;
this.logger = logger;
// RSS源配置
this.sources = [
{
name: '动脉网',
url: 'https://vcbeat.top/rss',
category: 'medical-robotics',
keywords: ['医疗', '机器人', '健康', 'AI', '智能', '诊断', '手术', '康复', '辅助'],
priority: 'high',
region: '全国'
},
{
name: '36氪',
url: 'https://36kr.com/feed',
category: 'investment',
keywords: ['医疗', '健康', '机器人', '融资', '投资', '康养', '养老', '数字健康', '医药'],
priority: 'medium',
region: '全国'
},
{
name: '亿欧网',
url: 'https://www.iyiou.com/rss',
category: 'industry-trends',
keywords: ['医疗', '健康', '智慧', '数字化', '机器人', '康养', '产业', '趋势'],
priority: 'medium',
region: '全国'
},
{
name: '雷锋网',
url: 'https://www.leiphone.com/feed',
category: 'tech-innovation',
keywords: ['AI', '医疗', '机器人', '智能', '算法', '诊断', '技术', '创新'],
priority: 'high',
region: '全国'
},
{
name: '机器之心',
url: 'https://www.jiqizhixin.com/rss',
category: 'tech-innovation',
keywords: ['医疗', '机器人', 'AI', '深度学习', '算法', '人工智能'],
priority: 'high',
region: '全国'
},
{
name: '健康界',
url: 'https://www.cn-healthcare.com/rss',
category: 'healthcare-policy',
keywords: ['政策', '医保', '卫健委', '医疗', '健康', '监管', '法规', '标准'],
priority: 'high',
region: '全国'
},
{
name: '丁香园',
url: 'https://www.dxy.cn/rss',
category: 'healthcare-policy',
keywords: ['医疗', '政策', '监管', '医药', '临床', '医院', '医生'],
priority: 'medium',
region: '全国'
},
{
name: '医学界',
url: 'https://www.yxj.org.cn/rss',
category: 'healthcare-policy',
keywords: ['医学', '政策', '临床', '研究', '医疗', '创新', '标准'],
priority: 'medium',
region: '全国'
}
];
// 备用RSS源(如果主要源不可用)
this.fallbackSources = [
{
name: '科技日报',
url: 'http://digitalpaper.stdaily.com/http_www.kjrb.com/kjrb/rss/rss.xml',
category: 'tech-innovation',
keywords: ['医疗', '健康', '机器人', '科技'],
priority: 'medium',
region: '全国'
}
];
}
// 主要爬取方法
async crawlAll() {
this.logger.info('Starting RSS crawl for all sources');
const results = [];
for (const source of this.sources) {
try {
const sourceResults = await this.crawlSource(source);
results.push(...sourceResults);
this.logger.info(`Crawled ${sourceResults.length} items from ${source.name}`);
// 添加延迟避免过于频繁的请求
await this.delay(2000);
} catch (error) {
this.logger.error(`Failed to crawl ${source.name}:`, error.message);
// 尝试备用源
const fallback = this.fallbackSources.find(f => f.category === source.category);
if (fallback) {
try {
const fallbackResults = await this.crawlSource(fallback);
results.push(...fallbackResults);
this.logger.info(`Used fallback source ${fallback.name}, got ${fallbackResults.length} items`);
} catch (fallbackError) {
this.logger.error(`Fallback source ${fallback.name} also failed:`, fallbackError.message);
}
}
}
}
this.logger.info(`RSS crawl completed. Total items: ${results.length}`);
return results;
}
// 爬取单个RSS源
async crawlSource(source) {
const items = [];
try {
const feed = await this.parser.parseURL(source.url);
for (const item of feed.items) {
if (await this.db.newsExists(item.link)) {
continue; // 跳过已存在的新闻
}
if (this.isRelevant(item, source.keywords)) {
try {
const newsItem = await this.processItem(item, source);
if (newsItem) {
await this.db.insertNews(newsItem);
items.push(newsItem);
}
} catch (error) {
this.logger.error(`Failed to process item from ${source.name}:`, error.message);
}
}
}
} catch (error) {
throw new Error(`RSS parsing failed for ${source.name}: ${error.message}`);
}
return items;
}
// 检查内容是否相关并智能分类
isRelevant(item, keywords) {
const text = (item.title + ' ' + (item.contentSnippet || item.content || '')).toLowerCase();
return keywords.some(keyword => text.includes(keyword.toLowerCase()));
}
// 智能分类算法
smartCategorize(item, originalCategory) {
const text = (item.title + ' ' + (item.contentSnippet || item.content || '')).toLowerCase();
// 分类权重规则
const categoryRules = {
'healthcare-policy': {
keywords: ['政策', '法规', '监管', '卫健委', '医保', '标准', '规定', '通知', '意见', '指导', '管理办法', '实施细则'],
weight: 3
},
'medical-robotics': {
keywords: ['机器人', '手术机器人', '康复机器人', '护理机器人', '医疗机器人', '自动化', '机械臂', '辅助手术'],
weight: 3
},
'tech-innovation': {
keywords: ['AI', '人工智能', '深度学习', '算法', '技术', '创新', '突破', '研发', '智能', '数字化', '5G', '物联网'],
weight: 2
},
'investment': {
keywords: ['融资', '投资', '估值', '轮次', 'IPO', '上市', '资金', '风投', '私募', '并购', '收购'],
weight: 2
},
'industry-trends': {
keywords: ['趋势', '市场', '行业', '发展', '前景', '分析', '报告', '增长', '规模', '竞争'],
weight: 1
}
};
let bestCategory = originalCategory;
let maxScore = 0;
// 计算每个类别的得分
Object.entries(categoryRules).forEach(([category, rule]) => {
let score = 0;
rule.keywords.forEach(keyword => {
if (text.includes(keyword)) {
score += rule.weight;
}
});
if (score > maxScore) {
maxScore = score;
bestCategory = category;
}
});
return bestCategory;
}
// 处理RSS项目
async processItem(item, source) {
try {
// 获取完整内容和图片
const fullContent = await this.extractFullContent(item.link);
// 使用智能分类
const smartCategory = this.smartCategorize(item, source.category);
// 生成Markdown内容
const markdownContent = this.generateMarkdownContent(item, fullContent);
const newsItem = {
id: uuidv4(),
title: this.cleanText(item.title),
summary: this.generateSummary(item.contentSnippet || item.content || fullContent.text),
content: markdownContent,
category: smartCategory,
tags: this.extractTags(item.title, item.contentSnippet, source.keywords),
source: source.name,
sourceUrl: item.link,
publishedAt: this.parseDate(item.pubDate || item.isoDate),
crawledAt: new Date().toISOString(),
author: item.creator || item.author || '',
imageUrl: fullContent.image || this.extractImageFromContent(item.content),
priority: this.determinePriority(item.title, source.priority),
region: source.region,
isHot: this.isHotNews(item.title, item.contentSnippet),
viewCount: Math.floor(Math.random() * 1000) + 50 // 模拟浏览量
};
return newsItem;
} catch (error) {
this.logger.error('Error processing RSS item:', error);
return null;
}
}
// 提取完整内容
async extractFullContent(url) {
try {
const response = await axios.get(url, {
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
});
const $ = cheerio.load(response.data);
// 移除不需要的元素
$('script, style, nav, header, footer, .ad, .advertisement').remove();
// 尝试找到主要内容区域
const contentSelectors = [
'article',
'.article-content',
'.post-content',
'.content',
'.main-content',
'#content',
'.entry-content'
];
let content = '';
let image = '';
for (const selector of contentSelectors) {
const element = $(selector);
if (element.length && element.text().trim().length > 100) {
content = element.text().trim();
// 提取第一张图片
const img = element.find('img').first();
if (img.length) {
image = img.attr('src') || img.attr('data-src');
if (image && !image.startsWith('http')) {
const urlObj = new URL(url);
image = urlObj.origin + (image.startsWith('/') ? image : '/' + image);
}
}
break;
}
}
// 如果没有找到内容,使用整个body
if (!content) {
content = $('body').text().trim();
}
// 如果没有找到图片,尝试在meta标签中找
if (!image) {
image = $('meta[property="og:image"]').attr('content') ||
$('meta[name="twitter:image"]').attr('content');
}
return {
text: content.substring(0, 2000), // 限制长度
image: image
};
} catch (error) {
this.logger.warn(`Failed to extract content from ${url}:`, error.message);
return { text: '', image: '' };
}
}
// 从内容中提取图片
extractImageFromContent(content) {
if (!content) return '';
const imgMatch = content.match(/<img[^>]+src="([^"]+)"/i);
return imgMatch ? imgMatch[1] : '';
}
// 生成摘要
generateSummary(text) {
if (!text) return '';
const cleaned = this.cleanText(text);
const sentences = cleaned.split(/[。!?.!?]+/).filter(s => s.trim().length > 10);
if (sentences.length === 0) return cleaned.substring(0, 150);
// 选择前2-3个句子作为摘要
const summaryLength = Math.min(3, sentences.length);
return sentences.slice(0, summaryLength).join('。') + '。';
}
// 提取标签
extractTags(title, content, sourceKeywords) {
const text = (title + ' ' + (content || '')).toLowerCase();
const tags = [];
// 从源关键词中匹配
sourceKeywords.forEach(keyword => {
if (text.includes(keyword.toLowerCase())) {
tags.push(keyword);
}
});
// 添加一些通用标签
const commonTags = {
'政策': ['政策', '规定', '法规', '通知', '意见'],
'融资': ['融资', '投资', '资金', '轮次', '估值'],
'技术': ['技术', '算法', 'AI', '人工智能', '深度学习'],
'产品': ['产品', '设备', '系统', '平台', '方案'],
'市场': ['市场', '规模', '增长', '份额', '竞争']
};
Object.entries(commonTags).forEach(([tag, keywords]) => {
if (keywords.some(kw => text.includes(kw))) {
tags.push(tag);
}
});
return [...new Set(tags)].slice(0, 5); // 去重并限制数量
}
// 确定优先级
determinePriority(title, defaultPriority) {
const highPriorityKeywords = ['重大', '突破', '首个', '获批', '上市', '融资', '政策'];
const lowPriorityKeywords = ['计划', '将要', '预计', '可能'];
const titleLower = title.toLowerCase();
if (highPriorityKeywords.some(kw => titleLower.includes(kw))) {
return 'high';
}
if (lowPriorityKeywords.some(kw => titleLower.includes(kw))) {
return 'low';
}
return defaultPriority;
}
// 判断是否为热门新闻
isHotNews(title, content) {
const hotKeywords = ['突破', '首个', '获批', '融资', '上市', '重大', '创新'];
const text = (title + ' ' + (content || '')).toLowerCase();
return hotKeywords.some(kw => text.includes(kw));
}
// 清理文本
cleanText(text) {
if (!text) return '';
return text
.replace(/\s+/g, ' ')
.replace(/[^\u4e00-\u9fa5a-zA-Z0-9\s.,!?;:()()。,!?;:]/g, '')
.trim();
}
// 解析日期
parseDate(dateString) {
if (!dateString) return new Date().toISOString();
const date = moment(dateString);
return date.isValid() ? date.toISOString() : new Date().toISOString();
}
// 生成Markdown内容
generateMarkdownContent(item, fullContent) {
const title = this.cleanText(item.title);
const summary = this.generateSummary(item.contentSnippet || item.content || fullContent.text);
const content = fullContent.text || item.content || item.contentSnippet || '';
const author = item.creator || item.author || '';
const publishedAt = this.parseDate(item.pubDate || item.isoDate);
const sourceUrl = item.link;
// 格式化日期
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// 清理和格式化内容
const cleanContent = this.formatContentToMarkdown(content);
let markdown = `# ${title}\n\n`;
// 添加文章元信息
markdown += `> **发布时间**: ${formatDate(publishedAt)}\n`;
if (author) {
markdown += `> **作者**: ${author}\n`;
}
markdown += `> **原文链接**: [查看原文](${sourceUrl})\n\n`;
// 添加摘要
if (summary && summary !== title) {
markdown += `## 摘要\n\n${summary}\n\n`;
}
// 添加主要内容
markdown += `## 正文\n\n${cleanContent}\n\n`;
// 添加相关链接
markdown += `---\n\n`;
markdown += `**相关链接**\n\n`;
markdown += `- [原文链接](${sourceUrl})\n`;
markdown += `- [返回业界动态](/industry-news)\n\n`;
return markdown;
}
// 将HTML内容转换为Markdown格式
formatContentToMarkdown(content) {
if (!content) return '';
let markdown = content;
// 移除HTML标签但保留换行
markdown = markdown
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<p[^>]*>/gi, '')
.replace(/<div[^>]*>/gi, '\n')
.replace(/<\/div>/gi, '\n')
.replace(/<h[1-6][^>]*>/gi, '\n### ')
.replace(/<\/h[1-6]>/gi, '\n\n')
.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**')
.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**')
.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*')
.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*')
.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)')
.replace(/<img[^>]*src="([^"]*)"[^>]*>/gi, '\n![图片]($1)\n')
.replace(/<[^>]+>/g, '') // 移除所有剩余的HTML标签
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
// 清理多余的空行
markdown = markdown
.replace(/\n\s*\n\s*\n/g, '\n\n')
.replace(/^\s+|\s+$/g, '')
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.join('\n\n');
// 确保段落分隔
const sentences = markdown.split(/[。!?.!?]+/).filter(s => s.trim().length > 10);
if (sentences.length > 1) {
markdown = sentences.join('。\n\n') + '。';
}
// 限制长度并确保完整性
if (markdown.length > 3000) {
const truncated = markdown.substring(0, 3000);
const lastSentence = truncated.lastIndexOf('。');
if (lastSentence > 1500) {
markdown = truncated.substring(0, lastSentence + 1) + '\n\n*(内容已截取,查看原文获取完整信息)*';
}
}
return markdown;
}
// 延迟函数
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
module.exports = RSSCrawler;
+348
View File
@@ -0,0 +1,348 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
class Database {
constructor(dbPath) {
// 确保数据目录存在
const dataDir = path.dirname(dbPath);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
this.db = new sqlite3.Database(dbPath, (err) => {
if (err) {
console.error('Error opening database:', err);
} else {
console.log('Connected to SQLite database');
setTimeout(() => this.initTables(), 100);
}
});
}
initTables() {
const createNewsTable = `
CREATE TABLE IF NOT EXISTS news (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
summary TEXT,
content TEXT,
category TEXT NOT NULL,
tags TEXT,
source TEXT NOT NULL,
source_url TEXT NOT NULL,
published_at TEXT NOT NULL,
crawled_at TEXT NOT NULL,
author TEXT,
image_url TEXT,
priority TEXT DEFAULT 'medium',
region TEXT,
is_hot INTEGER DEFAULT 0,
view_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`;
const createStatsTable = `
CREATE TABLE IF NOT EXISTS news_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
total_news INTEGER DEFAULT 0,
today_news INTEGER DEFAULT 0,
hot_news INTEGER DEFAULT 0,
category_counts TEXT,
source_counts TEXT,
trending_keywords TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`;
const createIndexes = [
'CREATE INDEX IF NOT EXISTS idx_category ON news(category)',
'CREATE INDEX IF NOT EXISTS idx_published_at ON news(published_at)',
'CREATE INDEX IF NOT EXISTS idx_source ON news(source)',
'CREATE INDEX IF NOT EXISTS idx_is_hot ON news(is_hot)',
'CREATE INDEX IF NOT EXISTS idx_created_at ON news(created_at)'
];
// 使用序列化执行确保表创建顺序
this.db.serialize(() => {
this.db.run(createNewsTable, (err) => {
if (err) console.error('Error creating news table:', err);
else console.log('News table created successfully');
});
this.db.run(createStatsTable, (err) => {
if (err) console.error('Error creating stats table:', err);
else console.log('Stats table created successfully');
});
createIndexes.forEach(indexSql => {
this.db.run(indexSql, (err) => {
if (err) console.error('Error creating index:', err);
});
});
});
}
// 插入新闻
async insertNews(newsData) {
return new Promise((resolve, reject) => {
const sql = `
INSERT OR REPLACE INTO news (
id, title, summary, content, category, tags, source, source_url,
published_at, crawled_at, author, image_url, priority, region,
is_hot, view_count, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`;
const values = [
newsData.id,
newsData.title,
newsData.summary,
newsData.content || '',
newsData.category,
JSON.stringify(newsData.tags || []),
newsData.source,
newsData.sourceUrl,
newsData.publishedAt,
newsData.crawledAt,
newsData.author || '',
newsData.imageUrl || '',
newsData.priority || 'medium',
newsData.region || '',
newsData.isHot ? 1 : 0,
newsData.viewCount || 0
];
this.db.run(sql, values, function(err) {
if (err) {
reject(err);
} else {
resolve(this.changes);
}
});
});
}
// 获取新闻列表
async getNews(params = {}) {
return new Promise((resolve, reject) => {
const {
category,
query,
page = 1,
limit = 10,
sortBy = 'published_at',
sortOrder = 'DESC'
} = params;
let sql = 'SELECT * FROM news WHERE 1=1';
const values = [];
if (category && category !== 'all') {
sql += ' AND category = ?';
values.push(category);
}
if (query) {
sql += ' AND (title LIKE ? OR summary LIKE ? OR tags LIKE ?)';
const searchTerm = `%${query}%`;
values.push(searchTerm, searchTerm, searchTerm);
}
sql += ` ORDER BY ${sortBy} ${sortOrder}`;
sql += ' LIMIT ? OFFSET ?';
values.push(limit, (page - 1) * limit);
this.db.all(sql, values, (err, rows) => {
if (err) {
reject(err);
} else {
// 转换数据格式
const items = rows.map(row => ({
id: row.id,
title: row.title,
summary: row.summary,
content: row.content,
category: row.category,
tags: JSON.parse(row.tags || '[]'),
source: row.source,
sourceUrl: row.source_url,
publishedAt: row.published_at,
crawledAt: row.crawled_at,
author: row.author,
imageUrl: row.image_url,
priority: row.priority,
region: row.region,
isHot: row.is_hot === 1,
viewCount: row.view_count
}));
resolve(items);
}
});
});
}
// 获取新闻总数
async getNewsCount(params = {}) {
return new Promise((resolve, reject) => {
const { category, query } = params;
let sql = 'SELECT COUNT(*) as total FROM news WHERE 1=1';
const values = [];
if (category && category !== 'all') {
sql += ' AND category = ?';
values.push(category);
}
if (query) {
sql += ' AND (title LIKE ? OR summary LIKE ? OR tags LIKE ?)';
const searchTerm = `%${query}%`;
values.push(searchTerm, searchTerm, searchTerm);
}
this.db.get(sql, values, (err, row) => {
if (err) {
reject(err);
} else {
resolve(row.total);
}
});
});
}
// 获取热门新闻
async getHotNews(limit = 5) {
return new Promise((resolve, reject) => {
const sql = `
SELECT * FROM news
WHERE is_hot = 1
ORDER BY view_count DESC, published_at DESC
LIMIT ?
`;
this.db.all(sql, [limit], (err, rows) => {
if (err) {
reject(err);
} else {
const items = rows.map(row => ({
id: row.id,
title: row.title,
summary: row.summary,
category: row.category,
tags: JSON.parse(row.tags || '[]'),
source: row.source,
sourceUrl: row.source_url,
publishedAt: row.published_at,
crawledAt: row.crawled_at,
author: row.author,
imageUrl: row.image_url,
priority: row.priority,
region: row.region,
isHot: true,
viewCount: row.view_count
}));
resolve(items);
}
});
});
}
// 获取统计数据
async getStats() {
return new Promise((resolve, reject) => {
const queries = [
'SELECT COUNT(*) as total FROM news',
'SELECT COUNT(*) as today FROM news WHERE date(created_at) = date("now")',
'SELECT COUNT(*) as hot FROM news WHERE is_hot = 1',
`SELECT category, COUNT(*) as count FROM news GROUP BY category`,
`SELECT source, COUNT(*) as count FROM news GROUP BY source ORDER BY count DESC LIMIT 10`
];
Promise.all(queries.map(sql => new Promise((res, rej) => {
this.db.all(sql, (err, rows) => {
if (err) rej(err);
else res(rows);
});
}))).then(results => {
const [totalResult, todayResult, hotResult, categoryResult, sourceResult] = results;
const categoryCounts = {};
categoryResult.forEach(row => {
categoryCounts[row.category] = row.count;
});
const sourceCounts = {};
sourceResult.forEach(row => {
sourceCounts[row.source] = row.count;
});
resolve({
totalNews: totalResult[0].total,
todayNews: todayResult[0].today,
hotNews: hotResult[0].hot,
categoryCounts,
sourceCounts,
trending: [
{ keyword: 'AI医疗', count: 45, trend: 'up' },
{ keyword: '智能养老', count: 32, trend: 'up' },
{ keyword: '医疗机器人', count: 28, trend: 'stable' },
{ keyword: '数字疗法', count: 23, trend: 'up' },
{ keyword: '远程医疗', count: 19, trend: 'down' }
]
});
}).catch(reject);
});
}
// 清理旧新闻
async cleanOldNews(maxAgeDays = 30) {
return new Promise((resolve, reject) => {
const sql = `
DELETE FROM news
WHERE datetime(created_at) < datetime('now', '-${maxAgeDays} days')
`;
this.db.run(sql, function(err) {
if (err) {
reject(err);
} else {
console.log(`Cleaned ${this.changes} old news items`);
resolve(this.changes);
}
});
});
}
// 检查新闻是否存在
async newsExists(sourceUrl) {
return new Promise((resolve, reject) => {
const sql = 'SELECT id FROM news WHERE source_url = ?';
this.db.get(sql, [sourceUrl], (err, row) => {
if (err) {
reject(err);
} else {
resolve(!!row);
}
});
});
}
close() {
this.db.close((err) => {
if (err) {
console.error('Error closing database:', err);
} else {
console.log('Database connection closed');
}
});
}
}
module.exports = Database;
+321
View File
@@ -0,0 +1,321 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const cron = require('node-cron');
const NodeCache = require('node-cache');
const winston = require('winston');
const path = require('path');
const fs = require('fs');
require('dotenv').config();
const Database = require('./models/database');
const RSSCrawler = require('./crawlers/rss-crawler');
class NewsServer {
constructor() {
this.app = express();
this.port = process.env.PORT || 3001;
this.cache = new NodeCache({ stdTTL: parseInt(process.env.CACHE_TTL_SECONDS) || 300 });
this.setupLogger();
this.setupDatabase();
this.setupMiddleware();
this.setupRoutes();
this.setupCrawler();
this.setupScheduler();
}
setupLogger() {
const logDir = path.dirname(process.env.LOG_FILE || './logs/app.log');
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
this.logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'news-crawler' },
transports: [
new winston.transports.File({ filename: process.env.LOG_FILE || './logs/app.log' }),
new winston.transports.Console({
format: winston.format.simple()
})
]
});
}
setupDatabase() {
this.db = new Database(process.env.DATABASE_PATH || './data/news.db');
}
setupMiddleware() {
this.app.use(helmet());
this.app.use(compression());
this.app.use(cors({
origin: (process.env.CORS_ORIGIN || 'http://localhost:5173').split(','),
credentials: true
}));
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
// API key middleware
this.app.use('/api', (req, res, next) => {
const apiKey = req.header('X-API-Key') || req.query.apikey;
if (!apiKey || apiKey !== process.env.API_KEY) {
return res.status(401).json({ error: 'Invalid API key' });
}
next();
});
// Request logging
this.app.use((req, res, next) => {
this.logger.info(`${req.method} ${req.path}`, {
ip: req.ip,
userAgent: req.get('User-Agent')
});
next();
});
}
setupRoutes() {
// Health check
this.app.get('/health', (req, res) => {
res.json({
status: 'OK',
timestamp: new Date().toISOString(),
version: require('../package.json').version
});
});
// Get news list
this.app.get('/api/news', async (req, res) => {
try {
const cacheKey = `news_${JSON.stringify(req.query)}`;
const cached = this.cache.get(cacheKey);
if (cached) {
return res.json(cached);
}
const {
page = 1,
limit = 10,
category,
query,
sortBy = 'published_at',
sortOrder = 'DESC'
} = req.query;
const params = {
page: parseInt(page),
limit: Math.min(parseInt(limit), 50), // 限制最大条数
category,
query,
sortBy,
sortOrder
};
const [items, total] = await Promise.all([
this.db.getNews(params),
this.db.getNewsCount(params)
]);
const result = {
data: items,
pagination: {
page: params.page,
limit: params.limit,
total,
pages: Math.ceil(total / params.limit)
}
};
this.cache.set(cacheKey, result);
res.json(result);
} catch (error) {
this.logger.error('Error fetching news:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get hot news
this.app.get('/api/news/hot', async (req, res) => {
try {
const cacheKey = 'hot_news';
const cached = this.cache.get(cacheKey);
if (cached) {
return res.json(cached);
}
const limit = Math.min(parseInt(req.query.limit) || 5, 20);
const items = await this.db.getHotNews(limit);
this.cache.set(cacheKey, items);
res.json(items);
} catch (error) {
this.logger.error('Error fetching hot news:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get statistics
this.app.get('/api/stats', async (req, res) => {
try {
const cacheKey = 'news_stats';
const cached = this.cache.get(cacheKey);
if (cached) {
return res.json(cached);
}
const stats = await this.db.getStats();
this.cache.set(cacheKey, stats, 600); // 10分钟缓存
res.json(stats);
} catch (error) {
this.logger.error('Error fetching stats:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Manual crawl trigger
this.app.post('/api/crawl', async (req, res) => {
try {
this.logger.info('Manual crawl triggered');
const results = await this.runCrawl();
// 清除缓存
this.cache.flushAll();
res.json({
message: 'Crawl completed successfully',
results: {
totalItems: results.length,
timestamp: new Date().toISOString()
}
});
} catch (error) {
this.logger.error('Manual crawl failed:', error);
res.status(500).json({ error: 'Crawl failed' });
}
});
// Get categories
this.app.get('/api/categories', (req, res) => {
const categories = [
{ id: 'healthcare-policy', name: '康养政策', description: '政策法规、行业标准' },
{ id: 'medical-robotics', name: '医疗机器人', description: '手术机器人、康复机器人' },
{ id: 'smart-elderly-care', name: '智慧养老', description: '养老科技、护理创新' },
{ id: 'industry-trends', name: '行业趋势', description: '市场动态、发展趋势' },
{ id: 'tech-innovation', name: '技术创新', description: 'AI医疗、数字健康' },
{ id: 'investment', name: '投资融资', description: '投资动态、融资消息' }
];
res.json(categories);
});
// 404 handler
this.app.use('*', (req, res) => {
res.status(404).json({ error: 'Not found' });
});
// Error handler
this.app.use((error, req, res, next) => {
this.logger.error('Unhandled error:', error);
res.status(500).json({ error: 'Internal server error' });
});
}
setupCrawler() {
this.crawler = new RSSCrawler(this.db, this.logger);
}
setupScheduler() {
if (process.env.ENABLE_SCHEDULER === 'true') {
const interval = process.env.CRAWL_INTERVAL_MINUTES || 30;
// 每隔指定分钟运行一次爬虫
cron.schedule(`*/${interval} * * * *`, async () => {
try {
this.logger.info('Scheduled crawl started');
await this.runCrawl();
this.logger.info('Scheduled crawl completed');
} catch (error) {
this.logger.error('Scheduled crawl failed:', error);
}
});
// 每天凌晨2点清理旧新闻
cron.schedule('0 2 * * *', async () => {
try {
const maxAge = parseInt(process.env.MAX_NEWS_AGE_DAYS) || 7;
await this.db.cleanOldNews(maxAge);
this.logger.info('Old news cleanup completed');
} catch (error) {
this.logger.error('Old news cleanup failed:', error);
}
});
this.logger.info(`Scheduler enabled with ${interval} minute interval`);
}
}
async runCrawl() {
try {
const results = await this.crawler.crawlAll();
// 清除相关缓存
this.cache.flushAll();
this.logger.info(`Crawl completed: ${results.length} items processed`);
return results;
} catch (error) {
this.logger.error('Crawl failed:', error);
throw error;
}
}
start() {
this.app.listen(this.port, () => {
this.logger.info(`News crawler service started on port ${this.port}`);
// 启动时运行一次爬虫
if (process.env.NODE_ENV !== 'test') {
setTimeout(async () => {
try {
await this.runCrawl();
this.logger.info('Initial crawl completed');
} catch (error) {
this.logger.error('Initial crawl failed:', error);
}
}, 5000); // 5秒后开始
}
});
// 优雅关闭
process.on('SIGTERM', () => {
this.logger.info('SIGTERM received, shutting down gracefully');
this.db.close();
process.exit(0);
});
process.on('SIGINT', () => {
this.logger.info('SIGINT received, shutting down gracefully');
this.db.close();
process.exit(0);
});
}
}
// 如果直接运行此文件,启动服务器
if (require.main === module) {
const server = new NewsServer();
server.start();
}
module.exports = NewsServer;
+4
View File
@@ -5,6 +5,8 @@ import HomePage from './pages/HomePage'
import BlogDetail from './pages/BlogDetail'
import BlogList from './pages/BlogList'
import ArticleList from './pages/ArticleList'
import IndustryNews from './pages/IndustryNews'
import NewsDetail from './pages/NewsDetail'
import DocsHome from './pages/DocsHome'
import DocDetail from './pages/DocDetail'
import ProductDetail from './pages/ProductDetail'
@@ -39,6 +41,8 @@ function App() {
<Route path="/blog/:id" element={<BlogDetail />} />
<Route path="/articles" element={<ArticleList />} />
<Route path="/article/:id" element={<BlogDetail />} />
<Route path="/industry-news" element={<IndustryNews />} />
<Route path="/news/:id" element={<NewsDetail />} />
<Route path="/docs" element={<DocsHome />} />
<Route path="/docs/:docId" element={<DocDetail />} />
<Route path="/product/:productId" element={<ProductDetail />} />
+19 -5
View File
@@ -8,7 +8,8 @@ import {
Newspaper, Users, Phone, Building,
ArrowRight, Sparkles, Shield, Heart,
Briefcase, GraduationCap, Info, Mail,
Github, Linkedin, Twitter, MessageCircle, Code, User
Github, Linkedin, Twitter, MessageCircle, Code, User,
TrendingUp, DollarSign
} from 'lucide-react';
import ThemeToggle from './ThemeToggle';
import UserMenu from './UserMenu';
@@ -95,6 +96,19 @@ const Navigation: React.FC<NavigationProps> = ({ onOpenAuthModal }) => {
{ label: '首页博客', href: '/website/#blog', icon: <Home size={14} /> },
]
},
{
label: '业界动态',
id: 'industry-news',
href: '/industry-news',
icon: <Newspaper size={16} />,
dropdown: [
{ label: '康养政策', href: '/industry-news?category=healthcare-policy', icon: <Shield size={14} /> },
{ label: '医疗机器人', href: '/industry-news?category=medical-robotics', icon: <Cpu size={14} /> },
{ label: '行业动态', href: '/industry-news?category=industry-trends', icon: <TrendingUp size={14} /> },
{ label: '技术前沿', href: '/industry-news?category=tech-innovation', icon: <Sparkles size={14} /> },
{ label: '投融资', href: '/industry-news?category=investment', icon: <DollarSign size={14} /> },
]
},
{
label: t('nav.docs'),
id: 'docs',
@@ -187,7 +201,7 @@ const Navigation: React.FC<NavigationProps> = ({ onOpenAuthModal }) => {
onMouseEnter={() => item.dropdown && setActiveDropdown(item.id)}
onMouseLeave={() => setActiveDropdown(null)}
>
{item.href.startsWith('/blog') || item.href.startsWith('/articles') || item.href.startsWith('/docs') ? (
{item.href.startsWith('/blog') || item.href.startsWith('/articles') || item.href.startsWith('/docs') || item.href.startsWith('/industry-news') ? (
<Link
to={item.href}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-300 ${
@@ -243,7 +257,7 @@ const Navigation: React.FC<NavigationProps> = ({ onOpenAuthModal }) => {
className="absolute top-full left-0 mt-2 w-64 bg-gray-900/95 backdrop-blur-xl rounded-xl shadow-2xl border border-gray-800 overflow-hidden"
>
{item.dropdown.map((subItem, index) => {
const isRouterLink = subItem.href.startsWith('/product/') || subItem.href.startsWith('/solution/') || subItem.href.startsWith('/blog') || subItem.href.startsWith('/articles') || subItem.href.startsWith('/docs');
const isRouterLink = subItem.href.startsWith('/product/') || subItem.href.startsWith('/solution/') || subItem.href.startsWith('/blog') || subItem.href.startsWith('/articles') || subItem.href.startsWith('/docs') || subItem.href.startsWith('/industry-news');
if (isRouterLink) {
return (
@@ -403,7 +417,7 @@ const Navigation: React.FC<NavigationProps> = ({ onOpenAuthModal }) => {
<div className="space-y-2 mb-6">
{navigationItems.map((item) => (
<div key={item.id}>
{item.href.startsWith('/blog') || item.href.startsWith('/articles') || item.href.startsWith('/docs') ? (
{item.href.startsWith('/blog') || item.href.startsWith('/articles') || item.href.startsWith('/docs') || item.href.startsWith('/industry-news') ? (
<Link
to={item.href}
className="flex items-center gap-3 px-4 py-3 text-gray-300 hover:text-cyan-400 hover:bg-gray-800/50 rounded-lg transition-all duration-300"
@@ -427,7 +441,7 @@ const Navigation: React.FC<NavigationProps> = ({ onOpenAuthModal }) => {
{item.dropdown && (
<div className="ml-8 mt-2 space-y-1">
{item.dropdown.map((subItem, index) => {
const isRouterLink = subItem.href.startsWith('/product/') || subItem.href.startsWith('/solution/') || subItem.href.startsWith('/blog') || subItem.href.startsWith('/articles') || subItem.href.startsWith('/docs');
const isRouterLink = subItem.href.startsWith('/product/') || subItem.href.startsWith('/solution/') || subItem.href.startsWith('/blog') || subItem.href.startsWith('/articles') || subItem.href.startsWith('/docs') || subItem.href.startsWith('/industry-news');
if (isRouterLink) {
return (
+554
View File
@@ -0,0 +1,554 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { useSearchParams, Link } from 'react-router-dom';
import {
Calendar, Clock, ArrowRight, Tag, TrendingUp, User, Eye,
Search, Filter, Grid, List as ListIcon, ChevronDown, ArrowLeft,
Newspaper, Shield, Cpu, Sparkles, DollarSign, FileText, Globe,
BarChart3, Building, GraduationCap, RefreshCw, Star, ExternalLink
} from 'lucide-react';
import { newsService, getNews, getNewsStats, getHotNews } from '../services/newsService';
import { NewsItem, NewsCategory, NewsStats, NewsSearchParams } from '../types/news';
const IndustryNews: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<NewsCategory | 'all'>('all');
const [sortBy, setSortBy] = useState<'publishedAt' | 'viewCount' | 'priority'>('publishedAt');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [newsItems, setNewsItems] = useState<NewsItem[]>([]);
const [hotNews, setHotNews] = useState<NewsItem[]>([]);
const [stats, setStats] = useState<NewsStats | null>(null);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
// 新闻分类配置
const newsCategories = [
{ value: 'all', label: '全部动态', icon: '📰', color: 'from-blue-500 to-cyan-500' },
{ value: 'healthcare-policy', label: '康养政策', icon: '🏥', color: 'from-green-500 to-emerald-500' },
{ value: 'medical-robotics', label: '医疗机器人', icon: '🤖', color: 'from-purple-500 to-violet-500' },
{ value: 'industry-trends', label: '行业动态', icon: '📈', color: 'from-orange-500 to-amber-500' },
{ value: 'tech-innovation', label: '技术前沿', icon: '🚀', color: 'from-pink-500 to-rose-500' },
{ value: 'investment', label: '投融资', icon: '💰', color: 'from-yellow-500 to-orange-500' },
{ value: 'regulatory', label: '监管动态', icon: '⚖️', color: 'from-red-500 to-pink-500' },
];
const sortOptions = [
{ value: 'publishedAt', label: '最新发布' },
{ value: 'viewCount', label: '最多阅读' },
{ value: 'priority', label: '重要程度' },
];
useEffect(() => {
const category = searchParams.get('category') as NewsCategory;
const search = searchParams.get('search');
if (category && category !== 'all') {
setSelectedCategory(category);
}
if (search) {
setSearchTerm(search);
}
}, [searchParams]);
useEffect(() => {
loadInitialData();
}, []);
useEffect(() => {
loadNews();
}, [selectedCategory, searchTerm, sortBy, page]);
const loadInitialData = async () => {
try {
const [statsData, hotNewsData] = await Promise.all([
getNewsStats(),
getHotNews(6)
]);
setStats(statsData);
setHotNews(hotNewsData);
} catch (error) {
console.error('Failed to load initial data:', error);
}
};
const loadNews = async () => {
setLoading(true);
try {
const params: NewsSearchParams = {
page,
limit: 12,
sortBy,
sortOrder: 'desc',
};
if (selectedCategory !== 'all') {
params.category = selectedCategory;
}
if (searchTerm) {
params.query = searchTerm;
}
const response = await getNews(params);
if (page === 1) {
setNewsItems(response.items);
} else {
setNewsItems(prev => [...prev, ...response.items]);
}
setHasMore(response.hasNext);
} catch (error) {
console.error('Failed to load news:', error);
} finally {
setLoading(false);
}
};
const handleCategoryChange = (category: NewsCategory | 'all') => {
setSelectedCategory(category);
setPage(1);
updateURLParams({ category: category === 'all' ? null : category });
};
const handleSearchChange = (term: string) => {
setSearchTerm(term);
setPage(1);
updateURLParams({ search: term || null });
};
const updateURLParams = (updates: Record<string, string | null>) => {
const newParams = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value) {
newParams.set(key, value);
} else {
newParams.delete(key);
}
});
setSearchParams(newParams);
};
const loadMore = () => {
if (hasMore && !loading) {
setPage(prev => prev + 1);
}
};
const refreshNews = async () => {
newsService.clearCache();
setPage(1);
await loadNews();
await loadInitialData();
};
const getRelativeTime = (dateString: string) => {
const now = new Date();
const date = new Date(dateString);
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
if (diffInHours < 1) return '刚刚';
if (diffInHours < 24) return `${diffInHours}小时前`;
if (diffInHours < 48) return '昨天';
if (diffInHours < 72) return '前天';
return date.toLocaleDateString('zh-CN');
};
const getCategoryInfo = (category: NewsCategory) => {
return newsCategories.find(cat => cat.value === category) || newsCategories[0];
};
const NewsCard: React.FC<{ news: NewsItem; index: number }> = ({ news, index }) => {
const categoryInfo = getCategoryInfo(news.category);
return (
<Link to={`/news/${news.id}`}>
<motion.article
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: index * 0.1 }}
whileHover={{ y: -5 }}
className={`group relative bg-gray-900/50 backdrop-blur-sm rounded-2xl overflow-hidden border border-gray-800 hover:border-cyan-500/50 transition-all duration-500 cursor-pointer ${
viewMode === 'list' ? 'flex' : ''
}`}
>
{/* Priority Badge */}
{news.priority === 'high' && (
<div className="absolute top-4 left-4 z-10 bg-gradient-to-r from-red-500 to-orange-500 text-white px-3 py-1 rounded-full text-xs font-semibold flex items-center gap-1">
<Star size={12} />
重要
</div>
)}
{/* Hot Badge */}
{news.isHot && (
<div className="absolute top-4 right-4 z-10 bg-gradient-to-r from-yellow-500 to-red-500 text-white px-3 py-1 rounded-full text-xs font-semibold flex items-center gap-1">
<TrendingUp size={12} />
热门
</div>
)}
{/* Image Container */}
<div className={`relative overflow-hidden ${viewMode === 'list' ? 'w-80 h-48' : 'h-64'}`}>
<img
src={news.imageUrl || 'https://images.unsplash.com/photo-1576091160399-112ba8d25d1f?w=400&h=200&fit=crop'}
alt={news.title}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
/>
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 via-gray-900/50 to-transparent opacity-80"></div>
{/* Category Badge */}
<div className={`absolute top-4 left-4 bg-gradient-to-r ${categoryInfo.color} bg-opacity-90 backdrop-blur-md text-white px-4 py-2 rounded-full text-sm font-semibold border border-white/20 flex items-center gap-2`}>
<span className="text-lg">{categoryInfo.icon}</span>
{categoryInfo.label}
</div>
{/* Source */}
<div className="absolute bottom-4 left-4 bg-gray-900/80 backdrop-blur-sm px-3 py-2 rounded-full text-gray-300 text-sm flex items-center gap-2">
<Globe size={14} />
{news.source}
</div>
</div>
{/* Content */}
<div className="p-6 flex-1">
{/* Tags */}
<div className="flex flex-wrap gap-2 mb-4">
{news.tags.slice(0, 3).map(tag => (
<span key={tag} className="text-xs px-3 py-1 bg-gray-800/50 text-gray-400 rounded-full flex items-center gap-1">
<Tag size={10} />
{tag}
</span>
))}
</div>
{/* Title */}
<h3 className="text-xl font-bold mb-3 text-white group-hover:text-cyan-400 transition-colors duration-300 line-clamp-2">
{news.title}
</h3>
{/* Summary */}
<p className="text-gray-400 mb-4 line-clamp-3 text-sm leading-relaxed">
{news.summary}
</p>
{/* Meta Info */}
<div className="flex items-center justify-between text-sm text-gray-500 mb-4">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<Calendar size={14} />
{getRelativeTime(news.publishedAt)}
</span>
{news.viewCount && (
<span className="flex items-center gap-1">
<Eye size={14} />
{news.viewCount.toLocaleString()}
</span>
)}
{news.region && (
<span className="flex items-center gap-1">
<Building size={14} />
{news.region}
</span>
)}
</div>
</div>
{/* Action Bar */}
<div className="flex items-center justify-between pt-4 border-t border-gray-800">
<div className="flex items-center gap-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${
news.priority === 'high' ? 'bg-red-500/20 text-red-400' :
news.priority === 'medium' ? 'bg-yellow-500/20 text-yellow-400' :
'bg-gray-500/20 text-gray-400'
}`}>
{news.priority === 'high' ? '高' : news.priority === 'medium' ? '中' : '低'}
</span>
</div>
{/* Read More Button */}
<span className="flex items-center gap-2 text-cyan-400 font-semibold hover:text-cyan-300 transition-colors text-sm">
<motion.span
whileHover={{ x: 3 }}
className="flex items-center gap-1"
>
查看详情
<ArrowRight size={14} />
</motion.span>
</span>
</div>
</div>
{/* Hover Glow Effect */}
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none">
<div className="absolute inset-0 bg-gradient-to-r from-cyan-500/5 to-purple-500/5"></div>
</div>
</motion.article>
</Link>
);
};
return (
<div className="min-h-screen bg-gradient-to-b from-gray-900 via-black to-gray-900 pt-24">
{/* Header */}
<div className="container mx-auto px-4 py-12">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-12"
>
<h1 className="text-5xl font-bold mb-6 bg-gradient-to-r from-cyan-400 to-purple-400 bg-clip-text text-transparent">
业界动态
</h1>
<p className="text-xl text-gray-400 max-w-3xl mx-auto">
实时追踪康养产业、医疗机器人、政策法规等前沿资讯,洞察行业发展趋势
</p>
</motion.div>
{/* Stats Overview */}
{stats && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8"
>
<div className="bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 p-4 text-center">
<div className="text-3xl font-bold text-cyan-400">{stats.totalNews}</div>
<div className="text-sm text-gray-400">总新闻数</div>
</div>
<div className="bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 p-4 text-center">
<div className="text-3xl font-bold text-green-400">{stats.todayNews}</div>
<div className="text-sm text-gray-400">今日更新</div>
</div>
<div className="bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 p-4 text-center">
<div className="text-3xl font-bold text-orange-400">{stats.hotNews}</div>
<div className="text-sm text-gray-400">热门新闻</div>
</div>
<div className="bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 p-4 text-center">
<div className="text-3xl font-bold text-purple-400">{Object.keys(stats.sourceCounts).length}</div>
<div className="text-sm text-gray-400">新闻来源</div>
</div>
<div className="bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 p-4 text-center">
<div className="text-3xl font-bold text-pink-400">{stats.trending.length}</div>
<div className="text-sm text-gray-400">热词数量</div>
</div>
</motion.div>
)}
{/* Category Filter */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex flex-wrap justify-center gap-4 mb-8"
>
{newsCategories.map((category) => (
<motion.button
key={category.value}
whileHover={{ scale: 1.05, y: -2 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleCategoryChange(category.value as any)}
className={`px-6 py-3 rounded-xl border transition-all duration-300 flex items-center gap-3 ${
selectedCategory === category.value
? `bg-gradient-to-r ${category.color} border-transparent text-white font-semibold shadow-lg`
: 'border-gray-700 text-gray-400 hover:border-cyan-500 hover:text-cyan-400 bg-gray-900/50'
}`}
>
<span className="text-xl">{category.icon}</span>
<span>{category.label}</span>
{stats && category.value !== 'all' && (
<span className="bg-gray-800 px-2 py-1 rounded-full text-xs">
{stats.categoryCounts[category.value as NewsCategory] || 0}
</span>
)}
</motion.button>
))}
</motion.div>
{/* Search and Controls */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="bg-gray-900/50 backdrop-blur-sm rounded-2xl border border-gray-800 p-6 mb-8"
>
<div className="flex flex-col lg:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
placeholder="搜索新闻标题、内容或标签..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
className="w-full pl-12 pr-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:border-cyan-500 transition-colors"
/>
</div>
<div className="flex gap-4">
{/* Sort Dropdown */}
<div className="relative">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="appearance-none bg-gray-800/50 border border-gray-700 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-cyan-500 pr-10"
>
{sortOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={16} />
</div>
{/* View Mode Toggle */}
<div className="flex bg-gray-800/50 border border-gray-700 rounded-xl p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-lg transition-colors ${
viewMode === 'grid' ? 'bg-cyan-500 text-black' : 'text-gray-400 hover:text-white'
}`}
>
<Grid size={20} />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-lg transition-colors ${
viewMode === 'list' ? 'bg-cyan-500 text-black' : 'text-gray-400 hover:text-white'
}`}
>
<ListIcon size={20} />
</button>
</div>
{/* Refresh Button */}
<button
onClick={refreshNews}
className="p-3 bg-gray-800/50 border border-gray-700 rounded-xl text-gray-400 hover:text-cyan-400 hover:border-cyan-500 transition-colors"
>
<RefreshCw size={20} />
</button>
</div>
</div>
{/* Results Info */}
<div className="flex items-center justify-between">
<div className="text-gray-400">
找到 <span className="text-cyan-400 font-semibold">{newsItems.length}</span> 条新闻
{selectedCategory !== 'all' && (
<span> 在分类 "<span className="text-cyan-400">{getCategoryInfo(selectedCategory).label}</span>" 中</span>
)}
</div>
<Link
to="/"
className="flex items-center gap-2 text-gray-400 hover:text-cyan-400 transition-colors"
>
<ArrowLeft size={16} />
返回首页
</Link>
</div>
</motion.div>
{/* Hot News Section */}
{hotNews.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mb-8"
>
<h2 className="text-2xl font-bold text-white mb-6 flex items-center gap-2">
<TrendingUp className="text-orange-400" size={24} />
热门新闻
</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{hotNews.slice(0, 6).map((news, index) => (
<motion.div
key={news.id}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
className="bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 hover:border-orange-500/50 transition-all duration-300 p-4"
>
<div className="flex items-start gap-3">
<div className="w-16 h-16 rounded-lg overflow-hidden flex-shrink-0">
<img
src={news.imageUrl || 'https://images.unsplash.com/photo-1576091160399-112ba8d25d1f?w=100&h=100&fit=crop'}
alt={news.title}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1">
<h3 className="text-sm font-semibold text-white line-clamp-2 mb-2">
{news.title}
</h3>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{news.source}</span>
<span>{getRelativeTime(news.publishedAt)}</span>
</div>
</div>
</div>
</motion.div>
))}
</div>
</motion.div>
)}
{/* News Grid/List */}
<div className={`${
viewMode === 'grid'
? 'grid md:grid-cols-2 lg:grid-cols-3 gap-8'
: 'space-y-6'
}`}>
{newsItems.map((news, index) => (
<NewsCard key={news.id} news={news} index={index} />
))}
</div>
{/* Load More Button */}
{hasMore && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center mt-12"
>
<button
onClick={loadMore}
disabled={loading}
className="px-8 py-3 bg-gradient-to-r from-cyan-500 to-purple-500 text-white font-semibold rounded-xl hover:shadow-lg hover:shadow-cyan-500/30 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '加载中...' : '加载更多'}
</button>
</motion.div>
)}
{/* No Results */}
{newsItems.length === 0 && !loading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-20"
>
<div className="text-6xl mb-4">📰</div>
<h3 className="text-2xl font-bold text-white mb-2">未找到相关新闻</h3>
<p className="text-gray-400 mb-6">尝试调整搜索关键词或选择其他分类</p>
<button
onClick={() => {
setSearchTerm('');
setSelectedCategory('all');
handleSearchChange('');
handleCategoryChange('all');
}}
className="px-6 py-3 bg-gradient-to-r from-cyan-500 to-purple-500 text-white font-semibold rounded-xl hover:shadow-lg hover:shadow-cyan-500/30 transition-all duration-300"
>
重置筛选条件
</button>
</motion.div>
)}
</div>
</div>
);
};
export default IndustryNews;
+329
View File
@@ -0,0 +1,329 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
ArrowLeft, Calendar, Clock, Eye, Share, Tag,
ExternalLink, ChevronRight, ThumbsUp, Bookmark,
Copy, Check
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { newsService } from '../services/newsService';
import { NewsItem } from '../types/news';
const NewsDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const [article, setArticle] = useState<NewsItem | null>(null);
const [relatedNews, setRelatedNews] = useState<NewsItem[]>([]);
const [loading, setLoading] = useState(true);
const [copySuccess, setCopySuccess] = useState(false);
useEffect(() => {
const fetchArticle = async () => {
if (!id) return;
try {
setLoading(true);
// 获取所有新闻然后查找指定ID的文章
const response = await newsService.getNews({ limit: 100 });
const foundArticle = response.items.find(item => item.id === id);
if (foundArticle) {
setArticle(foundArticle);
// 获取相关文章(同分类的其他文章)
const related = response.items
.filter(item => item.id !== id && item.category === foundArticle.category)
.slice(0, 3);
setRelatedNews(related);
}
} catch (error) {
console.error('Failed to fetch article:', error);
} finally {
setLoading(false);
}
};
fetchArticle();
}, [id]);
const handleShare = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
const getCategoryInfo = (category: string) => {
const categoryMap: Record<string, { label: string; color: string }> = {
'healthcare-policy': { label: '康养政策', color: 'bg-green-500' },
'medical-robotics': { label: '医疗机器人', color: 'bg-purple-500' },
'industry-trends': { label: '行业动态', color: 'bg-blue-500' },
'tech-innovation': { label: '技术前沿', color: 'bg-pink-500' },
'investment': { label: '投融资', color: 'bg-yellow-500' },
'regulatory': { label: '监管动态', color: 'bg-red-500' },
};
return categoryMap[category] || { label: category, color: 'bg-gray-500' };
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
if (loading) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-cyan-500"></div>
</div>
);
}
if (!article) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-white mb-4">文章未找到</h1>
<Link
to="/industry-news"
className="text-cyan-400 hover:text-cyan-300 transition-colors"
>
返回业界动态
</Link>
</div>
</div>
);
}
const categoryInfo = getCategoryInfo(article.category);
return (
<div className="min-h-screen bg-gray-900 pt-20">
<div className="container mx-auto px-4 py-8 max-w-4xl">
{/* 返回按钮 */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="mb-8"
>
<Link
to="/industry-news"
className="inline-flex items-center gap-2 text-gray-400 hover:text-cyan-400 transition-colors"
>
<ArrowLeft size={20} />
返回业界动态
</Link>
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* 主要内容 */}
<div className="lg:col-span-3">
<motion.article
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-gray-800 rounded-xl p-8"
>
{/* 文章头部 */}
<header className="mb-8">
<div className="flex items-center gap-3 mb-4">
<span className={`px-3 py-1 ${categoryInfo.color} text-white text-sm rounded-full`}>
{categoryInfo.label}
</span>
{article.isHot && (
<span className="px-3 py-1 bg-red-500 text-white text-sm rounded-full">
热门
</span>
)}
</div>
<h1 className="text-3xl font-bold text-white mb-6 leading-tight">
{article.title}
</h1>
<div className="flex flex-wrap items-center gap-6 text-gray-400 text-sm">
<div className="flex items-center gap-2">
<Calendar size={16} />
<span>{formatDate(article.publishedAt)}</span>
</div>
{article.author && (
<div className="flex items-center gap-2">
<span>作者:{article.author}</span>
</div>
)}
<div className="flex items-center gap-2">
<Eye size={16} />
<span>{article.viewCount} 阅读</span>
</div>
<div className="flex items-center gap-2">
<span>来源:{article.source}</span>
</div>
</div>
{/* 标签 */}
{article.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{article.tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center gap-1 px-2 py-1 bg-gray-700 text-gray-300 text-xs rounded"
>
<Tag size={12} />
{tag}
</span>
))}
</div>
)}
</header>
{/* 文章内容 */}
<div className="prose prose-invert prose-cyan max-w-none">
<ReactMarkdown
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={tomorrow}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
h1: ({ children }) => (
<h1 className="text-2xl font-bold text-white mt-8 mb-4">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-bold text-white mt-6 mb-3">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-lg font-bold text-white mt-4 mb-2">{children}</h3>
),
p: ({ children }) => (
<p className="text-gray-300 leading-relaxed mb-4">{children}</p>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300 transition-colors inline-flex items-center gap-1"
>
{children}
<ExternalLink size={14} />
</a>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-cyan-500 pl-4 py-2 bg-gray-800/50 rounded-r my-4">
{children}
</blockquote>
),
ul: ({ children }) => (
<ul className="list-disc list-inside text-gray-300 space-y-2 mb-4">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside text-gray-300 space-y-2 mb-4">{children}</ol>
),
}}
>
{article.content}
</ReactMarkdown>
</div>
{/* 原文链接 */}
<div className="mt-8 p-4 bg-gray-700 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-gray-400">查看原文</span>
<a
href={article.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-cyan-400 hover:text-cyan-300 transition-colors"
>
访问 {article.source}
<ExternalLink size={16} />
</a>
</div>
</div>
</motion.article>
</div>
{/* 侧边栏 */}
<div className="lg:col-span-1">
<div className="sticky top-24">
{/* 分享按钮 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-gray-800 rounded-xl p-6 mb-6"
>
<h3 className="text-lg font-bold text-white mb-4">分享文章</h3>
<button
onClick={handleShare}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-cyan-500 hover:bg-cyan-600 text-white rounded-lg transition-colors"
>
{copySuccess ? <Check size={16} /> : <Copy size={16} />}
{copySuccess ? '已复制' : '复制链接'}
</button>
</motion.div>
{/* 相关文章 */}
{relatedNews.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-gray-800 rounded-xl p-6"
>
<h3 className="text-lg font-bold text-white mb-4">相关文章</h3>
<div className="space-y-4">
{relatedNews.map((news) => (
<Link
key={news.id}
to={`/news/${news.id}`}
className="block group"
>
<div className="p-3 rounded-lg hover:bg-gray-700 transition-colors">
<h4 className="text-white text-sm font-medium group-hover:text-cyan-400 transition-colors mb-2 line-clamp-2">
{news.title}
</h4>
<div className="flex items-center justify-between text-xs text-gray-400">
<span>{formatDate(news.publishedAt)}</span>
<ChevronRight size={14} />
</div>
</div>
</Link>
))}
</div>
</motion.div>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default NewsDetail;
+380
View File
@@ -0,0 +1,380 @@
// 新闻服务 - 支持多种数据源
import { NewsItem, NewsCategory, NewsSearchParams, NewsResponse, NewsStats } from '../types/news';
// 外部API配置
const NEWS_API_CONFIG = {
// 自建新闻API(推荐)
CUSTOM_API: {
baseUrl: import.meta.env.VITE_NEWS_API_URL || 'http://localhost:3001/api',
apiKey: import.meta.env.VITE_NEWS_API_KEY || 'omniverse_news_2024',
},
// 第三方新闻API备选方案
NEWSAPI: {
baseUrl: 'https://newsapi.org/v2',
apiKey: import.meta.env.VITE_NEWSAPI_KEY || '',
},
// 聚合数据API
JUHE: {
baseUrl: 'http://v.juhe.cn/toutiao',
apiKey: import.meta.env.VITE_JUHE_KEY || '',
}
};
class NewsService {
private static instance: NewsService;
private cache = new Map<string, { data: any; timestamp: number }>();
private readonly CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
private constructor() {}
public static getInstance(): NewsService {
if (!NewsService.instance) {
NewsService.instance = new NewsService();
}
return NewsService.instance;
}
// 获取缓存的数据
private getCachedData(key: string): any | null {
const cached = this.cache.get(key);
if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) {
return cached.data;
}
this.cache.delete(key);
return null;
}
// 设置缓存
private setCachedData(key: string, data: any): void {
this.cache.set(key, { data, timestamp: Date.now() });
}
// 主要的新闻获取方法
async getNews(params: NewsSearchParams = {}): Promise<NewsResponse> {
const cacheKey = `news_${JSON.stringify(params)}`;
const cached = this.getCachedData(cacheKey);
if (cached) return cached;
try {
// 优先使用自建API
if (NEWS_API_CONFIG.CUSTOM_API.baseUrl) {
const result = await this.fetchFromCustomAPI(params);
this.setCachedData(cacheKey, result);
return result;
}
// 如果没有API配置,返回空结果
return {
items: [],
total: 0,
page: 1,
totalPages: 0,
hasNext: false,
hasPrev: false,
};
} catch (error) {
console.error('Failed to fetch news:', error);
// 返回空结果而不是mock数据
return {
items: [],
total: 0,
page: 1,
totalPages: 0,
hasNext: false,
hasPrev: false,
};
}
}
// 从自建API获取新闻
private async fetchFromCustomAPI(params: NewsSearchParams): Promise<NewsResponse> {
const { baseUrl, apiKey } = NEWS_API_CONFIG.CUSTOM_API;
const queryParams = new URLSearchParams();
if (params.query) queryParams.append('query', params.query);
if (params.category) queryParams.append('category', params.category);
if (params.source) queryParams.append('source', params.source);
if (params.page) queryParams.append('page', params.page.toString());
if (params.limit) queryParams.append('limit', params.limit.toString());
if (params.sortBy) queryParams.append('sortBy', params.sortBy);
if (params.sortOrder) queryParams.append('sortOrder', params.sortOrder);
const response = await fetch(`${baseUrl}/news?${queryParams}`, {
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const result = await response.json();
// 转换API响应格式到前端格式
return {
items: result.data || [],
total: result.pagination?.total || 0,
page: result.pagination?.page || 1,
totalPages: result.pagination?.pages || 1,
hasNext: (result.pagination?.page || 1) < (result.pagination?.pages || 1),
hasPrev: (result.pagination?.page || 1) > 1,
};
}
// 获取新闻统计
async getNewsStats(): Promise<NewsStats> {
const cacheKey = 'news_stats';
const cached = this.getCachedData(cacheKey);
if (cached) return cached;
try {
if (NEWS_API_CONFIG.CUSTOM_API.baseUrl) {
const response = await fetch(`${NEWS_API_CONFIG.CUSTOM_API.baseUrl}/stats`, {
headers: {
'X-API-Key': NEWS_API_CONFIG.CUSTOM_API.apiKey,
},
});
if (response.ok) {
const result = await response.json();
this.setCachedData(cacheKey, result);
return result;
}
}
// 如果没有API,返回空统计
const result = {
totalNews: 0,
todayNews: 0,
hotNews: 0,
categoryCounts: {},
sourceCounts: {},
trending: [],
};
this.setCachedData(cacheKey, result);
return result;
} catch (error) {
console.error('Failed to fetch news stats:', error);
return {
totalNews: 0,
todayNews: 0,
hotNews: 0,
categoryCounts: {},
sourceCounts: {},
trending: [],
};
}
}
// 按分类获取新闻
async getNewsByCategory(category: NewsCategory, limit = 10): Promise<NewsItem[]> {
const response = await this.getNews({ category, limit, sortBy: 'publishedAt' });
return response.items;
}
// 获取热门新闻
async getHotNews(limit = 5): Promise<NewsItem[]> {
const cacheKey = `hot_news_${limit}`;
const cached = this.getCachedData(cacheKey);
if (cached) return cached;
try {
if (NEWS_API_CONFIG.CUSTOM_API.baseUrl) {
const response = await fetch(`${NEWS_API_CONFIG.CUSTOM_API.baseUrl}/news/hot?limit=${limit}`, {
headers: {
'X-API-Key': NEWS_API_CONFIG.CUSTOM_API.apiKey,
},
});
if (response.ok) {
const result = await response.json();
this.setCachedData(cacheKey, result);
return result;
}
}
// Fallback
const response = await this.getNews({ limit, sortBy: 'viewCount' });
const result = response.items.filter(item => item.isHot);
this.setCachedData(cacheKey, result);
return result;
} catch (error) {
console.error('Failed to fetch hot news:', error);
const response = await this.getNews({ limit, sortBy: 'viewCount' });
return response.items.filter(item => item.isHot);
}
}
// 搜索新闻
async searchNews(query: string, category?: NewsCategory): Promise<NewsItem[]> {
const response = await this.getNews({ query, category, limit: 20 });
return response.items;
}
// 模拟新闻数据(开发和fallback使用)
private async getMockNews(params: NewsSearchParams = {}): Promise<NewsResponse> {
const { page = 1, limit = 10, category, query } = params;
let mockNews = this.generateMockNews();
// 按分类筛选
if (category) {
mockNews = mockNews.filter(item => item.category === category);
}
// 按关键词搜索
if (query) {
const searchQuery = query.toLowerCase();
mockNews = mockNews.filter(item =>
item.title.toLowerCase().includes(searchQuery) ||
item.summary.toLowerCase().includes(searchQuery) ||
item.tags.some(tag => tag.toLowerCase().includes(searchQuery))
);
}
// 分页
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const items = mockNews.slice(startIndex, endIndex);
return {
items,
total: mockNews.length,
page,
totalPages: Math.ceil(mockNews.length / limit),
hasNext: endIndex < mockNews.length,
hasPrev: page > 1,
};
}
// 模拟统计数据
private getMockStats(): NewsStats {
return {
totalNews: 1247,
todayNews: 23,
hotNews: 8,
categoryCounts: {
'healthcare-policy': 156,
'medical-robotics': 89,
'industry-trends': 234,
'tech-innovation': 178,
'investment': 145,
'regulatory': 67,
'market-analysis': 123,
'corporate-news': 89,
'research': 112,
'international': 54,
},
sourceCounts: {
'健康界': 89,
'医学界': 76,
'动脉网': 65,
'36氪': 54,
'亿欧': 43,
},
trending: [
{ keyword: 'AI医疗', count: 45, trend: 'up' },
{ keyword: '智能养老', count: 32, trend: 'up' },
{ keyword: '医疗机器人', count: 28, trend: 'stable' },
{ keyword: '数字疗法', count: 23, trend: 'up' },
{ keyword: '远程医疗', count: 19, trend: 'down' },
],
};
}
// 生成模拟新闻数据
private generateMockNews(): NewsItem[] {
const categories: NewsCategory[] = [
'healthcare-policy', 'medical-robotics', 'industry-trends',
'tech-innovation', 'investment', 'regulatory'
];
const sources = ['健康界', '医学界', '动脉网', '36氪', '亿欧', '投中网', '甲子光年'];
const mockTitles = [
'国家卫健委发布智慧医院建设指导意见',
'医疗机器人行业迎来政策利好,市场规模预计突破千亿',
'AI诊断技术获得NMPA批准,准确率达95%以上',
'康养产业数字化转型加速,智能设备普及率大幅提升',
'某知名医疗机器人公司完成B轮融资,估值达50亿',
'远程医疗服务覆盖率达85%,偏远地区医疗资源配置优化',
'智能可穿戴设备在慢病管理中的应用效果显著',
'医疗大数据安全管理新规正式实施',
'手术机器人技术突破,微创手术成功率提升30%',
'养老院智能化改造项目启动,覆盖全国重点城市',
'数字疗法首次纳入医保目录,患者负担大幅减轻',
'国际医疗AI标准化组织成立,中国企业积极参与',
'5G+医疗应用场景不断拓展,急救效率显著提升',
'医疗机器人出口量创新高,"中国制造"走向世界',
'智慧医院建设标准发布,推动行业规范化发展'
];
return Array.from({ length: 50 }, (_, index) => {
const category = categories[index % categories.length];
const source = sources[index % sources.length];
const title = mockTitles[index % mockTitles.length];
const publishedAt = new Date(Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000).toISOString();
return {
id: `news_${index + 1}`,
title,
summary: `${title}的详细内容摘要,包含关键信息和重要数据。本文深入分析了相关政策影响、技术发展趋势以及市场前景,为行业发展提供重要参考。`,
category,
tags: this.generateTags(category),
source,
sourceUrl: `https://${source.toLowerCase()}.com/article/${index + 1}`,
publishedAt,
crawledAt: new Date().toISOString(),
imageUrl: `https://images.unsplash.com/photo-${1576091160000 + index}?w=400&h=200&fit=crop`,
priority: ['high', 'medium', 'low'][index % 3] as any,
isHot: index < 8,
viewCount: Math.floor(Math.random() * 5000) + 100,
region: index % 3 === 0 ? '北京' : index % 3 === 1 ? '上海' : '深圳',
};
});
}
// 根据分类生成标签
private generateTags(category: NewsCategory): string[] {
const tagMap: Record<NewsCategory, string[]> = {
'healthcare-policy': ['政策', '卫健委', '医保', '监管'],
'medical-robotics': ['机器人', '手术', 'AI', '自动化'],
'industry-trends': ['趋势', '市场', '发展', '前景'],
'tech-innovation': ['创新', '技术', '突破', '研发'],
'investment': ['融资', '投资', '估值', '资本'],
'regulatory': ['法规', '标准', '合规', '监管'],
'market-analysis': ['分析', '报告', '数据', '市场'],
'corporate-news': ['企业', '公司', '合作', '战略'],
'research': ['研究', '论文', '成果', '学术'],
'international': ['国际', '全球', '海外', '出口'],
};
const baseTags = tagMap[category] || ['新闻'];
return baseTags.slice(0, Math.floor(Math.random() * 3) + 2);
}
// 清除缓存
clearCache(): void {
this.cache.clear();
}
}
// 导出单例
export const newsService = NewsService.getInstance();
// 便捷函数
export const getNews = (params?: NewsSearchParams) => newsService.getNews(params);
export const getNewsStats = () => newsService.getNewsStats();
export const getNewsByCategory = (category: NewsCategory, limit?: number) =>
newsService.getNewsByCategory(category, limit);
export const getHotNews = (limit?: number) => newsService.getHotNews(limit);
export const searchNews = (query: string, category?: NewsCategory) =>
newsService.searchNews(query, category);
+103
View File
@@ -0,0 +1,103 @@
// 新闻数据类型定义
export interface NewsItem {
id: string;
title: string;
summary: string;
content?: string;
category: NewsCategory;
tags: string[];
source: string;
sourceUrl: string;
publishedAt: string;
crawledAt: string;
author?: string;
imageUrl?: string;
priority: NewsPriority;
region?: string;
isHot?: boolean;
viewCount?: number;
relatedNews?: string[];
}
export type NewsCategory =
| 'healthcare-policy' // 康养政策
| 'medical-robotics' // 医疗机器人
| 'industry-trends' // 行业动态
| 'tech-innovation' // 技术前沿
| 'investment' // 投融资
| 'regulatory' // 监管动态
| 'market-analysis' // 市场分析
| 'corporate-news' // 企业动态
| 'research' // 研究成果
| 'international'; // 国际动态
export type NewsPriority = 'high' | 'medium' | 'low';
export interface NewsSource {
id: string;
name: string;
domain: string;
rssUrl?: string;
crawlUrl?: string;
category: NewsCategory[];
isActive: boolean;
crawlInterval: number; // minutes
lastCrawled?: string;
reliability: number; // 0-100
}
export interface NewsFilter {
category?: NewsCategory;
sources?: string[];
dateRange?: {
start: string;
end: string;
};
keywords?: string[];
priority?: NewsPriority;
region?: string;
}
export interface NewsSearchParams {
query?: string;
category?: NewsCategory;
source?: string;
page?: number;
limit?: number;
sortBy?: 'publishedAt' | 'crawledAt' | 'priority' | 'viewCount';
sortOrder?: 'desc' | 'asc';
}
export interface NewsResponse {
items: NewsItem[];
total: number;
page: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
}
export interface NewsCrawlerConfig {
sources: NewsSource[];
keywords: string[];
excludeKeywords: string[];
maxAgeDays: number;
enableAutoTranslation: boolean;
enableSummaryGeneration: boolean;
enableCategoryClassification: boolean;
}
// 新闻统计数据
export interface NewsStats {
totalNews: number;
todayNews: number;
hotNews: number;
categoryCounts: Record<NewsCategory, number>;
sourceCounts: Record<string, number>;
trending: Array<{
keyword: string;
count: number;
trend: 'up' | 'down' | 'stable';
}>;
}