feat: add full-stack usage statistics
This commit is contained in:
parent
9d74feaa62
commit
1582ee8b94
6
.gitignore
vendored
6
.gitignore
vendored
@ -2,3 +2,9 @@ dist/
|
|||||||
node_modules/
|
node_modules/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
backend/data/
|
||||||
|
|||||||
127
README.md
127
README.md
@ -1,60 +1,85 @@
|
|||||||
# 蝶雅产品AI中心
|
# 蝶雅产品AI中心
|
||||||
|
|
||||||
面向产品部的对外统一工作台,当前只展示两个正式产品工具:风险预警、成本识别。项目采用原生 HTML、CSS、JavaScript 模块,无第三方依赖,可直接部署到宝塔 Nginx 静态网站。
|
面向产品部的对外统一工作台,当前只展示两个正式产品工具:风险预警、成本识别。项目按 frontend/ + backend/ 分层,采用 Vite 前端、FastAPI 后端与 SQLite 持久化统计。
|
||||||
|
|
||||||
## 已集成入口
|
## 目录
|
||||||
|
|
||||||
|
- frontend/:工作台卡片、浅色/深色主题、使用统计页面、前端测试及构建配置。
|
||||||
|
- backend/:FastAPI API、SQLite 数据库、统计接口和后端测试。
|
||||||
|
- deploy/nginx.conf.example:Nginx 静态站点与 API 反向代理配置示例。
|
||||||
|
|
||||||
|
## 使用统计
|
||||||
|
|
||||||
|
点击“打开工作台”时,前端向 POST /api/apps/{app_id}/click 发送一次请求,后端在 SQLite 中为该入口增加一次点击。使用统计页面通过 GET /api/stats 读取总次数、分应用次数和最近更新时间。后端只接受 risk、cost 两个白名单入口。
|
||||||
|
|
||||||
|
数据默认保存在 backend/data/usage.sqlite3。数据库使用事务与 WAL,服务重启后记录会保留。运行数据、密钥和依赖环境不会提交 Git。
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
后端需要 Python 3.10+:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
python -m venv .venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
uvicorn app.main:app --reload --host 127.0.0.1 --port 8800
|
||||||
|
```
|
||||||
|
|
||||||
|
另开终端启动前端:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 http://127.0.0.1:5173。Vite 会把 /api 代理到 127.0.0.1:8800。
|
||||||
|
|
||||||
|
前端检查和构建:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
后端测试:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
python -m unittest discover -s tests -p "test_*.py" -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 宝塔部署
|
||||||
|
|
||||||
|
先在 /www/wwwroot/dieya-app-portal/backend 安装后端依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/python -m pip install -r requirements.txt
|
||||||
|
.venv/bin/gunicorn -w 2 -k uvicorn.workers.UvicornWorker app.main:app -b 127.0.0.1:8800
|
||||||
|
```
|
||||||
|
|
||||||
|
宝塔使用 Python 项目或 Supervisor 托管该命令。工作目录是 backend,运行用户建议为 www,并确保 backend/data 可写。
|
||||||
|
|
||||||
|
再构建前端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/dieya-app-portal/frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
网站根目录设为 /www/wwwroot/dieya-app-portal/frontend/dist。Nginx 把 /api/ 转发到 127.0.0.1:8800,其余路径返回 index.html,配置示例见 deploy/nginx.conf.example。前后端使用同一门户域名时无需额外配置 CORS。
|
||||||
|
|
||||||
|
部署后可用 curl https://你的域名/api/health 检查后端,用 curl https://你的域名/api/stats 查看统计。打开工作台后刷新“使用统计”,对应次数应增加 1。
|
||||||
|
|
||||||
|
## 应用范围
|
||||||
|
|
||||||
| 应用 | 地址 | 环境 |
|
| 应用 | 地址 | 环境 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 风险预警 | http://risk-prod.dieya.net.cn/ | 正式 |
|
| 风险预警 | http://risk-prod.dieya.net.cn/ | 正式 |
|
||||||
| 成本识别 | http://cost.dieya.net.cn/ | 正式 |
|
| 成本识别 | http://cost.dieya.net.cn/ | 正式 |
|
||||||
|
|
||||||
其他业务入口、测试版应用和制度小助手不在本门户展示。
|
其他业务入口、测试版应用和制度小助手不在门户展示。目标业务系统仍负责登录、钉钉免登及权限校验。
|
||||||
|
|
||||||
## 本地启动
|
|
||||||
|
|
||||||
需要 Node.js 18 或更高版本。项目没有第三方依赖,不需要 `npm install`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm test
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
默认访问 http://127.0.0.1:5173,也可以指定端口:`npm run dev -- --port 8080`。
|
|
||||||
|
|
||||||
## 宝塔部署
|
|
||||||
|
|
||||||
生产环境只部署 `dist/` 目录中的静态文件:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
将 `dist/` 里面的文件上传到网站目录,或在宝塔网站设置中将网站根目录指向:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/www/wwwroot/dieya-product-ai-center/dist
|
|
||||||
```
|
|
||||||
|
|
||||||
不需要创建 Node 项目,不需要 PHP,不需要反向代理。网站默认文档使用 `index.html`。服务器需为 `.mjs` 文件返回 JavaScript 类型,Nginx 可在站点配置中加入:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
location ~ \.mjs$ {
|
|
||||||
types { application/javascript mjs; }
|
|
||||||
add_header Cache-Control "no-cache";
|
|
||||||
try_files $uri =404;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 修改入口
|
|
||||||
|
|
||||||
编辑 `src/apps.mjs` 中的 `applications` 数组,然后重新构建并部署 `dist`。当前配置会校验应用必须是产品部正式入口,不允许测试环境配置混入对外门户。
|
|
||||||
|
|
||||||
## 页面功能
|
|
||||||
|
|
||||||
- 参考多智能体工作台的简洁卡片风格,支持浅色和深色主题按钮切换。
|
|
||||||
- 顶部品牌导航、产品部工具卡片和响应式布局,避免复杂的筛选与侧栏层级。
|
|
||||||
- 每张卡片展示产品部标签、正式应用状态和打开工作台按钮。
|
|
||||||
- 主题选择保存在当前浏览器中,不影响应用权限。
|
|
||||||
|
|
||||||
目标业务系统仍需自行负责登录、钉钉免登、可信域名和权限校验。本门户只负责统一展示和跳转。
|
|
||||||
|
|||||||
4
backend/.env.example
Normal file
4
backend/.env.example
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
PORTAL_DATABASE=./data/usage.sqlite3
|
||||||
|
PORTAL_CORS_ORIGINS=
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=8800
|
||||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""蝶雅产品AI中心统计 API。"""
|
||||||
16
backend/app/catalog.py
Normal file
16
backend/app/catalog.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""Server-side allowlist for public product workbench links."""
|
||||||
|
|
||||||
|
APPLICATIONS = {
|
||||||
|
"risk": {
|
||||||
|
"id": "risk",
|
||||||
|
"name": "风险预警",
|
||||||
|
"url": "http://risk-prod.dieya.net.cn/",
|
||||||
|
"category": "product",
|
||||||
|
},
|
||||||
|
"cost": {
|
||||||
|
"id": "cost",
|
||||||
|
"name": "成本识别",
|
||||||
|
"url": "http://cost.dieya.net.cn/",
|
||||||
|
"category": "product",
|
||||||
|
},
|
||||||
|
}
|
||||||
56
backend/app/database.py
Normal file
56
backend/app/database.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
"""SQLite persistence for workbench click counts."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def database_path() -> Path:
|
||||||
|
configured = Path(os.getenv("PORTAL_DATABASE", "./data/usage.sqlite3")).expanduser()
|
||||||
|
return configured if configured.is_absolute() else BACKEND_ROOT / configured
|
||||||
|
|
||||||
|
|
||||||
|
def connect(path: Path | None = None) -> sqlite3.Connection:
|
||||||
|
resolved = path or database_path()
|
||||||
|
resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
connection = sqlite3.connect(resolved, timeout=15)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA busy_timeout=15000")
|
||||||
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def initialize(path: Path | None = None) -> None:
|
||||||
|
with connect(path) as connection:
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS application_usage (
|
||||||
|
app_id TEXT PRIMARY KEY,
|
||||||
|
click_count INTEGER NOT NULL DEFAULT 0 CHECK (click_count >= 0),
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session(path: Path | None = None):
|
||||||
|
connection = connect(path)
|
||||||
|
try:
|
||||||
|
yield connection
|
||||||
|
connection.commit()
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
85
backend/app/main.py
Normal file
85
backend/app/main.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"""FastAPI entry point for persistent product workbench usage statistics."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from .catalog import APPLICATIONS
|
||||||
|
from .database import initialize, session, utc_now
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
initialize()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="蝶雅产品AI中心统计 API", version="1.0.0", lifespan=lifespan)
|
||||||
|
cors_origins = [origin.strip() for origin in os.getenv("PORTAL_CORS_ORIGINS", "").split(",") if origin.strip()]
|
||||||
|
if cors_origins:
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=cors_origins,
|
||||||
|
allow_methods=["GET", "POST"],
|
||||||
|
allow_headers=["Content-Type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
with session() as connection:
|
||||||
|
connection.execute("SELECT 1").fetchone()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/stats")
|
||||||
|
def get_stats():
|
||||||
|
with session() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT app_id, click_count, updated_at FROM application_usage ORDER BY app_id"
|
||||||
|
).fetchall()
|
||||||
|
counts = {row["app_id"]: row for row in rows}
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
**application,
|
||||||
|
"click_count": counts[app_id]["click_count"] if app_id in counts else 0,
|
||||||
|
"updated_at": counts[app_id]["updated_at"] if app_id in counts else None,
|
||||||
|
}
|
||||||
|
for app_id, application in APPLICATIONS.items()
|
||||||
|
]
|
||||||
|
timestamps = [item["updated_at"] for item in items if item["updated_at"]]
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"total_clicks": sum(item["click_count"] for item in items),
|
||||||
|
"updated_at": max(timestamps) if timestamps else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/apps/{app_id}/click", status_code=201)
|
||||||
|
def record_click(app_id: str, request: Request):
|
||||||
|
application = APPLICATIONS.get(app_id)
|
||||||
|
if application is None:
|
||||||
|
raise HTTPException(status_code=404, detail="工作台不存在")
|
||||||
|
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
request_host = request.headers.get("x-forwarded-host") or request.headers.get("host", "")
|
||||||
|
origin_host = urlsplit(origin).netloc if origin else ""
|
||||||
|
if origin and origin_host.lower() != request_host.lower():
|
||||||
|
allowed = {item.strip().rstrip("/") for item in os.getenv("PORTAL_CORS_ORIGINS", "").split(",") if item.strip()}
|
||||||
|
if origin.rstrip("/") not in allowed:
|
||||||
|
raise HTTPException(status_code=403, detail="来源不受允许")
|
||||||
|
|
||||||
|
now = utc_now()
|
||||||
|
with session() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""INSERT INTO application_usage (app_id, click_count, updated_at) VALUES (?, 1, ?)
|
||||||
|
ON CONFLICT(app_id) DO UPDATE SET click_count = click_count + 1, updated_at = excluded.updated_at""",
|
||||||
|
(app_id, now),
|
||||||
|
)
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT click_count FROM application_usage WHERE app_id = ?", (app_id,)
|
||||||
|
).fetchone()
|
||||||
|
return {"app_id": app_id, "click_count": row["click_count"], "updated_at": now}
|
||||||
3
backend/requirements.txt
Normal file
3
backend/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fastapi>=0.115,<1.0
|
||||||
|
uvicorn[standard]>=0.34,<1.0
|
||||||
|
httpx>=0.28,<1.0
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
56
backend/tests/test_api.py
Normal file
56
backend/tests/test_api.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
"""API tests using isolated SQLite files."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.database import initialize, session
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
class UsageApiTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = Path(self.temp.name) / "usage.sqlite3"
|
||||||
|
initialize(self.db_path)
|
||||||
|
self.initialize_patch = patch("app.main.initialize", side_effect=lambda: initialize(self.db_path))
|
||||||
|
self.initialize_patch.start()
|
||||||
|
self.database_patch = patch("app.main.session", side_effect=lambda: session(self.db_path))
|
||||||
|
self.database_patch.start()
|
||||||
|
self.client_context = TestClient(app)
|
||||||
|
self.client = self.client_context.__enter__()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client_context.__exit__(None, None, None)
|
||||||
|
self.database_patch.stop()
|
||||||
|
self.initialize_patch.stop()
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def test_health_and_empty_stats(self):
|
||||||
|
self.assertEqual(self.client.get("/api/health").json(), {"status": "ok"})
|
||||||
|
result = self.client.get("/api/stats").json()
|
||||||
|
self.assertEqual(result["total_clicks"], 0)
|
||||||
|
self.assertEqual({item["id"]: item["click_count"] for item in result["items"]}, {"risk": 0, "cost": 0})
|
||||||
|
|
||||||
|
def test_each_click_increments_once_and_persists(self):
|
||||||
|
self.assertEqual(self.client.post("/api/apps/risk/click", json={}).status_code, 201)
|
||||||
|
self.assertEqual(self.client.post("/api/apps/risk/click", json={}).status_code, 201)
|
||||||
|
self.assertEqual(self.client.post("/api/apps/cost/click", json={}).status_code, 201)
|
||||||
|
stats = self.client.get("/api/stats").json()
|
||||||
|
self.assertEqual(stats["total_clicks"], 3)
|
||||||
|
self.assertEqual({item["id"]: item["click_count"] for item in stats["items"]}, {"risk": 2, "cost": 1})
|
||||||
|
self.assertIsNotNone(stats["updated_at"])
|
||||||
|
|
||||||
|
def test_only_allowlisted_apps_can_be_counted(self):
|
||||||
|
self.assertEqual(self.client.post("/api/apps/unknown/click", json={}).status_code, 404)
|
||||||
|
|
||||||
|
def test_rejects_cross_origin_click(self):
|
||||||
|
response = self.client.post("/api/apps/risk/click", json={}, headers={"Origin": "https://untrusted.example"})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@ -1,21 +1,19 @@
|
|||||||
# 把 dist/ 的内容上传到此目录,再按实际域名、路径与 HTTPS 证书修改配置。
|
# 在宝塔现有站点 server {} 中合并 location;保留证书和域名配置。
|
||||||
server {
|
root /www/wwwroot/dieya-app-portal/frontend/dist;
|
||||||
listen 80;
|
|
||||||
server_name portal.example.com;
|
|
||||||
root /var/www/dieya-product-ai-center;
|
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
location ^~ /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8800;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
|
||||||
|
|
||||||
# .mjs 必须使用 JavaScript MIME 类型。
|
|
||||||
location ~ \.mjs$ {
|
|
||||||
types { application/javascript mjs; }
|
|
||||||
add_header Cache-Control "no-cache";
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /index.html {
|
|
||||||
add_header Cache-Control "no-cache";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 257 B After Width: | Height: | Size: 257 B |
@ -8,6 +8,7 @@
|
|||||||
<title>蝶雅产品AI中心</title>
|
<title>蝶雅产品AI中心</title>
|
||||||
<link rel="icon" href="./favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="./favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="./src/styles.css">
|
<link rel="stylesheet" href="./src/styles.css">
|
||||||
|
<link rel="stylesheet" href="./src/stats.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a class="skip-link" href="#main">跳转到智能体列表</a>
|
<a class="skip-link" href="#main">跳转到智能体列表</a>
|
||||||
@ -20,8 +21,8 @@
|
|||||||
</a>
|
</a>
|
||||||
<nav class="nav" aria-label="主导航">
|
<nav class="nav" aria-label="主导航">
|
||||||
<button class="nav-icon" type="button" aria-label="打开新的工作台标签页" title="打开新的工作台标签页">+</button>
|
<button class="nav-icon" type="button" aria-label="打开新的工作台标签页" title="打开新的工作台标签页">+</button>
|
||||||
<a class="nav-link active" href="./">智能体广场</a>
|
<button class="nav-link active" type="button" data-view="home">智能体广场</button>
|
||||||
<a class="nav-link" href="#stats">使用统计</a>
|
<button class="nav-link" type="button" data-view="stats">使用统计</button>
|
||||||
<a class="nav-link" href="#feedback">问题反馈</a>
|
<a class="nav-link" href="#feedback">问题反馈</a>
|
||||||
<div class="theme-switch" role="group" aria-label="选择主题">
|
<div class="theme-switch" role="group" aria-label="选择主题">
|
||||||
<button class="theme-button active" type="button" data-theme="light" aria-label="浅色主题" aria-pressed="true">☼</button>
|
<button class="theme-button active" type="button" data-theme="light" aria-label="浅色主题" aria-pressed="true">☼</button>
|
||||||
@ -30,13 +31,21 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main id="main" class="main" tabindex="-1">
|
<main id="main" class="main" tabindex="-1">
|
||||||
<section class="intro" aria-labelledby="page-title">
|
<section id="home-view" aria-labelledby="page-title">
|
||||||
|
<section class="intro">
|
||||||
<p class="eyebrow">PRODUCT AI CENTER</p>
|
<p class="eyebrow">PRODUCT AI CENTER</p>
|
||||||
<h1 id="page-title">选择智能体</h1>
|
<h1 id="page-title">选择智能体</h1>
|
||||||
<p class="intro-description">选择需要使用的产品工具,立即开始工作。</p>
|
<p class="intro-description">选择需要使用的产品工具,立即开始工作。</p>
|
||||||
</section>
|
</section>
|
||||||
<section id="agent-grid" class="agent-grid" aria-label="产品部智能体列表"></section>
|
<section id="agent-grid" class="agent-grid" aria-label="产品部智能体列表"></section>
|
||||||
<section id="empty-state" class="empty-state" hidden aria-live="polite">暂无可用的产品工具。</section>
|
<section id="empty-state" class="empty-state" hidden aria-live="polite">暂无可用的产品工具。</section>
|
||||||
|
</section>
|
||||||
|
<section id="stats-view" class="stats-view" hidden aria-labelledby="stats-title">
|
||||||
|
<div class="stats-heading"><div><p class="eyebrow">USAGE ANALYTICS</p><h1 id="stats-title">使用统计</h1><p class="intro-description">记录每次打开工作台的使用情况。</p></div><button id="refresh-stats" class="refresh-button" type="button">刷新数据</button></div>
|
||||||
|
<div class="stats-cards"><article class="stat-card"><span>累计打开次数</span><strong id="stats-total">--</strong></article><article class="stat-card"><span>统计范围</span><strong id="stats-scope">产品部工具</strong></article><article class="stat-card"><span>最近更新</span><strong id="stats-updated">--</strong></article></div>
|
||||||
|
<div class="stats-table-wrap"><table class="stats-table"><thead><tr><th>工作台</th><th>访问次数</th><th>入口状态</th></tr></thead><tbody id="stats-table-body"></tbody></table></div>
|
||||||
|
<p id="stats-error" class="stats-error" hidden>统计服务暂时不可用,请确认后端服务已启动。</p>
|
||||||
|
</section>
|
||||||
<footer class="footer"><span>蝶雅产品AI中心</span><span>连接产品 · 提升效率</span></footer>
|
<footer class="footer"><span>蝶雅产品AI中心</span><span>连接产品 · 提升效率</span></footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
17
frontend/package.json
Normal file
17
frontend/package.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "dieya-product-ai-center-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "蝶雅产品AI中心",
|
||||||
|
"engines": { "node": ">=18" },
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"start": "vite preview --host 127.0.0.1",
|
||||||
|
"build": "vite build --outDir dist",
|
||||||
|
"test": "node --test tests/*.test.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,7 +8,7 @@ const arg = (name, fallback) => { const index = args.indexOf(name); return index
|
|||||||
const port = Number(arg('--port', process.env.PORT || '5173'));
|
const port = Number(arg('--port', process.env.PORT || '5173'));
|
||||||
const host = arg('--host', '127.0.0.1');
|
const host = arg('--host', '127.0.0.1');
|
||||||
const root = path.resolve(fileURLToPath(new URL('../', import.meta.url)));
|
const root = path.resolve(fileURLToPath(new URL('../', import.meta.url)));
|
||||||
const publicFiles = new Set(['index.html', 'favicon.svg', 'src/styles.css', 'src/apps.mjs', 'src/main.mjs']);
|
const publicFiles = new Set(['index.html', 'favicon.svg', 'src/styles.css', 'src/stats.css', 'src/apps.mjs', 'src/main.mjs']);
|
||||||
const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' };
|
const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' };
|
||||||
const server = http.createServer(async (request, response) => {
|
const server = http.createServer(async (request, response) => {
|
||||||
if (!['GET', 'HEAD'].includes(request.method)) { response.writeHead(405, { Allow: 'GET, HEAD' }); response.end(); return; }
|
if (!['GET', 'HEAD'].includes(request.method)) { response.writeHead(405, { Allow: 'GET, HEAD' }); response.end(); return; }
|
||||||
@ -38,6 +38,61 @@ function render() {
|
|||||||
document.querySelector('#empty-state').hidden = applications.length > 0;
|
document.querySelector('#empty-state').hidden = applications.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showView(view, updateHash = true) {
|
||||||
|
const stats = view === 'stats';
|
||||||
|
document.querySelector('#home-view').hidden = stats;
|
||||||
|
document.querySelector('#stats-view').hidden = !stats;
|
||||||
|
document.querySelectorAll('[data-view]').forEach(button => {
|
||||||
|
const active = button.dataset.view === view;
|
||||||
|
button.classList.toggle('active', active);
|
||||||
|
button.setAttribute('aria-pressed', String(active));
|
||||||
|
});
|
||||||
|
if (updateHash) history.replaceState(null, '', stats ? '#stats' : '#');
|
||||||
|
if (stats) loadStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
const error = document.querySelector('#stats-error');
|
||||||
|
error.hidden = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/stats', { headers: { Accept: 'application/json' } });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const payload = await response.json();
|
||||||
|
const records = payload.items || [];
|
||||||
|
document.querySelector('#stats-total').textContent = Number(payload.total_clicks || 0).toLocaleString('zh-CN');
|
||||||
|
document.querySelector('#stats-updated').textContent = payload.updated_at
|
||||||
|
? new Date(payload.updated_at).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
: '暂无记录';
|
||||||
|
document.querySelector('#stats-table-body').innerHTML = records.map(item => `<tr><td><span class="stats-app-name">${escapeHtml(item.name)}</span><small>${escapeHtml(item.url)}</small></td><td class="stats-count">${Number(item.click_count).toLocaleString('zh-CN')}</td><td><span class="stats-ready">正式应用</span></td></tr>`).join('');
|
||||||
|
} catch {
|
||||||
|
error.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trackAndOpen(app) {
|
||||||
|
const request = fetch(`/api/apps/${encodeURIComponent(app.id)}/click`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
keepalive: true
|
||||||
|
}).catch(() => undefined);
|
||||||
|
// Give the same-origin request a chance to reach the API, then open the tool.
|
||||||
|
await Promise.race([request, new Promise(resolve => setTimeout(resolve, 180))]);
|
||||||
|
window.location.assign(app.url);
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('[data-theme]').forEach(button => button.addEventListener('click', () => applyTheme(button.dataset.theme)));
|
document.querySelectorAll('[data-theme]').forEach(button => button.addEventListener('click', () => applyTheme(button.dataset.theme)));
|
||||||
|
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', () => showView(button.dataset.view)));
|
||||||
|
document.querySelector('#refresh-stats').addEventListener('click', loadStats);
|
||||||
|
window.addEventListener('hashchange', () => showView(location.hash === '#stats' ? 'stats' : 'home', false));
|
||||||
|
grid.addEventListener('click', event => {
|
||||||
|
const link = event.target.closest('.agent-card__button');
|
||||||
|
if (!link) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const card = link.closest('.agent-card');
|
||||||
|
const app = applications.find(item => item.url === link.href);
|
||||||
|
if (app && card) trackAndOpen(app);
|
||||||
|
});
|
||||||
applyTheme(preferredTheme);
|
applyTheme(preferredTheme);
|
||||||
render();
|
render();
|
||||||
|
showView(location.hash === '#stats' ? 'stats' : 'home', false);
|
||||||
14
frontend/src/stats.css
Normal file
14
frontend/src/stats.css
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
.nav-link{background:transparent}
|
||||||
|
.stats-view{padding:42px 0 0}
|
||||||
|
.stats-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:20px}
|
||||||
|
.stats-heading h1{margin:0;color:var(--text);font-size:32px}
|
||||||
|
.refresh-button{padding:10px 16px;border:1px solid var(--line);border-radius:9px;background:var(--card);color:var(--text);font-size:12px}
|
||||||
|
.refresh-button:hover{border-color:#8b9dff;background:var(--soft)}
|
||||||
|
.stats-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:30px}
|
||||||
|
.stat-card{display:flex;flex-direction:column;gap:11px;padding:22px;border:1px solid var(--line);border-radius:14px;background:var(--card);box-shadow:var(--shadow)}
|
||||||
|
.stat-card span{color:var(--muted);font-size:12px}.stat-card strong{color:var(--text);font-size:24px}
|
||||||
|
.stats-table-wrap{margin-top:20px;overflow:hidden;border:1px solid var(--line);border-radius:14px;background:var(--card);box-shadow:var(--shadow)}
|
||||||
|
.stats-table{width:100%;border-collapse:collapse;text-align:left}.stats-table th,.stats-table td{padding:16px 20px;border-bottom:1px solid var(--line);font-size:12px}.stats-table th{color:var(--muted);font-weight:500;background:var(--soft)}.stats-table tr:last-child td{border-bottom:0}
|
||||||
|
.stats-app-name{display:block;color:var(--text);font-weight:600}.stats-table small{display:block;margin-top:5px;color:var(--muted);font-size:10px}.stats-count{color:var(--text);font-size:16px!important;font-weight:700}.stats-ready{display:inline-flex;padding:5px 8px;border-radius:999px;background:rgba(49,185,122,.12);color:#299c6b;font-size:10px}
|
||||||
|
.stats-error{margin-top:18px;padding:13px 16px;border:1px solid #efb1b1;border-radius:9px;background:#fff3f3;color:#b34d4d;font-size:12px}:root[data-theme=dark] .stats-error{background:#321d27;color:#f0a4a4;border-color:#73404d}
|
||||||
|
@media(max-width:700px){.stats-view{padding-top:31px}.stats-heading{align-items:flex-start;flex-direction:column}.stats-heading h1{font-size:27px}.stats-cards{grid-template-columns:1fr;gap:12px;margin-top:24px}.stats-table th,.stats-table td{padding:13px 12px}.stats-table small{max-width:145px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||||
10
frontend/vite.config.mjs
Normal file
10
frontend/vite.config.mjs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 5173,
|
||||||
|
proxy: { '/api': 'http://127.0.0.1:8800' }
|
||||||
|
},
|
||||||
|
preview: { host: '127.0.0.1', port: 4173 }
|
||||||
|
});
|
||||||
14
package.json
14
package.json
@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "dieya-product-ai-center",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"description": "蝶雅产品AI中心",
|
|
||||||
"engines": { "node": ">=18" },
|
|
||||||
"scripts": {
|
|
||||||
"dev": "node scripts/serve.mjs",
|
|
||||||
"start": "node scripts/serve.mjs",
|
|
||||||
"build": "node scripts/build.mjs",
|
|
||||||
"test": "node --test tests/*.test.mjs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user