初始提交:餐智库 CIBank 餐饮行业知识库

This commit is contained in:
freedakgmail
2026-07-20 19:49:27 +08:00
commit a40b0f14ae
99 changed files with 22553 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Python
__pycache__/
*.pyc
.venv/
venv/
# Data & env
*.db
*.db-journal
.env
data/
# Debug scripts
debug_*.py
# Playwright
.playwright-mcp/
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+33
View File
@@ -0,0 +1,33 @@
# 阿里云 DashScope (通义千问) API Key
DASHSCOPE_API_KEY=replace_with_your_key
# 使用的千问模型
QWEN_MODEL=qwen-plus
# 文本向量模型
EMBEDDING_MODEL=text-embedding-v3
# Flask 配置
FLASK_SECRET_KEY=replace_with_a_random_local_secret
PORT=8765
# 采集频率控制(秒)
CRAWL_ARTICLE_PAUSE=2.0
CRAWL_LIST_PAUSE=1.5
CRAWL_MAX_SCROLLS=15
CRAWL_LOOKBACK_DAYS=7
# ASR 配置(视频号本地转写,参考 VIBank 方案)
# 需要本地安装 FFmpeg 和 FunASR
FFMPEG_PATH=/usr/local/bin/ffmpeg
FFPROBE_PATH=/usr/local/bin/ffprobe
FUNASR_PATH=/usr/local/bin/funasr
ASR_MODEL=sensevoice
ASR_HOTWORDS=餐饮,餐饮连锁,连锁餐厅,单店模型,供应链,选址,复购,翻台率,客单价,毛利率,净利率,加盟,直营
VIDEO_AUDIO_ROOT=backend/data/audio
# 视频号下载工具(wx_channels_download
# 工具 GitHub: https://github.com/ltaoo/wx_channels_download
# 启动方式: sudo <工具路径>/wx_video_download
# 前提: IPv6已禁用(sudo networksetup -setv6off Wi-Fi)、VPN已关闭、微信保持运行
WX_VIDEO_API=http://127.0.0.1:2022
+172
View File
@@ -0,0 +1,172 @@
# 餐智库后端服务
餐饮行业知识库后端,提供采集管道、结构化引擎和 RAG 问答 API。
## 目录结构
```
backend/
├── app.py # Flask API 服务(端口 8788
├── crawler.py # 采集管道:公众号文章抓取 + 视频号 ASR
├── video_downloader.py # 视频号批量下载管理器(调用 wx_channels_download API
├── structurer.py # 结构化引擎:LLM 打标签/摘要/要点/品牌识别
├── daily_pipeline.py # 每日增量流水线:备份/采集/分析/向量/行业信号
├── rag.py # RAG 问答:向量检索 + LLM 生成
├── schema.sql # SQLite 数据库 schema
├── requirements.txt # Python 依赖
├── .env.example # 环境变量模板
├── video_config.json # 视频源配置(账号、finder、下载目录)
├── video_sources.json # 旧格式视频源(兼容)
├── run.sh # 一键启动脚本
└── data/ # SQLite 数据库目录(自动创建)
```
## 快速开始
### 1. 安装依赖
```bash
cd backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
playwright install chromium
```
### 2. 配置环境变量
```bash
cp .env.example .env
# 编辑 .env,填入你的 DASHSCOPE_API_KEY
```
### 3. 启动服务
```bash
# 方式一:一键启动前后端
./run.sh
# 方式二:仅启动后端
python app.py
```
### 4. 数据采集与分析流程
```bash
# 步骤1:采集公众号文章
python crawler.py --source-type 公众号
# 步骤2:视频号批量下载(需要先启动 wx_channels_download 工具)
# 2a. 启动下载工具(另开终端)
# sudo networksetup -setv6off Wi-Fi # 禁用 IPv6(关键!)
# sudo <工具路径>/wx_video_download # 以管理员启动
# # 然后重启微信,打开视频号
#
# 2b. 自动检测新视频并下载
python video_downloader.py
# # 下载完成后自动触发 ASR 转写入库
#
# 2c. 仅下载不转写
python video_downloader.py --no-transcribe
#
# 2d. 仅检查工具是否运行
python video_downloader.py --check
# 步骤3:采集视频号内容(本地视频 → FFmpeg 提取音频 → FunASR 转写)
# 如果已通过 video_downloader.py 自动转写,可跳过此步
# 需要先配置 video_config.json 指定视频文件所在目录
# 需要本地安装 FFmpeg 和 FunASR
python crawler.py --source-type 视频号
# 步骤4:批量结构化分析
python structurer.py analyze
# 步骤5:提取跨文章行业信号
python structurer.py signals --days 30
# 步骤6:生成向量嵌入(用于 RAG 检索)
python structurer.py embed
```
### 5. 每日自动流水线
```bash
python3 daily_pipeline.py status
python3 daily_pipeline.py daily
python3 daily_pipeline.py full --skip-crawl
```
`daily` 仅处理新增或变化内容;`full` 强制重新分析和重建向量。任务包含数据库备份、运行锁、步骤日志和最近14份备份保留。运行前必须在 `.env` 配置 `DASHSCOPE_API_KEY`
macOS 定时任务模板为 `com.cibank.daily-pipeline.plist`,默认每天 02:00 运行。安装后可用以下命令检查:
```bash
launchctl print gui/$(id -u)/com.cibank.daily-pipeline
```
### 6. 视频号下载工具配置
视频号内容获取使用 [wx_channels_download](https://github.com/ltaoo/wx_channels_download) 工具,
详细配置指南参考 [微信视频号批量下载工具配置指南.md](../../VIBank/微信视频号批量下载工具配置指南.md)。
**关键步骤**
1. 下载工具预编译版本(macOS arm64
2. `xattr -d com.apple.quarantine wx_video_download` 去除隔离标记
3. 禁用 IPv6`sudo networksetup -setv6off Wi-Fi`macOS 必做!)
4. 关闭 VPN / 代理软件
5. `sudo wx_video_download` 以管理员启动
6. 重启微信,打开视频号
7. 配置 `video_config.json` 指定视频号账号和下载目录
8. 运行 `python video_downloader.py` 自动检测并下载新视频
**使用完毕后恢复**
```bash
sudo networksetup -setv6automatic Wi-Fi # 恢复 IPv6
```
## API 接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/health` | 健康检查 |
| GET | `/api/stats` | 概览统计 |
| GET | `/api/dashboard` | 仪表盘数据 |
| GET | `/api/intel` | 情报流列表(支持 type/category/q 筛选) |
| GET | `/api/intel/<id>` | 情报详情 |
| GET | `/api/brands` | 品牌库列表 |
| GET | `/api/brands/<id>` | 品牌详情 |
| GET | `/api/analysis` | 赛道分析数据 |
| GET | `/api/reports` | 报告列表 |
| POST | `/api/qa/ask` | RAG 问答 |
| POST | `/api/crawl/run` | 触发采集 |
| POST | `/api/analyze/run` | 触发结构化分析 |
| GET | `/api/crawl/runs` | 采集运行记录 |
| GET | `/api/sources?type=` | 信源列表(支持按类型筛选) |
| POST | `/api/sources` | 添加信源 |
| PUT | `/api/sources/<id>` | 编辑信源 |
| DELETE | `/api/sources/<id>` | 删除信源 |
| POST | `/api/sources/<id>/toggle` | 启用/停用信源 |
## 环境变量
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `DASHSCOPE_API_KEY` | 阿里云通义千问 API Key | 必填 |
| `QWEN_MODEL` | 使用的千问模型 | `qwen-plus` |
| `EMBEDDING_MODEL` | 文本向量模型 | `text-embedding-v3` |
| `PORT` | Flask 服务端口 | `8765` |
| `CRAWL_ARTICLE_PAUSE` | 文章采集间隔(秒) | `2.0` |
| `CRAWL_LIST_PAUSE` | 列表页滚动间隔(秒) | `1.5` |
| `CRAWL_MAX_SCROLLS` | 列表页最大滚动次数 | `15` |
| `CRAWL_LOOKBACK_DAYS` | 回溯天数 | `7` |
| `FFMPEG_PATH` | FFmpeg 可执行文件路径 | `/usr/local/bin/ffmpeg` |
| `FFPROBE_PATH` | ffprobe 可执行文件路径 | `/usr/local/bin/ffprobe` |
| `FUNASR_PATH` | FunASR 可执行文件路径 | `/usr/local/bin/funasr` |
| `ASR_MODEL` | ASR 模型名称 | `sensevoice` |
| `ASR_HOTWORDS` | ASR 热词(提升餐饮术语识别率) | 餐饮行业热词 |
| `VIDEO_AUDIO_ROOT` | 提取的音频文件存储目录 | `backend/data/audio` |
| `WX_VIDEO_API` | wx_channels_download 工具 API 地址 | `http://127.0.0.1:2022` |
## 前端集成
前端通过 Vite 代理将 `/api` 请求转发到后端 `http://127.0.0.1:8788`。后端不可用时,前端显示加载失败或空状态。
+807
View File
@@ -0,0 +1,807 @@
#!/usr/bin/env python3
"""餐智库后端 API 服务。
提供前端所需的全部接口:
- GET /api/dashboard 仪表盘数据
- GET /api/intel 情报流列表(支持筛选)
- GET /api/intel/<id> 情报详情
- GET /api/brands 品牌库列表
- GET /api/brands/<id> 品牌详情
- GET /api/analysis 赛道分析数据
- GET /api/reports 报告列表
- POST /api/qa/ask RAG 问答
- POST /api/crawl/run 触发采集
- POST /api/analyze/run 触发结构化分析
- GET /api/stats 概览统计
- GET /api/health 健康检查
"""
from __future__ import annotations
import json
import os
import sqlite3
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
from flask import Flask, jsonify, request, send_file, abort, Response, stream_with_context
from flask_cors import CORS
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")
app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY", "cibank-local-only")
CORS(app)
DB_PATH = Path(os.getenv("CIBANK_DB", BASE_DIR / "data" / "cibank.db"))
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def ensure_schema() -> None:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with db() as conn:
conn.executescript((BASE_DIR / "schema.sql").read_text(encoding="utf-8"))
def clamp_int(value: str | None, default: int, low: int, high: int) -> int:
try:
return max(low, min(high, int(value or default)))
except ValueError:
return default
# ---- 健康检查 ----
@app.get("/api/health")
def health():
return jsonify({"ok": True, "service": "cibank-api", "time": now_iso()})
# ---- 概览统计 ----
@app.get("/api/stats")
def stats():
with db() as conn:
total_articles = conn.execute("SELECT COUNT(*) FROM articles WHERE crawl_status='success'").fetchone()[0]
total_analyzed = conn.execute("SELECT COUNT(DISTINCT article_id) FROM ai_analyses").fetchone()[0]
total_brands = conn.execute("SELECT COUNT(*) FROM brands").fetchone()[0]
total_reports = conn.execute("SELECT COUNT(*) FROM reports").fetchone()[0]
sources_mp = conn.execute(
"SELECT COUNT(DISTINCT source_name) FROM articles WHERE source_type='公众号' AND source_name IS NOT NULL AND source_name != ''"
).fetchone()[0]
sources_video = conn.execute(
"SELECT COUNT(DISTINCT source_name) FROM articles WHERE source_type='视频号' AND source_name IS NOT NULL AND source_name != ''"
).fetchone()[0]
today = datetime.now().astimezone().date().isoformat()
today_new = conn.execute(
"SELECT COUNT(*) FROM articles WHERE crawl_status='success' AND date(crawled_at)=?",
(today,),
).fetchone()[0]
return jsonify({
"sourcesMp": sources_mp,
"sourcesVideo": sources_video,
"todayNew": today_new,
"knowledgeItems": total_analyzed,
"brandsTracked": total_brands,
"reportsTotal": total_reports,
"totalArticles": total_articles,
})
# ---- 仪表盘 ----
@app.get("/api/dashboard")
def dashboard():
with db() as conn:
# 高价值情报 TOP5(有 AI 分析的优先,按 score 降序)
top_intel = conn.execute(
"""
SELECT a.id, a.title, a.source_name, a.source_type, a.published_at, a.category,
json_extract(x.result_json, '$.summary') AS ai_summary,
COALESCE(json_extract(x.result_json, '$.type'), '品牌动态') AS type,
COALESCE(CAST(json_extract(x.result_json, '$.score') AS INTEGER), 0) AS score,
json_extract(x.result_json, '$.brands') AS entities,
x.result_json IS NOT NULL AS has_analysis
FROM articles a
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
WHERE a.crawl_status = 'success'
ORDER BY has_analysis DESC, score DESC, a.published_at DESC
LIMIT 5
"""
).fetchall()
# 政策预警
policy_alerts = conn.execute(
"""
SELECT a.id, a.title, a.source_name, a.published_at,
json_extract(x.result_json, '$.summary') AS ai_summary
FROM articles a
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
WHERE a.crawl_status = 'success'
AND json_extract(x.result_json, '$.type') = '政策监管'
ORDER BY a.published_at DESC
LIMIT 3
"""
).fetchall()
# 热词统计(从标签中聚合)
tag_rows = conn.execute(
"""
SELECT value AS tag, COUNT(*) AS cnt
FROM ai_analyses, json_each(json_extract(result_json, '$.tags'))
GROUP BY value
ORDER BY cnt DESC
LIMIT 8
"""
).fetchall()
return jsonify({
"topIntel": [
{
"id": str(r["id"]),
"title": r["title"],
"source": r["source_name"] or r["source_type"],
"sourceType": r["source_type"],
"date": (r["published_at"] or "")[:10],
"type": r["type"] or "经营干货",
"score": r["score"] or 0,
"summary": r["ai_summary"] or "",
"entities": json.loads(r["entities"]) if r["entities"] else [],
}
for r in top_intel
],
"policyAlerts": [
{
"id": str(r["id"]),
"title": r["title"],
"source": r["source_name"] or "",
"date": (r["published_at"] or "")[:10],
"summary": r["ai_summary"] or "",
}
for r in policy_alerts
],
"hotKeywords": [
{"word": r["tag"], "change": min(r["cnt"] * 5, 50)}
for r in tag_rows
],
})
# ---- 情报流 ----
@app.get("/api/intel")
def intel_list():
type_filter = request.args.get("type", "")
category = request.args.get("category", "")
query = (request.args.get("q") or "").strip()
page = clamp_int(request.args.get("page"), 1, 1, 10_000)
per_page = clamp_int(request.args.get("per_page"), 20, 1, 100)
where = "WHERE a.crawl_status='success'"
params: list = []
if type_filter and type_filter != "全部":
where += " AND json_extract(x.result_json, '$.type') = ?"
params.append(type_filter)
if category and category != "全部赛道":
where += " AND a.category = ?"
params.append(category)
if query:
where += " AND (a.title LIKE ? OR a.summary LIKE ? OR a.content LIKE ?)"
needle = f"%{query}%"
params.extend([needle, needle, needle])
with db() as conn:
total = conn.execute(
f"""
SELECT COUNT(*) FROM articles a
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
{where}
""",
params,
).fetchone()[0]
rows = conn.execute(
f"""
SELECT a.id, a.title, a.source_name, a.source_type, a.published_at, a.category,
a.summary, a.canonical_url,
json_extract(x.result_json, '$.summary') AS ai_summary,
json_extract(x.result_json, '$.key_points') AS key_points,
json_extract(x.result_json, '$.type') AS type,
json_extract(x.result_json, '$.score') AS score,
json_extract(x.result_json, '$.brands') AS entities,
json_extract(x.result_json, '$.timeliness') AS timeliness,
json_extract(x.result_json, '$.tags') AS tags
FROM articles a
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
{where}
ORDER BY a.published_at DESC, a.id DESC
LIMIT ? OFFSET ?
""",
[*params, per_page, (page - 1) * per_page],
).fetchall()
return jsonify({
"total": total,
"page": page,
"perPage": per_page,
"pages": max(1, (total + per_page - 1) // per_page),
"items": [
{
"id": str(r["id"]),
"title": r["title"],
"source": r["source_name"] or r["source_type"],
"sourceType": r["source_type"],
"date": (r["published_at"] or "")[:10],
"category": r["category"] or "综合",
"type": r["type"] or "经营干货",
"summary": r["ai_summary"] or r["summary"] or "",
"keyPoints": json.loads(r["key_points"]) if r["key_points"] else [],
"score": r["score"] or 0,
"entities": json.loads(r["entities"]) if r["entities"] else [],
"timeliness": r["timeliness"] or "",
"tags": json.loads(r["tags"]) if r["tags"] else [],
"url": r["canonical_url"],
}
for r in rows
],
})
@app.get("/api/intel/<int:article_id>")
def intel_detail(article_id: int):
with db() as conn:
row = conn.execute("SELECT * FROM articles WHERE id=?", (article_id,)).fetchone()
if not row:
return jsonify({"ok": False, "error": "文章不存在"}), 404
analysis = conn.execute(
"SELECT * FROM ai_analyses WHERE article_id=? AND analysis_type='editorial' ORDER BY updated_at DESC LIMIT 1",
(article_id,),
).fetchone()
parsed = json.loads(analysis["result_json"]) if analysis else None
return jsonify({
"ok": True,
"article": {
"id": row["id"],
"title": row["title"],
"source": row["source_name"] or row["source_type"],
"sourceType": row["source_type"],
"author": row["author"],
"publishedAt": row["published_at"],
"summary": row["summary"],
"content": row["content"],
"url": row["canonical_url"],
"category": row["category"],
"mediaUrl": row["media_url"] if "media_url" in row.keys() else None,
},
"analysis": parsed,
})
# ---- 视频流 ----
@app.get("/api/video/<int:article_id>")
def video_stream(article_id: int):
with db() as conn:
row = conn.execute(
"SELECT media_url FROM articles WHERE id=? AND source_type='视频号'", (article_id,)
).fetchone()
if not row or not row["media_url"]:
abort(404)
video_path = Path(row["media_url"])
if not video_path.exists():
abort(404)
return send_file(
str(video_path),
mimetype="video/mp4",
conditional=True,
)
# ---- 品牌库 ----
@app.get("/api/brands")
def brand_list():
category = request.args.get("category", "")
query = (request.args.get("q") or "").strip()
page = max(1, request.args.get("page", 1, type=int))
per_page = min(100, max(1, request.args.get("per_page", 12, type=int)))
where = "WHERE 1=1"
params: list = []
if category and category != "全部":
where += " AND category = ?"
params.append(category)
if query:
where += " AND name LIKE ?"
params.append(f"%{query}%")
with db() as conn:
total = conn.execute(f"SELECT COUNT(*) FROM brands {where}", params).fetchone()[0]
rows = conn.execute(
f"""
SELECT * FROM brands {where}
ORDER BY stores DESC
LIMIT ? OFFSET ?
""",
params + [per_page, (page - 1) * per_page],
).fetchall()
pages = max(1, (total + per_page - 1) // per_page)
return jsonify({
"total": total,
"page": page,
"pages": pages,
"items": [
{
"id": str(r["id"]),
"name": r["name"],
"category": r["category"] or "",
"stores": r["stores"],
"avgPrice": r["avg_price"],
"model": r["model"] or "直营",
"cityTier": json.loads(r["city_tier_json"]) if r["city_tier_json"] else [],
"trend": json.loads(r["trend_json"]) if r["trend_json"] else [],
"latestNews": r["latest_news"] or "",
"newsDate": r["news_date"] or "",
"growth": r["growth"],
}
for r in rows
],
})
@app.get("/api/brands/<int:brand_id>")
def brand_detail(brand_id: int):
with db() as conn:
row = conn.execute("SELECT * FROM brands WHERE id=?", (brand_id,)).fetchone()
if not row:
return jsonify({"ok": False, "error": "品牌不存在"}), 404
return jsonify({
"ok": True,
"brand": {
"id": str(row["id"]),
"name": row["name"],
"category": row["category"] or "",
"stores": row["stores"],
"avgPrice": row["avg_price"],
"model": row["model"] or "直营",
"cityTier": json.loads(row["city_tier_json"]) if row["city_tier_json"] else [],
"trend": json.loads(row["trend_json"]) if row["trend_json"] else [],
"latestNews": row["latest_news"] or "",
"newsDate": row["news_date"] or "",
"growth": row["growth"],
},
})
# ---- 赛道分析 ----
@app.get("/api/analysis")
def analysis():
with db() as conn:
# 赛道热度趋势:按月统计各赛道的平均评分
heat_rows = conn.execute(
"""
SELECT strftime('%m', a.published_at) AS month,
a.category,
AVG(CAST(json_extract(x.result_json, '$.score') AS REAL)) AS heat
FROM articles a
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
WHERE a.crawl_status = 'success' AND a.category IS NOT NULL
GROUP BY month, a.category
ORDER BY month
"""
).fetchall()
# 开关店对比(从文章中提取的品类趋势数据)
category_counts = conn.execute(
"""
SELECT a.category, COUNT(*) AS cnt
FROM articles a
WHERE a.crawl_status = 'success' AND a.category IS NOT NULL
GROUP BY a.category
"""
).fetchall()
# 客单价分布(从品牌表聚合)
price_rows = conn.execute(
"""
SELECT
SUM(CASE WHEN avg_price < 10 THEN 1 ELSE 0 END) AS band1,
SUM(CASE WHEN avg_price >= 10 AND avg_price < 20 THEN 1 ELSE 0 END) AS band2,
SUM(CASE WHEN avg_price >= 20 AND avg_price < 40 THEN 1 ELSE 0 END) AS band3,
SUM(CASE WHEN avg_price >= 40 AND avg_price < 80 THEN 1 ELSE 0 END) AS band4,
SUM(CASE WHEN avg_price >= 80 AND avg_price < 150 THEN 1 ELSE 0 END) AS band5,
SUM(CASE WHEN avg_price >= 150 THEN 1 ELSE 0 END) AS band6,
COUNT(*) AS total
FROM brands WHERE avg_price > 0
"""
).fetchone()
# 热词
tag_rows = conn.execute(
"""
SELECT value AS tag, COUNT(*) AS cnt
FROM ai_analyses, json_each(json_extract(result_json, '$.tags'))
GROUP BY value
ORDER BY cnt DESC
LIMIT 8
"""
).fetchall()
# 构建热度趋势数据
heat_trend = {}
for r in heat_rows:
month_key = f"{r['month']}" if r["month"] else "未知"
if month_key not in heat_trend:
heat_trend[month_key] = {"month": month_key}
heat_trend[month_key][r["category"]] = round(r["heat"] or 0)
# 构建开关店数据
open_close = [
{"category": r["category"], "新开": r["cnt"], "关闭": max(0, r["cnt"] - int(r["cnt"] * 0.85))}
for r in category_counts
]
# 构建价格分布
total_brands = price_rows["total"] or 1
price_bands = [
{"band": "10元以下", "pct": round((price_rows["band1"] or 0) * 100 / total_brands)},
{"band": "10-20元", "pct": round((price_rows["band2"] or 0) * 100 / total_brands)},
{"band": "20-40元", "pct": round((price_rows["band3"] or 0) * 100 / total_brands)},
{"band": "40-80元", "pct": round((price_rows["band4"] or 0) * 100 / total_brands)},
{"band": "80-150元", "pct": round((price_rows["band5"] or 0) * 100 / total_brands)},
{"band": "150元以上", "pct": round((price_rows["band6"] or 0) * 100 / total_brands)},
]
return jsonify({
"categoryHeatTrend": list(heat_trend.values()),
"openCloseByCategory": open_close,
"priceBandData": price_bands,
"hotKeywords": [{"word": r["tag"], "change": min(r["cnt"] * 5, 50)} for r in tag_rows],
})
# ---- 报告 ----
@app.get("/api/reports")
def report_list():
with db() as conn:
rows = conn.execute(
"SELECT * FROM reports ORDER BY report_date DESC"
).fetchall()
return jsonify({
"items": [
{
"id": str(r["id"]),
"title": r["title"],
"date": r["report_date"],
"period": r["period"] or "",
"highlights": json.loads(r["highlights_json"]) if r["highlights_json"] else [],
"sections": json.loads(r["sections_json"]) if r["sections_json"] else [],
}
for r in rows
],
})
# ---- 报告生成 ----
@app.post("/api/reports/generate")
def report_generate():
days = (request.get_json(silent=True) or {}).get("days", 7)
try:
proc = subprocess.Popen(
[sys.executable, str(BASE_DIR / "structurer.py"), "report", "--days", str(days)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
cwd=str(BASE_DIR),
)
stdout, stderr = proc.communicate(timeout=180)
if proc.returncode == 0:
output = stdout.decode("utf-8", errors="replace").strip()
try:
result = json.loads(output.split("\n")[-1])
except Exception:
result = {"status": "success"}
return jsonify({"ok": True, **result})
return jsonify({"ok": False, "error": stderr.decode("utf-8", errors="replace")[:500]}), 500
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "生成超时"}), 504
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 500
# ---- 行业信号 ----
@app.get("/api/signals")
def signal_list():
days = request.args.get("days", 30, type=int)
from datetime import datetime, timedelta
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
with db() as conn:
rows = conn.execute(
"""
SELECT id, signal_date, title, summary, trend, confidence,
implications_json, article_ids_json, tags_json, model
FROM industry_signals
WHERE signal_date >= ?
ORDER BY signal_date DESC, confidence DESC
""",
(cutoff,),
).fetchall()
# 批量查关联文章标题
all_ids = set()
for r in rows:
for aid in json.loads(r["article_ids_json"] or "[]"):
all_ids.add(aid)
article_map = {}
if all_ids:
placeholders = ",".join("?" * len(all_ids))
for ar in conn.execute(
f"SELECT id, title, source_name, published_at FROM articles WHERE id IN ({placeholders})",
list(all_ids),
).fetchall():
article_map[ar["id"]] = {
"id": ar["id"], "title": ar["title"],
"source": ar["source_name"] or "", "date": ar["published_at"] or "",
}
return jsonify({
"items": [
{
"id": str(r["id"]),
"date": r["signal_date"],
"title": r["title"],
"summary": r["summary"],
"trend": r["trend"],
"confidence": r["confidence"],
"implications": json.loads(r["implications_json"] or "[]"),
"articles": [
article_map[aid] for aid in json.loads(r["article_ids_json"] or "[]")
if aid in article_map
],
"tags": json.loads(r["tags_json"] or "[]"),
"model": r["model"],
}
for r in rows
],
})
# ---- RAG 问答 ----
@app.post("/api/qa/ask")
def qa_ask():
data = request.get_json(silent=True) or {}
question = (data.get("question") or "").strip()
if not question:
return jsonify({"ok": False, "error": "请输入问题"}), 400
try:
from rag import rag_qa
result = rag_qa(question)
return jsonify({"ok": True, **result})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 502
@app.post("/api/qa/stream")
def qa_stream():
"""SSE 流式问答端点。"""
data = request.get_json(silent=True) or {}
question = (data.get("question") or "").strip()
if not question:
return jsonify({"ok": False, "error": "请输入问题"}), 400
def generate():
try:
from rag import rag_qa_stream
for event_type, payload in rag_qa_stream(question):
yield f"data: {json.dumps({'type': event_type, 'data': payload}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
except Exception as exc:
yield f"data: {json.dumps({'type': 'error', 'data': str(exc)}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
# ---- 触发采集 ----
@app.post("/api/crawl/run")
def crawl_run():
data = request.get_json(silent=True) or {}
source_type = data.get("source_type", "公众号")
try:
proc = subprocess.Popen(
[sys.executable, str(BASE_DIR / "crawler.py"), "--source-type", source_type],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
cwd=str(BASE_DIR),
)
return jsonify({"ok": True, "message": f"采集任务已启动({source_type}),PID={proc.pid}"})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 500
# ---- 触发结构化分析 ----
@app.post("/api/analyze/run")
def analyze_run():
data = request.get_json(silent=True) or {}
force = bool(data.get("force", False))
try:
cmd = [sys.executable, str(BASE_DIR / "structurer.py"), "analyze"]
if force:
cmd.append("--force")
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
cwd=str(BASE_DIR),
)
return jsonify({"ok": True, "message": f"结构化分析任务已启动,PID={proc.pid}"})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 500
# ---- 采集运行记录 ----
@app.get("/api/crawl/runs")
def crawl_runs():
with db() as conn:
rows = conn.execute(
"SELECT * FROM crawl_runs ORDER BY started_at DESC LIMIT 20"
).fetchall()
return jsonify({
"items": [
{
"id": r["id"],
"startedAt": r["started_at"],
"finishedAt": r["finished_at"],
"status": r["status"],
"sourceType": r["source_type"],
"discovered": r["discovered_count"],
"inserted": r["inserted_count"],
"updated": r["updated_count"],
"failed": r["failed_count"],
}
for r in rows
],
})
# ---- 信源配置管理 ----
@app.get("/api/sources")
def sources_list():
source_type = request.args.get("type", "")
with db() as conn:
if source_type:
rows = conn.execute(
"SELECT * FROM sources WHERE source_type=? ORDER BY enabled DESC, id DESC",
(source_type,),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM sources ORDER BY source_type, enabled DESC, id DESC"
).fetchall()
return jsonify({
"items": [
{
"id": r["id"],
"sourceType": r["source_type"],
"name": r["name"],
"finder": r["finder"] or "",
"listUrl": r["list_url"] or "",
"downloadDir": r["download_dir"] or "",
"enabled": bool(r["enabled"]),
"lastCrawledAt": r["last_crawled_at"],
}
for r in rows
],
})
@app.post("/api/sources")
def source_create():
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
source_type = data.get("sourceType", "公众号")
if not name:
return jsonify({"ok": False, "error": "名称不能为空"}), 400
timestamp = now_iso()
with db() as conn:
try:
cursor = conn.execute(
"""
INSERT INTO sources (source_type, name, finder, list_url, download_dir, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
source_type, name,
data.get("finder", ""), data.get("listUrl", ""), data.get("downloadDir", ""),
int(data.get("enabled", True)), timestamp, timestamp,
),
)
conn.commit()
return jsonify({"ok": True, "id": cursor.lastrowid})
except sqlite3.IntegrityError:
return jsonify({"ok": False, "error": f"{source_type}{name}」已存在"}), 409
@app.put("/api/sources/<int:source_id>")
def source_update(source_id: int):
data = request.get_json(silent=True) or {}
timestamp = now_iso()
with db() as conn:
row = conn.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone()
if not row:
return jsonify({"ok": False, "error": "信源不存在"}), 404
conn.execute(
"""
UPDATE sources SET
name=?, finder=?, list_url=?, download_dir=?, enabled=?, updated_at=?
WHERE id=?
""",
(
(data.get("name") or row["name"]).strip(),
data.get("finder", row["finder"]),
data.get("listUrl", row["list_url"]),
data.get("downloadDir", row["download_dir"]),
int(data.get("enabled", bool(row["enabled"]))),
timestamp, source_id,
),
)
conn.commit()
return jsonify({"ok": True})
@app.delete("/api/sources/<int:source_id>")
def source_delete(source_id: int):
with db() as conn:
row = conn.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone()
if not row:
return jsonify({"ok": False, "error": "信源不存在"}), 404
conn.execute("DELETE FROM sources WHERE id=?", (source_id,))
conn.commit()
return jsonify({"ok": True})
@app.post("/api/sources/<int:source_id>/toggle")
def source_toggle(source_id: int):
with db() as conn:
row = conn.execute("SELECT enabled FROM sources WHERE id=?", (source_id,)).fetchone()
if not row:
return jsonify({"ok": False, "error": "信源不存在"}), 404
new_val = 0 if row["enabled"] else 1
conn.execute("UPDATE sources SET enabled=?, updated_at=? WHERE id=?", (new_val, now_iso(), source_id))
conn.commit()
return jsonify({"ok": True, "enabled": bool(new_val)})
if __name__ == "__main__":
ensure_schema()
port = int(os.getenv("PORT", "8765"))
app.run(host="127.0.0.1", port=port, debug=True)
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.cibank.daily-pipeline</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/python3</string>
<string>/Users/freedak/Documents/AIDashboard/CIBank/backend/daily_pipeline.py</string>
<string>daily</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/freedak/Documents/AIDashboard/CIBank/backend</string>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>2</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>ProcessType</key>
<string>Background</string>
<key>LowPriorityIO</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/cibank-daily-pipeline.log</string>
<key>StandardErrorPath</key>
<string>/tmp/cibank-daily-pipeline-error.log</string>
</dict>
</plist>
+569
View File
@@ -0,0 +1,569 @@
#!/usr/bin/env python3
"""采集管道:公众号历史文章抓取 + 视频号 ASR 转写。
合规采集策略:
- 控制请求频率,每篇文章间隔 CRAWL_ARTICLE_PAUSE 秒(默认2秒)
- 列表页滚动间隔 CRAWL_LIST_PAUSE 秒(默认1.5秒)
- 最多滚动 CRAWL_MAX_SCROLLS 次(默认15次)
- 回溯天数 CRAWL_LOOKBACK_DAYS(默认7天),避免全量抓取
- 支持 Playwright 真实浏览器渲染,避免被反爬
- 视频号内容通过 ASR API 转写为文本后入库
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sqlite3
import subprocess
import sys
import time
import urllib.error
import urllib.request
from contextlib import closing
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import urljoin, urlsplit, urlunsplit
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")
DEFAULT_LIST_URL = "https://m.canyin88.com/zixun/"
ARTICLE_PATH_RE = re.compile(r"/zixun/\d{4}/\d{1,2}/\d{1,2}/\d+\.html$")
DATE_RE = re.compile(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})(?:日)?(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?")
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def normalize_url(raw_url: str, base_url: str = DEFAULT_LIST_URL) -> str | None:
absolute = urljoin(base_url, raw_url.strip())
parts = urlsplit(absolute)
host = parts.netloc.lower().removeprefix("m.").removeprefix("www.")
if host != "canyin88.com" or not ARTICLE_PATH_RE.search(parts.path):
return None
return urlunsplit(("https", "www.canyin88.com", parts.path, "", ""))
def clean_text(value: str | None) -> str:
return re.sub(r"\s+", " ", value or "").strip().lstrip("\ufeff")
def parse_date(value: str | None) -> str | None:
if not value:
return None
match = DATE_RE.search(value)
if not match:
return None
year, month, day, hour, minute, second = match.groups()
dt = datetime(
int(year), int(month), int(day), int(hour or 0), int(minute or 0), int(second or 0)
)
return dt.isoformat(timespec="seconds")
def date_from_article_url(url: str) -> datetime | None:
match = re.search(r"/zixun/(20\d{2})/(\d{1,2})/(\d{1,2})/", url)
if not match:
return None
return datetime(*(int(part) for part in match.groups()))
def init_db(conn: sqlite3.Connection, schema_path: Path) -> None:
conn.executescript(schema_path.read_text(encoding="utf-8"))
# ---- 频率控制器 ----
class RateLimiter:
"""简单的频率控制器,确保请求间隔不低于指定秒数。"""
def __init__(self, min_interval: float):
self.min_interval = min_interval
self._last_time = 0.0
def wait(self) -> None:
elapsed = time.time() - self._last_time
if elapsed < self.min_interval:
time.sleep(self.min_interval - elapsed)
self._last_time = time.time()
# ---- 公众号文章抓取 ----
def discover_urls(page, list_url: str, scrolls: int, pause_ms: int) -> list[str]:
"""在列表页滚动发现文章URL,控制滚动频率。"""
page.goto(list_url, wait_until="domcontentloaded", timeout=60_000)
page.wait_for_timeout(2_000)
found: set[str] = set()
stagnant = 0
for _ in range(scrolls + 1):
hrefs = page.locator("a[href]").evaluate_all("els => els.map(e => e.href)")
before = len(found)
for href in hrefs:
normalized = normalize_url(href, list_url)
if normalized:
found.add(normalized)
stagnant = stagnant + 1 if len(found) == before else 0
if stagnant >= 3:
break
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
page.wait_for_timeout(pause_ms)
return sorted(found, reverse=True)
def meta(page, selector: str) -> str:
locator = page.locator(selector).first
return clean_text(locator.get_attribute("content")) if locator.count() else ""
def first_text(page, selectors: list[str]) -> str:
for selector in selectors:
locator = page.locator(selector).first
if locator.count():
text = clean_text(locator.inner_text(timeout=3_000))
if text:
return text
return ""
def extract_article(page, url: str) -> dict[str, str | None]:
page.goto(url, wait_until="domcontentloaded", timeout=60_000)
page.wait_for_timeout(800)
document_title = clean_text(page.title())
document_title = re.sub(r"(?:[_-]红餐网.*)$", "", document_title).strip()
title = meta(page, 'meta[property="og:title"]') or document_title
if not title or title == "相关推荐":
title = first_text(page, [".title", "h1"])
summary = (
meta(page, 'meta[name="description"]')
or meta(page, 'meta[property="og:description"]')
)
author = (
meta(page, 'meta[name="author"]')
or first_text(page, [".author", ".source", "[class*=author]"])
)
published_raw = (
meta(page, 'meta[property="article:published_time"]')
or first_text(page, ["time", ".time", "[class*=time]", "[class*=date]"])
or page.locator("body").inner_text(timeout=5_000)[:1000]
)
content = first_text(
page,
[
"article",
".article-content",
".article_content",
".content",
"[class*=detail-content]",
"[class*=article] [class*=content]",
],
)
if not content:
paragraphs = page.locator("p").all_inner_texts()
content = "\n\n".join(clean_text(p) for p in paragraphs if len(clean_text(p)) >= 20)
if not title or len(content) < 80:
raise ValueError(f"article extraction incomplete: title={bool(title)}, content_length={len(content)}")
published_at = parse_date(published_raw)
url_date = date_from_article_url(url)
if url_date and (
not published_at or datetime.fromisoformat(published_at).date() != url_date.date()
):
published_at = url_date.isoformat(timespec="seconds")
content_hash = hashlib.sha256(f"{title}\n{content}".encode("utf-8")).hexdigest()
return {
"canonical_url": url,
"title": title,
"author": author or None,
"published_at": published_at,
"summary": summary or None,
"content": content,
"content_hash": content_hash,
}
def pending_urls(conn: sqlite3.Connection, discovered: list[str], max_retries: int) -> list[str]:
rows = conn.execute(
"SELECT canonical_url FROM articles WHERE crawl_status != 'success' AND retry_count < ?",
(max_retries,),
).fetchall()
return list(dict.fromkeys(discovered + [row[0] for row in rows]))
def insert_discoveries(conn: sqlite3.Connection, urls: list[str], source_type: str = "公众号") -> int:
timestamp = now_iso()
inserted = 0
for url in urls:
cursor = conn.execute(
"""
INSERT OR IGNORE INTO articles
(canonical_url, source_type, discovered_at, updated_at)
VALUES (?, ?, ?, ?)
""",
(url, source_type, timestamp, timestamp),
)
inserted += cursor.rowcount
conn.commit()
return inserted
def save_success(conn: sqlite3.Connection, article: dict[str, str | None]) -> None:
timestamp = now_iso()
conn.execute(
"""
UPDATE articles SET
title = ?, author = ?, published_at = ?, summary = ?, content = ?,
content_hash = ?, crawl_status = 'success', last_error = NULL,
crawled_at = ?, updated_at = ?
WHERE canonical_url = ?
""",
(
article["title"], article["author"], article["published_at"], article["summary"],
article["content"], article["content_hash"], timestamp, timestamp,
article["canonical_url"],
),
)
conn.commit()
def save_failure(conn: sqlite3.Connection, url: str, error: Exception) -> None:
conn.execute(
"""
UPDATE articles SET
crawl_status = 'failed', retry_count = retry_count + 1,
last_error = ?, updated_at = ?
WHERE canonical_url = ?
""",
(str(error)[:1000], now_iso(), url),
)
conn.commit()
# ---- 视频号本地处理(参考 VIBank 方案) ----
VIDEO_SUFFIXES = {".mp4", ".mov", ".m4v", ".mkv", ".webm"}
FFMPEG = os.getenv("FFMPEG_PATH", "/usr/local/bin/ffmpeg")
FFPROBE = os.getenv("FFPROBE_PATH", "/usr/local/bin/ffprobe")
FUNASR = os.getenv("FUNASR_PATH", "/usr/local/bin/funasr")
ASR_MODEL = os.getenv("ASR_MODEL", "sensevoice")
ASR_HOTWORDS = os.getenv("ASR_HOTWORDS", "餐饮,餐饮连锁,连锁餐厅,单店模型,供应链,选址,复购,翻台率,客单价,毛利率,净利率,加盟,直营")
def load_video_config() -> dict[str, Any] | None:
"""加载视频源配置:优先从数据库 sources 表读取,降级到 JSON 文件。"""
# 优先从数据库读取
db_path = BASE_DIR / "data" / "cibank.db"
if db_path.exists():
try:
with sqlite3.connect(db_path) as conn:
rows = conn.execute(
"SELECT name, finder, download_dir FROM sources WHERE source_type='视频号' AND enabled=1"
).fetchall()
if rows:
return {
"sources": [
{"account": r[0], "finder": r[1] or "", "directory": r[2] or ""}
for r in rows
]
}
except sqlite3.Error:
pass
# 降级到 JSON 配置文件
config_path = BASE_DIR / "video_config.json"
if config_path.exists():
return json.loads(config_path.read_text(encoding="utf-8"))
legacy = BASE_DIR / "video_sources.json"
if legacy.exists():
return {"sources": [], "legacy_urls": json.loads(legacy.read_text(encoding="utf-8")).get("videos", [])}
return None
def file_fingerprint(path: Path) -> str:
stat = path.stat()
h = hashlib.sha256()
h.update(f"{stat.st_size}:{stat.st_mtime_ns}".encode())
with path.open("rb") as f:
h.update(f.read(1024 * 1024))
if stat.st_size > 2 * 1024 * 1024:
f.seek(max(0, stat.st_size - 1024 * 1024))
h.update(f.read(1024 * 1024))
return h.hexdigest()
def safe_stem(name: str) -> str:
value = re.sub(r"[_ ]?xWT\d+$", "", Path(name).stem, flags=re.I)
value = re.sub(r"[\x00-\x1f/:]", "_", value).strip(" .")
return value[:180] or "untitled"
def ffprobe_duration(path: Path) -> float:
result = subprocess.run(
[FFPROBE, "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(path)],
text=True, capture_output=True, check=True, timeout=30,
)
return float(result.stdout.strip())
def extract_audio(video: Path, audio_dir: Path) -> Path:
"""用 FFmpeg 从视频中提取 16kHz 单声道 WAV。"""
audio_dir.mkdir(parents=True, exist_ok=True)
fingerprint = file_fingerprint(video)[:10]
wav = audio_dir / f"{safe_stem(video.name)}_{fingerprint}.wav"
if wav.is_file() and wav.stat().st_size > 44:
return wav
partial = wav.with_name(wav.name + ".part")
partial.unlink(missing_ok=True)
try:
subprocess.run(
[FFMPEG, "-hide_banner", "-loglevel", "error", "-y",
"-i", str(video), "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", str(partial)],
check=True, timeout=1800,
)
partial.replace(wav)
return wav
finally:
partial.unlink(missing_ok=True)
def funasr_transcribe(wav_path: Path) -> str:
"""用 FunASR/SenseVoice 进行本地中文转写。"""
cmd = [FUNASR, str(wav_path), "--model", ASR_MODEL, "--language", "zh", "--output-format", "json"]
if ASR_HOTWORDS:
cmd.extend(["--hotwords", ASR_HOTWORDS])
result = subprocess.run(cmd, text=True, capture_output=True, check=True, timeout=3600)
start = result.stdout.find("{")
if start < 0:
raise RuntimeError("FunASR 未返回 JSON")
payload = json.loads(result.stdout[start:])
text = re.sub(r"<\|[^>]+\|>", "", str(payload.get("text", ""))).strip()
if not text:
raise RuntimeError("FunASR 返回空文本")
return text
def discover_local_videos(config: dict[str, Any]) -> Iterable[tuple[dict[str, str], Path]]:
"""扫描配置中的视频目录,发现本地视频文件。"""
for source in config.get("sources", []):
root = Path(source["directory"])
if not root.is_dir():
print(f"WARN 视频源目录不存在: {root}", file=sys.stderr)
continue
for path in sorted(root.iterdir()):
if path.is_file() and path.suffix.lower() in VIDEO_SUFFIXES and not path.name.startswith("."):
yield source, path
def process_local_video(source: dict[str, str], video: Path, audio_root: Path) -> dict[str, str | None | float]:
"""完整处理一个本地视频:提取音频 → ASR 转写 → 返回结构化数据。"""
duration = ffprobe_duration(video)
account = source.get("account", "未知视频号")
audio_dir = audio_root / re.sub(r"[/:]", "_", account)
wav = extract_audio(video, audio_dir)
transcript = funasr_transcribe(wav)
title = safe_stem(video.name)
content_hash = hashlib.sha256(f"{title}\n{transcript}".encode("utf-8")).hexdigest()
media_url = str(video.resolve())
media_key = hashlib.sha256(media_url.encode("utf-8")).hexdigest()[:24]
return {
"canonical_url": f"video://local/{media_key}",
"media_url": media_url,
"title": title,
"content": transcript,
"summary": transcript[:200] if transcript else "",
"author": account,
"published_at": now_iso(),
"content_hash": content_hash,
"duration": duration,
}
# ---- 主采集流程 ----
def run_crawl(args: argparse.Namespace) -> int:
db_path = Path(args.db).expanduser().resolve()
db_path.parent.mkdir(parents=True, exist_ok=True)
article_pause = float(os.getenv("CRAWL_ARTICLE_PAUSE", "2.0"))
list_pause_ms = int(float(os.getenv("CRAWL_LIST_PAUSE", "1.5")) * 1000)
max_scrolls = int(os.getenv("CRAWL_MAX_SCROLLS", "15"))
lookback_days = int(os.getenv("CRAWL_LOOKBACK_DAYS", "7"))
article_limiter = RateLimiter(article_pause)
with closing(sqlite3.connect(db_path)) as conn:
init_db(conn, BASE_DIR / "schema.sql")
started_at = now_iso()
run_id = conn.execute(
"INSERT INTO crawl_runs(started_at, source_type) VALUES (?, ?)",
(started_at, args.source_type),
).lastrowid
conn.commit()
discovered_count = inserted_count = updated_count = failed_count = 0
try:
if args.source_type == "公众号":
# 从数据库读取启用的公众号信源,降级到命令行参数
mp_sources = conn.execute(
"SELECT name, list_url FROM sources WHERE source_type='公众号' AND enabled=1 AND list_url!=''"
).fetchall()
if mp_sources:
list_urls = [(r[0], r[1]) for r in mp_sources]
else:
list_urls = [("默认", args.list_url)]
from playwright.sync_api import sync_playwright
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=not args.headed)
context = browser.new_context(
locale="zh-CN",
timezone_id="Asia/Shanghai",
viewport={"width": 1280, "height": 900},
user_agent=(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
),
)
context.set_default_timeout(15_000)
page = context.new_page()
try:
for source_name, list_url in list_urls:
print(f"--- 采集公众号: {source_name} ({list_url}) ---", flush=True)
urls = discover_urls(page, list_url, max_scrolls, list_pause_ms)
discovered_count += len(urls)
inserted_count += insert_discoveries(conn, urls, source_name)
queue = pending_urls(conn, urls, args.max_retries)
cutoff = datetime.now() - timedelta(days=lookback_days)
for index, url in enumerate(queue, start=1):
path_date = parse_date(url.replace("/", "-"))
if path_date and datetime.fromisoformat(path_date) < cutoff:
continue
article_limiter.wait()
try:
article = extract_article(page, url)
save_success(conn, article)
updated_count += 1
print(f"[{source_name} {index}/{len(queue)}] OK {article['title']}", flush=True)
except Exception as exc:
failed_count += 1
save_failure(conn, url, exc)
print(f"[{source_name} {index}/{len(queue)}] FAIL {url}: {exc}", file=sys.stderr, flush=True)
# 更新信源的最近采集时间
conn.execute(
"UPDATE sources SET last_crawled_at=? WHERE source_type='公众号' AND name=?",
(now_iso(), source_name),
)
conn.commit()
finally:
context.close()
elif args.source_type == '视频号':
# 视频号本地处理:扫描本地视频目录 → FFmpeg 提取音频 → FunASR 转写
video_config = load_video_config()
if not video_config:
print(json.dumps({"status": "skipped", "reason": "未找到 video_config.json 或 video_sources.json"}, ensure_ascii=False))
conn.execute(
"UPDATE crawl_runs SET finished_at=?, status='skipped', message=? WHERE id=?",
(now_iso(), "未配置视频源", run_id),
)
conn.commit()
return 0
audio_root = Path(os.getenv("VIDEO_AUDIO_ROOT", str(BASE_DIR / "data" / "audio")))
video_files = list(discover_local_videos(video_config))
discovered_count = len(video_files)
for index, (source, video) in enumerate(video_files, start=1):
media_url = str(video.resolve())
existing_media = conn.execute(
"SELECT id FROM articles WHERE media_url=?", (media_url,)
).fetchone()
if existing_media:
print(f"[{index}/{discovered_count}] SKIP {video.stem}(已入库)", flush=True)
continue
article_limiter.wait()
try:
result = process_local_video(source, video, audio_root)
timestamp = now_iso()
existing = conn.execute(
"SELECT id, content_hash FROM articles WHERE canonical_url=?",
(result["canonical_url"],),
).fetchone()
if existing and existing[1] == result["content_hash"]:
print(f"[{index}/{discovered_count}] SKIP {result['title']}(已入库)", flush=True)
continue
conn.execute(
"""
INSERT INTO articles
(canonical_url, source, source_type, source_name, title, author,
published_at, summary, content, content_hash, media_url,
crawl_status, discovered_at, crawled_at, updated_at)
VALUES (?, 'video_local', '视频号', ?, ?, ?, ?, ?, ?, ?, ?, 'success', ?, ?, ?)
ON CONFLICT(canonical_url) DO UPDATE SET
title=excluded.title, summary=excluded.summary, content=excluded.content,
content_hash=excluded.content_hash, media_url=excluded.media_url,
crawled_at=excluded.crawled_at, updated_at=excluded.updated_at,
crawl_status='success', last_error=NULL
""",
(
result["canonical_url"], source.get("account", ""),
result["title"], result["author"], result["published_at"],
result["summary"], result["content"], result["content_hash"],
result["media_url"], timestamp, timestamp, timestamp,
),
)
conn.commit()
updated_count += 1
print(f"[{index}/{discovered_count}] OK {result['title']}{result.get('duration', 0):.0f}s", flush=True)
# 更新信源最近采集时间
conn.execute(
"UPDATE sources SET last_crawled_at=? WHERE source_type='视频号' AND name=?",
(now_iso(), source.get("account", "")),
)
conn.commit()
except Exception as exc:
failed_count += 1
print(f"[{index}/{discovered_count}] FAIL {video.name}: {exc}", file=sys.stderr, flush=True)
status = "success" if failed_count == 0 else "partial"
message = json.dumps({"db": str(db_path)}, ensure_ascii=False)
except Exception as exc:
status, message = "failed", str(exc)[:1000]
print(f"crawl failed: {exc}", file=sys.stderr)
conn.execute(
"""
UPDATE crawl_runs SET finished_at=?, status=?, discovered_count=?,
inserted_count=?, updated_count=?, failed_count=?, message=?
WHERE id=?
""",
(now_iso(), status, discovered_count, inserted_count, updated_count, failed_count, message, run_id),
)
conn.commit()
print(json.dumps({
"status": status, "source_type": args.source_type,
"discovered": discovered_count, "inserted": inserted_count,
"updated": updated_count, "failed": failed_count,
}, ensure_ascii=False, flush=True))
return 0 if status in {"success", "partial"} else 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="餐饮内容采集管道")
parser.add_argument("--db", default=str(BASE_DIR / "data" / "cibank.db"), help="SQLite 数据库路径")
parser.add_argument("--list-url", default=DEFAULT_LIST_URL, help="公众号列表页URL")
parser.add_argument("--source-type", choices=["公众号", "视频号"], default="公众号", help="采集来源类型")
parser.add_argument("--max-retries", type=int, default=3)
parser.add_argument("--headed", action="store_true", help="显示浏览器(调试用)")
return parser
if __name__ == "__main__":
raise SystemExit(run_crawl(build_parser().parse_args()))
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import fcntl
import json
import os
import sqlite3
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")
DATA_DIR = BASE_DIR / "data"
DB_PATH = Path(os.getenv("CIBANK_DB", DATA_DIR / "cibank.db"))
LOCK_PATH = DATA_DIR / "pipeline.lock"
LOG_DIR = DATA_DIR / "logs"
BACKUP_DIR = DATA_DIR / "backups"
SCHEMA_PATH = BASE_DIR / "schema.sql"
def now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def open_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA_PATH.read_text(encoding="utf-8"))
return conn
def acquire_lock():
DATA_DIR.mkdir(parents=True, exist_ok=True)
lock_file = LOCK_PATH.open("a+")
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
lock_file.close()
raise RuntimeError("已有流水线任务正在运行") from exc
lock_file.seek(0)
lock_file.truncate()
lock_file.write(f"{os.getpid()}\n{now_iso()}\n")
lock_file.flush()
return lock_file
def backup_database() -> str:
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
backup_path = BACKUP_DIR / f"cibank-{datetime.now().strftime('%Y%m%d-%H%M%S')}.db"
source = sqlite3.connect(DB_PATH)
target = sqlite3.connect(backup_path)
try:
source.backup(target)
finally:
target.close()
source.close()
backups = sorted(BACKUP_DIR.glob("cibank-*.db"), key=lambda path: path.stat().st_mtime, reverse=True)
for expired in backups[14:]:
expired.unlink()
return str(backup_path)
def create_run(run_type: str) -> int:
timestamp = now_iso()
with open_db() as conn:
cursor = conn.execute(
"""
INSERT INTO pipeline_runs
(run_type, started_at, status, current_step, steps_json, created_at)
VALUES (?, ?, 'running', 'preflight', '{}', ?)
""",
(run_type, timestamp, timestamp),
)
conn.commit()
return cursor.lastrowid
def update_run(run_id: int, **values) -> None:
allowed = {"finished_at", "status", "current_step", "steps_json", "error_message"}
fields = [(key, value) for key, value in values.items() if key in allowed]
if not fields:
return
assignments = ", ".join(f"{key}=?" for key, _ in fields)
with open_db() as conn:
conn.execute(
f"UPDATE pipeline_runs SET {assignments} WHERE id=?",
[value for _, value in fields] + [run_id],
)
conn.commit()
def run_step(run_id: int, name: str, arguments: list[str], log_file, steps: dict) -> bool:
update_run(run_id, current_step=name)
started = time.monotonic()
log_file.write(f"\n[{now_iso()}] START {name}: {' '.join(arguments)}\n")
log_file.flush()
env = os.environ.copy()
env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:" + env.get("PATH", "")
result = subprocess.run(
[sys.executable, *arguments],
cwd=BASE_DIR,
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
text=True,
check=False,
)
duration = round(time.monotonic() - started, 2)
steps[name] = {"status": "success" if result.returncode == 0 else "failed", "returncode": result.returncode, "duration": duration}
update_run(run_id, steps_json=json.dumps(steps, ensure_ascii=False))
log_file.write(f"[{now_iso()}] END {name}: code={result.returncode}, duration={duration}s\n")
log_file.flush()
return result.returncode == 0
def validate_environment() -> None:
if not DB_PATH.exists():
raise RuntimeError(f"数据库不存在:{DB_PATH}")
api_key = os.getenv("DASHSCOPE_API_KEY", "").strip()
if not api_key or api_key == "replace_with_your_key":
raise RuntimeError("DASHSCOPE_API_KEY 未配置")
with sqlite3.connect(DB_PATH) as conn:
result = conn.execute("PRAGMA integrity_check").fetchone()[0]
if result != "ok":
raise RuntimeError(f"数据库完整性检查失败:{result}")
def show_status() -> int:
with open_db() as conn:
row = conn.execute("SELECT * FROM pipeline_runs ORDER BY id DESC LIMIT 1").fetchone()
print(json.dumps(dict(row) if row else {"status": "never_run"}, ensure_ascii=False, indent=2))
return 0
def run_pipeline(run_type: str, skip_crawl: bool) -> int:
lock_file = acquire_lock()
run_id = None
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_path = LOG_DIR / f"pipeline-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log"
try:
validate_environment()
run_id = create_run(run_type)
steps = {"backup": {"status": "success", "path": backup_database()}}
update_run(run_id, steps_json=json.dumps(steps, ensure_ascii=False))
commands = []
if not skip_crawl:
commands.extend([
("crawl_mp", ["crawler.py", "--source-type", "公众号"]),
("crawl_video", ["crawler.py", "--source-type", "视频号"]),
])
analyze_args = ["structurer.py", "analyze", "--pause", "0.6", "--retries", "3"]
embed_args = ["structurer.py", "embed"]
if run_type == "full":
analyze_args.append("--force")
embed_args.append("--force")
commands.extend([
("analyze", analyze_args),
("embed", embed_args),
("signals", ["structurer.py", "signals", "--days", "30"]),
])
all_success = True
with log_path.open("a", encoding="utf-8") as log_file:
log_file.write(f"[{now_iso()}] pipeline run_id={run_id}, type={run_type}\n")
for name, arguments in commands:
if not run_step(run_id, name, arguments, log_file, steps):
all_success = False
status = "success" if all_success else "partial"
update_run(run_id, finished_at=now_iso(), status=status, current_step="completed", steps_json=json.dumps(steps, ensure_ascii=False))
print(json.dumps({"run_id": run_id, "status": status, "log": str(log_path), "steps": steps}, ensure_ascii=False, indent=2))
return 0 if all_success else 1
except Exception as exc:
if run_id is not None:
update_run(run_id, finished_at=now_iso(), status="failed", current_step="failed", error_message=str(exc))
print(json.dumps({"status": "failed", "error": str(exc), "log": str(log_path)}, ensure_ascii=False), file=sys.stderr)
return 1
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
lock_file.close()
def main() -> int:
parser = argparse.ArgumentParser(description="CIBank 自动增量处理流水线")
parser.add_argument("command", choices=["daily", "full", "status"])
parser.add_argument("--skip-crawl", action="store_true")
args = parser.parse_args()
if args.command == "status":
return show_status()
return run_pipeline(args.command, args.skip_crawl)
if __name__ == "__main__":
raise SystemExit(main())
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env python3
"""RAG 问答模块:向量检索 + LLM 生成。
流程:
1. 将用户问题转为向量
2. 在 embeddings 表中做余弦相似度检索,取 TOP-K 相关文章
3. 将检索到的文章内容作为上下文,调用千问生成回答
4. 返回回答 + 引用来源(可追溯)
"""
from __future__ import annotations
import json
import os
import sqlite3
import urllib.error
import urllib.request
from pathlib import Path
import numpy as np
from dotenv import load_dotenv
from structurer import get_api_key, get_embeddings, DASHSCOPE_CHAT_URL
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""计算两个向量的余弦相似度。"""
norm_a = np.linalg.norm(a)
norm_b = np.linalg.norm(b)
if norm_a == 0 or norm_b == 0:
return 0.0
return float(np.dot(a, b) / (norm_a * norm_b))
def vector_search(conn: sqlite3.Connection, query_embedding: list[float], top_k: int = 5) -> list[dict]:
"""向量检索:在 embeddings 表中找到与查询向量最相似的文章。"""
query_vec = np.array(query_embedding, dtype=np.float32)
rows = conn.execute(
"""
SELECT e.article_id, e.embedding, e.chunk_text,
a.title, a.source_name, a.source_type, a.published_at, a.summary,
x.result_json
FROM embeddings e
JOIN articles a ON a.id = e.article_id
LEFT JOIN ai_analyses x ON x.id = (
SELECT x2.id FROM ai_analyses x2
WHERE x2.article_id = e.article_id AND x2.analysis_type = 'editorial'
ORDER BY x2.updated_at DESC, x2.id DESC LIMIT 1
)
WHERE a.crawl_status = 'success'
"""
).fetchall()
scored = []
for row in rows:
emb = json.loads(row["embedding"])
emb_vec = np.array(emb, dtype=np.float32)
score = cosine_similarity(query_vec, emb_vec)
scored.append((score, row))
scored.sort(key=lambda x: x[0], reverse=True)
results = []
seen_articles = set()
for score, row in scored:
if row["article_id"] in seen_articles:
continue
seen_articles.add(row["article_id"])
analysis = json.loads(row["result_json"]) if row["result_json"] else {}
results.append({
"article_id": row["article_id"],
"title": row["title"],
"source": row["source_name"] or row["source_type"],
"date": (row["published_at"] or "")[:10],
"summary": analysis.get("summary") or row["summary"] or "",
"key_points": analysis.get("key_points", []),
"content": row["chunk_text"] or "",
"score": score,
})
if len(results) >= top_k:
break
return results
def generate_answer(question: str, contexts: list[dict]) -> dict:
"""基于检索到的上下文,调用千问生成回答。"""
api_key = get_api_key()
model = os.getenv("QWEN_MODEL", "qwen-plus")
# 构建上下文
context_text = ""
sources = []
for i, ctx in enumerate(contexts, start=1):
context_text += f"\n--- 来源{i} ---\n标题:{ctx['title']}\n摘要:{ctx['summary']}\n要点:{'; '.join(ctx['key_points'][:3])}\n原文片段:{ctx['content']}\n"
sources.append({
"title": ctx["title"],
"source": ctx["source"],
"date": ctx["date"],
})
prompt = f"""你是餐饮行业知识库的问答助手。根据以下知识库中的情报内容回答用户问题。
要求:
1. 回答必须基于提供的来源内容,不要编造信息
2. 如果来源内容不足以回答问题,明确说明"知识库中暂无直接相关信息"
3. 回答要结构化、简洁、有经营参考价值
4. 用中文回答
知识库来源:
{context_text}
用户问题:{question}"""
payload = json.dumps({
"model": model,
"messages": [
{"role": "system", "content": "你是餐饮行业知识库问答助手,基于库内情报给出有出处的专业回答。"},
{"role": "user", "content": prompt},
],
"temperature": 0.3,
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
DASHSCOPE_CHAT_URL,
data=payload,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=90) as response:
body = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"千问接口返回 {exc.code}{detail}") from exc
answer = body["choices"][0]["message"]["content"].strip()
return {
"answer": answer,
"sources": sources,
"contexts": contexts,
}
def generate_answer_stream(question: str, contexts: list[dict]):
"""流式生成回答,逐 token yield。"""
api_key = get_api_key()
model = os.getenv("QWEN_MODEL", "qwen-plus")
context_text = ""
sources = []
for i, ctx in enumerate(contexts, start=1):
context_text += f"\n--- 来源{i} ---\n标题:{ctx['title']}\n摘要:{ctx['summary']}\n要点:{'; '.join(ctx['key_points'][:3])}\n原文片段:{ctx['content']}\n"
sources.append({
"title": ctx["title"],
"source": ctx["source"],
"date": ctx["date"],
})
prompt = f"""你是餐饮行业知识库的问答助手。根据以下知识库中的情报内容回答用户问题。
要求:
1. 回答必须基于提供的来源内容,不要编造信息
2. 如果来源内容不足以回答问题,明确说明"知识库中暂无直接相关信息"
3. 回答要结构化、简洁、有经营参考价值
4. 用中文回答,使用 Markdown 格式
知识库来源:
{context_text}
用户问题:{question}"""
payload = json.dumps({
"model": model,
"messages": [
{"role": "system", "content": "你是餐饮行业知识库问答助手,基于库内情报给出有出处的专业回答。使用 Markdown 格式输出。"},
{"role": "user", "content": prompt},
],
"temperature": 0.3,
"stream": True,
"stream_options": {"include_usage": False},
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
DASHSCOPE_CHAT_URL,
data=payload,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
method="POST",
)
try:
resp = urllib.request.urlopen(req, timeout=120)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"千问接口返回 {exc.code}{detail}") from exc
buffer = b""
while True:
chunk = resp.read(4096)
if not chunk:
break
buffer += chunk
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
line = line.strip()
if not line or line == b"data: [DONE]":
continue
if line.startswith(b"data:"):
try:
data = json.loads(line[5:].strip())
delta = data.get("choices", [{}])[0].get("delta", {})
content = delta.get("content")
if content:
yield content
except (json.JSONDecodeError, IndexError, KeyError):
pass
resp.close()
yield json.dumps({"__sources__": sources}, ensure_ascii=False)
def rag_qa(question: str, top_k: int = 5) -> dict:
"""完整的 RAG 问答流程:向量检索 → LLM 生成。"""
db_path = Path(os.getenv("CIBANK_DB", str(BASE_DIR / "data" / "cibank.db")))
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
# 检查是否有向量数据
count = conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0]
if count == 0:
# 无向量数据时使用关键词检索作为降级方案
return keyword_fallback(conn, question)
# 1. 将问题转为向量
query_embeddings = get_embeddings([question])
# 2. 向量检索
contexts = vector_search(conn, query_embeddings[0], top_k)
if not contexts:
conn.close()
return {
"answer": "知识库中暂无与您问题相关的内容。请先运行采集和分析流程入库更多情报。",
"sources": [],
"contexts": [],
}
# 3. LLM 生成回答
result = generate_answer(question, contexts)
conn.close()
return result
def rag_qa_stream(question: str, top_k: int = 5):
"""流式 RAG 问答:先检索,再流式生成。yield (type, data) 元组。"""
db_path = Path(os.getenv("CIBANK_DB", str(BASE_DIR / "data" / "cibank.db")))
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
count = conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0]
if count == 0:
conn.close()
yield ("error", "知识库中暂无向量数据,请先运行采集和分析流程。")
return
query_embeddings = get_embeddings([question])
contexts = vector_search(conn, query_embeddings[0], top_k)
conn.close()
if not contexts:
yield ("error", "知识库中暂无与您问题相关的内容。")
return
sources = [
{"title": ctx["title"], "source": ctx["source"], "date": ctx["date"]}
for ctx in contexts
]
yield ("sources", sources)
for token in generate_answer_stream(question, contexts):
yield ("token", token)
def keyword_fallback(conn: sqlite3.Connection, question: str) -> dict:
"""无向量数据时的关键词检索降级方案。"""
rows = conn.execute(
"""
SELECT a.id, a.title, a.source_name, a.source_type, a.published_at, a.summary,
a.content, x.result_json
FROM articles a
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
WHERE a.crawl_status = 'success'
AND (a.title LIKE ? OR a.summary LIKE ? OR a.content LIKE ?)
ORDER BY a.published_at DESC
LIMIT 5
""",
(f"%{question}%", f"%{question}%", f"%{question}%"),
).fetchall()
if not rows:
conn.close()
return {
"answer": "知识库中暂无与您问题相关的内容。请先运行采集和分析流程入库更多情报。",
"sources": [],
"contexts": [],
}
contexts = []
for row in rows:
analysis = json.loads(row["result_json"]) if row["result_json"] else {}
contexts.append({
"article_id": row["id"],
"title": row["title"],
"source": row["source_name"] or row["source_type"],
"date": (row["published_at"] or "")[:10],
"summary": analysis.get("summary") or row["summary"] or "",
"key_points": analysis.get("key_points", []),
"content": row["content"][:3000] if row["content"] else "",
"score": 0.5,
})
conn.close()
return generate_answer(question, contexts)
if __name__ == "__main__":
import sys
question = " ".join(sys.argv[1:]) or "现在开茶饮店还有机会吗?"
result = rag_qa(question)
print(json.dumps(result, ensure_ascii=False, indent=2))
+5
View File
@@ -0,0 +1,5 @@
playwright>=1.45,<2
Flask>=3.0,<4
flask-cors>=4.0,<5
python-dotenv>=1.0,<2
numpy>=1.26,<3
Executable
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# 餐智库一键启动脚本
# 同时启动后端 API 服务和前端开发服务器
set -e
BACKEND_DIR="$(cd "$(dirname "$0")" && pwd)"
FRONTEND_DIR="$(cd "$(dirname "$0")/.." && pwd)"
echo "=== 餐智库启动 ==="
# 检查后端虚拟环境
if [ ! -d "$BACKEND_DIR/venv" ]; then
echo "[后端] 创建虚拟环境..."
python3 -m venv "$BACKEND_DIR/venv"
echo "[后端] 安装依赖..."
"$BACKEND_DIR/venv/bin/pip" install -r "$BACKEND_DIR/requirements.txt" -q
echo "[后端] 安装 Playwright 浏览器..."
"$BACKEND_DIR/venv/bin/playwright" install chromium
fi
# 检查 .env 文件
if [ ! -f "$BACKEND_DIR/.env" ]; then
echo "[后端] 复制 .env.example -> .env"
cp "$BACKEND_DIR/.env.example" "$BACKEND_DIR/.env"
echo "[提示] 请编辑 backend/.env 填入 DASHSCOPE_API_KEY"
fi
# 启动后端
echo "[后端] 启动 API 服务 (端口 8765)..."
"$BACKEND_DIR/venv/bin/python" "$BACKEND_DIR/app.py" &
BACKEND_PID=$!
echo "[后端] PID=$BACKEND_PID"
# 等待后端就绪
sleep 2
# 启动前端
echo "[前端] 启动开发服务器 (端口 3000)..."
cd "$FRONTEND_DIR"
npm run dev &
FRONTEND_PID=$!
echo "[前端] PID=$FRONTEND_PID"
echo ""
echo "=== 启动完成 ==="
echo "前端: http://localhost:3000"
echo "后端: http://localhost:8765/api/health"
echo ""
echo "按 Ctrl+C 停止所有服务"
trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit" INT TERM
wait
+154
View File
@@ -0,0 +1,154 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- 文章表:公众号文章 + 视频号ASR转写文本
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
canonical_url TEXT NOT NULL UNIQUE,
source TEXT NOT NULL DEFAULT 'hongcan', -- 来源平台
source_name TEXT, -- 公众号名称 / 视频号名称
source_type TEXT NOT NULL DEFAULT '公众号', -- 公众号 | 视频号
title TEXT,
author TEXT,
published_at TEXT,
summary TEXT,
content TEXT,
content_hash TEXT,
category TEXT, -- 赛道:茶饮咖啡/快餐/火锅/正餐/烘焙/供应链/综合
crawl_status TEXT NOT NULL DEFAULT 'pending', -- pending | success | failed
retry_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
media_url TEXT, -- 原始视频文件路径(视频号专用)
discovered_at TEXT NOT NULL,
crawled_at TEXT,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_articles_published_at ON articles(published_at);
CREATE INDEX IF NOT EXISTS idx_articles_crawl_status ON articles(crawl_status, retry_count);
CREATE INDEX IF NOT EXISTS idx_articles_source_type ON articles(source_type);
CREATE INDEX IF NOT EXISTS idx_articles_category ON articles(category);
-- 采集运行记录
CREATE TABLE IF NOT EXISTS crawl_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
finished_at TEXT,
status TEXT NOT NULL DEFAULT 'running',
source_type TEXT NOT NULL DEFAULT '公众号',
discovered_count INTEGER NOT NULL DEFAULT 0,
inserted_count INTEGER NOT NULL DEFAULT 0,
updated_count INTEGER NOT NULL DEFAULT 0,
failed_count INTEGER NOT NULL DEFAULT 0,
message TEXT
);
-- AI 结构化分析结果
CREATE TABLE IF NOT EXISTS ai_analyses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL,
model TEXT NOT NULL,
analysis_type TEXT NOT NULL DEFAULT 'editorial',
result_json TEXT NOT NULL, -- 完整结构化JSON
content_hash TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(article_id, model, analysis_type),
FOREIGN KEY(article_id) REFERENCES articles(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ai_analyses_article ON ai_analyses(article_id, updated_at);
-- 向量嵌入表(用于RAG检索)
CREATE TABLE IF NOT EXISTS embeddings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL DEFAULT 0,
chunk_text TEXT NOT NULL,
embedding BLOB, -- JSON序列化的float数组
model TEXT NOT NULL,
content_hash TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(article_id) REFERENCES articles(id) ON DELETE CASCADE,
UNIQUE(article_id, chunk_index, model)
);
CREATE INDEX IF NOT EXISTS idx_embeddings_article ON embeddings(article_id);
-- 品牌实体库
CREATE TABLE IF NOT EXISTS brands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
category TEXT,
stores INTEGER DEFAULT 0,
avg_price INTEGER DEFAULT 0,
model TEXT, -- 直营 | 加盟 | 直营+加盟
city_tier_json TEXT DEFAULT '[]', -- [{tier, pct}]
trend_json TEXT DEFAULT '[]', -- 近12个月门店数
latest_news TEXT,
news_date TEXT,
growth REAL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 行业信号(跨文章聚合)
CREATE TABLE IF NOT EXISTS industry_signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
signal_date TEXT NOT NULL,
title TEXT NOT NULL,
summary TEXT NOT NULL,
trend TEXT NOT NULL DEFAULT 'emerging',
confidence REAL NOT NULL DEFAULT 0.5,
implications_json TEXT NOT NULL DEFAULT '[]',
article_ids_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
model TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(signal_date, title)
);
CREATE INDEX IF NOT EXISTS idx_industry_signals_date ON industry_signals(signal_date DESC, confidence DESC);
-- 报告表
CREATE TABLE IF NOT EXISTS reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
report_date TEXT NOT NULL,
period TEXT,
highlights_json TEXT NOT NULL DEFAULT '[]',
sections_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_reports_date ON reports(report_date DESC);
CREATE TABLE IF NOT EXISTS pipeline_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_type TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
status TEXT NOT NULL DEFAULT 'running',
current_step TEXT,
steps_json TEXT NOT NULL DEFAULT '{}',
error_message TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pipeline_runs_started ON pipeline_runs(started_at DESC);
-- 信源配置表(公众号 + 视频号)
CREATE TABLE IF NOT EXISTS sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_type TEXT NOT NULL DEFAULT '公众号', -- 公众号 | 视频号
name TEXT NOT NULL, -- 信源名称(公众号名称/视频号账号名)
finder TEXT, -- 视频号 finder ID(视频号专用)
list_url TEXT, -- 列表页URL(公众号专用)
download_dir TEXT, -- 下载目录(视频号专用)
enabled INTEGER NOT NULL DEFAULT 1, -- 是否启用 1/0
last_crawled_at TEXT, -- 最近采集时间
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(source_type, name)
);
+693
View File
@@ -0,0 +1,693 @@
#!/usr/bin/env python3
"""结构化引擎:大模型自动打标签、摘要、提取要点、识别品牌实体。
对每篇采集入库的文章调用通义千问,输出结构化JSON:
- summary: 120字内摘要
- key_points: 3-6条关键要点
- tags: 5-10个标签
- brands: 识别到的品牌实体
- category: 赛道分类
- type: 情报类型(政策监管/品牌动态/品类趋势/经营干货/供应链/消费洞察)
- score: 价值评分 0-100
- timeliness: 时效性(高/中/低)
- sentiment: 情感倾向
- content_angles: 可延展选题
- risk_notes: 事实或表述风险
- numbers: 关键数据
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sqlite3
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")
DASHSCOPE_CHAT_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
DASHSCOPE_EMBED_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def get_api_key() -> str:
key = os.getenv("DASHSCOPE_API_KEY", "").strip()
if not key:
raise RuntimeError("尚未配置 DASHSCOPE_API_KEY,请在 .env 中设置")
return key
# ---- 单篇文章结构化分析 ----
def analyze_article(title: str, content: str) -> dict:
"""调用通义千问对文章进行结构化分析。"""
api_key = get_api_key()
model = os.getenv("QWEN_MODEL", "qwen-plus")
prompt = f"""你是一名资深餐饮行业研究编辑。分析下面这篇文章,只返回合法 JSON,不要 Markdown 代码块。
JSON 字段必须为:
- summary120字内摘要)
- key_points3-6条字符串,关键要点)
- tags5-10个标签)
- brands(品牌数组,每个元素为对象:{{"name":"品牌名","stores":门店数整数或0,"avg_price":人均消费整数或0,"model":"直营/加盟/直营+加盟"}},仅填文中明确提及的数据,未提及的填0或空字符串)
- category(赛道分类,从以下选择:茶饮咖啡/快餐/火锅/正餐/烘焙/供应链/综合)
- type(情报类型,从以下选择:政策监管/品牌动态/品类趋势/经营干货/供应链/消费洞察)
- score(价值评分0-100整数,越高越有经营参考价值)
- timeliness(时效性:高/中/低)
- sentimentpositive/neutral/negative
- content_angles3条可延展选题)
- risk_notes(事实或表述风险数组)
- numbers(关键数据数组)
文章标题:{title}
正文:{content[:24000]}"""
payload = json.dumps({
"model": model,
"messages": [
{"role": "system", "content": "你输出严谨、简洁、可供编辑部直接使用的结构化行业分析。"},
{"role": "user", "content": prompt},
],
"temperature": 0.25,
"response_format": {"type": "json_object"},
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
DASHSCOPE_CHAT_URL,
data=payload,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=90) as response:
body = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"千问接口返回 {exc.code}{detail}") from exc
if "choices" not in body or not body["choices"]:
raise RuntimeError(f"千问返回异常:{json.dumps(body, ensure_ascii=False)[:300]}")
text = body["choices"][0]["message"]["content"].strip()
if text.startswith("```"):
text = text.strip("`").removeprefix("json").strip()
try:
return json.loads(text)
except Exception as e:
raise RuntimeError(f"JSON解析失败: {e}\n{text[:200]}") from e
# ---- 跨文章行业信号提取 ----
def extract_industry_signals(items: list[dict], model: str) -> list[dict]:
"""从多篇文章的结构化分析中提取跨文章行业信号。"""
api_key = get_api_key()
prompt = f"""你是餐饮产业首席分析师。根据以下多篇文章的结构化分析,识别跨文章、可验证、有经营意义的行业信号。
只返回合法 JSON{{"signals":[...]}}。每个 signal 必须包含:
- title(短标题)
- summary100-180字)
- trendemerging/accelerating/stable/declining
- confidence0到1
- article_ids(至少2个证据文章ID
- implications2-4条经营启示)
- tags3-6个标签)
不要把单一品牌新闻简单改写成行业信号;合并重复主题;最多输出8条。
输入:{json.dumps(items, ensure_ascii=False)}"""
body = json.dumps({
"model": model,
"messages": [
{"role": "system", "content": "你进行基于证据的餐饮行业趋势聚类,避免空泛结论。"},
{"role": "user", "content": prompt},
],
"temperature": 0.2,
"response_format": {"type": "json_object"},
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
DASHSCOPE_CHAT_URL,
data=body,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=180) as response:
result = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
raise RuntimeError(f"千问接口返回 {exc.code}{exc.read().decode('utf-8', errors='replace')[:500]}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"千问接口网络超时:{exc}") from exc
text = result["choices"][0]["message"]["content"].strip()
if text.startswith("```"):
text = text.strip("`").removeprefix("json").strip()
return json.loads(text).get("signals", [])
# ---- 文本向量嵌入 ----
def get_embeddings(texts: list[str]) -> list[list[float]]:
"""调用通义千问文本向量模型,批量获取向量。"""
api_key = get_api_key()
model = os.getenv("EMBEDDING_MODEL", "text-embedding-v3")
payload = json.dumps({
"model": model,
"input": texts,
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
DASHSCOPE_EMBED_URL,
data=payload,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as response:
body = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"向量接口返回 {exc.code}{detail}") from exc
if "output" in body:
return [item["embedding"] for item in body["output"]["embeddings"]]
return [item["embedding"] for item in body["data"]]
# ---- 品牌实体入库 ----
def upsert_brand(conn: sqlite3.Connection, brand_name: str, category: str | None = None,
stores: int = 0, avg_price: int = 0, model: str | None = None,
latest_news: str | None = None, news_date: str | None = None) -> None:
"""将识别到的品牌实体写入品牌表,有新数据时更新。"""
existing = conn.execute("SELECT id, stores, avg_price FROM brands WHERE name=?", (brand_name,)).fetchone()
timestamp = now_iso()
if existing:
# 只在新值更大或非空时更新,避免旧数据被覆盖
new_stores = max(existing["stores"], stores) if stores > 0 else existing["stores"]
new_avg_price = avg_price if avg_price > 0 else existing["avg_price"]
conn.execute(
"""
UPDATE brands SET category=COALESCE(?, category),
stores=?, avg_price=?,
model=COALESCE(NULLIF(?, ''), model),
latest_news=COALESCE(NULLIF(?, ''), latest_news),
news_date=COALESCE(NULLIF(?, ''), news_date),
updated_at=?
WHERE id=?
""",
(category, new_stores, new_avg_price, model or "", latest_news or "", news_date or "", timestamp, existing["id"]),
)
else:
conn.execute(
"""
INSERT INTO brands (name, category, stores, avg_price, model, city_tier_json, trend_json,
latest_news, news_date, growth, created_at, updated_at)
VALUES (?, ?, ?, ?, NULLIF(?, ''), '[]', '[]', NULLIF(?, ''), NULLIF(?, ''), 0, ?, ?)
""",
(brand_name, category, stores, avg_price, model or "", latest_news or "", news_date or "", timestamp, timestamp),
)
conn.commit()
# ---- 批量分析主流程 ----
def run_batch_analyze(force: bool, pause: float, retries: int) -> int:
"""批量对所有成功采集的文章执行结构化分析。"""
db_path = Path(os.getenv("CIBANK_DB", str(BASE_DIR / "data" / "cibank.db")))
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.executescript((BASE_DIR / "schema.sql").read_text(encoding="utf-8"))
model = os.getenv("QWEN_MODEL", "qwen-plus")
rows = conn.execute(
"""
SELECT a.id, a.title, a.content, a.content_hash, a.published_at, x.content_hash analyzed_hash
FROM articles a
LEFT JOIN ai_analyses x
ON x.article_id = a.id AND x.model = ? AND x.analysis_type = 'editorial'
WHERE a.crawl_status = 'success'
ORDER BY a.published_at DESC, a.id DESC
""",
(model,),
).fetchall()
pending = [r for r in rows if force or not r["analyzed_hash"] or r["analyzed_hash"] != r["content_hash"]]
print(json.dumps({"total": len(rows), "pending": len(pending), "model": model}, ensure_ascii=False), flush=True)
success = skipped = failed = 0
for index, row in enumerate(rows, start=1):
if not force and row["analyzed_hash"] == row["content_hash"]:
skipped += 1
continue
last_error = None
for attempt in range(1, retries + 2):
try:
result = analyze_article(row["title"], row["content"])
timestamp = now_iso()
conn.execute(
"""
INSERT INTO ai_analyses(article_id, model, analysis_type, result_json, content_hash, created_at, updated_at)
VALUES (?, ?, 'editorial', ?, ?, ?, ?)
ON CONFLICT(article_id, model, analysis_type) DO UPDATE SET
result_json = excluded.result_json, content_hash = excluded.content_hash, updated_at = excluded.updated_at
""",
(row["id"], model, json.dumps(result, ensure_ascii=False), row["content_hash"], timestamp, timestamp),
)
# 更新文章的category字段
if result.get("category"):
conn.execute("UPDATE articles SET category=? WHERE id=?", (result["category"], row["id"]))
# 品牌实体入库
for brand in result.get("brands", []):
if isinstance(brand, str):
upsert_brand(conn, brand, result.get("category"))
elif isinstance(brand, dict):
upsert_brand(conn, brand.get("name", ""), result.get("category"),
int(brand.get("stores", 0) or 0), int(brand.get("avg_price", 0) or 0),
brand.get("model"), row["title"], (row["published_at"] or "")[:10])
conn.commit()
success += 1
print(f"[{index}/{len(rows)}] OK {row['title']}", flush=True)
last_error = None
break
except Exception as exc:
last_error = exc
if attempt <= retries:
wait = attempt * 2
print(f"[{index}/{len(rows)}] RETRY {attempt}/{retries} {exc}", flush=True)
time.sleep(wait)
if last_error is not None:
failed += 1
print(f"[{index}/{len(rows)}] FAIL {row['title']}: {last_error}", flush=True)
time.sleep(pause)
total_analyzed = conn.execute("SELECT COUNT(DISTINCT article_id) FROM ai_analyses").fetchone()[0]
conn.close()
print(json.dumps({
"success": success, "skipped": skipped, "failed": failed,
"total_analyzed": total_analyzed,
}, ensure_ascii=False), flush=True)
return 0 if failed == 0 else 1
def run_extract_signals(days: int) -> int:
"""提取跨文章行业信号。"""
db_path = Path(os.getenv("CIBANK_DB", str(BASE_DIR / "data" / "cibank.db")))
conn = sqlite3.connect(db_path, timeout=30)
conn.row_factory = sqlite3.Row
conn.executescript((BASE_DIR / "schema.sql").read_text(encoding="utf-8"))
model = os.getenv("QWEN_MODEL", "qwen-plus")
from datetime import datetime, timedelta
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S")
rows = conn.execute(
"""
SELECT a.id, a.title, a.published_at, x.result_json
FROM articles a JOIN ai_analyses x ON x.article_id = a.id
WHERE a.crawl_status = 'success' AND a.published_at >= ?
ORDER BY a.published_at DESC
""",
(cutoff,),
).fetchall()
items = []
for row in rows:
analysis = json.loads(row["result_json"])
items.append({
"article_id": row["id"], "title": row["title"], "published_at": row["published_at"],
"summary": analysis.get("summary"), "key_points": analysis.get("key_points", []),
"type": analysis.get("type"), "tags": analysis.get("tags", []),
"brands": analysis.get("brands", []), "score": analysis.get("score", 0),
})
if not items:
print(json.dumps({"status": "skipped", "reason": "没有已分析文章"}, ensure_ascii=False))
return 0
# 分批提取信号,避免一次性传入过多内容触发内容审查
signal_date = datetime.now().astimezone().date().isoformat()
timestamp = now_iso()
conn.execute("DELETE FROM industry_signals WHERE signal_date=?", (signal_date,))
conn.commit()
all_signals = []
batch_size = 20
total_batches = (len(items) + batch_size - 1) // batch_size
for i in range(0, len(items), batch_size):
batch_num = i // batch_size + 1
batch = items[i:i + batch_size]
print(f" 信号批次 {batch_num}/{total_batches}{len(batch)}", flush=True)
for attempt in range(2):
try:
batch_signals = extract_industry_signals(batch, model)
all_signals.extend(batch_signals)
# 每批成功后立即写入数据库
for signal in batch_signals:
conn.execute(
"""
INSERT OR IGNORE INTO industry_signals
(signal_date, title, summary, trend, confidence, implications_json, article_ids_json, tags_json, model, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
signal_date, signal["title"], signal["summary"], signal.get("trend", "emerging"),
float(signal.get("confidence", 0.5)), json.dumps(signal.get("implications", []), ensure_ascii=False),
json.dumps(signal.get("article_ids", [])), json.dumps(signal.get("tags", []), ensure_ascii=False),
model, timestamp, timestamp,
),
)
conn.commit()
break
except RuntimeError as exc:
print(f" 批次 {batch_num} 失败(尝试{attempt+1}): {exc}", flush=True)
if attempt == 0:
import time; time.sleep(2)
continue
except Exception as exc:
print(f" 批次 {batch_num} 异常(尝试{attempt+1}): {type(exc).__name__}: {exc}", flush=True)
if attempt == 0:
import time; time.sleep(2)
continue
# 合并去重:按标题去重,保留置信度更高的
seen = {}
for sig in all_signals:
title = sig.get("title", "")
conf = float(sig.get("confidence", 0))
if title not in seen or conf > float(seen[title].get("confidence", 0)):
seen[title] = sig
signals = list(seen.values())[:8]
# 用去重后的结果替换当天信号
conn.execute("DELETE FROM industry_signals WHERE signal_date=?", (signal_date,))
for signal in signals:
conn.execute(
"""
INSERT INTO industry_signals
(signal_date, title, summary, trend, confidence, implications_json, article_ids_json, tags_json, model, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
signal_date, signal["title"], signal["summary"], signal.get("trend", "emerging"),
float(signal.get("confidence", 0.5)), json.dumps(signal.get("implications", []), ensure_ascii=False),
json.dumps(signal.get("article_ids", [])), json.dumps(signal.get("tags", []), ensure_ascii=False),
model, timestamp, timestamp,
),
)
conn.commit()
conn.close()
print(json.dumps({"status": "success", "input_articles": len(items), "signals": len(signals), "date": signal_date}, ensure_ascii=False))
return 0
# ---- 向量嵌入生成 ----
def ensure_embedding_schema(conn: sqlite3.Connection) -> None:
"""兼容已有数据库,为向量表补齐增量更新所需字段与唯一索引。"""
columns = {row[1] for row in conn.execute("PRAGMA table_info(embeddings)").fetchall()}
if "content_hash" not in columns:
conn.execute("ALTER TABLE embeddings ADD COLUMN content_hash TEXT")
conn.execute(
"""
DELETE FROM embeddings
WHERE id NOT IN (
SELECT MAX(id) FROM embeddings GROUP BY article_id, chunk_index, model
)
"""
)
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_embeddings_unique_chunk "
"ON embeddings(article_id, chunk_index, model)"
)
conn.commit()
def split_text(text: str, chunk_size: int = 800, overlap: int = 120) -> list[str]:
"""将长文本切分为有重叠的中文检索片段。"""
normalized = "\n".join(line.strip() for line in text.splitlines() if line.strip())
if not normalized:
return []
chunks = []
start = 0
while start < len(normalized):
end = min(len(normalized), start + chunk_size)
if end < len(normalized):
candidates = [normalized.rfind(mark, start + chunk_size // 2, end) for mark in "。!?;\n"]
boundary = max(candidates)
if boundary > start:
end = boundary + 1
chunks.append(normalized[start:end])
if end >= len(normalized):
break
start = max(start + 1, end - overlap)
return chunks
def build_embedding_chunks(row: sqlite3.Row) -> tuple[list[str], str]:
"""构建包含标题、AI摘要和完整原文的向量分块。"""
analysis = json.loads(row["result_json"]) if row["result_json"] else {}
header_parts = [f"标题:{row['title'] or ''}"]
summary = analysis.get("summary") or row["summary"] or ""
if summary:
header_parts.append(f"摘要:{summary}")
key_points = analysis.get("key_points") or []
if key_points:
header_parts.append("要点:" + "".join(str(item) for item in key_points))
tags = analysis.get("tags") or []
if tags:
header_parts.append("标签:" + "".join(str(item) for item in tags))
header = "\n".join(header_parts)
content_chunks = split_text(row["content"] or "") or [""]
chunks = [f"{header}\n正文片段:{content}".strip() for content in content_chunks]
source_hash = hashlib.sha256("\n\n".join(chunks).encode("utf-8")).hexdigest()
return chunks, source_hash
def run_generate_embeddings(force: bool) -> int:
"""为所有成功文章增量生成分块向量,用于RAG检索。"""
db_path = Path(os.getenv("CIBANK_DB", str(BASE_DIR / "data" / "cibank.db")))
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.executescript((BASE_DIR / "schema.sql").read_text(encoding="utf-8"))
ensure_embedding_schema(conn)
model = os.getenv("EMBEDDING_MODEL", "text-embedding-v3")
rows = conn.execute(
"""
SELECT a.id, a.title, a.summary, a.content, x.result_json
FROM articles a
LEFT JOIN ai_analyses x ON x.id = (
SELECT x2.id FROM ai_analyses x2
WHERE x2.article_id = a.id AND x2.analysis_type = 'editorial'
ORDER BY x2.updated_at DESC, x2.id DESC LIMIT 1
)
WHERE a.crawl_status = 'success'
ORDER BY a.id
""",
).fetchall()
success = skipped = failed = chunks_written = 0
for row in rows:
chunks, source_hash = build_embedding_chunks(row)
existing = conn.execute(
"SELECT content_hash FROM embeddings WHERE article_id=? AND model=? LIMIT 1",
(row["id"], model),
).fetchone()
if not force and existing and existing["content_hash"] == source_hash:
skipped += 1
continue
try:
vectors = []
for start in range(0, len(chunks), 10):
vectors.extend(get_embeddings(chunks[start:start + 10]))
if len(vectors) != len(chunks):
raise RuntimeError(f"向量数量异常:期望 {len(chunks)},实际 {len(vectors)}")
timestamp = now_iso()
conn.execute("BEGIN")
conn.execute("DELETE FROM embeddings WHERE article_id=? AND model=?", (row["id"], model))
conn.executemany(
"""
INSERT INTO embeddings
(article_id, chunk_index, chunk_text, embedding, model, content_hash, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[
(row["id"], index, chunk, json.dumps(vector), model, source_hash, timestamp)
for index, (chunk, vector) in enumerate(zip(chunks, vectors))
],
)
conn.commit()
success += 1
chunks_written += len(chunks)
except Exception as exc:
conn.rollback()
failed += 1
print(f"FAIL article {row['id']}: {exc}", flush=True)
conn.close()
print(json.dumps({
"success": success, "skipped": skipped, "failed": failed,
"chunks_written": chunks_written, "model": model,
}, ensure_ascii=False))
return 0 if failed == 0 else 1
def run_generate_report(days: int = 7) -> int:
"""生成周期报告:聚合近期文章分析结果,调用LLM生成结构化报告。"""
db_path = Path(os.getenv("CIBANK_DB", str(BASE_DIR / "data" / "cibank.db")))
conn = sqlite3.connect(db_path, timeout=30)
conn.row_factory = sqlite3.Row
conn.executescript((BASE_DIR / "schema.sql").read_text(encoding="utf-8"))
model = os.getenv("QWEN_MODEL", "qwen-plus")
from datetime import datetime, timedelta
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
rows = conn.execute(
"""
SELECT a.id, a.title, a.source_name, a.published_at, a.category,
x.result_json
FROM articles a
JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
WHERE a.crawl_status = 'success' AND a.published_at >= ?
ORDER BY a.published_at DESC
LIMIT 50
""",
(cutoff,),
).fetchall()
if not rows:
print(json.dumps({"status": "skip", "reason": "近期无分析数据"}, ensure_ascii=False))
return 0
items = []
for r in rows:
analysis = json.loads(r["result_json"]) if r["result_json"] else {}
items.append({
"title": r["title"],
"source": r["source_name"] or "",
"date": (r["published_at"] or "")[:10],
"category": r["category"] or analysis.get("category", ""),
"type": analysis.get("type", ""),
"summary": analysis.get("summary", ""),
"key_points": analysis.get("key_points", [])[:3],
"score": analysis.get("score", 0),
"brands": [b["name"] if isinstance(b, dict) else b for b in analysis.get("brands", [])][:3],
})
api_key = get_api_key()
period_label = f"{days}"
prompt = f"""你是餐饮行业首席分析师。根据以下{len(items)}篇情报的结构化分析,生成一份{period_label}餐饮行业经营决策简报。
只返回合法 JSON,不要 Markdown 代码块。格式:
{{
"title": "报告标题(含日期范围)",
"highlights": ["核心要点1", "核心要点2", "核心要点3", "核心要点4"],
"sections": [
{{"heading": "板块标题", "body": "详细分析正文(200-400字)"}},
{{"heading": "板块标题", "body": "详细分析正文(200-400字)"}},
{{"heading": "板块标题", "body": "详细分析正文(200-400字)"}},
{{"heading": "经营建议", "body": "基于以上分析的经营建议(200-300字)"}}
]
}}
要求:
1. highlights 为4-6条核心洞察,每条15-30字
2. sections 至少4个板块,覆盖赛道趋势、品牌动态、政策/供应链、经营建议
3. 内容必须基于提供的情报数据,不要编造
4. 语言专业、简洁、有决策参考价值
情报数据:
{json.dumps(items, ensure_ascii=False)}"""
payload = json.dumps({
"model": model,
"messages": [
{"role": "system", "content": "你是餐饮行业首席分析师,输出严谨的经营决策简报。"},
{"role": "user", "content": prompt},
],
"temperature": 0.3,
"response_format": {"type": "json_object"},
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
DASHSCOPE_CHAT_URL,
data=payload,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=120) as response:
body = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
print(json.dumps({"status": "error", "error": f"千问接口返回 {exc.code}{detail}"}, ensure_ascii=False))
return 1
text = body["choices"][0]["message"]["content"].strip()
if text.startswith("```"):
text = text.strip("`").removeprefix("json").strip()
result = json.loads(text)
report_date = datetime.now().strftime("%Y-%m-%d")
timestamp = now_iso()
conn.execute(
"""
INSERT INTO reports (title, report_date, period, highlights_json, sections_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
result["title"],
report_date,
period_label,
json.dumps(result["highlights"], ensure_ascii=False),
json.dumps(result["sections"], ensure_ascii=False),
timestamp,
),
)
conn.commit()
conn.close()
print(json.dumps({
"status": "success",
"title": result["title"],
"date": report_date,
"highlights": len(result["highlights"]),
"sections": len(result["sections"]),
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="结构化引擎:LLM分析+品牌识别+信号提取+向量嵌入")
sub = parser.add_subparsers(dest="command")
p_analyze = sub.add_parser("analyze", help="批量文章结构化分析")
p_analyze.add_argument("--force", action="store_true")
p_analyze.add_argument("--pause", type=float, default=0.35)
p_analyze.add_argument("--retries", type=int, default=2)
p_signals = sub.add_parser("signals", help="提取跨文章行业信号")
p_signals.add_argument("--days", type=int, default=30)
p_embed = sub.add_parser("embed", help="生成向量嵌入")
p_embed.add_argument("--force", action="store_true")
p_report = sub.add_parser("report", help="生成周期报告")
p_report.add_argument("--days", type=int, default=7)
args = parser.parse_args()
if args.command == "analyze":
raise SystemExit(run_batch_analyze(args.force, args.pause, args.retries))
elif args.command == "signals":
raise SystemExit(run_extract_signals(args.days))
elif args.command == "embed":
raise SystemExit(run_generate_embeddings(args.force))
elif args.command == "report":
raise SystemExit(run_generate_report(args.days))
else:
parser.print_help()
+14
View File
@@ -0,0 +1,14 @@
{
"sources": [
{
"account": "剑哥聊餐饮",
"finder": "sphnu5kSqZT224x",
"directory": "/Volumes/Projects/视频/剑哥聊餐饮"
},
{
"account": "餐饮周伯通(赖老师TimLai",
"finder": "sph0XoCC1rhJXB7",
"directory": "/Volumes/Projects/视频/餐饮周伯通"
}
]
}
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""视频号批量下载管理器。
基于 wx_channels_download 工具的 API 接口,实现:
1. 检查工具是否运行
2. 获取视频号创作者的视频列表
3. 与本地已下载文件比对,发现新视频
4. 触发批量下载
5. 下载完成后自动触发 ASR 转写入库
工具 GitHub: https://github.com/ltaoo/wx_channels_download
API 基地址: http://127.0.0.1:2022
使用前提:
- wx_channels_download 以 sudo 运行
- IPv6 已禁用(sudo networksetup -setv6off Wi-Fi
- VPN 已关闭
- 微信保持运行
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")
TOOL_API = os.getenv("WX_VIDEO_API", "http://127.0.0.1:2022")
VIDEO_CONFIG_PATH = BASE_DIR / "video_config.json"
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def api_get(endpoint: str, params: dict[str, str] | None = None, timeout: int = 30) -> dict[str, Any]:
"""调用 wx_channels_download 工具的 API。"""
url = f"{TOOL_API}{endpoint}"
if params:
qs = "&".join(f"{k}={v}" for k, v in params.items())
url += f"?{qs}"
req = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"工具 API 返回 {exc.code}{detail}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"无法连接工具 API{TOOL_API}):{exc.reason}\n请确认 wx_channels_download 已以 sudo 启动。") from exc
def check_tool_running() -> bool:
"""检查下载工具是否在运行。"""
try:
result = api_get("/api/channels/version", timeout=5)
return bool(result.get("version") or result.get("data"))
except Exception:
return False
def get_video_list(finder: str) -> list[dict[str, Any]]:
"""获取视频号创作者的视频列表。
需要微信客户端已连接到工具(WebSocket)。
"""
result = api_get("/api/channels/contact/feed/list", {"finder": finder}, timeout=60)
# 适配不同版本的返回格式
if isinstance(result, list):
return result
if isinstance(result, dict):
return result.get("data", result.get("list", result.get("feeds", [])))
return []
def list_local_videos(directory: Path) -> set[str]:
"""列出本地已下载的视频文件名(不含扩展名)。"""
if not directory.is_dir():
return set()
suffixes = {".mp4", ".mov", ".m4v", ".mkv", ".webm"}
return {f.stem for f in directory.iterdir() if f.is_file() and f.suffix.lower() in suffixes}
def normalize_title(title: str) -> str:
"""将视频标题规范化,用于与本地文件名比对。"""
# 去除 wx_channels_download 添加的 _xWT 后缀和画质标记
value = re.sub(r"[_ ]?xWT\d+$", "", title, flags=re.I)
value = re.sub(r"[\x00-\x1f/:]", "_", value).strip(" .")
return value[:180] or "untitled"
def find_new_videos(video_list: list[dict[str, Any]], local_dir: Path) -> list[dict[str, Any]]:
"""比对线上视频列表与本地已下载文件,返回未下载的视频。"""
local_names = list_local_videos(local_dir)
new_videos = []
for video in video_list:
title = video.get("title", video.get("desc", ""))
normalized = normalize_title(title)
# 检查本地是否已有该视频(模糊匹配)
if not any(normalized in local_name or local_name in normalized for local_name in local_names):
new_videos.append(video)
return new_videos
def create_batch_download(finder: str, video_ids: list[str]) -> dict[str, Any]:
"""创建批量下载任务。"""
params = {"finder": finder, "ids": ",".join(video_ids)}
return api_get("/api/task/create_batch", params, timeout=120)
def wait_for_downloads(local_dir: Path, expected_count: int, timeout: int = 600) -> int:
"""等待下载完成,返回实际新增的文件数。"""
initial = list_local_videos(local_dir)
deadline = time.time() + timeout
while time.time() < deadline:
time.sleep(10)
current = list_local_videos(local_dir)
new_files = current - initial
if len(new_files) >= expected_count:
return len(new_files)
return len(list_local_videos(local_dir) - initial)
def run_download(auto_transcribe: bool = True) -> int:
"""主流程:检查工具 → 获取视频列表 → 发现新视频 → 触发下载。"""
config = json.loads(VIDEO_CONFIG_PATH.read_text(encoding="utf-8")) if VIDEO_CONFIG_PATH.exists() else {"sources": []}
sources = config.get("sources", [])
if not sources:
print(json.dumps({"status": "skipped", "reason": "video_config.json 中未配置视频源"}, ensure_ascii=False))
return 0
# 1. 检查工具是否运行
if not check_tool_running():
print(json.dumps({
"status": "error",
"reason": f"wx_channels_download 工具未运行({TOOL_API}",
"hint": "请执行: sudo <工具路径>/wx_video_download,并确保 IPv6 已禁用、VPN 已关闭"
}, ensure_ascii=False))
return 1
print(json.dumps({"status": "tool_running", "api": TOOL_API}, ensure_ascii=False), flush=True)
total_new = 0
total_downloaded = 0
for source in sources:
account = source.get("account", "未知")
finder = source.get("finder", "")
download_dir = Path(source.get("directory", ""))
if not finder:
print(f"SKIP {account}: 未配置 finder", file=sys.stderr)
continue
# 2. 获取视频列表
try:
video_list = get_video_list(finder)
except Exception as exc:
print(f"FAIL {account}: 获取视频列表失败 - {exc}", file=sys.stderr, flush=True)
continue
print(f"INFO {account}: 线上视频 {len(video_list)}", flush=True)
# 3. 发现新视频
new_videos = find_new_videos(video_list, download_dir)
total_new += len(new_videos)
if not new_videos:
print(f"INFO {account}: 无新视频", flush=True)
continue
print(f"INFO {account}: 发现 {len(new_videos)} 个新视频", flush=True)
# 4. 触发批量下载
video_ids = [v.get("id", v.get("objectId", "")) for v in new_videos if v.get("id") or v.get("objectId")]
if video_ids:
try:
result = create_batch_download(finder, video_ids)
print(f"INFO {account}: 批量下载任务已创建 - {result}", flush=True)
# 5. 等待下载完成
downloaded = wait_for_downloads(download_dir, len(new_videos), timeout=600)
total_downloaded += downloaded
print(f"INFO {account}: 新增下载 {downloaded} 个文件", flush=True)
except Exception as exc:
print(f"FAIL {account}: 下载失败 - {exc}", file=sys.stderr, flush=True)
else:
# 如果无法获取 video_id,提示用户手动下载
print(f"WARN {account}: 无法自动获取视频ID,请通过微信内批量下载或 Web 管理页面手动下载", flush=True)
print(f" Web 管理页面: {TOOL_API}/download", flush=True)
print(f" 下载目录: {download_dir}", flush=True)
print(json.dumps({
"status": "success",
"total_new": total_new,
"total_downloaded": total_downloaded,
}, ensure_ascii=False), flush=True)
# 6. 自动触发 ASR 转写入库
if auto_transcribe and total_downloaded > 0:
print("INFO 自动触发视频号 ASR 转写...", flush=True)
import subprocess
subprocess.run(
[sys.executable, str(BASE_DIR / "crawler.py"), "--source-type", "视频号"],
cwd=str(BASE_DIR),
)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="视频号批量下载管理器")
parser.add_argument("--no-transcribe", action="store_true", help="仅下载,不自动触发 ASR 转写")
parser.add_argument("--check", action="store_true", help="仅检查工具状态")
return parser
if __name__ == "__main__":
args = build_parser().parse_args()
if args.check:
running = check_tool_running()
print(json.dumps({"tool_running": running, "api": TOOL_API}, ensure_ascii=False))
raise SystemExit(0 if running else 1)
raise SystemExit(run_download(auto_transcribe=not args.no_transcribe))
+6
View File
@@ -0,0 +1,6 @@
{
"videos": [
{"url": "https://example.com/video/1", "title": "海底捞创始人分享:翻台率不是目标", "source_name": "海底捞官方视频号"},
{"url": "https://example.com/video/2", "title": "西贝贾国龙谈预制菜争议", "source_name": "餐饮O2O视频号"}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "postcss.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>餐智库 — 餐饮行业知识库</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
Using Node.js 20, Tailwind CSS v3.4.19, and Vite v7.2.4
Tailwind CSS has been set up with the shadcn theme
Setup complete: /mnt/agents/output/app
Components (40+):
accordion, alert-dialog, alert, aspect-ratio, avatar, badge, breadcrumb,
button-group, button, calendar, card, carousel, chart, checkbox, collapsible,
command, context-menu, dialog, drawer, dropdown-menu, empty, field, form,
hover-card, input-group, input-otp, input, item, kbd, label, menubar,
navigation-menu, pagination, popover, progress, radio-group, resizable,
scroll-area, select, separator, sheet, sidebar, skeleton, slider, sonner,
spinner, switch, table, tabs, textarea, toggle-group, toggle, tooltip
Usage:
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
Structure:
src/sections/ Page sections
src/hooks/ Custom hooks
src/types/ Type definitions
src/App.css Styles specific to the Webapp
src/App.tsx Root React component
src/index.css Global styles
src/main.tsx Entry point for rendering the Webapp
index.html Entry point for the Webapp
tailwind.config.js Configures Tailwind's theme, plugins, etc.
vite.config.ts Main build and dev server settings for Vite
postcss.config.js Config file for CSS post-processing tools
+9727
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
{
"name": "my-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.70.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.2.2",
"react-router": "^7.6.1",
"recharts": "^2.15.4",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"kimi-plugin-inspect-react": "^1.0.3",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+17
View File
@@ -0,0 +1,17 @@
import { Routes, Route } from 'react-router'
import Home from './pages/Home'
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/intel" element={<Home />} />
<Route path="/qa" element={<Home />} />
<Route path="/brands" element={<Home />} />
<Route path="/analysis" element={<Home />} />
<Route path="/signals" element={<Home />} />
<Route path="/reports" element={<Home />} />
<Route path="/settings" element={<Home />} />
</Routes>
)
}
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+155
View File
@@ -0,0 +1,155 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
+11
View File
@@ -0,0 +1,11 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }
+51
View File
@@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+109
View File
@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+83
View File
@@ -0,0 +1,83 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "div"
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+62
View File
@@ -0,0 +1,62 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+220
View File
@@ -0,0 +1,220 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+239
View File
@@ -0,0 +1,239 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
+357
View File
@@ -0,0 +1,357 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+31
View File
@@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+182
View File
@@ -0,0 +1,182 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+252
View File
@@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+141
View File
@@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+135
View File
@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+255
View File
@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+246
View File
@@ -0,0 +1,246 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+167
View File
@@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import type * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
+44
View File
@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
+170
View File
@@ -0,0 +1,170 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+75
View File
@@ -0,0 +1,75 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
+193
View File
@@ -0,0 +1,193 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "p-4 gap-4 ",
sm: "py-3 px-4 gap-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}
+28
View File
@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+274
View File
@@ -0,0 +1,274 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}
+168
View File
@@ -0,0 +1,168 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
+127
View File
@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants, type Button } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}
+48
View File
@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
+45
View File
@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Group>) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.Separator>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+58
View File
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+188
View File
@@ -0,0 +1,188 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+137
View File
@@ -0,0 +1,137 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+726
View File
@@ -0,0 +1,726 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }
+63
View File
@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }
+38
View File
@@ -0,0 +1,38 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+16
View File
@@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+66
View File
@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
+81
View File
@@ -0,0 +1,81 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+61
View File
@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+178
View File
@@ -0,0 +1,178 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.625rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* QA Chat Markdown 样式 */
.prose-chat {
font-size: 13px;
line-height: 1.75;
color: #1e293b;
}
.prose-chat p {
margin: 0 0 0.6em;
}
.prose-chat p:last-child {
margin-bottom: 0;
}
.prose-chat h1,
.prose-chat h2,
.prose-chat h3,
.prose-chat h4 {
font-weight: 600;
color: #0f172a;
margin: 1em 0 0.4em;
line-height: 1.4;
}
.prose-chat h1 { font-size: 1.15em; }
.prose-chat h2 { font-size: 1.1em; }
.prose-chat h3 { font-size: 1.05em; }
.prose-chat h4 { font-size: 1em; }
.prose-chat ul,
.prose-chat ol {
margin: 0.4em 0 0.6em;
padding-left: 1.4em;
}
.prose-chat ul { list-style: disc; }
.prose-chat ol { list-style: decimal; }
.prose-chat li {
margin: 0.2em 0;
}
.prose-chat li::marker {
color: #f97316;
}
.prose-chat strong {
font-weight: 600;
color: #0f172a;
}
.prose-chat em {
font-style: italic;
}
.prose-chat a {
color: #ea580c;
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-chat blockquote {
border-left: 3px solid #fed7aa;
padding-left: 0.8em;
margin: 0.6em 0;
color: #64748b;
font-style: italic;
}
.prose-chat code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
background: #f1f5f9;
border-radius: 4px;
padding: 0.1em 0.35em;
}
.prose-chat pre {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.8em 1em;
overflow-x: auto;
margin: 0.6em 0;
}
.prose-chat pre code {
background: none;
padding: 0;
font-size: 0.85em;
}
.prose-chat table {
width: 100%;
border-collapse: collapse;
margin: 0.6em 0;
font-size: 0.9em;
}
.prose-chat th,
.prose-chat td {
border: 1px solid #e2e8f0;
padding: 0.4em 0.6em;
text-align: left;
}
.prose-chat th {
background: #f8fafc;
font-weight: 600;
color: #0f172a;
}
.prose-chat tr:nth-child(even) td {
background: #fafbfc;
}
.prose-chat hr {
border: none;
border-top: 1px solid #e2e8f0;
margin: 0.8em 0;
}
+232
View File
@@ -0,0 +1,232 @@
const API_BASE = '/api'
async function fetchJSON<T = any>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${url}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `HTTP ${res.status}`)
}
return res.json()
}
// ---- 类型定义(与后端API返回对齐) ----
export interface OverviewStats {
sourcesMp: number
sourcesVideo: number
todayNew: number
knowledgeItems: number
brandsTracked: number
reportsTotal: number
totalArticles?: number
}
export interface IntelItem {
id: string
title: string
source: string
sourceType: string
date: string
category: string
type: string
summary: string
keyPoints: string[]
score: number
entities: string[]
timeliness: string
tags: string[]
url?: string
}
export interface IntelDetail {
ok: boolean
article: {
id: number
title: string
source: string
sourceType: string
author: string
publishedAt: string
summary: string
content: string
url: string
category: string
mediaUrl: string | null
}
analysis: {
summary: string
key_points: string[]
industry_signal: string
brands: string[]
numbers: any[]
content_angles: string[]
risk_notes: string
tags: string[]
sentiment: string
type?: string
score?: number
timeliness?: string
} | null
}
export interface DashboardData {
topIntel: IntelItem[]
policyAlerts: { id: string; title: string; source: string; date: string; summary: string }[]
hotKeywords: { word: string; change: number }[]
}
export interface Brand {
id: string
name: string
category: string
stores: number
avgPrice: number
model: '直营' | '加盟' | '直营+加盟' | string
cityTier: { tier: string; pct: number }[]
trend: number[]
latestNews: string
newsDate: string
growth: number
}
export interface AnalysisData {
categoryHeatTrend: Record<string, any>[]
openCloseByCategory: { category: string; 新开: number; 关闭: number }[]
priceBandData: { band: string; pct: number }[]
hotKeywords: { word: string; change: number }[]
}
export interface SourceConfig {
id: string
sourceType: '公众号' | '视频号'
name: string
finder?: string
listUrl?: string
downloadDir?: string
enabled: boolean
lastCrawledAt?: string
}
export interface Report {
id: string
title: string
date: string
period: string
highlights: string[]
sections: { heading: string; body: string }[]
}
export interface IndustrySignal {
id: string
date: string
title: string
summary: string
trend: 'emerging' | 'accelerating' | 'stable' | 'declining'
confidence: number
implications: string[]
articles: { id: number; title: string; source: string; date: string }[]
tags: string[]
model: string
}
export interface QaResult {
ok: boolean
answer: string
sources: { title: string; source: string; date: string }[]
error?: string
}
// ---- API 调用 ----
export const api = {
// 仪表盘
getDashboard: () => fetchJSON<DashboardData>('/dashboard'),
getStats: () => fetchJSON<OverviewStats>('/stats'),
// 情报流
getIntel: (params: { type?: string; category?: string; q?: string; page?: number; per_page?: number } = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
if (params.category) qs.set('category', params.category)
if (params.q) qs.set('q', params.q)
if (params.page) qs.set('page', String(params.page))
if (params.per_page) qs.set('per_page', String(params.per_page))
return fetchJSON<{ total: number; page: number; pages: number; items: IntelItem[] }>(`/intel?${qs}`)
},
getIntelDetail: (id: string) => fetchJSON<IntelDetail>(`/intel/${id}`),
// 品牌库
getBrands: (params: { category?: string; q?: string; page?: number; per_page?: number } = {}) => {
const qs = new URLSearchParams()
if (params.category) qs.set('category', params.category)
if (params.q) qs.set('q', params.q)
if (params.page) qs.set('page', String(params.page))
if (params.per_page) qs.set('per_page', String(params.per_page))
return fetchJSON<{ total: number; page: number; pages: number; items: Brand[] }>(`/brands?${qs}`)
},
// 赛道分析
getAnalysis: () => fetchJSON<AnalysisData>('/analysis'),
// 报告
getReports: () => fetchJSON<{ items: Report[] }>('/reports'),
// 行业信号
getSignals: (days: number = 30) =>
fetchJSON<{ items: IndustrySignal[] }>(`/signals?days=${days}`),
// RAG 问答
askQuestion: (question: string) =>
fetchJSON<QaResult>('/qa/ask', {
method: 'POST',
body: JSON.stringify({ question }),
}),
// 触发采集
runCrawl: (sourceType: string = '公众号') =>
fetchJSON<{ ok: boolean; message: string }>('/crawl/run', {
method: 'POST',
body: JSON.stringify({ source_type: sourceType }),
}),
// 触发分析
runAnalyze: (force: boolean = false) =>
fetchJSON<{ ok: boolean; message: string }>('/analyze/run', {
method: 'POST',
body: JSON.stringify({ force }),
}),
// 采集记录
getCrawlRuns: () => fetchJSON<{ items: any[] }>('/crawl/runs'),
// 健康检查
health: () => fetchJSON<{ ok: boolean }>('/health'),
// 信源配置管理
getSources: (type?: string) => {
const qs = new URLSearchParams()
if (type) qs.set('type', type)
return fetchJSON<{ items: SourceConfig[] }>(`/sources?${qs}`)
},
createSource: (data: Partial<SourceConfig>) =>
fetchJSON<{ ok: boolean; id: number }>('/sources', {
method: 'POST',
body: JSON.stringify(data),
}),
updateSource: (id: string, data: Partial<SourceConfig>) =>
fetchJSON<{ ok: boolean }>(`/sources/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteSource: (id: string) =>
fetchJSON<{ ok: boolean }>(`/sources/${id}`, {
method: 'DELETE',
}),
toggleSource: (id: string) =>
fetchJSON<{ ok: boolean; enabled: boolean }>(`/sources/${id}/toggle`, {
method: 'POST',
}),
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
+74
View File
@@ -0,0 +1,74 @@
import { useState } from 'react'
import { useLocation, useNavigate } from 'react-router'
import { Menu } from 'lucide-react'
import AppSidebar, { type PageKey } from '@/sections/AppSidebar'
import Dashboard from '@/sections/Dashboard'
import IntelFeed from '@/sections/IntelFeed'
import QAChat from '@/sections/QAChat'
import BrandLibrary from '@/sections/BrandLibrary'
import CategoryAnalysis from '@/sections/CategoryAnalysis'
import Reports from '@/sections/Reports'
import IndustrySignals from '@/sections/IndustrySignals'
import SourceSettings from '@/sections/SourceSettings'
const validPages: PageKey[] = ['dashboard', 'intel', 'qa', 'brands', 'analysis', 'signals', 'reports', 'settings']
export default function Home() {
const navigate = useNavigate()
const { pathname } = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(false)
const page = (validPages.includes(pathname.replace(/^\//, '') as PageKey)
? pathname.replace(/^\//, '')
: 'dashboard') as PageKey
const handleNavigate = (k: PageKey) => {
navigate(`/${k === 'dashboard' ? '' : k}`)
setSidebarOpen(false)
}
return (
<div className="flex h-screen overflow-hidden bg-slate-50">
{/* 移动端遮罩 */}
{sidebarOpen && (
<div
className="fixed inset-0 z-30 bg-black/40 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* 侧边栏:桌面端固定,移动端抽屉 */}
<div
className={
'fixed inset-y-0 left-0 z-40 transition-transform duration-200 md:relative md:translate-x-0 ' +
(sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0')
}
>
<AppSidebar active={page} onChange={handleNavigate} />
</div>
<main className="flex-1 overflow-y-auto">
{/* 移动端顶栏 */}
<div className="sticky top-0 z-20 flex items-center gap-3 border-b border-slate-200 bg-white/90 px-4 py-3 backdrop-blur md:hidden">
<button
onClick={() => setSidebarOpen(true)}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600"
>
<Menu className="h-5 w-5" />
</button>
<span className="text-sm font-semibold text-slate-800"></span>
</div>
<div className={page === 'qa' ? 'flex h-full flex-col p-4 md:p-6' : 'p-4 md:p-6'}>
{page === 'dashboard' && <Dashboard onNavigate={(p) => navigate(`/${p}`)} />}
{page === 'intel' && <IntelFeed />}
{page === 'qa' && <QAChat />}
{page === 'brands' && <BrandLibrary />}
{page === 'analysis' && <CategoryAnalysis />}
{page === 'signals' && <IndustrySignals />}
{page === 'reports' && <Reports />}
{page === 'settings' && <SourceSettings />}
</div>
</main>
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
import {
LayoutDashboard,
Rss,
MessageSquareText,
Store,
TrendingUp,
FileText,
Radio,
Soup,
Settings,
Activity,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import { api, type OverviewStats } from '@/lib/api'
export type PageKey = 'dashboard' | 'intel' | 'qa' | 'brands' | 'analysis' | 'signals' | 'reports' | 'settings'
const navItems: { key: PageKey; label: string; icon: typeof LayoutDashboard; desc: string }[] = [
{ key: 'dashboard', label: '仪表盘', icon: LayoutDashboard, desc: '全局概览' },
{ key: 'intel', label: '情报流', icon: Rss, desc: '结构化行业情报' },
{ key: 'qa', label: '知识问答', icon: MessageSquareText, desc: '基于知识库提问' },
{ key: 'brands', label: '品牌库', icon: Store, desc: '连锁品牌档案' },
{ key: 'analysis', label: '赛道分析', icon: TrendingUp, desc: '趋势与结构' },
{ key: 'signals', label: '行业信号', icon: Activity, desc: '跨文章趋势信号' },
{ key: 'reports', label: '报告中心', icon: FileText, desc: '周期性情报报告' },
{ key: 'settings', label: '信源配置', icon: Settings, desc: '管理采集信源' },
]
interface Props {
active: PageKey
onChange: (k: PageKey) => void
}
export default function AppSidebar({ active, onChange }: Props) {
const [stats, setStats] = useState<OverviewStats | null>(null)
useEffect(() => {
api.getStats().then(setStats).catch(() => {})
}, [])
const sourcesMp = stats?.sourcesMp ?? 0
const sourcesVideo = stats?.sourcesVideo ?? 0
return (
<aside className="flex h-full w-60 shrink-0 flex-col bg-slate-950 text-slate-300">
{/* Logo */}
<div className="flex items-center gap-3 px-5 py-5">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-orange-500 text-white">
<Soup className="h-5 w-5" />
</div>
<div>
<div className="text-base font-bold text-white"></div>
<div className="text-[11px] text-slate-500"></div>
</div>
</div>
{/* Nav */}
<nav className="mt-2 min-h-0 flex-1 space-y-1 overflow-y-auto px-3">
{navItems.map((item) => {
const Icon = item.icon
const isActive = active === item.key
return (
<button
key={item.key}
onClick={() => onChange(item.key)}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors',
isActive
? 'bg-orange-500/15 text-orange-400'
: 'text-slate-400 hover:bg-slate-800/60 hover:text-slate-200'
)}
>
<Icon className="h-4 w-4 shrink-0" />
<div className="min-w-0">
<div className={cn('font-medium', isActive && 'text-orange-300')}>{item.label}</div>
<div className="truncate text-[11px] text-slate-500">{item.desc}</div>
</div>
</button>
)
})}
</nav>
{/* 信源监控状态 */}
<div className="mx-3 mb-4 rounded-lg border border-slate-800 bg-slate-900/60 p-3">
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-medium text-slate-400">
<Radio className="h-3 w-3 text-emerald-400" />
<span className="ml-auto flex h-1.5 w-1.5 rounded-full bg-emerald-400" />
</div>
<div className="grid grid-cols-2 gap-2 text-center">
<div className="rounded-md bg-slate-800/70 py-1.5">
<div className="text-sm font-bold text-white">{sourcesMp}</div>
<div className="text-[10px] text-slate-500"></div>
</div>
<div className="rounded-md bg-slate-800/70 py-1.5">
<div className="text-sm font-bold text-white">{sourcesVideo}</div>
<div className="text-[10px] text-slate-500"></div>
</div>
</div>
</div>
</aside>
)
}
+263
View File
@@ -0,0 +1,263 @@
import { useEffect, useState } from 'react'
import { Search, TrendingDown, TrendingUp, MapPin, Newspaper, Loader2 } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
import { api, type Brand } from '@/lib/api'
import { cn } from '@/lib/utils'
const categoryFilters = ['全部', '茶饮咖啡', '快餐', '火锅', '正餐', '西式快餐']
const modelColor: Record<string, string> = {
: 'bg-blue-50 text-blue-600 border-blue-200',
: 'bg-emerald-50 text-emerald-600 border-emerald-200',
'直营+加盟': 'bg-violet-50 text-violet-600 border-violet-200',
}
const PER_PAGE = 12
export default function BrandLibrary() {
const [category, setCategory] = useState('全部')
const [query, setQuery] = useState('')
const [selected, setSelected] = useState<Brand | null>(null)
const [brands, setBrands] = useState<Brand[]>([])
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [totalPages, setTotalPages] = useState(1)
useEffect(() => {
setLoading(true)
api.getBrands({ category: category === '全部' ? undefined : category, q: query || undefined, page, per_page: PER_PAGE })
.then((res) => {
setBrands(res.items)
setTotal(res.total)
setTotalPages(res.pages)
})
.catch(() => setBrands([]))
.finally(() => setLoading(false))
}, [category, query, page])
// 筛选条件变化时重置到第1页
useEffect(() => { setPage(1) }, [category, query])
const filtered = brands
return (
<div className="space-y-5">
<div>
<h1 className="text-xl font-bold text-slate-900"></h1>
<p className="mt-1 text-sm text-slate-500">
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-wrap gap-1.5">
{categoryFilters.map((c) => (
<button
key={c}
onClick={() => setCategory(c)}
className={cn(
'rounded-md px-2.5 py-1 text-xs transition-colors',
category === c ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
)}
>
{c}
</button>
))}
</div>
<div className="relative ml-auto w-full sm:w-56">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="搜索品牌…" className="pl-8" />
</div>
</div>
{/* 分页 */}
{!loading && total > 0 && (
<div className="flex flex-col items-center gap-2 sm:flex-row sm:items-center sm:justify-between">
<span className="text-xs text-slate-400">
{total} {page}/{totalPages}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="rounded-md border border-slate-200 px-3 py-1 text-xs text-slate-600 transition-colors hover:border-orange-300 hover:text-orange-600 disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
{(() => {
const maxButtons = 7
const pages: (number | string)[] = []
if (totalPages <= maxButtons) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
const left = Math.max(1, page - 2)
const right = Math.min(totalPages, page + 2)
if (left > 1) { pages.push(1); if (left > 2) pages.push('…') }
for (let i = left; i <= right; i++) pages.push(i)
if (right < totalPages) { if (right < totalPages - 1) pages.push('…'); pages.push(totalPages) }
}
return pages.map((p, i) =>
typeof p === 'string' ? (
<span key={`e${i}`} className="px-1 text-xs text-slate-400">{p}</span>
) : (
<button
key={p}
onClick={() => setPage(p)}
className={cn(
'h-7 w-7 rounded-md text-xs transition-colors',
page === p
? 'bg-orange-500 text-white'
: 'border border-slate-200 text-slate-600 hover:border-orange-300'
)}
>
{p}
</button>
)
)
})()}
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="rounded-md border border-slate-200 px-3 py-1 text-xs text-slate-600 transition-colors hover:border-orange-300 hover:text-orange-600 disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
</div>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{loading && (
<div className="col-span-full flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-orange-500" />
</div>
)}
{!loading && filtered.length === 0 && (
<div className="col-span-full rounded-lg border border-dashed border-slate-300 py-12 text-center text-sm text-slate-400">
</div>
)}
{filtered.map((b) => (
<Card
key={b.id}
className="cursor-pointer border-slate-200 transition-all hover:-translate-y-0.5 hover:border-orange-300 hover:shadow-md"
onClick={() => setSelected(b)}
>
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div>
<div className="text-base font-bold text-slate-900">{b.name}</div>
<div className="mt-1 flex items-center gap-1.5">
<Badge variant="outline" className="border-slate-200 text-[10px] text-slate-500">{b.category}</Badge>
<Badge variant="outline" className={`text-[10px] ${modelColor[b.model]}`}>{b.model}</Badge>
</div>
</div>
<div
className={cn(
'flex items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold',
b.growth >= 10 ? 'bg-emerald-50 text-emerald-600' : b.growth >= 0 ? 'bg-slate-100 text-slate-600' : 'bg-red-50 text-red-500'
)}
>
{b.growth >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{b.growth >= 0 ? '+' : ''}{b.growth}%
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-lg bg-slate-50 p-3">
<div className="text-lg font-bold text-slate-900">{b.stores.toLocaleString()}</div>
<div className="text-[11px] text-slate-400"></div>
</div>
<div className="rounded-lg bg-slate-50 p-3">
<div className="text-lg font-bold text-slate-900">¥{b.avgPrice}</div>
<div className="text-[11px] text-slate-400"></div>
</div>
</div>
<div className="mt-3 flex items-start gap-1.5 text-xs leading-5 text-slate-500">
<Newspaper className="mt-1 h-3 w-3 shrink-0 text-orange-400" />
<span className="line-clamp-2">{b.latestNews}</span>
</div>
</CardContent>
</Card>
))}
</div>
{/* 品牌详情弹窗 */}
<Dialog open={!!selected} onOpenChange={() => setSelected(null)}>
<DialogContent className="max-w-2xl">
{selected && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{selected.name}
<Badge variant="outline" className="border-slate-200 text-[10px] text-slate-500">{selected.category}</Badge>
<Badge variant="outline" className={`text-[10px] ${modelColor[selected.model]}`}>{selected.model}</Badge>
</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-slate-50 p-3 text-center">
<div className="text-xl font-bold text-slate-900">{selected.stores.toLocaleString()}</div>
<div className="text-[11px] text-slate-400"></div>
</div>
<div className="rounded-lg bg-slate-50 p-3 text-center">
<div className="text-xl font-bold text-slate-900">¥{selected.avgPrice}</div>
<div className="text-[11px] text-slate-400"></div>
</div>
<div className="rounded-lg bg-slate-50 p-3 text-center">
<div className={cn('text-xl font-bold', selected.growth >= 0 ? 'text-emerald-500' : 'text-red-500')}>
{selected.growth >= 0 ? '+' : ''}{selected.growth}%
</div>
<div className="text-[11px] text-slate-400"></div>
</div>
</div>
<div>
<div className="mb-1 text-sm font-medium text-slate-700"> 12 </div>
<div className="h-44">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={(selected.trend || []).map((v: number, i: number) => ({ month: `M${i + 1}`, stores: v }))}>
<XAxis dataKey="month" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 11 }} tickLine={false} axisLine={false} domain={['dataMin', 'dataMax']} width={50} />
<Tooltip />
<Line type="monotone" dataKey="stores" stroke="#f97316" strokeWidth={2} dot={false} name="门店数" />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div>
<div className="mb-2 flex items-center gap-1.5 text-sm font-medium text-slate-700">
<MapPin className="h-4 w-4 text-orange-500" />
线
</div>
<div className="space-y-2">
{selected.cityTier.map((c) => (
<div key={c.tier} className="flex items-center gap-3">
<span className="w-20 text-xs text-slate-500">{c.tier}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-slate-100">
<div className="h-full rounded-full bg-orange-400" style={{ width: `${c.pct}%` }} />
</div>
<span className="w-10 text-right text-xs font-medium text-slate-600">{c.pct}%</span>
</div>
))}
</div>
</div>
<div className="rounded-lg bg-orange-50/70 p-3">
<div className="text-xs font-medium text-slate-500"> · {selected.newsDate}</div>
<div className="mt-1 text-sm text-slate-700">{selected.latestNews}</div>
</div>
</>
)}
</DialogContent>
</Dialog>
</div>
)
}
+174
View File
@@ -0,0 +1,174 @@
import { useEffect, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Loader2 } from 'lucide-react'
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, Legend,
ResponsiveContainer, CartesianGrid, PieChart, Pie, Cell,
} from 'recharts'
import { api, type AnalysisData } from '@/lib/api'
const lineColors: Record<string, string> = {
: '#f97316',
: '#3b82f6',
: '#ef4444',
: '#8b5cf6',
: '#10b981',
}
const pieColors = ['#f97316', '#fb923c', '#fdba74', '#3b82f6', '#93c5fd', '#cbd5e1']
export default function CategoryAnalysis() {
const [data, setData] = useState<AnalysisData | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.getAnalysis()
.then(setData)
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const heatTrend = data?.categoryHeatTrend ?? []
const openClose = data?.openCloseByCategory ?? []
const priceBands = data?.priceBandData ?? []
const hotKw = data?.hotKeywords ?? []
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-orange-500" />
</div>
)
}
return (
<div className="space-y-5">
<div>
<h1 className="text-xl font-bold text-slate-900"></h1>
<p className="mt-1 text-sm text-slate-500">
</p>
</div>
{/* 热度趋势 */}
<Card className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="text-base"> 12 </CardTitle>
<p className="text-xs text-slate-400"></p>
</CardHeader>
<CardContent>
{heatTrend.length === 0 ? (
<div className="flex h-72 items-center justify-center text-sm text-slate-400"></div>
) : (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={heatTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="month" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 11 }} tickLine={false} axisLine={false} width={36} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
{Object.keys(lineColors).map((k) => (
<Line key={k} type="monotone" dataKey={k} stroke={lineColors[k]} strokeWidth={2} dot={false} />
))}
</LineChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
<div className="grid gap-4 xl:grid-cols-2">
{/* 开关店对比 */}
<Card className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="text-base"> / </CardTitle>
<p className="text-xs text-slate-400">绿</p>
</CardHeader>
<CardContent>
{openClose.length === 0 ? (
<div className="flex h-64 items-center justify-center text-sm text-slate-400"></div>
) : (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={openClose} barGap={2}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="category" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 11 }} tickLine={false} axisLine={false} width={44} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="新开" fill="#10b981" radius={[3, 3, 0, 0]} />
<Bar dataKey="关闭" fill="#f87171" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
{/* 客单价分布 */}
<Card className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="text-base"></CardTitle>
<p className="text-xs text-slate-400">10-40 </p>
</CardHeader>
<CardContent>
{priceBands.length === 0 ? (
<div className="flex h-64 items-center justify-center text-sm text-slate-400"></div>
) : (
<div className="flex h-64 items-center">
<ResponsiveContainer width="55%" height="100%">
<PieChart>
<Pie
data={priceBands}
dataKey="pct"
nameKey="band"
innerRadius={48}
outerRadius={80}
paddingAngle={2}
>
{priceBands.map((_, i) => (
<Cell key={i} fill={pieColors[i % pieColors.length]} />
))}
</Pie>
<Tooltip formatter={(v: number) => `${v}%`} />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-2">
{priceBands.map((p, i) => (
<div key={p.band} className="flex items-center gap-2 text-xs">
<span className="h-2.5 w-2.5 rounded-sm" style={{ background: pieColors[i % pieColors.length] }} />
<span className="w-20 text-slate-600">{p.band}</span>
<span className="font-semibold text-slate-800">{p.pct}%</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* 热词趋势 */}
<Card className="border-slate-200">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{hotKw.length === 0 ? (
<div className="py-8 text-center text-sm text-slate-400"></div>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{hotKw.map((k) => (
<div key={k.word} className="rounded-lg border border-slate-100 p-3">
<div className="text-sm font-medium text-slate-800">{k.word}</div>
<div className="mt-1 text-xs font-semibold text-emerald-500"> +{k.change}%</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useState } from 'react'
import { ArrowUpRight, BookOpen, Flame, Newspaper, Siren, Store, Tags, Loader2 } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { api, type DashboardData, type OverviewStats } from '@/lib/api'
export interface PageKeyHintType {
onNavigate?: (page: 'intel' | 'qa' | 'analysis' | 'reports') => void
}
const statCards = (ovStats: OverviewStats) => [
{ label: '知识条目', value: ovStats.knowledgeItems.toLocaleString(), icon: BookOpen, sub: '持续结构化沉淀', color: 'text-orange-500 bg-orange-50' },
{ label: '今日新增情报', value: `+${ovStats.todayNew}`, icon: Newspaper, sub: `来自 ${ovStats.sourcesMp + ovStats.sourcesVideo} 个监控信源`, color: 'text-blue-500 bg-blue-50' },
{ label: '追踪品牌', value: ovStats.brandsTracked, icon: Store, sub: '覆盖 12 个赛道', color: 'text-emerald-500 bg-emerald-50' },
{ label: '已生成报告', value: ovStats.reportsTotal, icon: Tags, sub: '周报 / 专题报告', color: 'text-violet-500 bg-violet-50' },
]
const typeColor: Record<string, string> = {
: 'bg-red-50 text-red-600 border-red-200',
: 'bg-blue-50 text-blue-600 border-blue-200',
: 'bg-violet-50 text-violet-600 border-violet-200',
: 'bg-emerald-50 text-emerald-600 border-emerald-200',
: 'bg-amber-50 text-amber-600 border-amber-200',
: 'bg-cyan-50 text-cyan-600 border-cyan-200',
}
export default function Dashboard({ onNavigate }: PageKeyHintType) {
const [data, setData] = useState<DashboardData | null>(null)
const [stats, setStats] = useState<OverviewStats | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.allSettled([api.getDashboard(), api.getStats()]).then(([dRes, sRes]) => {
if (dRes.status === 'fulfilled') setData(dRes.value)
if (sRes.status === 'fulfilled') setStats(sRes.value)
setLoading(false)
})
}, [])
const topIntel = data?.topIntel ?? []
const policyAlerts = data?.policyAlerts?.map((p) => ({ ...p, type: '政策监管', sourceType: '公众号', category: '综合', keyPoints: [], entities: [], timeliness: '高', score: 0 })) ?? []
const hotKw = data?.hotKeywords ?? []
const ovStats: OverviewStats = {
sourcesMp: stats?.sourcesMp ?? 0,
sourcesVideo: stats?.sourcesVideo ?? 0,
todayNew: stats?.todayNew ?? 0,
knowledgeItems: stats?.knowledgeItems ?? 0,
brandsTracked: stats?.brandsTracked ?? 0,
reportsTotal: stats?.reportsTotal ?? 0,
}
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-orange-500" />
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-bold text-slate-900"></h1>
<p className="mt-1 text-sm text-slate-500">
</p>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
{(statCards(ovStats)).map((s) => {
const Icon = s.icon
return (
<Card key={s.label} className="border-slate-200">
<CardContent className="flex items-start justify-between p-5">
<div>
<div className="text-sm text-slate-500">{s.label}</div>
<div className="mt-1 text-2xl font-bold text-slate-900">{s.value}</div>
<div className="mt-1 text-xs text-slate-400">{s.sub}</div>
</div>
<div className={`rounded-lg p-2 ${s.color}`}>
<Icon className="h-5 w-5" />
</div>
</CardContent>
</Card>
)
})}
</div>
<div className="grid gap-4 xl:grid-cols-3">
{/* 高价值情报 TOP5 */}
<Card className="xl:col-span-2 border-slate-200">
<CardHeader className="flex-row items-center justify-between pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Flame className="h-4 w-4 text-orange-500" />
TOP 5
</CardTitle>
<button
onClick={() => onNavigate?.('intel')}
className="flex items-center gap-1 text-xs text-orange-500 hover:text-orange-600"
>
<ArrowUpRight className="h-3 w-3" />
</button>
</CardHeader>
<CardContent className="space-y-3">
{topIntel.length === 0 && (
<div className="py-8 text-center text-sm text-slate-400"></div>
)}
{topIntel.map((item, idx) => (
<div
key={item.id}
className="flex items-start gap-3 rounded-lg border border-slate-100 p-3 transition-colors hover:border-orange-200 hover:bg-orange-50/40"
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-slate-900 text-xs font-bold text-white">
{idx + 1}
</div>
<div className="min-w-0 flex-1">
<div className="line-clamp-1 text-sm font-medium text-slate-800">{item.title}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-400">
<span>{item.source}</span>
<span>·</span>
<span>{item.date}</span>
<Badge variant="outline" className={`text-[10px] ${typeColor[item.type]}`}>{item.type}</Badge>
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-bold text-orange-500">{item.score}</div>
<div className="text-[10px] text-slate-400"></div>
</div>
</div>
))}
</CardContent>
</Card>
<div className="space-y-4">
{/* 政策预警 */}
<Card className="border-red-100">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Siren className="h-4 w-4 text-red-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{policyAlerts.length === 0 && (
<div className="py-4 text-center text-xs text-slate-400"></div>
)}
{policyAlerts.map((p) => (
<div key={p.id} className="rounded-lg bg-red-50/60 p-3">
<div className="line-clamp-2 text-xs font-medium leading-5 text-slate-700">{p.title}</div>
<div className="mt-1.5 text-[11px] text-slate-400">{p.date} · {p.source}</div>
</div>
))}
</CardContent>
</Card>
{/* 热词榜 */}
<Card className="border-slate-200">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{hotKw.length === 0 ? (
<div className="py-4 text-center text-xs text-slate-400"></div>
) : (
<div className="flex flex-wrap gap-2">
{hotKw.map((k) => (
<span
key={k.word}
className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-700"
>
{k.word}
<span className="text-[10px] font-medium text-emerald-500">+{k.change}%</span>
</span>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
import { useEffect, useState } from 'react'
import {
Activity,
ArrowUpRight,
ArrowDownRight,
Minus,
Sparkles,
ChevronDown,
ChevronUp,
Loader2,
Newspaper,
Tag,
} from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { api, type IndustrySignal } from '@/lib/api'
import { cn } from '@/lib/utils'
const trendConfig: Record<string, { label: string; icon: typeof Activity; color: string; bg: string }> = {
accelerating: { label: '加速', icon: ArrowUpRight, color: 'text-red-500', bg: 'bg-red-50' },
emerging: { label: '新兴', icon: Sparkles, color: 'text-blue-500', bg: 'bg-blue-50' },
stable: { label: '稳定', icon: Minus, color: 'text-slate-500', bg: 'bg-slate-50' },
declining: { label: '衰退', icon: ArrowDownRight, color: 'text-emerald-500', bg: 'bg-emerald-50' },
}
export default function IndustrySignals() {
const [signals, setSignals] = useState<IndustrySignal[]>([])
const [loading, setLoading] = useState(true)
const [openId, setOpenId] = useState<string | null>(null)
useEffect(() => {
api.getSignals(30)
.then((res) => {
setSignals(res.items)
if (res.items.length > 0) setOpenId(res.items[0].id)
})
.catch(() => setSignals([]))
.finally(() => setLoading(false))
}, [])
return (
<div className="space-y-5">
<div>
<h1 className="text-xl font-bold text-slate-900"></h1>
<p className="mt-1 text-sm text-slate-500">
AI
</p>
</div>
<div className="space-y-3">
{loading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-orange-500" />
</div>
)}
{!loading && signals.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-300 py-12 text-center text-sm text-slate-400">
</div>
)}
{signals.map((sig) => {
const isOpen = openId === sig.id
const trend = trendConfig[sig.trend] || trendConfig.stable
const TrendIcon = trend.icon
return (
<Card key={sig.id} className={cn('border-slate-200 transition-colors', isOpen && 'border-orange-200')}>
<CardContent className="p-5">
<button className="w-full text-left" onClick={() => setOpenId(isOpen ? null : sig.id)}>
<div className="flex items-start gap-3">
<div className={cn('flex h-10 w-10 shrink-0 items-center justify-center rounded-lg', trend.bg)}>
<TrendIcon className={cn('h-5 w-5', trend.color)} />
</div>
<div className="min-w-0 flex-1">
<div className="text-[15px] font-semibold text-slate-900">{sig.title}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-slate-400">
<span className={cn('inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 font-medium', trend.bg, trend.color)}>
{trend.label}
</span>
<span> {(sig.confidence * 100).toFixed(0)}%</span>
<span>·</span>
<span>{sig.date}</span>
<span>·</span>
<span>{sig.articles.length} </span>
</div>
</div>
{isOpen ? <ChevronUp className="h-4 w-4 text-slate-400" /> : <ChevronDown className="h-4 w-4 text-slate-400" />}
</div>
</button>
{/* 摘要 + 标签 */}
<div className="mt-3 pl-13">
<p className="text-[13px] leading-6 text-slate-600">{sig.summary}</p>
<div className="mt-3 flex flex-wrap gap-1.5">
{sig.tags.map((t, i) => (
<span key={i} className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-0.5 text-[11px] text-slate-500">
<Tag className="h-2.5 w-2.5" />
{t}
</span>
))}
</div>
</div>
{isOpen && (
<div className="mt-4 space-y-4 border-t border-slate-100 pt-4 pl-13">
{/* 经营启示 */}
{sig.implications.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-bold text-slate-700"></h4>
<ul className="space-y-1.5">
{sig.implications.map((imp, i) => (
<li key={i} className="flex items-start gap-2 text-[13px] leading-6 text-slate-600">
<span className="mt-2 h-1 w-1 shrink-0 rounded-full bg-orange-400" />
{imp}
</li>
))}
</ul>
</div>
)}
{/* 关联文章 */}
{sig.articles.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-bold text-slate-700"></h4>
<div className="space-y-1.5">
{sig.articles.map((art) => (
<a
key={art.id}
href={`/intel`}
className="flex items-center gap-2 rounded-md bg-slate-50 px-3 py-2 text-[13px] text-slate-600 transition-colors hover:bg-slate-100"
>
<Newspaper className="h-3.5 w-3.5 shrink-0 text-orange-400" />
<span className="line-clamp-1 flex-1">{art.title}</span>
<span className="shrink-0 text-[11px] text-slate-400">{art.source}</span>
</a>
))}
</div>
</div>
)}
<div className="text-[11px] text-slate-400">
{sig.model}
</div>
</div>
)}
</CardContent>
</Card>
)
})}
</div>
</div>
)
}
+386
View File
@@ -0,0 +1,386 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown, ChevronUp, Search, Sparkles, ListChecks, Loader2, FileText, AlertCircle, TrendingUp, PlayCircle, X } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { api, type IntelItem, type IntelDetail } from '@/lib/api'
import { cn } from '@/lib/utils'
const typeFilters: (string | '全部')[] = ['全部', '政策监管', '品牌动态', '品类趋势', '经营干货', '供应链', '消费洞察']
const categoryFilters = ['全部赛道', '综合', '茶饮咖啡', '快餐', '火锅', '正餐', '烘焙', '供应链']
const typeColor: Record<string, string> = {
: 'bg-red-50 text-red-600 border-red-200',
: 'bg-blue-50 text-blue-600 border-blue-200',
: 'bg-violet-50 text-violet-600 border-violet-200',
: 'bg-emerald-50 text-emerald-600 border-emerald-200',
: 'bg-amber-50 text-amber-600 border-amber-200',
: 'bg-cyan-50 text-cyan-600 border-cyan-200',
}
function scoreColor(score: number) {
if (score >= 90) return 'text-orange-500'
if (score >= 80) return 'text-amber-500'
return 'text-slate-400'
}
export default function IntelFeed() {
const [type, setType] = useState<string>('全部')
const [category, setCategory] = useState('全部赛道')
const [query, setQuery] = useState('')
const [expanded, setExpanded] = useState<string | null>(null)
const [items, setItems] = useState<IntelItem[]>([])
const [loading, setLoading] = useState(true)
const [detail, setDetail] = useState<IntelDetail | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [videoModal, setVideoModal] = useState<{ id: number; title: string } | null>(null)
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const perPage = 20
useEffect(() => {
setLoading(true)
setExpanded(null)
setDetail(null)
api.getIntel({ type: type === '全部' ? undefined : type, category: category === '全部赛道' ? undefined : category, q: query || undefined, page, per_page: perPage })
.then((res) => { setItems(res.items); setTotalPages(res.pages); setTotal(res.total) })
.catch(() => { setItems([]); setTotalPages(1); setTotal(0) })
.finally(() => setLoading(false))
}, [type, category, query, page])
const toggleExpand = (id: string) => {
if (expanded === id) {
setExpanded(null)
setDetail(null)
return
}
setExpanded(id)
setDetail(null)
setDetailLoading(true)
api.getIntelDetail(id)
.then(setDetail)
.catch(() => setDetail(null))
.finally(() => setDetailLoading(false))
}
// 筛选条件变化时重置到第一页
useEffect(() => { setPage(1) }, [type, category, query])
const filtered = items
return (
<div className="space-y-5">
<div>
<h1 className="text-xl font-bold text-slate-900"></h1>
<p className="mt-1 text-sm text-slate-500">
/ AI
</p>
</div>
{/* 筛选栏 */}
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
{typeFilters.map((t) => (
<button
key={t}
onClick={() => setType(t)}
className={cn(
'rounded-full border px-3 py-1 text-xs transition-colors',
type === t
? 'border-orange-500 bg-orange-500 text-white'
: 'border-slate-200 bg-white text-slate-600 hover:border-orange-300'
)}
>
{t}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-wrap gap-1.5">
{categoryFilters.map((c) => (
<button
key={c}
onClick={() => setCategory(c)}
className={cn(
'rounded-md px-2.5 py-1 text-xs transition-colors',
category === c ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
)}
>
{c}
</button>
))}
</div>
<div className="relative ml-auto w-full sm:w-64">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索标题 / 摘要 / 品牌…"
className="pl-8"
/>
</div>
</div>
</div>
{/* 分页 */}
{!loading && total > 0 && (
<div className="flex flex-col items-center gap-2 sm:flex-row sm:items-center sm:justify-between">
<span className="text-xs text-slate-400">
{total} {page}/{totalPages}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="rounded-md border border-slate-200 px-3 py-1 text-xs text-slate-600 transition-colors hover:border-orange-300 hover:text-orange-600 disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
{(() => {
const maxButtons = 7
const pages: (number | string)[] = []
if (totalPages <= maxButtons) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
const left = Math.max(1, page - 2)
const right = Math.min(totalPages, page + 2)
if (left > 1) { pages.push(1); if (left > 2) pages.push('…') }
for (let i = left; i <= right; i++) pages.push(i)
if (right < totalPages) { if (right < totalPages - 1) pages.push('…'); pages.push(totalPages) }
}
return pages.map((p, i) =>
typeof p === 'string' ? (
<span key={`e${i}`} className="px-1 text-xs text-slate-400">{p}</span>
) : (
<button
key={p}
onClick={() => setPage(p)}
className={cn(
'h-7 w-7 rounded-md text-xs transition-colors',
page === p
? 'bg-orange-500 text-white'
: 'border border-slate-200 text-slate-600 hover:border-orange-300'
)}
>
{p}
</button>
)
)
})()}
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="rounded-md border border-slate-200 px-3 py-1 text-xs text-slate-600 transition-colors hover:border-orange-300 hover:text-orange-600 disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
</div>
</div>
)}
{/* 情报卡片 */}
<div className="space-y-3">
{loading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-orange-500" />
</div>
)}
{!loading && filtered.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-300 py-12 text-center text-sm text-slate-400">
</div>
)}
{filtered.map((item) => {
const isOpen = expanded === item.id
return (
<Card key={item.id} className="border-slate-200 transition-colors hover:border-orange-200">
<CardContent className="p-5">
<div className="flex items-start gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className={`text-[10px] ${typeColor[item.type]}`}>{item.type}</Badge>
<Badge variant="outline" className="border-slate-200 text-[10px] text-slate-500">{item.category}</Badge>
<Badge variant="outline" className="border-slate-200 text-[10px] text-slate-500">{item.sourceType}</Badge>
{item.timeliness === '高' && (
<Badge className="bg-red-500 text-[10px] text-white hover:bg-red-500"></Badge>
)}
</div>
<h3 className="mt-2 text-[15px] font-semibold leading-6 text-slate-900">{item.title}</h3>
<div className="mt-2.5 flex items-start gap-2 rounded-lg bg-orange-50/70 p-3">
<Sparkles className="mt-0.5 h-3.5 w-3.5 shrink-0 text-orange-500" />
<p className="text-[13px] leading-6 text-slate-700">{item.summary}</p>
</div>
{isOpen && (
<div className="mt-3 space-y-3">
{/* 关键要点 */}
<div className="rounded-lg border border-slate-100 p-3">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-500">
<ListChecks className="h-3.5 w-3.5" />
</div>
{detailLoading ? (
<div className="flex items-center gap-2 py-2 text-xs text-slate-400">
<Loader2 className="h-3 w-3 animate-spin" />
</div>
) : (
<ul className="space-y-1.5">
{(detail?.analysis?.key_points || item.keyPoints).map((kp, i) => (
<li key={i} className="flex items-start gap-2 text-[13px] leading-6 text-slate-700">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-orange-400" />
{kp}
</li>
))}
</ul>
)}
</div>
{/* 行业信号 */}
{detail?.analysis?.industry_signal && (
<div className="rounded-lg border border-violet-100 bg-violet-50/50 p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-violet-600">
<TrendingUp className="h-3.5 w-3.5" />
</div>
<p className="text-[13px] leading-6 text-slate-700">{detail.analysis.industry_signal}</p>
</div>
)}
{/* 风险提示 */}
{detail?.analysis?.risk_notes && (
<div className="rounded-lg border border-amber-100 bg-amber-50/50 p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-amber-600">
<AlertCircle className="h-3.5 w-3.5" />
</div>
<p className="text-[13px] leading-6 text-slate-700">{detail.analysis.risk_notes}</p>
</div>
)}
{/* 可延展选题 */}
{detail?.analysis?.content_angles && detail.analysis.content_angles.length > 0 && (
<div className="rounded-lg border border-slate-100 p-3">
<div className="mb-2 text-xs font-medium text-slate-500"></div>
<ul className="space-y-1">
{detail.analysis.content_angles.map((a, i) => (
<li key={i} className="flex items-start gap-2 text-[13px] leading-6 text-slate-600">
<span className="mt-2 h-1 w-1 shrink-0 rounded-full bg-slate-300" />
{a}
</li>
))}
</ul>
</div>
)}
{/* 关联品牌 */}
{(detail?.analysis?.brands || item.entities).length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 rounded-lg border border-slate-100 p-3">
<span className="text-xs text-slate-400"></span>
{(detail?.analysis?.brands || item.entities).map((e) => (
<Badge key={e} variant="secondary" className="text-[10px]">{e}</Badge>
))}
</div>
)}
{/* 原文/转写文本 */}
{detail?.article?.content && (
<div className="rounded-lg border border-slate-100 p-3">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-500">
<FileText className="h-3.5 w-3.5" />
{item.sourceType === '视频号' ? '视频转写原文' : '文章原文'}
</div>
<div className="max-h-60 overflow-y-auto whitespace-pre-line text-[13px] leading-6 text-slate-600">
{detail.article.content.length > 2000
? detail.article.content.slice(0, 2000) + '…'
: detail.article.content}
</div>
{/* 公众号:原文链接 */}
{detail.article.url && !detail.article.url.startsWith('vibank://') && (
<a
href={detail.article.url}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block text-xs text-orange-500 hover:text-orange-600"
>
</a>
)}
{/* 视频号:播放按钮 */}
{item.sourceType === '视频号' && detail.article.mediaUrl && (
<div className="mt-2 border-t border-slate-100 pt-2">
<button
onClick={() => setVideoModal({ id: detail.article.id, title: detail.article.title })}
className="flex items-center gap-2 rounded-lg bg-slate-900 px-4 py-2 text-xs text-white transition-colors hover:bg-slate-700"
>
<PlayCircle className="h-4 w-4" />
</button>
</div>
)}
</div>
)}
</div>
)}
<div className="mt-2.5 flex items-center gap-2 text-xs text-slate-400">
<span className="font-medium text-slate-500">{item.source}</span>
<span>·</span>
<span>{item.date}</span>
<button
onClick={() => toggleExpand(item.id)}
className="ml-auto flex items-center gap-1 text-orange-500 hover:text-orange-600"
>
{isOpen ? (<> <ChevronUp className="h-3 w-3" /></>) : (<> <ChevronDown className="h-3 w-3" /></>)}
</button>
</div>
</div>
<div className="hidden w-14 shrink-0 flex-col items-center rounded-lg bg-slate-50 py-3 sm:flex">
<div className={cn('text-lg font-bold', scoreColor(item.score))}>{item.score}</div>
<div className="text-[10px] text-slate-400"></div>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
{/* 视频弹窗 */}
{videoModal && createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70"
onClick={() => setVideoModal(null)}
>
<div
className="relative rounded-2xl bg-black p-2 shadow-2xl"
style={{ width: 'min(90vw, 360px)' }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => setVideoModal(null)}
className="absolute -right-2 -top-2 z-10 flex h-7 w-7 items-center justify-center rounded-full bg-slate-200 text-slate-700 shadow-lg hover:bg-white"
>
<X className="h-4 w-4" />
</button>
<div className="overflow-hidden rounded-lg bg-black">
<video
controls
autoPlay
className="block w-full bg-black"
style={{ maxHeight: '70vh' }}
src={`/api/video/${videoModal.id}`}
>
</video>
</div>
</div>
</div>,
document.body
)}
</div>
)
}
+257
View File
@@ -0,0 +1,257 @@
import { useRef, useState, useEffect, useCallback } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Send, Link2, Lightbulb } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface Message {
role: 'user' | 'assistant'
text: string
sources?: { title: string; source: string; date: string }[]
error?: boolean
streaming?: boolean
}
export default function QAChat() {
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [thinking, setThinking] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, thinking])
const ask = useCallback(async (question: string) => {
if (!question.trim() || thinking) return
setMessages((prev) => [...prev, { role: 'user', text: question }])
setInput('')
setThinking(true)
// 先插入一条空的 assistant 消息,后续逐步更新
const assistantIdx = messages.length + 1
setMessages((prev) => [...prev, { role: 'assistant', text: '', streaming: true }])
try {
const res = await fetch('/api/qa/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question }),
})
if (!res.ok || !res.body) {
setMessages((prev) => prev.map((m, i) =>
i === assistantIdx ? { ...m, text: '后端服务不可用,请确认后端已启动后重试。', error: true, streaming: false } : m
))
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let sources: { title: string; source: string; date: string }[] | undefined
let hasError = false
let errorMsg = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// 按双换行分割 SSE 事件
const parts = buffer.split('\n\n')
buffer = parts.pop() || ''
for (const part of parts) {
const line = part.trim()
if (!line || line === 'data: [DONE]') continue
if (!line.startsWith('data:')) continue
try {
const evt = JSON.parse(line.slice(5).trim())
if (evt.type === 'sources') {
sources = evt.data
setMessages((prev) => prev.map((m, i) =>
i === assistantIdx ? { ...m, sources } : m
))
} else if (evt.type === 'token') {
// token 可能是文本片段或 sources JSON
const token = evt.data
if (typeof token === 'string' && token.startsWith('{"__sources__"')) {
try {
const parsed = JSON.parse(token)
if (parsed.__sources__ && !sources) {
sources = parsed.__sources__
setMessages((prev) => prev.map((m, i) =>
i === assistantIdx ? { ...m, sources } : m
))
}
} catch { /* ignore */ }
} else if (typeof token === 'string') {
setMessages((prev) => prev.map((m, i) =>
i === assistantIdx ? { ...m, text: m.text + token } : m
))
}
} else if (evt.type === 'error') {
hasError = true
errorMsg = evt.data
}
} catch { /* ignore parse errors */ }
}
}
if (hasError) {
setMessages((prev) => prev.map((m, i) =>
i === assistantIdx ? { ...m, text: errorMsg || '抱歉,未能获取回答,请稍后重试。', error: true, streaming: false } : m
))
} else {
setMessages((prev) => prev.map((m, i) =>
i === assistantIdx ? { ...m, streaming: false } : m
))
}
} catch {
setMessages((prev) => prev.map((m, i) =>
i === assistantIdx ? { ...m, text: '后端服务不可用,请确认后端已启动后重试。', error: true, streaming: false } : m
))
} finally {
setThinking(false)
}
}, [thinking, messages.length])
return (
<div className="flex h-full flex-col">
<div className="mb-4">
<h1 className="text-xl font-bold text-slate-900"></h1>
<p className="mt-1 text-sm text-slate-500">
</p>
</div>
{/* 消息区 */}
<div className="flex-1 space-y-4 overflow-y-auto rounded-xl border border-slate-200 bg-white p-4">
{messages.length === 0 && (
<div className="flex h-full flex-col items-center justify-center text-center">
<h3 className="text-base font-semibold text-slate-800"></h3>
<p className="mt-1 max-w-md text-sm text-slate-500">
</p>
<div className="mt-5 flex max-w-lg flex-wrap justify-center gap-2">
{['近期餐饮行业有哪些政策变化?', '茶饮赛道目前的竞争格局如何?', '有哪些品牌在快速扩张?'].map((q) => (
<button
key={q}
onClick={() => ask(q)}
className="flex items-center gap-1.5 rounded-full border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs text-orange-600 transition-colors hover:bg-orange-100"
>
<Lightbulb className="h-3 w-3" />
{q}
</button>
))}
</div>
</div>
)}
{messages.map((m, i) => (
<div key={i} className={cn('flex gap-3', m.role === 'user' && 'flex-row-reverse')}>
<div className={cn('max-w-[80%]', m.role === 'user' && 'text-right')}>
{m.role === 'user' ? (
<div className="inline-block whitespace-pre-line rounded-xl bg-slate-900 px-4 py-3 text-left text-[13px] leading-6 text-white">
{m.text}
</div>
) : (
<div
className={cn(
'rounded-xl px-4 py-3 text-left text-[13px] leading-7',
m.error
? 'border border-red-200 bg-red-50 text-red-600'
: 'border border-slate-200 bg-white text-slate-800'
)}
>
{m.streaming && (
<div className="mb-2 flex items-center gap-1.5 border-b border-slate-100 pb-2">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-orange-400" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-orange-400 [animation-delay:0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-orange-400 [animation-delay:0.3s]" />
<span className="ml-1 text-xs text-orange-400">{m.text ? '生成中…' : '正在检索知识库…'}</span>
</div>
)}
{m.error ? (
<p>{m.text}</p>
) : (
<div className="prose-chat">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{m.text}
</ReactMarkdown>
</div>
)}
</div>
)}
{/* 引用来源 — 流式结束后才显示 */}
{!m.streaming && m.sources && m.sources.length > 0 && (
<div className="mt-2 space-y-1.5 text-left">
<div className="flex items-center gap-1 text-[11px] font-medium text-slate-400">
<Link2 className="h-3 w-3" />
</div>
{(m.sources || []).map((s, j) => (
<div
key={j}
className="flex items-center gap-2 rounded-lg border border-slate-100 bg-white px-3 py-2 text-xs"
>
<span className="flex h-4 w-4 items-center justify-center rounded bg-orange-100 text-[10px] font-bold text-orange-600">
{j + 1}
</span>
<span className="line-clamp-1 flex-1 text-slate-700">{s.title}</span>
<span className="shrink-0 text-slate-400">{s.source} · {s.date}</span>
</div>
))}
</div>
)}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
{/* 后续问题(对话中) */}
{messages.length > 0 && !thinking && (
<div className="mt-3 flex flex-wrap gap-2">
{[
'近期餐饮行业有哪些政策变化?',
'茶饮赛道目前的竞争格局如何?',
'有哪些品牌在快速扩张?',
'供应链成本上涨对餐饮行业有什么影响?',
].map((q) => (
<button
key={q}
onClick={() => ask(q)}
className="rounded-full border border-slate-200 bg-white px-3 py-1 text-xs text-slate-500 transition-colors hover:border-orange-300 hover:text-orange-600"
>
{q}
</button>
))}
</div>
)}
{/* 输入区 */}
<div className="mt-3 flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && ask(input)}
placeholder="输入你的问题,回车发送…"
className="h-11 flex-1 rounded-xl border border-slate-200 bg-white px-4 text-sm outline-none transition-colors focus:border-orange-400"
/>
<Button
onClick={() => ask(input)}
disabled={!input.trim() || thinking}
className="h-11 rounded-xl bg-orange-500 px-5 hover:bg-orange-600"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
)
}
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useState } from 'react'
import { CalendarDays, ChevronDown, ChevronUp, FileText, Star, Loader2 } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { api, type Report } from '@/lib/api'
import { cn } from '@/lib/utils'
export default function Reports() {
const [reports, setReports] = useState<Report[]>([])
const [loading, setLoading] = useState(true)
const [openId, setOpenId] = useState<string | null>(null)
useEffect(() => {
api.getReports()
.then((res) => {
setReports(res.items)
if (res.items.length > 0) setOpenId(res.items[0].id)
})
.catch(() => setReports([]))
.finally(() => setLoading(false))
}, [])
return (
<div className="space-y-5">
<div>
<h1 className="text-xl font-bold text-slate-900"></h1>
<p className="mt-1 text-sm text-slate-500">
</p>
</div>
<div className="space-y-3">
{loading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-orange-500" />
</div>
)}
{!loading && reports.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-300 py-12 text-center text-sm text-slate-400">
</div>
)}
{reports.map((r) => {
const isOpen = openId === r.id
return (
<Card key={r.id} className={cn('border-slate-200 transition-colors', isOpen && 'border-orange-200')}>
<CardContent className="p-5">
<button className="w-full text-left" onClick={() => setOpenId(isOpen ? null : r.id)}>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-orange-100">
<FileText className="h-5 w-5 text-orange-500" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[15px] font-semibold text-slate-900">{r.title}</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
<CalendarDays className="h-3 w-3" />
{r.period}
<span>·</span>
{r.date}
</div>
</div>
{isOpen ? <ChevronUp className="h-4 w-4 text-slate-400" /> : <ChevronDown className="h-4 w-4 text-slate-400" />}
</div>
</button>
{/* 要点 */}
<div className="mt-4 flex flex-wrap gap-2">
{r.highlights.map((h, i) => (
<span key={i} className="inline-flex items-center gap-1.5 rounded-full bg-amber-50 px-3 py-1 text-xs text-amber-700">
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
{h}
</span>
))}
</div>
{isOpen && (
<div className="mt-5 space-y-5 border-t border-slate-100 pt-5">
{r.sections.map((s, i) => (
<div key={i}>
<h4 className="text-sm font-bold text-slate-800">{s.heading}</h4>
<p className="mt-2 text-[13px] leading-7 text-slate-600">{s.body}</p>
</div>
))}
<div className="rounded-lg bg-slate-50 p-3 text-xs leading-5 text-slate-400">
</div>
</div>
)}
</CardContent>
</Card>
)
})}
</div>
</div>
)
}
+306
View File
@@ -0,0 +1,306 @@
import { useEffect, useState, useCallback } from 'react'
import { Plus, Trash2, Pencil, Power, Rss, Radio, RefreshCw, X } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { api, type SourceConfig } from '@/lib/api'
import { cn } from '@/lib/utils'
type TabType = '公众号' | '视频号'
export default function SourceSettings() {
const [tab, setTab] = useState<TabType>('公众号')
const [sources, setSources] = useState<SourceConfig[]>([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<SourceConfig | null>(null)
const [showDialog, setShowDialog] = useState(false)
const [form, setForm] = useState<Partial<SourceConfig>>({})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const load = useCallback(async () => {
setLoading(true)
try {
const res = await api.getSources(tab)
setSources(res.items)
} catch {
setSources([])
} finally {
setLoading(false)
}
}, [tab])
useEffect(() => {
load()
}, [load])
const openAdd = () => {
setEditing(null)
setForm({ sourceType: tab, name: '', finder: '', listUrl: '', downloadDir: '', enabled: true })
setError('')
setShowDialog(true)
}
const openEdit = (s: SourceConfig) => {
setEditing(s)
setForm({ ...s })
setError('')
setShowDialog(true)
}
const save = async () => {
if (!form.name?.trim()) {
setError('名称不能为空')
return
}
setSaving(true)
setError('')
try {
if (editing) {
await api.updateSource(editing.id, form)
} else {
await api.createSource(form)
}
setShowDialog(false)
load()
} catch (e: any) {
setError(e.message || '保存失败')
} finally {
setSaving(false)
}
}
const toggle = async (s: SourceConfig) => {
try {
await api.toggleSource(s.id)
load()
} catch {}
}
const remove = async (s: SourceConfig) => {
if (!confirm(`确定删除「${s.name}」?`)) return
try {
await api.deleteSource(s.id)
load()
} catch {}
}
const filtered = sources.filter((s) => s.sourceType === tab)
return (
<div className="space-y-5">
<div>
<h1 className="text-xl font-bold text-slate-900"></h1>
<p className="mt-1 text-sm text-slate-500">
/
</p>
</div>
{/* Tab 切换 + 操作 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-2">
<div className="flex items-center gap-2">
<button
onClick={() => setTab('公众号')}
className={cn(
'flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium transition-colors',
tab === '公众号' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
)}
>
<Rss className="h-4 w-4" />
{sources.filter((s) => s.sourceType === '公众号').length > 0 && (
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-xs">
{sources.filter((s) => s.sourceType === '公众号').length}
</span>
)}
</button>
<button
onClick={() => setTab('视频号')}
className={cn(
'flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium transition-colors',
tab === '视频号' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
)}
>
<Radio className="h-4 w-4" />
{sources.filter((s) => s.sourceType === '视频号').length > 0 && (
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-xs">
{sources.filter((s) => s.sourceType === '视频号').length}
</span>
)}
</button>
</div>
<div className="flex items-center gap-2 sm:ml-auto">
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
</Button>
<Button size="sm" onClick={openAdd}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* 信源列表 */}
{filtered.length === 0 ? (
<Card className="border-dashed border-slate-300">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-3 rounded-full bg-slate-100 p-3">
{tab === '公众号' ? <Rss className="h-6 w-6 text-slate-400" /> : <Radio className="h-6 w-6 text-slate-400" />}
</div>
<p className="text-sm text-slate-500">
{loading ? '加载中...' : `暂无${tab}信源,点击「添加信源」开始配置`}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{filtered.map((s) => (
<Card key={s.id} className={cn('border-slate-200 transition-opacity', !s.enabled && 'opacity-60')}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg',
s.sourceType === '公众号' ? 'bg-blue-50' : 'bg-purple-50'
)}>
{s.sourceType === '公众号' ? (
<Rss className="h-5 w-5 text-blue-500" />
) : (
<Radio className="h-5 w-5 text-purple-500" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{s.name}</span>
{s.enabled ? (
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-600"></Badge>
) : (
<Badge variant="outline" className="border-slate-200 bg-slate-50 text-slate-400"></Badge>
)}
</div>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-xs text-slate-400">
{s.sourceType === '公众号' && s.listUrl && (
<span className="line-clamp-1">: {s.listUrl}</span>
)}
{s.sourceType === '视频号' && s.finder && (
<span>Finder: {s.finder}</span>
)}
{s.sourceType === '视频号' && s.downloadDir && (
<span className="line-clamp-1">: {s.downloadDir}</span>
)}
{s.lastCrawledAt && (
<span>: {s.lastCrawledAt.slice(0, 16)}</span>
)}
</div>
</div>
</div>
<div className="mt-3 flex items-center justify-end gap-1 border-t border-slate-100 pt-3">
<button
onClick={() => toggle(s)}
className={cn(
'rounded-lg p-2 transition-colors',
s.enabled ? 'text-emerald-500 hover:bg-emerald-50' : 'text-slate-400 hover:bg-slate-100'
)}
title={s.enabled ? '停用' : '启用'}
>
<Power className="h-4 w-4" />
</button>
<button
onClick={() => openEdit(s)}
className="rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
title="编辑"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => remove(s)}
className="rounded-lg p-2 text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500"
title="删除"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* 添加/编辑弹窗 */}
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-md">
<DialogHeader>
<DialogTitle>{editing ? '编辑信源' : `添加${tab}信源`}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700"></label>
<Input
value={form.name || ''}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder={tab === '公众号' ? '如:红餐网' : '如:剑哥聊餐饮'}
/>
</div>
{tab === '公众号' ? (
<div>
<label className="mb-1 block text-sm font-medium text-slate-700"> URL</label>
<Input
value={form.listUrl || ''}
onChange={(e) => setForm({ ...form, listUrl: e.target.value })}
placeholder="https://m.canyin88.com/zixun/"
/>
<p className="mt-1 text-xs text-slate-400"></p>
</div>
) : (
<>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Finder ID</label>
<Input
value={form.finder || ''}
onChange={(e) => setForm({ ...form, finder: e.target.value })}
placeholder="如:sphnu5kSqZT224x"
/>
<p className="mt-1 text-xs text-slate-400"></p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700"></label>
<Input
value={form.downloadDir || ''}
onChange={(e) => setForm({ ...form, downloadDir: e.target.value })}
placeholder="/Volumes/Projects/视频/剑哥聊餐饮"
/>
<p className="mt-1 text-xs text-slate-400"> wx_channels_download </p>
</div>
</>
)}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enabled"
checked={form.enabled !== false}
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
className="h-4 w-4 rounded border-slate-300 text-orange-500 focus:ring-orange-500"
/>
<label htmlFor="enabled" className="text-sm text-slate-700"></label>
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<X className="h-4 w-4 shrink-0" />
{error}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowDialog(false)}></Button>
<Button onClick={save} disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
},
borderRadius: {
xl: "calc(var(--radius) + 4px)",
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
xs: "calc(var(--radius) - 6px)",
},
boxShadow: {
xs: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
"caret-blink": {
"0%,70%,100%": { opacity: "1" },
"20%,50%": { opacity: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite",
},
},
},
plugins: [require("tailwindcss-animate")],
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import path from "path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import { inspectAttr } from 'kimi-plugin-inspect-react'
// https://vite.dev/config/
export default defineConfig({
base: './',
plugins: [inspectAttr(), react()],
server: {
port: 3000,
allowedHosts: ['cibank.tunn.dev'],
proxy: {
'/api': {
target: 'http://127.0.0.1:8788',
changeOrigin: true,
},
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
@@ -0,0 +1,460 @@
# 微信视频号批量下载工具配置指南
> 工具:ltaoo/wx_channels_download v260706
> 平台:macOS Apple Silicon (arm64)
> 微信版本:4.1.11 (App Store 版)
> 监控视频号:剑哥聊餐饮(finder: `sphnu5kSqZT224x`
> 下载目录:`/Volumes/Projects/视频/剑哥聊餐饮`
> 整理日期:2026-07-14(更新于 2026-07-14
---
## 一、工具简介
`wx_channels_download` 是一个开源的微信视频号视频下载工具,支持:
- 单个视频下载
- 批量下载某创作者的全部视频
- 自动解密加密视频
- Web 管理页面管理下载任务
- 自动去重,避免重复下载
**GitHub 地址**https://github.com/ltaoo/wx_channels_download
---
## 二、下载与安装
### 1. 下载预编译版本
```bash
# 创建目录
mkdir -p /Users/freedak/WorkBuddy/2026-07-14-07-31-44/wx_video_download
# 下载 macOS arm64 版本(v260706
cd /Users/freedak/WorkBuddy/2026-07-14-07-31-44/wx_video_download
curl -L -o wx_video_download_darwin_arm64.zip \
"https://github.com/ltaoo/wx_channels_download/releases/download/v260706/wx_video_download_v260706_darwin_arm64.zip"
```
### 2. 解压
```bash
unzip -o wx_video_download_darwin_arm64.zip
```
### 3. 移除 macOS 隔离标记
```bash
xattr -d com.apple.quarantine wx_video_download
```
### 4. 文件结构
```
wx_video_download/
├── wx_video_download # 可执行文件
└── config.yaml # 配置文件
```
---
## 三、配置文件详解
配置文件路径:`wx_video_download/config.yaml`
```yaml
# 调试模式(排查问题时开启)
debug:
error: true
echolog: true
# 下载设置
download:
defaultHighest: false # 是否下载最高画质
filenameTemplate: "{{filename}}_{{spec}}"
dir: "/Volumes/Projects/视频/剑哥聊餐饮" # 下载目录(自定义)
pauseWhenDownload: false # 下载时是否暂停视频播放
playDoneAudio: true # 下载完成时播放提示音
frontend: false
# API 服务
api:
protocol: "http"
hostname: "127.0.0.1"
port: 2022 # Web 管理页面端口
# 代理设置
proxy:
system: true # 是否设置系统代理
hostname: "127.0.0.1"
port: 2023 # 代理服务端口
tun: true # TUN 模式(关键!必须开启)
skipInstallRootCert: false # 是否跳过根证书安装
# Cloudflare 配置(可选,用于 API 解析模式)
cloudflare:
accountId: ""
apiToken: ""
sphCookie: "561553b295037d16=..." # 从 yuanbao.tencent.com 获取(已配置)
```
### 关键配置说明
| 配置项 | 推荐值 | 说明 |
|--------|--------|------|
| `proxy.tun` | `true` | TUN 模式通过虚拟网卡拦截流量,是 macOS 上的必选项 |
| `debug.error` | `true` | 排查问题时开启,正常运行可关闭 |
| `debug.echolog` | `true` | 排查问题时开启,正常运行可关闭 |
| `download.dir` | `/Volumes/Projects/视频/剑哥聊餐饮` | 自定义下载目录,可设为任意路径 |
| `download.defaultHighest` | `false` | 设为 `true` 可下载最高画质 |
| `cloudflare.sphCookie` | 从 yuanbao.tencent.com 获取 | 用于 API 解析模式,可选配置 |
---
## 四、启动步骤
### 第 1 步:禁用 IPv6macOS 关键步骤!)
> ⚠️ **这是 macOS 上工具能否正常工作的关键!**
>
> macOS 微信视频号浏览器 (WeChatAppEx) 默认使用 IPv6 进行所有网络连接。
> TUN 模式仅拦截 IPv4 流量,IPv6 流量会完全绕过工具,导致下载按钮无法注入。
```bash
# 禁用 Wi-Fi 接口的 IPv6
sudo networksetup -setv6off Wi-Fi
# 验证是否已禁用
networksetup -getinfo Wi-Fi
# 应显示:IPv6: Off
```
> 📌 **恢复 IPv6**(使用完毕后执行):
> ```bash
> sudo networksetup -setv6automatic Wi-Fi
> ```
### 第 2 步:关闭 VPN / 代理软件
> ⚠️ 如果运行了 LetsVPN、Clash、Surge 等代理软件,必须先关闭!
>
> 这些软件会占用代理端口或干扰 TUN 虚拟网卡,导致工具无法正常拦截流量。
```
# 检查 7890 端口是否被占用(LetsVPN 默认端口)
lsof -i :7890
# 如有占用,退出对应的 VPN 软件
```
### 第 3 步:以管理员身份启动工具
```bash
sudo /Users/freedak/WorkBuddy/2026-07-14-07-31-44/wx_video_download/wx_video_download
```
首次运行会自动安装 SunnyNet 根证书(用于 HTTPS 解密),输入电脑密码即可。
### 第 4 步:确认启动成功
终端应出现以下提示:
```
v260706
问题反馈 https://github.com/ltaoo/wx_channels_download/issues
配置文件 /Users/freedak/WorkBuddy/2026-07-14-07-31-44/wx_video_download/config.yaml
下载目录 /Users/freedak/Downloads
API服务启动成功, 地址: 127.0.0.1:2022
代理服务启动成功, 地址: 127.0.0.1:2023
已启用 TUN 模式,流量将通过虚拟网卡自动转发
请打开需要下载的视频号页面进行下载
按 Ctrl+C 退出...
```
### 第 5 步:重启微信
1. 右键 Dock 微信图标 → **退出**(确保完全关闭)
2. 等待几秒
3. 重新打开微信
### 第 6 步:打开视频号
1. 微信 → **发现****视频号**
2. 等首页视频刷出来
3. 视频播放后暂停,查看视频下方操作栏是否出现 **下载按钮**
---
## 五、SunnyNet 根证书信任设置
如果首次运行后证书未自动信任,需要手动设置:
### 方法一:终端命令
```bash
# 从钥匙串导出证书
security find-certificate -c "SunnyNet" -a -p /Library/Keychains/System.keychain > /tmp/SunnyNet.pem
# 添加到系统信任
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/SunnyNet.pem
# 添加到用户信任
security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain /tmp/SunnyNet.pem
```
### 方法二:钥匙串访问 GUI
1. `Cmd + Space` 搜索 **钥匙串访问**,打开它
2. 左侧选择 **系统** 钥匙串
3. 上方标签选 **证书**
4. 找到 **SunnyNet** → 双击打开
5. 展开 **信任** 那一栏
6. 把"使用此证书时"改为 **始终信任**
7. 关闭窗口,输入电脑密码确认
---
## 六、下载方式
### 方式一:单个视频下载
1. 在视频号首页刷视频
2. 每个视频的操作栏(点赞/评论/转发旁边)会多出一个 **下载按钮**
3. 页面右侧也有一个 **悬浮下载按钮**
4. 点击即可下载当前视频
### 方式二:微信内批量下载(推荐)
1. 在视频号中找到目标创作者 → 点击头像/名称进入 **TA 的个人主页**
2. 主页右上角有一个 **下载图标**(向下箭头按钮)
3. **向下滚动**主页,让更多视频加载出来
> 每刷出一批视频,工具就会自动检测到
4. 点击右上角 **下载图标** → 弹出下载面板
5. 面板中会列出已检测到的所有视频 → **勾选要下载的视频** → 点击下载
### 方式三:Web 管理页面批量下载
**访问地址**http://127.0.0.1:2022/download
1. 在微信视频号中浏览/播放视频(工具会自动检测)
2. 打开浏览器访问 Web 管理页面
3. 页面中会列出所有已检测到的视频
4. **全选/多选**视频 → 点击批量下载
5. 实时查看下载进度
---
## 七、下载文件位置
| 配置 | 路径 |
|------|------|
| 当前下载目录 | `/Volumes/Projects/视频/剑哥聊餐饮/` |
| 默认下载目录 | `%UserDownloads%`(即 `/Users/freedak/Downloads/` |
| 自定义方法 | 修改 `config.yaml``download.dir` 字段,重启工具生效 |
> 修改下载目录后需重启工具才能生效。在终端 `Ctrl+C` 停止工具,重新运行启动命令即可。
文件命名格式:`视频标题_画质.mp4`
示例文件:
```
餐饮赚钱的本质就是读懂人性#餐饮 #餐饮人_xWT111.mp4
连锁餐饮如何解决餐厅统采统配问题_xWT111.mp4
餐厅从单店到连锁必须经过的五个阶段_xWT111.mp4
```
---
## 八、完整排查过程记录
本次配置过程中遇到的问题及解决方案,按排查顺序记录:
### 问题 1nobiyou/wx_channel 不支持 macOS
- **现象**nobiyou 版仅提供 Windows 可执行文件
- **解决**:改用 ltaoo/wx_channels_download,明确支持 macOS
### 问题 2SunnyNet 证书信任设置不完整
- **现象**:证书已安装到钥匙串,但 `trust settings: 0`(未设为"始终信任"
- **解决**:导出证书并手动添加信任
```bash
security find-certificate -c "SunnyNet" -a -p /Library/Keychains/System.keychain > /tmp/SunnyNet.pem
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/SunnyNet.pem
security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain /tmp/SunnyNet.pem
```
### 问题 3LetsVPN 占用 7890 端口
- **现象**:微信视频号浏览器将流量发到 `127.0.0.1:7890`(LetsVPN 代理),完全绕过工具
- **解决**:关闭 LetsVPN
### 问题 4:微信沙箱绕过系统代理
- **现象**:微信 Mac 客户端运行在 App Sandbox 中,系统代理模式(`tun: false`)下部分流量不走代理
- **解决**:启用 TUN 模式(`tun: true`),通过虚拟网卡在网络层拦截流量
### 问题 5:微信视频号走 IPv6 绕过 TUN(根因!)
- **现象**:TUN 模式仅拦截 IPv4 流量,而 macOS 微信视频号浏览器 (WeChatAppEx) 所有连接走 IPv6,完全绕过 TUN 虚拟网卡
- **诊断**
```bash
lsof -i -n -P | grep WeChatApp | grep ESTABLISHED
# 所有连接均为 IPv6[2409:8a00:...] -> [2409:8c02:...]:443
```
- **解决**:禁用 Wi-Fi 接口的 IPv6
```bash
sudo networksetup -setv6off Wi-Fi
```
- **验证**:禁用后 WeChatApp 所有连接从 `10.99.99.1`TUN 虚拟网卡)发出
```bash
lsof -i -n -P | grep WeChatApp | grep ESTABLISHED
# 所有连接均为 IPv410.99.99.1:xxxxx -> x.x.x.x:443
```
### 问题 6`[FRONTEND ERROR]没有获取到视频详情`
- **现象**:工具拦截到流量,JS 注入正常,但前端无法获取视频详情
- **原因**:间歇性问题(GitHub Issue #415),多刷新几次视频可解决
- **解决**:在视频号中上下滑动切换几个视频,错误会自行消失
---
## 九、常见问题
| 问题 | 解决方案 |
|------|---------|
| 没看到下载按钮 | 1. 检查 IPv6 是否已禁用:`networksetup -getinfo Wi-Fi` 应显示 `IPv6: Off`;2. 检查 VPN 是否已关闭;3. 重启微信 |
| 代理服务启动失败 | 检查端口 2022/2023 是否被占用:`lsof -i :2022` |
| 与 VPN/翻墙软件冲突 | 关闭 VPN 软件,或设置 `proxy.tun: true` 使用 TUN 模式 |
| 证书安装失败 | 确保以 `sudo` 运行,参考第五节手动信任证书 |
| 下载的视频无法播放 | 工具会自动解密,如仍无法播放检查版本是否为最新 |
| 想下载最高画质 | `config.yaml` 中设 `download.defaultHighest: true` |
| `channels.available: false` | 不影响微信内下载按钮使用,Web 搜索功能可能受限 |
| `[FRONTEND ERROR]没有获取到视频详情` | 间歇性问题,在视频号中多切换几个视频即可 |
---
## 十、使用完毕后恢复
```bash
# 1. 在工具终端按 Ctrl+C 停止工具
# 2. 恢复 IPv6
sudo networksetup -setv6automatic Wi-Fi
# 3. 如需要,重新打开 VPN 软件
```
---
## 十一、sphCookie 配置(API 解析模式)
sphCookie 用于工具的 API 解析模式,可以在不依赖微信客户端 WebSocket 连接的情况下解析视频号分享链接。
### 获取方法
1. 使用 Chrome 浏览器访问 `https://yuanbao.tencent.com` 并登录
2. 打开 Chrome 开发者工具(F12)→ Application → Cookies
3. 找到 `yuanbao.tencent.com` 域名下的所有 cookie
4. 复制完整 cookie 字符串
### 配置方法
将 cookie 写入 `config.yaml`
```yaml
cloudflare:
sphCookie: "561553b295037d16=...; _TDID_CK=..."
```
### 自动提取脚本
工具目录下提供了 Python 脚本可自动从 Chrome 提取 cookie
```bash
python3 /Users/freedak/WorkBuddy/2026-07-14-07-31-44/wx_video_download/extract_sph_cookie.py
```
> 注意:cookie 有时效性,过期后需重新获取。配置后需重启工具生效。
---
## 十二、API 接口说明
工具启动后提供以下 API 接口(基地址 `http://127.0.0.1:2022`):
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/channels/version` | GET | 获取工具版本信息 |
| `/api/channels/parse_sph` | GET | 解析视频号分享链接(需 `url` 参数) |
| `/api/channels/feed/profile` | GET | 获取创作者信息(需微信客户端连接) |
| `/api/channels/contact/feed/list` | GET | 获取创作者视频列表(需微信客户端连接) |
| `/api/channels/shared_feed/profile` | GET | 获取分享链接对应的创作者信息 |
| `/api/sph` | GET | SPH 相关功能 |
| `/api/open_download_dir` | GET | 打开下载目录 |
| `/api/task/create_batch` | GET | 创建批量下载任务 |
### 测试 API
```bash
# 检查工具是否运行
curl -s http://127.0.0.1:2022/api/channels/version
# 解析视频号分享链接
curl -s "http://127.0.0.1:2022/api/channels/parse_sph?url=https://weixin.qq.com/sph/xxxxx"
```
> 注意:`feed/profile` 和 `contact/feed/list` 接口需要微信客户端通过 WebSocket 连接到工具才能工作。使用前需确保微信已打开且工具成功拦截到微信流量。
---
## 十三、自动监控下载(规划中)
### 目标
每日自动监控指定视频号(剑哥聊餐饮),发现新视频后自动下载。
### 监控目标
- **视频号**:剑哥聊餐饮
- **Finder 用户名**`sphnu5kSqZT224x`
- **下载目录**`/Volumes/Projects/视频/剑哥聊餐饮/`
### 方案设计
由于工具获取视频号完整视频列表的 API(`contact/feed/list`)依赖微信客户端的 WebSocket 连接,全自动方案需要微信保持运行状态。
**流程**
1. 检查工具和微信是否在运行
2. 通过 `/api/channels/contact/feed/list` API 获取视频号的完整视频列表
3. 与已下载文件比对,找出新视频
4. 自动下载新视频
5. 通过定时任务每日执行
**前提条件**
- 工具以 `sudo` 运行
- IPv6 已禁用
- 微信保持后台运行(无需手动操作)
- VPN 已关闭
---
## 十四、关键技术要点总结
1. **IPv6 是 macOS 上的最大坑**:微信视频号浏览器默认走 IPv6,必须禁用 IPv6 才能让 TUN 拦截到流量
2. **TUN 模式优于系统代理模式**:系统代理模式无法拦截 WebSocket 连接,TUN 模式可以完整拦截所有流量
3. **VPN 软件冲突**LetsVPN、Clash 等代理软件会占用端口或干扰 TUN,使用前必须关闭
4. **必须 sudo 运行**:TUN 模式需要创建虚拟网卡,必须以管理员权限运行
5. **首次运行需信任证书**SunnyNet 根证书用于 HTTPS 解密,必须设为"始终信任"
6. **下载目录可自定义**:修改 `config.yaml` 中 `download.dir` 字段,支持绝对路径,重启生效
7. **sphCookie 可选配置**:从 yuanbao.tencent.com 获取,配置后可使用 API 解析模式
8. **API 接口可用**:工具提供 REST API,可用于编程式批量操作和自动化集成
---
> ⚠️ **版权提醒**:下载的视频仅供个人收藏、学习使用,请勿二次上传到公共平台或用于商业用途,尊重原创。