From 1582ee8b9403aee2ad5557550b8020fb6aeeb1c6 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Mon, 26 Oct 2026 16:30:32 +0800 Subject: [PATCH] feat: add full-stack usage statistics --- .gitignore | 6 ++ README.md | 127 ++++++++++++++---------- backend/.env.example | 4 + backend/app/__init__.py | 1 + backend/app/catalog.py | 16 +++ backend/app/database.py | 56 +++++++++++ backend/app/main.py | 85 ++++++++++++++++ backend/requirements.txt | 3 + backend/tests/__init__.py | 0 backend/tests/test_api.py | 56 +++++++++++ deploy/nginx.conf.example | 36 ++++--- favicon.svg => frontend/favicon.svg | 0 index.html => frontend/index.html | 25 +++-- frontend/package.json | 17 ++++ {scripts => frontend/scripts}/build.mjs | 0 {scripts => frontend/scripts}/serve.mjs | 2 +- {src => frontend/src}/apps.mjs | 0 {src => frontend/src}/main.mjs | 55 ++++++++++ frontend/src/stats.css | 14 +++ {src => frontend/src}/styles.css | 0 {tests => frontend/tests}/apps.test.mjs | 0 frontend/vite.config.mjs | 10 ++ package.json | 14 --- 23 files changed, 434 insertions(+), 93 deletions(-) create mode 100644 backend/.env.example create mode 100644 backend/app/__init__.py create mode 100644 backend/app/catalog.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_api.py rename favicon.svg => frontend/favicon.svg (100%) rename index.html => frontend/index.html (50%) create mode 100644 frontend/package.json rename {scripts => frontend/scripts}/build.mjs (100%) rename {scripts => frontend/scripts}/serve.mjs (96%) rename {src => frontend/src}/apps.mjs (100%) rename {src => frontend/src}/main.mjs (50%) create mode 100644 frontend/src/stats.css rename {src => frontend/src}/styles.css (100%) rename {tests => frontend/tests}/apps.test.mjs (100%) create mode 100644 frontend/vite.config.mjs delete mode 100644 package.json diff --git a/.gitignore b/.gitignore index 2824b07..2b22a7f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,9 @@ dist/ node_modules/ .DS_Store *.log +__pycache__/ +*.py[cod] +.venv/ +venv/ +.env +backend/data/ diff --git a/README.md b/README.md index 2c665f7..9578e82 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,85 @@ # 蝶雅产品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://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`。当前配置会校验应用必须是产品部正式入口,不允许测试环境配置混入对外门户。 - -## 页面功能 - -- 参考多智能体工作台的简洁卡片风格,支持浅色和深色主题按钮切换。 -- 顶部品牌导航、产品部工具卡片和响应式布局,避免复杂的筛选与侧栏层级。 -- 每张卡片展示产品部标签、正式应用状态和打开工作台按钮。 -- 主题选择保存在当前浏览器中,不影响应用权限。 - -目标业务系统仍需自行负责登录、钉钉免登、可信域名和权限校验。本门户只负责统一展示和跳转。 +其他业务入口、测试版应用和制度小助手不在门户展示。目标业务系统仍负责登录、钉钉免登及权限校验。 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..dde64cd --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,4 @@ +PORTAL_DATABASE=./data/usage.sqlite3 +PORTAL_CORS_ORIGINS= +HOST=127.0.0.1 +PORT=8800 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..925ab13 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""蝶雅产品AI中心统计 API。""" diff --git a/backend/app/catalog.py b/backend/app/catalog.py new file mode 100644 index 0000000..9952659 --- /dev/null +++ b/backend/app/catalog.py @@ -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", + }, +} diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..2659fbf --- /dev/null +++ b/backend/app/database.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..b8faf82 --- /dev/null +++ b/backend/app/main.py @@ -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} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..4a481cc --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115,<1.0 +uvicorn[standard]>=0.34,<1.0 +httpx>=0.28,<1.0 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..8fbcc2e --- /dev/null +++ b/backend/tests/test_api.py @@ -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() diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example index 10a2cca..0b10bf0 100644 --- a/deploy/nginx.conf.example +++ b/deploy/nginx.conf.example @@ -1,21 +1,19 @@ -# 把 dist/ 的内容上传到此目录,再按实际域名、路径与 HTTPS 证书修改配置。 -server { - listen 80; - server_name portal.example.com; - root /var/www/dieya-product-ai-center; - index index.html; +# 在宝塔现有站点 server {} 中合并 location;保留证书和域名配置。 +root /www/wwwroot/dieya-app-portal/frontend/dist; +index index.html; - location / { - try_files $uri $uri/ =404; - } - - # .mjs 必须使用 JavaScript MIME 类型。 - location ~ \.mjs$ { - types { application/javascript mjs; } - add_header Cache-Control "no-cache"; - } - - location = /index.html { - add_header Cache-Control "no-cache"; - } +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 / { + try_files $uri $uri/ /index.html; } diff --git a/favicon.svg b/frontend/favicon.svg similarity index 100% rename from favicon.svg rename to frontend/favicon.svg diff --git a/index.html b/frontend/index.html similarity index 50% rename from index.html rename to frontend/index.html index b0ac2eb..356ee7b 100644 --- a/index.html +++ b/frontend/index.html @@ -8,6 +8,7 @@ 蝶雅产品AI中心 + @@ -20,8 +21,8 @@
-
-

PRODUCT AI CENTER

-

选择智能体

-

选择需要使用的产品工具,立即开始工作。

+
+
+

PRODUCT AI CENTER

+

选择智能体

+

选择需要使用的产品工具,立即开始工作。

+
+
+ +
+ -
-
蝶雅产品AI中心连接产品 · 提升效率
diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fa990ae --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/scripts/build.mjs b/frontend/scripts/build.mjs similarity index 100% rename from scripts/build.mjs rename to frontend/scripts/build.mjs diff --git a/scripts/serve.mjs b/frontend/scripts/serve.mjs similarity index 96% rename from scripts/serve.mjs rename to frontend/scripts/serve.mjs index 4664021..0f9112b 100644 --- a/scripts/serve.mjs +++ b/frontend/scripts/serve.mjs @@ -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 host = arg('--host', '127.0.0.1'); 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 server = http.createServer(async (request, response) => { if (!['GET', 'HEAD'].includes(request.method)) { response.writeHead(405, { Allow: 'GET, HEAD' }); response.end(); return; } diff --git a/src/apps.mjs b/frontend/src/apps.mjs similarity index 100% rename from src/apps.mjs rename to frontend/src/apps.mjs diff --git a/src/main.mjs b/frontend/src/main.mjs similarity index 50% rename from src/main.mjs rename to frontend/src/main.mjs index 1903d77..35b8c5b 100644 --- a/src/main.mjs +++ b/frontend/src/main.mjs @@ -38,6 +38,61 @@ function render() { 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 => `${escapeHtml(item.name)}${escapeHtml(item.url)}${Number(item.click_count).toLocaleString('zh-CN')}正式应用`).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-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); render(); +showView(location.hash === '#stats' ? 'stats' : 'home', false); diff --git a/frontend/src/stats.css b/frontend/src/stats.css new file mode 100644 index 0000000..0842874 --- /dev/null +++ b/frontend/src/stats.css @@ -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}} diff --git a/src/styles.css b/frontend/src/styles.css similarity index 100% rename from src/styles.css rename to frontend/src/styles.css diff --git a/tests/apps.test.mjs b/frontend/tests/apps.test.mjs similarity index 100% rename from tests/apps.test.mjs rename to frontend/tests/apps.test.mjs diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs new file mode 100644 index 0000000..e912d6d --- /dev/null +++ b/frontend/vite.config.mjs @@ -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 } +}); diff --git a/package.json b/package.json deleted file mode 100644 index acd604a..0000000 --- a/package.json +++ /dev/null @@ -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" - } -}