Compare commits

..
22 Commits
Author SHA1 Message Date
enzo 0e393f265d fix: color tokens aligned to reference image pixel extraction 2026-07-22 22:28:07 +08:00
enzoandClaude ae04ffa9a0 fix: Gateway trend bars missing height — now render 48 visible bars
Bars had no `height` property, making them invisible despite the legend
showing correctly. Each bar now computes height proportional to anomaly
count (max 36px), with a 3px minimum for normal intervals.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 22:05:55 +08:00
enzoandClaude c79b1af392 feat: pixel-perfect rewrite — Login/Sidebar/Header/Dashboard/Gateway
- Login: 400px card with 24px radius, white 28px logo, password eye toggle,
  42px inputs, 48px honey button, placeholder var(--text-muted)
- Sidebar: NEW badges golden, count badges red, hover bg-hover, active honey-glow
- Header: search with search icon, status dots, inline hover dropdown
- Dashboard: 4-column stat grid, horizontal stacked health bar (8px height),
  48-bar gateway trend (no dupes), tables with height 44px and padding 10px 14px
- Gateway: "Gateway 健康监控" title, stat summary, honey filter buttons,
  honey-outline action buttons, search + type filter, last-update timestamp

All colors from var(--*), spacing in 4px multiples, animations ≤300ms,
table rows 44px, shadows only 3 layers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 22:03:55 +08:00
enzo cbd8b347db feat: visual overhaul — Sidebar/Header/Dashboard aligned to prototype 2026-07-22 20:32:08 +08:00
enzoandClaude a97b885081 fix: comma-separate D-level profiles, map restart to blue in timeline
- D-level profile names now joined with commas instead of concatenated spans
- Gateway trend restart indicator changed from yellow to blue (red/orange/blue scheme)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 20:22:50 +08:00
enzoandClaude db11548414 fix: visual fixes — dark theme default, sidebar, dashboard cards, timeline dots, header counts
- Set data-theme=dark as default in index.html, useTheme defaults to 'dark' (issues 1, 10)
- Rename "Cron" to "Cron 管理" in sidebar (issue 2)
- Set card grid gap to 14px per design spec (issue 3)
- Show D-grade profile names below health bar chart (issue 4)
- Remove R/F/H/OK text from timeline, use colored dots with hover tooltip (issue 5)
- Wire live online/running/offline counts via usePolling in header, add offline badge (issue 9)
- Fix search placeholder to "搜索 Profile / 任务 / Cron / 事件..." (issue 9)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 19:54:44 +08:00
enzo e5bb7ec49a fix: explicit navigate after login + flat routes + venv gitignore 2026-07-22 19:23:09 +08:00
enzo cb296ba09e chore: remove accidentally committed venv, fix gitignore 2026-07-22 19:22:03 +08:00
enzo 1de5fcf698 fix: flatten React Router structure — no nested Routes, each page is a top-level route 2026-07-22 19:21:29 +08:00
enzo 07ff8c2a12 fix: nested route navigation — use relative paths, redirect to / not /dashboard 2026-07-22 19:17:30 +08:00
enzo 681e4b3a7d fix: double navigation on login causing 404 2026-07-22 19:12:06 +08:00
enzo c7563ede47 fix: login button variant=primary 2026-07-22 18:11:18 +08:00
enzo ff813824fa fix: UserRole enum case mismatch with PostgreSQL (OWNER→owner) 2026-07-22 18:06:17 +08:00
enzoandClaude eaf13e5f51 feat: Event archive management — Event model, migration, API, frontend page (Phase 8)
- Event ORM model with status/level enums, domain categories, review tracking
- Alembic migration e7f3a9b1c5d0 for events table
- Event crawler service with placeholder data (12 sample events)
- GET /api/v1/events with status/domain/search filtering and overdue_days
- EventArchivePage with filter toolbar (status, domain, search) and adaptive card grid
- Status badges, level badges, domain tags, overdue markers on event cards
- Loading/error/empty states, hover-to-honey card interaction

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 17:34:19 +08:00
enzoandClaude 5c0352c768 feat: Kanban board with five-column view, drag-and-drop, detail drawer, anomaly detection (Phase 7)
Backend: new kanban API with mock data, five-column task aggregation, task detail, drag-to-move
with block reason validation, and server-side anomaly detection (blocked_timeout, unclaimed,
retry_failure).

Frontend: full kanban page with summary bar + filters, five-column board with BLOCKED orange
border, task cards with color-coded left bar and anomaly highlighting, HTML5 native drag-and-drop
(desktop only), block reason modal, done confirmation, 480px detail drawer with meta/description/
dependencies/activity timeline, floating batch operations bar, and optimistic updates with rollback.

Drawer component: title prop changed from string to ReactNode to support rich title content.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 17:31:03 +08:00
enzo 3d905b606a feat: Key batch management (Key Manager service, API, frontend) (Phase 6)
Backend service for reading/writing profile .env files with masking,
auto-backup, batch overwrite, batch scan, diff view, and CSV export.
Owner/operator-only API endpoints. Frontend KeysPage with batch-overwrite
modal, batch-scan results, diff view with highlight, and CSV download.
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 17:24:53 +08:00
enzoandClaude 670d222665 feat: Cron management API and frontend page (Phase 5)
Backend: CRON API with list/logs/run/pause/resume endpoints, Hermes CLI
integration, system cron jobs, audit logging for operations.
Frontend: CronPage with filter bar (status/profile), table with 30s polling,
log modal with level-colored messages, action buttons (run/pause/resume),
run confirmation dialog, 4-state coverage. Sidebar shows cron failure badge.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 17:19:11 +08:00
enzoandClaude 8bfefd199c feat: Profile health score system (scoring engine, API, frontend)
Health scoring engine with 4 dimensions: Gateway stability (40%), config
completeness (25%), skill consistency (20%), and Cron success rate (15%).
Grades A(80-100) B(60-79) C(40-59) D(0-39) with detailed deduction items
and fix suggestions per dimension.

API endpoints:
  - GET /health/scores — list all profiles with grade filter + sort
  - GET /health/scores/{profile} — detail with deductions + suggestions
  - POST /health/compare — multi-profile config/skill comparison
  - POST /health/calculate — manual recalculation (owner only)

Frontend HealthPage: grade distribution cards, score table with expandable
detail rows, batch compare view with diff highlighting, grade/select/sort
filters, and owner-only recalculation.

Data sources use placeholders (TODO: hermes CLI / file reads) but the full
scoring logic is implemented end-to-end.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 17:14:30 +08:00
enzoandClaude 6d4a5c955b feat: Gateway health monitoring (Hermes CLI, API, frontend)
- Hermes CLI wrapper using subprocess.run for gateway status
- Gateway API: status list (with filter), events (placeholder), start/stop/restart with audit logging
- Frontend GatewayPage: timeline, filter bar, status table with 10s polling, operation modals
- usePolling hook for periodic data refresh

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 17:05:41 +08:00
enzoandClaude 76045538bb feat: Dashboard with summary cards, health distribution, gateway trend, global search
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 17:01:18 +08:00
enzo 10404be62d feat: frontend auth flow (login, user management, audit logs, route guards) 2026-07-22 16:55:11 +08:00
enzoandClaude e13be8eb1c feat: JWT auth, user management, audit logging system
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:52:09 +08:00
83 changed files with 9879 additions and 232 deletions
+1
View File
@@ -6,4 +6,5 @@ dist/
.pytest_cache/
*.egg-info/
venv/
.venv/
.DS_Store
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
@@ -0,0 +1,46 @@
"""create events table
Revision ID: e7f3a9b1c5d0
Revises: ab0c56ae4242
Create Date: 2026-07-22 17:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e7f3a9b1c5d0'
down_revision: Union[str, Sequence[str], None] = 'ab0c56ae4242'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'events',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('title', sa.String(255), nullable=False),
sa.Column('level', sa.Enum('A', 'B', 'C', name='eventlevel'), nullable=False),
sa.Column('domain', sa.String(50), nullable=False, index=True),
sa.Column(
'status',
sa.Enum('active', 'fading', 'dissipating', 'closed', name='eventstatus'),
nullable=False,
server_default='active',
),
sa.Column('occurred_date', sa.Date, nullable=False),
sa.Column('last_reviewed', sa.DateTime, nullable=True),
sa.Column('no_new_info_count', sa.Integer, nullable=False, server_default='0'),
sa.Column('next_review', sa.DateTime, nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table('events')
op.execute('DROP TYPE IF EXISTS eventlevel')
op.execute('DROP TYPE IF EXISTS eventstatus')
View File
+27
View File
@@ -0,0 +1,27 @@
"""Audit API: list audit logs — only owner."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import require_role
from app.models.audit import AuditLog
from app.models.user import User
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
@router.get("")
async def list_audit(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
action: str | None = Query(None),
user: User = Depends(require_role("owner")),
db: AsyncSession = Depends(get_db),
):
query = select(AuditLog).order_by(AuditLog.created_at.desc())
if action:
query = query.where(AuditLog.action.ilike(f"%{action}%"))
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
return result.scalars().all()
+74
View File
@@ -0,0 +1,74 @@
"""Auth API: register, login, refresh, change-password."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.middleware.auth import require_auth
from app.models.user import User
from app.schemas.auth import (
ChangePasswordRequest,
LoginRequest,
RegisterRequest,
TokenResponse,
)
from app.schemas.user import UserResponse
from app.services.auth_service import (
authenticate_user,
create_access_token,
create_user,
decode_access_token,
get_user_by_username,
hash_password,
verify_password,
)
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
@router.post("/register", status_code=201, response_model=UserResponse)
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
existing = await get_user_by_username(db, body.username)
if existing:
raise HTTPException(status_code=409, detail="Username already exists")
result = await db.execute(select(User).where(User.email == body.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Email already exists")
user = await create_user(db, body.username, body.email, body.password)
return user
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
user = await authenticate_user(db, body.username, body.password)
token = create_access_token(user.id, user.role.value)
return TokenResponse(
access_token=token,
expires_in=settings.jwt_expire_minutes * 60,
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(user: User = Depends(require_auth)):
token = create_access_token(user.id, user.role.value)
return TokenResponse(
access_token=token,
expires_in=settings.jwt_expire_minutes * 60,
)
@router.post("/change-password")
async def change_password(
body: ChangePasswordRequest,
user: User = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
if not verify_password(body.old_password, user.hashed_password):
raise HTTPException(status_code=400, detail="Old password is incorrect")
user.hashed_password = hash_password(body.new_password)
await db.commit()
return {"detail": "Password changed successfully"}
+330
View File
@@ -0,0 +1,330 @@
"""Cron API — 跨 Profile Cron 聚合列表、日志、启停操作。"""
import json
import logging
import subprocess
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.middleware.auth import require_auth, require_role
from app.models.audit import AuditLog
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/cron", tags=["cron"])
# ── 系统 Cron 硬编码清单 ──
SYSTEM_CRON_JOBS = [
{
"id": "system-hindsight",
"job_name": "hindsight",
"profile": "system",
"schedule": "0 21 * * *",
"status": "active",
"last_run": None,
"last_run_success": None,
"success_rate": 100.0,
"has_logs": False,
"system": True,
"consecutive_failures": 0,
},
{
"id": "system-health-check",
"job_name": "健康巡检",
"profile": "system",
"schedule": "*/30 * * * *",
"status": "active",
"last_run": None,
"last_run_success": None,
"success_rate": 100.0,
"has_logs": False,
"system": True,
"consecutive_failures": 0,
},
{
"id": "system-skill-check",
"job_name": "Skill检查",
"profile": "system",
"schedule": "0 2 * * *",
"status": "active",
"last_run": None,
"last_run_success": None,
"success_rate": 100.0,
"has_logs": False,
"system": True,
"consecutive_failures": 0,
},
{
"id": "system-memory-archive",
"job_name": "Memory归档",
"profile": "system",
"schedule": "0 3 * * 0",
"status": "paused",
"last_run": None,
"last_run_success": None,
"success_rate": 100.0,
"has_logs": False,
"system": True,
"consecutive_failures": 0,
},
]
# ── 占位 Profile Cron(hermes CLI 返回为空时的后备) ──
FALLBACK_PROFILE_JOBS = [
{
"id": "profile-taiwan-briefing",
"job_name": "taiwan-briefing",
"profile": "taiwan",
"schedule": "0 7 * * *",
"status": "active",
"last_run": "2026-07-22T07:15:00",
"last_run_success": True,
"success_rate": 95.0,
"has_logs": True,
"system": False,
"consecutive_failures": 0,
},
{
"id": "profile-guangxi-donation",
"job_name": "guangxi-donation",
"profile": "guangxi",
"schedule": "0 9 * * *",
"status": "paused",
"last_run": "2026-07-21T09:00:00",
"last_run_success": True,
"success_rate": 100.0,
"has_logs": True,
"system": False,
"consecutive_failures": 0,
},
{
"id": "profile-daily-report",
"job_name": "daily-report",
"profile": "ops-engineer",
"schedule": "0 8 * * *",
"status": "active",
"last_run": "2026-07-22T08:00:00",
"last_run_success": True,
"success_rate": 88.0,
"has_logs": True,
"system": False,
"consecutive_failures": 0,
},
{
"id": "profile-weekly-digest",
"job_name": "weekly-digest",
"profile": "ops-engineer",
"schedule": "30 9 * * 1",
"status": "active",
"last_run": "2026-07-20T09:30:00",
"last_run_success": True,
"success_rate": 100.0,
"has_logs": True,
"system": False,
"consecutive_failures": 0,
},
{
"id": "profile-hindsight",
"job_name": "hindsight",
"profile": "hebo",
"schedule": "0 21 * * *",
"status": "active",
"last_run": "2026-07-21T21:13:00",
"last_run_success": False,
"success_rate": 60.0,
"has_logs": True,
"system": False,
"consecutive_failures": 2,
},
]
# ── 占位日志数据 ──
PLACEHOLDER_LOGS = {
"system-hindsight": [
{"time": "2026-07-21T21:13:00", "status": "failure", "message": "ERROR: 连接数据库超时 (timeout: 30s)"},
{"time": "2026-07-20T21:05:00", "status": "failure", "message": "WARN: 响应慢 (3200ms), 重试后仍然失败"},
{"time": "2026-07-19T21:01:00", "status": "success", "message": "INFO: 归档完成, 处理 128 条记录"},
],
"profile-hindsight": [
{"time": "2026-07-21T21:13:00", "status": "failure", "message": "ERROR: API 返回 502 Bad Gateway"},
{"time": "2026-07-20T21:05:00", "status": "failure", "message": "ERROR: 上游服务未响应 (timeout: 60s)"},
{"time": "2026-07-19T21:01:00", "status": "success", "message": "INFO: 推送完成, 成功发送 12 条消息"},
{"time": "2026-07-18T21:00:00", "status": "success", "message": "INFO: 按计划执行完毕"},
{"time": "2026-07-17T21:00:00", "status": "success", "message": "INFO: 正常执行"},
],
}
def _fetch_profile_jobs() -> list[dict]:
"""从 hermes CLI 获取 Profile Cron 列表,失败时返回后备数据。"""
try:
proc = subprocess.run(
[settings.hermes_cli_path, "cron", "list", "--all"],
capture_output=True, text=True, timeout=15,
)
if proc.returncode == 0:
data = json.loads(proc.stdout)
jobs = data if isinstance(data, list) else data.get("jobs", [])
for j in jobs:
j.setdefault("system", False)
j.setdefault("consecutive_failures", 0)
j.setdefault("has_logs", False)
j.setdefault("success_rate", 100.0)
return jobs
except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired) as e:
logger.warning("hermes cron list --all 失败: %s", e)
except Exception as e:
logger.warning("调用 hermes cron list 异常: %s", e)
return FALLBACK_PROFILE_JOBS
def _merge_jobs(profile_jobs: list[dict]) -> list[dict]:
"""合并 Profile Cron 和系统 Cron,按 id 去重。"""
seen = {j["id"] for j in SYSTEM_CRON_JOBS}
merged = list(SYSTEM_CRON_JOBS)
for j in profile_jobs:
if j["id"] not in seen:
merged.append(j)
seen.add(j["id"])
return merged
def _check_has_failures(jobs: list[dict]) -> bool:
"""检查当日是否有任何 Cron 失败。"""
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
for j in jobs:
last_run = j.get("last_run") or ""
if last_run.startswith(today) and j.get("last_run_success") is False:
return True
return False
@router.get("/jobs")
async def list_cron_jobs(
status: str | None = Query(None, pattern="^(active|paused)$"),
profile: str = Query("all", pattern="^(all|system|[a-z][a-z0-9_-]+)$"),
_user: User = Depends(require_auth),
):
"""跨 Profile Cron 聚合列表。
- ?status=active|paused 按状态筛选
- ?profile=all|system|xxx 按来源/Profile 筛选
"""
profile_jobs = _fetch_profile_jobs()
jobs = _merge_jobs(profile_jobs)
if status:
jobs = [j for j in jobs if j.get("status") == status]
if profile == "system":
jobs = [j for j in jobs if j.get("system")]
elif profile != "all":
jobs = [j for j in jobs if j.get("profile") == profile and not j.get("system")]
has_failures = _check_has_failures(jobs)
return {"jobs": jobs, "has_failures": has_failures}
@router.get("/jobs/{job_id}/logs")
async def get_cron_logs(
job_id: str,
_user: User = Depends(require_auth),
):
"""返回最近 8 条运行日志(暂用占位数据)。"""
logs = PLACEHOLDER_LOGS.get(job_id, [])
return {"logs": logs[:8]}
async def _operate_cron(
job_id: str,
action: str,
user: User,
db: AsyncSession,
) -> dict:
"""执行 Cron 操作并写入审计日志。"""
# 解析 profile 和 job_name
if job_id.startswith("system-"):
profile = "system"
job_name = job_id[len("system-"):]
elif job_id.startswith("profile-"):
parts = job_id.split("-", 2)
if len(parts) >= 3:
profile = parts[1]
job_name = parts[2]
else:
profile = "unknown"
job_name = job_id
else:
profile = "unknown"
job_name = job_id
try:
# run 操作需要额外的 --job 参数
if action == "run":
args = [settings.hermes_cli_path, "cron", "run", "--profile", profile, "--job", job_name]
elif action == "pause":
args = [settings.hermes_cli_path, "cron", "pause", "--profile", profile, "--job", job_name]
elif action == "resume":
args = [settings.hermes_cli_path, "cron", "resume", "--profile", profile, "--job", job_name]
else:
raise HTTPException(status_code=400, detail=f"不支持的操作: {action}")
proc = subprocess.run(args, capture_output=True, text=True, timeout=30)
success = proc.returncode == 0
detail = (proc.stdout or proc.stderr).strip()
except HTTPException:
raise
except Exception as e:
success = False
detail = str(e)
audit = AuditLog(
user_id=user.id,
username=user.username,
action=f"cron:{action}",
target=f"job:{job_id}",
detail=detail,
)
db.add(audit)
await db.commit()
if not success:
raise HTTPException(status_code=500, detail=f"Cron {action} 失败: {detail}")
return {"success": True, "action": action, "job_id": job_id, "detail": detail}
@router.post("/jobs/{job_id}/run")
async def run_cron_job(
job_id: str,
user: User = Depends(require_role("owner", "operator")),
db: AsyncSession = Depends(get_db),
):
"""手动触发一次 Cron 执行。"""
return await _operate_cron(job_id, "run", user, db)
@router.post("/jobs/{job_id}/pause")
async def pause_cron_job(
job_id: str,
user: User = Depends(require_role("owner", "operator")),
db: AsyncSession = Depends(get_db),
):
"""暂停 Cron。"""
return await _operate_cron(job_id, "pause", user, db)
@router.post("/jobs/{job_id}/resume")
async def resume_cron_job(
job_id: str,
user: User = Depends(require_role("owner", "operator")),
db: AsyncSession = Depends(get_db),
):
"""恢复 Cron。"""
return await _operate_cron(job_id, "resume", user, db)
+117
View File
@@ -0,0 +1,117 @@
"""Dashboard API: aggregated stats, health distribution, gateway trend,
kanban overview, cron recent, and global search — all mock data for now."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Query
from app.middleware.auth import require_auth
from app.models.user import User
router = APIRouter(tags=["dashboard"])
@router.get("/dashboard/summary")
async def dashboard_summary(_user: User = Depends(require_auth)):
# TODO: replace with real data — hermes gateway status + kanban + cron
return {
"online_count": 12,
"offline_count": 46,
"total_profiles": 58,
"running_tasks": 3,
"cron_failures_today": 1,
"updated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
@router.get("/dashboard/health-distribution")
async def health_distribution(_user: User = Depends(require_auth)):
# TODO: replace with real health-score calculation (Phase 4)
return {
"A": 22,
"B": 18,
"C": 10,
"D": 8,
"total": 58,
"d_profiles": ["ops-engineer", "monitor", "visualizer", "hebo", "..."],
}
@router.get("/dashboard/gateway-trend")
async def gateway_trend(_user: User = Depends(require_auth)):
# TODO: replace with real 24h aggregation from event store
return [
{"hour": "2026-07-22T08:00", "restarts": 2, "feishu_disconnects": 1, "heartbeat_timeouts": 0},
{"hour": "2026-07-22T09:00", "restarts": 0, "feishu_disconnects": 0, "heartbeat_timeouts": 1},
{"hour": "2026-07-22T10:00", "restarts": 1, "feishu_disconnects": 0, "heartbeat_timeouts": 0},
{"hour": "2026-07-22T11:00", "restarts": 0, "feishu_disconnects": 2, "heartbeat_timeouts": 0},
{"hour": "2026-07-22T12:00", "restarts": 0, "feishu_disconnects": 0, "heartbeat_timeouts": 0},
{"hour": "2026-07-22T13:00", "restarts": 3, "feishu_disconnects": 1, "heartbeat_timeouts": 2},
{"hour": "2026-07-22T14:00", "restarts": 0, "feishu_disconnects": 0, "heartbeat_timeouts": 1},
{"hour": "2026-07-22T15:00", "restarts": 1, "feishu_disconnects": 0, "heartbeat_timeouts": 0},
]
@router.get("/dashboard/kanban-summary")
async def kanban_summary(_user: User = Depends(require_auth)):
# TODO: replace with real kanban task data
return [
{"board": "europe-briefing", "running": "S4", "ready": "S5"},
{"board": "tech-weekly", "running": "S1", "ready": None},
{"board": "ops-review", "running": "M2", "ready": "M3"},
]
@router.get("/dashboard/cron-recent")
async def cron_recent(_user: User = Depends(require_auth)):
# TODO: replace with real cron execution history
return [
{"job": "taiwan-briefing", "status": "success", "time": "07:15"},
{"job": "hindsight", "status": "failure", "time": "21:13"},
{"job": "guangxi-donation", "status": "paused"},
{"job": "daily-report", "status": "success", "time": "08:00"},
{"job": "weekly-digest", "status": "success", "time": "09:30"},
]
@router.get("/search")
async def global_search(
q: str = Query("", min_length=1),
_user: User = Depends(require_auth),
):
# TODO: replace with real full-text search across profiles, tasks, cron, events
keyword = q.lower()
profiles = []
if "gateway" in keyword or "monitor" in keyword:
profiles.append({"name": "gateway-monitor", "status": "online"})
if "visual" in keyword:
profiles.append({"name": "visualizer", "status": "online"})
if "hebo" in keyword or "ops" in keyword:
profiles.append({"name": "ops-engineer", "status": "online"})
kanban_tasks = []
if "briefing" in keyword or "europe" in keyword:
kanban_tasks.append({"title": "Europe Briefing S4", "column": "RUNNING"})
if "tech" in keyword or "weekly" in keyword:
kanban_tasks.append({"title": "Tech Weekly S1", "column": "RUNNING"})
cron_jobs = []
if "taiwan" in keyword or "briefing" in keyword:
cron_jobs.append({"job": "taiwan-briefing", "status": "active"})
if "hindsight" in keyword:
cron_jobs.append({"job": "hindsight", "status": "active"})
if "donation" in keyword or "guangxi" in keyword:
cron_jobs.append({"job": "guangxi-donation", "status": "paused"})
events = []
if "gateway" in keyword or keyword == "restart":
events.append({"title": "Gateway restart detected", "status": "active"})
if "feishu" in keyword or "disconnect" in keyword:
events.append({"title": "Feishu disconnect alert", "status": "active"})
return {
"profiles": profiles,
"kanban_tasks": kanban_tasks,
"cron_jobs": cron_jobs,
"events": events,
}
+65
View File
@@ -0,0 +1,65 @@
"""Events API — 事件档案列表,三维筛选。"""
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import require_auth
from app.models.event import Event, EventStatus, DOMAINS
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/events", tags=["events"])
@router.get("")
async def list_events(
status: str | None = Query(None, description="按状态筛选"),
domain: str | None = Query(None, description="按领域筛选"),
search: str = Query("", description="按标题模糊搜索"),
_user: User = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
"""事件卡片列表,支持三维筛选。"""
stmt = select(Event).order_by(Event.occurred_date.desc())
valid_statuses = {s.value for s in EventStatus}
if status and status in valid_statuses:
stmt = stmt.where(Event.status == status)
if domain and domain in DOMAINS:
stmt = stmt.where(Event.domain == domain)
if search:
stmt = stmt.where(Event.title.ilike(f"%{search}%"))
result = await db.execute(stmt)
events = result.scalars().all()
now = datetime.now(timezone.utc)
data = []
for ev in events:
overdue_days = 0
if ev.next_review and ev.next_review < now:
overdue_days = (now - ev.next_review).days
data.append({
"id": ev.id,
"title": ev.title,
"level": ev.level.value,
"domain": ev.domain,
"status": ev.status.value,
"occurred_date": ev.occurred_date.isoformat(),
"last_reviewed": ev.last_reviewed.isoformat() if ev.last_reviewed else None,
"no_new_info_count": ev.no_new_info_count,
"next_review": ev.next_review.isoformat() if ev.next_review else None,
"overdue_days": overdue_days,
"created_at": ev.created_at.isoformat() if ev.created_at else None,
"updated_at": ev.updated_at.isoformat() if ev.updated_at else None,
})
return {"events": data}
+120
View File
@@ -0,0 +1,120 @@
"""Gateway API — 状态、事件、启停操作。"""
import logging
import subprocess
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.middleware.auth import require_auth, require_role
from app.models.audit import AuditLog
from app.models.user import User
from app.services import hermes_cli
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/gateway", tags=["gateway"])
# ── 占位事件数据(TODO: 从 hermes 日志或系统日志解析) ──
PLACEHOLDER_EVENTS = [
{"time": "2026-07-22T18:45", "profile": "monitor", "type": "heartbeat_timeout",
"detail": "连续 3 次心跳无响应"},
{"time": "2026-07-22T14:20", "profile": "feishu-gw", "type": "feishu_disconnect",
"detail": "WebSocket 断开 8 分钟"},
{"time": "2026-07-22T10:30", "profile": "gateway-monitor", "type": "restart",
"detail": "Gateway 重启"},
]
@router.get("/status")
async def get_gateway_status(
status: str | None = Query(None, pattern="^(online|offline|warn)$"),
_user: User = Depends(require_auth),
):
"""返回全部 Profile 的 Gateway 状态列表,支持 ?status= 筛选。"""
gateways = hermes_cli.gateway_status_all()
for gw in gateways:
gw.setdefault("downtime_minutes", 0)
gw.setdefault("restarts_today", 0)
gw.setdefault("last_event", "")
gw.setdefault("profile_type", "worker")
if status:
gateways = [g for g in gateways if g.get("status") == status]
return gateways
@router.get("/events")
async def get_gateway_events(
_user: User = Depends(require_auth),
):
"""返回最近 24h 异常事件。"""
# TODO: 从 hermes CLI 日志或系统日志解析真实事件
return PLACEHOLDER_EVENTS
async def _operate_gateway(
profile: str,
action: str,
user: User,
db: AsyncSession,
) -> dict:
"""执行 hermes gateway {action} --profile {profile} 并写入审计日志。"""
try:
proc = subprocess.run(
[settings.hermes_cli_path, "gateway", action, "--profile", profile],
capture_output=True,
text=True,
timeout=30,
)
success = proc.returncode == 0
detail = (proc.stdout or proc.stderr).strip()
except Exception as e:
success = False
detail = str(e)
audit = AuditLog(
user_id=user.id,
username=user.username,
action=f"gateway:{action}",
target=f"profile:{profile}",
detail=detail,
)
db.add(audit)
await db.commit()
if not success:
raise HTTPException(status_code=500, detail=f"Gateway {action} 失败: {detail}")
return {"success": True, "action": action, "profile": profile, "detail": detail}
@router.post("/{profile}/start")
async def start_gateway(
profile: str,
user: User = Depends(require_role("owner", "operator")),
db: AsyncSession = Depends(get_db),
):
"""启动 Gateway。"""
return await _operate_gateway(profile, "start", user, db)
@router.post("/{profile}/stop")
async def stop_gateway(
profile: str,
user: User = Depends(require_role("owner", "operator")),
db: AsyncSession = Depends(get_db),
):
"""停止 Gateway。"""
return await _operate_gateway(profile, "stop", user, db)
@router.post("/{profile}/restart")
async def restart_gateway(
profile: str,
user: User = Depends(require_role("owner", "operator")),
db: AsyncSession = Depends(get_db),
):
"""重启 Gateway。"""
return await _operate_gateway(profile, "restart", user, db)
+187
View File
@@ -0,0 +1,187 @@
"""Health API — 健康分查询、对比、手动刷新。"""
from fastapi import APIRouter, Depends, HTTPException, Query
from app.middleware.auth import require_auth, require_role
from app.models.user import User
from app.services.health_scorer import (
PLACEHOLDER_PROFILES,
calculate_all_scores,
calculate_health_score,
invalidate_cache,
)
router = APIRouter(prefix="/health", tags=["health"])
def _score_to_row(hs, rank: int) -> dict:
return {
"rank": rank,
"name": hs.profile_name,
"total_score": hs.total_score,
"grade": hs.grade,
"gateway_score": hs.gateway.score,
"config_score": hs.config.score,
"skill_score": hs.skill.score,
"cron_score": hs.cron.score,
"deduct_summary": hs.deduct_summary,
}
@router.get("/scores")
async def list_scores(
grade: str | None = Query(None, pattern="^(A|B|C|D)$"),
sort: str = Query("asc", pattern="^(asc|desc)$"),
_user: User = Depends(require_auth),
):
"""返回全部 Profile 健康分排行列表。
- ?grade=A|B|C|D 按等级筛选
- ?sort=asc|desc 排序,默认从低到高(问题优先)
"""
scores = calculate_all_scores()
# 筛选
if grade:
scores = [s for s in scores if s.grade == grade]
# 排序
scores.sort(key=lambda s: s.total_score, reverse=(sort == "desc"))
rows = [_score_to_row(hs, i + 1) for i, hs in enumerate(scores)]
return {
"total": len(rows),
"distribution": {
"A": sum(1 for s in scores if s.grade == "A"),
"B": sum(1 for s in scores if s.grade == "B"),
"C": sum(1 for s in scores if s.grade == "C"),
"D": sum(1 for s in scores if s.grade == "D"),
},
"scores": rows,
}
@router.get("/scores/{profile}")
async def get_profile_score(
profile: str,
_user: User = Depends(require_auth),
):
"""返回单个 Profile 的健康分详情,含四维度扣分项和修复建议。"""
if profile not in PLACEHOLDER_PROFILES:
raise HTTPException(status_code=404, detail=f"Profile '{profile}' 不存在")
hs = calculate_health_score(profile)
return {
"profile_name": hs.profile_name,
"total_score": hs.total_score,
"grade": hs.grade,
"calculated_at": hs.calculated_at,
"dimensions": [
{
"name": "Gateway 稳定性",
"weight": "40%",
"score": hs.gateway.score,
"max_score": hs.gateway.max_score,
"deductions": hs.gateway.deductions,
"suggestions": hs.gateway.suggestions,
},
{
"name": "配置完整性",
"weight": "25%",
"score": hs.config.score,
"max_score": hs.config.max_score,
"deductions": hs.config.deductions,
"suggestions": hs.config.suggestions,
},
{
"name": "Skill 一致性",
"weight": "20%",
"score": hs.skill.score,
"max_score": hs.skill.max_score,
"deductions": hs.skill.deductions,
"suggestions": hs.skill.suggestions,
},
{
"name": "Cron 成功率",
"weight": "15%",
"score": hs.cron.score,
"max_score": hs.cron.max_score,
"deductions": hs.cron.deductions,
"suggestions": hs.cron.suggestions,
},
],
}
@router.post("/compare")
async def compare_profiles(
body: dict,
_user: User = Depends(require_auth),
):
"""返回多个 Profile 的配置/Skill 横向对比。
body: {"profile_names": ["hebo", "ops-engineer"]}
"""
names = body.get("profile_names", [])
if len(names) < 2:
raise HTTPException(status_code=400, detail="至少选择 2 个 Profile")
if len(names) > 20:
raise HTTPException(status_code=400, detail="最多对比 20 个 Profile")
invalid = [n for n in names if n not in PLACEHOLDER_PROFILES]
if invalid:
raise HTTPException(status_code=404, detail=f"Profile 不存在: {', '.join(invalid)}")
scores = {hs.profile_name: hs for hs in calculate_all_scores(names)}
# 收集所有配置字段和 Skill
from app.services.health_scorer import _REQUIRED_CONFIG_FIELDS, _BASELINE_SKILLS
from app.services.health_scorer import _placeholder_config_data, _placeholder_skill_data
config_fields = list(_REQUIRED_CONFIG_FIELDS)
skill_fields = sorted(_BASELINE_SKILLS)
rows: list[dict] = []
for field in config_fields:
row: dict = {"field": field, "category": "config"}
for name in names:
s = scores.get(name)
if s is None:
row[name] = "⚠"
else:
data = _placeholder_config_data(name)
row[name] = "✅" if field in data["present_fields"] else "❌"
rows.append(row)
for field in skill_fields:
row = {"field": field, "category": "skill"}
for name in names:
s = scores.get(name)
if s is None:
row[name] = "⚠"
else:
data = _placeholder_skill_data(name)
row[name] = "✅" if field in data["profile_skills"] else "❌"
rows.append(row)
# 检测差异行
for row in rows:
vals = {str(row[n]) for n in names}
row["has_diff"] = len(vals) > 1
return {
"profiles": names,
"rows": rows,
}
@router.post("/calculate")
async def recalculate_scores(
_user: User = Depends(require_role("owner")),
):
"""手动触发全量评分重算(仅 owner)。"""
invalidate_cache()
scores = calculate_all_scores()
return {
"message": f"已重新计算 {len(scores)} 个 Profile 的健康分",
"count": len(scores),
}
+385
View File
@@ -0,0 +1,385 @@
"""Kanban API — 五列任务看板 + 异常检测。"""
import copy
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from app.middleware.auth import require_auth
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/kanban", tags=["kanban"])
# ── Mock 数据(TODO: CLI integration) ──
NOW = datetime.now(timezone.utc)
def _ago(hours: int = 0, minutes: int = 0) -> str:
return (NOW - timedelta(hours=hours, minutes=minutes)).isoformat()
MOCK_TASKS: list[dict[str, Any]] = [
{
"id": "task-001",
"title": "生成 taiwan 早报简报",
"board": "简报生成",
"worker_profile": "taiwan",
"worker_type": "news-crawler",
"priority": 2,
"column": "running",
"created_at": _ago(hours=2),
"updated_at": _ago(minutes=15),
"goal_mode": False,
"retries": 0,
"failures": 0,
"description": "## 任务\n\n每日 07:00 生成 taiwan 地区早报简报\n\n### 步骤\n\n1. 爬取新闻源\n2. 摘要生成\n3. 推送飞书",
"block_reason": None,
"dependencies": [],
"dependents": ["task-004"],
"activities": [
{"time": _ago(minutes=15), "user": "system", "action": "开始执行"},
{"time": _ago(hours=2), "user": "hermes", "action": "加入队列"},
],
},
{
"id": "task-002",
"title": "guangxi 捐赠数据汇总",
"board": "数据采集",
"worker_profile": "guangxi",
"worker_type": "data-collector",
"priority": 1,
"column": "ready",
"created_at": _ago(hours=6),
"updated_at": _ago(hours=6),
"goal_mode": False,
"retries": 0,
"failures": 0,
"description": "## 任务\n\n汇总 guangxi 地区捐赠数据\n\n### 数据源\n\n- 内部 API\n- 公开报表",
"block_reason": None,
"dependencies": [],
"dependents": [],
"activities": [
{"time": _ago(hours=6), "user": "hermes", "action": "就绪"},
],
},
{
"id": "task-003",
"title": "运维日报推送 (ops-engineer)",
"board": "日报推送",
"worker_profile": "ops-engineer",
"worker_type": "reporter",
"priority": 3,
"column": "todo",
"created_at": _ago(hours=96),
"updated_at": _ago(hours=96),
"goal_mode": True,
"retries": 0,
"failures": 0,
"description": "## 任务\n\n每日 08:00 推送运维日报\n\n### 验收标准\n\n- [ ] 数据源正常\n- [ ] 推送成功",
"block_reason": None,
"dependencies": ["task-002"],
"dependents": [],
"activities": [
{"time": _ago(hours=96), "user": "hermes", "action": "创建任务"},
],
},
{
"id": "task-004",
"title": "hebo hindsight 归档",
"board": "数据归档",
"worker_profile": "hebo",
"worker_type": "archiver",
"priority": 2,
"column": "blocked",
"created_at": _ago(hours=30),
"updated_at": _ago(hours=30),
"goal_mode": False,
"retries": 2,
"failures": 3,
"description": "## 任务\n\n每日 21:00 hindsight 归档处理\n\n### 说明\n\n最近连续失败,上游 API 不稳定",
"block_reason": "上游 API 连续返回 502,需联系运维排查",
"dependencies": [],
"dependents": [],
"activities": [
{"time": _ago(hours=30), "user": "system", "action": "标记阻塞"},
{"time": _ago(hours=32), "user": "hermes", "action": "重试失败 (第3次)"},
],
},
{
"id": "task-005",
"title": "guangxi 周报生成",
"board": "简报生成",
"worker_profile": "guangxi",
"worker_type": "reporter",
"priority": 1,
"column": "todo",
"created_at": _ago(hours=12),
"updated_at": _ago(hours=12),
"goal_mode": False,
"retries": 0,
"failures": 0,
"description": "## 任务\n\n每周一生成 guangxi 周报",
"block_reason": None,
"dependencies": ["task-002"],
"dependents": [],
"activities": [
{"time": _ago(hours=12), "user": "hermes", "action": "创建任务"},
],
},
{
"id": "task-006",
"title": "taiwan 突发新闻推送",
"board": "简报生成",
"worker_profile": "taiwan",
"worker_type": "news-crawler",
"priority": 0,
"column": "running",
"created_at": _ago(hours=1),
"updated_at": _ago(minutes=5),
"goal_mode": False,
"retries": 1,
"failures": 0,
"description": "## 任务\n\n监测到突发新闻,立即推送飞书通知",
"block_reason": None,
"dependencies": [],
"dependents": [],
"activities": [
{"time": _ago(minutes=5), "user": "system", "action": "内容生成中"},
{"time": _ago(hours=1), "user": "hermes", "action": "触发执行"},
],
},
{
"id": "task-007",
"title": "飞书健康巡检",
"board": "系统维护",
"worker_profile": "ops-engineer",
"worker_type": "health-checker",
"priority": 0,
"column": "done",
"created_at": _ago(hours=72),
"updated_at": _ago(hours=48),
"goal_mode": True,
"retries": 0,
"failures": 0,
"description": "## 任务\n\n检查飞书连接健康状况\n\n### 验收标准\n\n- [x] API 连通性正常\n- [x] Webhook 可达",
"block_reason": None,
"dependencies": [],
"dependents": [],
"activities": [
{"time": _ago(hours=48), "user": "system", "action": "标记完成"},
{"time": _ago(hours=60), "user": "system", "action": "开始执行"},
{"time": _ago(hours=72), "user": "hermes", "action": "加入队列"},
],
},
{
"id": "task-008",
"title": "密钥轮换检查 (all profiles)",
"board": "安全审计",
"worker_profile": "ops-engineer",
"worker_type": "auditor",
"priority": 1,
"column": "blocked",
"created_at": _ago(hours=50),
"updated_at": _ago(hours=48),
"goal_mode": False,
"retries": 0,
"failures": 0,
"description": "## 任务\n\n检查所有 Profile 密钥有效期,轮换即将过期的密钥",
"block_reason": "等待安全审批,预计下周二完成",
"dependencies": [],
"dependents": [],
"activities": [
{"time": _ago(hours=48), "user": "manual", "action": "标记阻塞"},
{"time": _ago(hours=50), "user": "hermes", "action": "创建任务"},
],
},
{
"id": "task-009",
"title": "Gateway 配置同步",
"board": "系统维护",
"worker_profile": "taiwan",
"worker_type": "syncer",
"priority": 2,
"column": "ready",
"created_at": _ago(hours=4),
"updated_at": _ago(hours=4),
"goal_mode": False,
"retries": 0,
"failures": 1,
"description": "## 任务\n\n同步 taiwan Gateway 最新配置",
"block_reason": None,
"dependencies": [],
"dependents": [],
"activities": [
{"time": _ago(hours=4), "user": "hermes", "action": "就绪"},
],
},
{
"id": "task-010",
"title": "历史数据归档 (daily)",
"board": "数据归档",
"worker_profile": "hebo",
"worker_type": "archiver",
"priority": 2,
"column": "todo",
"created_at": _ago(hours=18),
"updated_at": _ago(hours=18),
"goal_mode": False,
"retries": 0,
"failures": 0,
"description": "## 任务\n\n每日历史数据归档",
"block_reason": None,
"dependencies": [],
"dependents": [],
"activities": [
{"time": _ago(hours=18), "user": "hermes", "action": "创建任务"},
],
},
]
# 用 createdAt 判断 unclaimed,为了测试效果把 task-003 设为 96h ago (>72h)
# task-004 failure=3 触发 retry_failure
# task-004/task-008 blocked 且 updated_at>24h 触发 blocked_timeout
def _detect_anomalies(task: dict) -> list[str]:
"""返回任务异常标记列表。"""
anomalies: list[str] = []
updated = datetime.fromisoformat(task["updated_at"])
created = datetime.fromisoformat(task["created_at"])
now = NOW
# blocked_timeout:BLOCKED 列超 24h → 标红
if task["column"] == "blocked" and (now - updated) > timedelta(hours=24):
anomalies.append("blocked_timeout")
# unclaimed:TODO 列超 3 天 → 标黄
if task["column"] == "todo" and (now - created) > timedelta(days=3):
anomalies.append("unclaimed")
# retry_failure:失败重试≥3 次 → 标红
if task.get("failures", 0) >= 3:
anomalies.append("retry_failure")
return anomalies
# ── API ──
@router.get("/tasks")
async def list_tasks(
worker: str = Query("", description="Worker profile 筛选"),
board: str = Query("", description="看板名筛选"),
anomaly: str = Query("", description="异常类型: blocked_timeout|unclaimed|retry_failure"),
_user: User = Depends(require_auth),
):
"""跨 Profile 五列任务聚合。"""
tasks = copy.deepcopy(MOCK_TASKS)
if worker:
tasks = [t for t in tasks if t["worker_profile"] == worker]
if board:
tasks = [t for t in tasks if t["board"] == board]
if anomaly:
tasks = [t for t in tasks if anomaly in _detect_anomalies(t)]
# 五列分组
columns = {"todo": [], "ready": [], "running": [], "blocked": [], "done": []}
for t in tasks:
anomalies = _detect_anomalies(t)
col = t["column"]
if col in columns:
columns[col].append({**t, "anomalies": anomalies})
# 每列按优先级排序
for col in columns:
columns[col].sort(key=lambda x: (x["priority"], x["created_at"]))
profiles = {t["worker_profile"] for t in tasks}
boards = {t["board"] for t in tasks}
return {
"todo": columns["todo"],
"ready": columns["ready"],
"running": columns["running"],
"blocked": columns["blocked"],
"done": columns["done"],
"overview": {
"total_tasks": len(tasks),
"profile_count": len(profiles),
"board_count": len(boards),
"running_count": len(columns["running"]),
"blocked_count": len(columns["blocked"]),
},
}
@router.get("/tasks/{task_id}")
async def get_task_detail(
task_id: str,
_user: User = Depends(require_auth),
):
"""任务详情。"""
for t in MOCK_TASKS:
if t["id"] == task_id:
detail = copy.deepcopy(t)
detail["anomalies"] = _detect_anomalies(t)
# 补全依赖/被依赖任务信息
dep_ids = detail.get("dependencies", [])
dep_tasks = [m for m in MOCK_TASKS if m["id"] in dep_ids]
detail["dependency_tasks"] = [
{"id": m["id"], "title": m["title"], "column": m["column"]}
for m in dep_tasks
]
dep_by_ids = detail.get("dependents", [])
dep_by_tasks = [m for m in MOCK_TASKS if m["id"] in dep_by_ids]
detail["dependent_tasks"] = [
{"id": m["id"], "title": m["title"], "column": m["column"]}
for m in dep_by_tasks
]
return detail
raise HTTPException(status_code=404, detail="任务不存在")
@router.patch("/tasks/{task_id}/move")
async def move_task(
task_id: str,
body: dict,
_user: User = Depends(require_auth),
):
"""拖拽移动任务。"""
column = body.get("column")
block_reason = body.get("block_reason")
valid_columns = {"todo", "ready", "running", "blocked", "done"}
if column not in valid_columns:
raise HTTPException(status_code=400, detail="无效的列名")
for t in MOCK_TASKS:
if t["id"] == task_id:
# 拖入 BLOCKED 需填写阻塞原因
if column == "blocked":
if not block_reason or len(block_reason.strip()) < 10:
raise HTTPException(
status_code=400,
detail="拖入 BLOCKED 列需填写阻塞原因(至少 10 字)",
)
t["block_reason"] = block_reason.strip()
else:
t["block_reason"] = None
t["column"] = column
t["updated_at"] = datetime.now(timezone.utc).isoformat()
t["activities"].insert(0, {
"time": t["updated_at"],
"user": "manual",
"action": f"移动到 {column}",
})
return {"success": True, "task": {**t, "anomalies": _detect_anomalies(t)}}
raise HTTPException(status_code=404, detail="任务不存在")
+106
View File
@@ -0,0 +1,106 @@
"""Keys API — 密钥批量管理。"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import PlainTextResponse
from app.database import get_db
from app.middleware.auth import require_role
from app.models.audit import AuditLog
from app.models.user import User
from app.services.key_manager import (
WHITELIST_FIELDS,
get_keys,
batch_overwrite,
batch_scan as scan_profiles,
get_diff,
export_csv,
list_profiles,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/keys", tags=["keys"])
@router.get("")
async def list_keys(
profiles: str = Query("", description="逗号分隔的 Profile 名"),
_user: User = Depends(require_role("owner", "operator")),
):
"""返回密钥字段列表(脱敏显示)。"""
profile_list = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else None
data = get_keys(profile_list)
return {"data": data}
@router.post("/batch-overwrite")
async def batch_overwrite_keys(
body: dict,
user: User = Depends(require_role("owner", "operator")),
db: AsyncSession = Depends(get_db),
):
"""批量覆盖密钥。"""
profile_names = body.get("profile_names", [])
key_name = body.get("key_name", "")
new_value = body.get("new_value", "")
confirmation = body.get("confirmation", "")
if key_name not in WHITELIST_FIELDS:
raise HTTPException(status_code=400, detail=f"不支持的密钥字段: {key_name}")
# 验证确认短语
expected = f"我确认覆盖 {len(profile_names)} 个 Profile"
if confirmation != expected:
raise HTTPException(status_code=400, detail="确认短语不匹配")
results = batch_overwrite(profile_names, key_name, new_value)
# 审计日志(不记录原文)
audit = AuditLog(
user_id=user.id,
username=user.username,
action="keys:batch-overwrite",
target=f"profiles:{','.join(profile_names)}",
detail=f"key={key_name}, profiles={','.join(profile_names)}, "
f"success={sum(1 for r in results if r['success'])}/{len(results)}",
)
db.add(audit)
await db.commit()
return {"results": results}
@router.post("/batch-scan")
async def batch_scan_keys(
body: dict,
_user: User = Depends(require_role("owner", "operator")),
):
"""批量检查缺失密钥。"""
profile_names = body.get("profile_names")
if profile_names is not None and not isinstance(profile_names, list):
raise HTTPException(status_code=400, detail="profile_names 必须为数组")
results = scan_profiles(profile_names)
return {"results": results}
@router.get("/diff")
async def diff_keys(
profiles: str = Query("", description="逗号分隔的 Profile 名"),
_user: User = Depends(require_role("owner", "operator")),
):
"""差异视图。"""
profile_list = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else list_profiles()
rows = get_diff(profile_list)
return {"profiles": profile_list, "rows": rows}
@router.get("/export", response_class=PlainTextResponse)
async def export_keys_csv(
profiles: str = Query("", description="逗号分隔的 Profile 名"),
_user: User = Depends(require_role("owner", "operator")),
):
"""CSV 导出批量检查结果。"""
profile_list = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else None
return export_csv(profile_list)
+78
View File
@@ -0,0 +1,78 @@
"""User management API: list, detail, update, approve — only owner."""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import require_role
from app.models.user import User
from app.schemas.user import UserResponse, UserUpdateRequest
router = APIRouter(prefix="/api/v1/users", tags=["users"])
@router.get("", response_model=list[UserResponse])
async def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: User = Depends(require_role("owner")),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(User).offset((page - 1) * page_size).limit(page_size)
)
return result.scalars().all()
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: str,
user: User = Depends(require_role("owner")),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
target = result.scalar_one_or_none()
if not target:
raise HTTPException(status_code=404, detail="User not found")
return target
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: str,
body: UserUpdateRequest,
user: User = Depends(require_role("owner")),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
target = result.scalar_one_or_none()
if not target:
raise HTTPException(status_code=404, detail="User not found")
if body.role is not None:
target.role = body.role
if body.is_active is not None:
target.is_active = body.is_active
await db.commit()
await db.refresh(target)
return target
@router.post("/{user_id}/approve", response_model=UserResponse)
async def approve_user(
user_id: str,
user: User = Depends(require_role("owner")),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
target = result.scalar_one_or_none()
if not target:
raise HTTPException(status_code=404, detail="User not found")
if target.is_approved:
raise HTTPException(status_code=400, detail="User already approved")
target.is_approved = True
await db.commit()
await db.refresh(target)
return target
+14
View File
@@ -4,6 +4,8 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import engine, Base
from app.api import auth, users, audit, dashboard, gateway, health, cron, keys, kanban, events
from app.middleware.audit import AuditMiddleware
@asynccontextmanager
@@ -27,6 +29,18 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(AuditMiddleware)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(audit.router)
app.include_router(dashboard.router, prefix="/api/v1")
app.include_router(gateway.router, prefix="/api/v1")
app.include_router(health.router, prefix="/api/v1")
app.include_router(cron.router, prefix="/api/v1")
app.include_router(kanban.router, prefix="/api/v1")
app.include_router(events.router, prefix="/api/v1")
app.include_router(keys.router, prefix="/api/v1")
@app.get("/api/v1/health")
View File
+63
View File
@@ -0,0 +1,63 @@
"""Audit middleware: log all mutating requests to AuditLog."""
import json
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from app.database import async_session
from app.models.audit import AuditLog
from app.services.auth_service import decode_access_token, get_user_by_id
class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
response = await call_next(request)
await self._log(request, response)
return response
return await call_next(request)
async def _log(self, request: Request, response: Response):
try:
user_id = "anonymous"
username = "anonymous"
auth_header = request.headers.get("authorization")
if auth_header and auth_header.startswith("Bearer "):
try:
payload = decode_access_token(auth_header[7:])
async with async_session() as db:
user = await get_user_by_id(db, payload["sub"])
if user:
user_id = user.id
username = user.username
except Exception:
pass
path = request.url.path
action = f"{request.method} {path}"
detail = {}
if response.status_code >= 400:
body = b""
try:
body = await request.body()
if body:
detail["request_body"] = json.loads(body)
except Exception:
pass
async with async_session() as db:
log = AuditLog(
user_id=user_id,
username=username,
action=action,
target=path,
detail=json.dumps(detail) if detail else None,
ip_address=request.client.host if request.client else None,
)
db.add(log)
await db.commit()
except Exception:
pass
+27
View File
@@ -0,0 +1,27 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.auth_service import decode_access_token, get_user_by_id
security = HTTPBearer()
async def require_auth(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
):
payload = decode_access_token(credentials.credentials)
user = await get_user_by_id(db, payload["sub"])
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="Invalid or inactive user")
return user
def require_role(*roles: str):
async def role_check(user=Depends(require_auth)):
if user.role.value not in roles:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user
return role_check
+3 -1
View File
@@ -1 +1,3 @@
from app.models.user import User
from app.models.audit import AuditLog
from app.models.event import Event
+53
View File
@@ -0,0 +1,53 @@
"""事件档案 ORM 模型。"""
import uuid
from datetime import datetime, date
from sqlalchemy import String, DateTime, Date, Integer, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column
import enum
from app.database import Base
class EventStatus(str, enum.Enum):
ACTIVE = "active"
FADING = "fading"
DISSIPATING = "dissipating"
CLOSED = "closed"
class EventLevel(str, enum.Enum):
A = "A"
B = "B"
C = "C"
DOMAINS = [
"gateway",
"crawler",
"reporter",
"feishu",
"database",
"security",
"network",
"storage",
"monitor",
"scheduler",
"api",
"other",
]
class Event(Base):
__tablename__ = "events"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
title: Mapped[str] = mapped_column(String(255), nullable=False)
level: Mapped[EventLevel] = mapped_column(SAEnum(EventLevel), nullable=False)
domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
status: Mapped[EventStatus] = mapped_column(SAEnum(EventStatus), default=EventStatus.ACTIVE, nullable=False, index=True)
occurred_date: Mapped[date] = mapped_column(Date, nullable=False)
last_reviewed: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
no_new_info_count: Mapped[int] = mapped_column(Integer, default=0)
next_review: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+4 -4
View File
@@ -9,9 +9,9 @@ from app.database import Base
class UserRole(str, enum.Enum):
OWNER = "owner"
OPERATOR = "operator"
VIEWER = "viewer"
owner = "owner"
operator = "operator"
viewer = "viewer"
class User(Base):
@@ -21,7 +21,7 @@ class User(Base):
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), default=UserRole.VIEWER, nullable=False)
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), default=UserRole.viewer, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_approved: Mapped[bool] = mapped_column(Boolean, default=False)
login_attempts: Mapped[int] = mapped_column(default=0)
View File
+23
View File
@@ -0,0 +1,23 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class RegisterRequest(BaseModel):
username: str
email: str # 先用 str,后续升级 EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int = 0
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
+19
View File
@@ -0,0 +1,19 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime
class UserResponse(BaseModel):
id: str
username: str
email: str
role: str
is_active: bool
is_approved: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class UserUpdateRequest(BaseModel):
role: str | None = None
is_active: bool | None = None
View File
+96
View File
@@ -0,0 +1,96 @@
"""Auth service: password hashing, JWT, user queries."""
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException, status
from jose import JWTError, ExpiredSignatureError, jwt
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.user import User, UserRole
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return _pwd.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return _pwd.verify(plain, hashed)
def create_access_token(user_id: str, role: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": user_id, "role": role, "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> dict:
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
async def get_user_by_id(db: AsyncSession, user_id: str) -> User | None:
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
async def authenticate_user(db: AsyncSession, username: str, password: str) -> User:
user = await get_user_by_username(db, username)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
# Check lock
if user.locked_until and user.locked_until > datetime.utcnow():
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail=f"Account locked until {user.locked_until.isoformat()}",
)
if not verify_password(password, user.hashed_password):
user.login_attempts += 1
if user.login_attempts >= 5:
user.locked_until = datetime.utcnow() + timedelta(minutes=15)
await db.commit()
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
# Success — reset
user.login_attempts = 0
user.locked_until = None
await db.commit()
return user
async def create_user(db: AsyncSession, username: str, email: str, password: str) -> User:
result = await db.execute(select(User))
existing_count = len(result.scalars().all())
if existing_count == 0:
role = UserRole.owner
is_approved = True
else:
role = UserRole.viewer
is_approved = False
user = User(
username=username,
email=email,
hashed_password=hash_password(password),
role=role,
is_approved=is_approved,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
+162
View File
@@ -0,0 +1,162 @@
"""Event Crawler — 爬取 hm.zhiweidata.ai/events 数据存入 events 表。
TODO: 替换为真实爬虫逻辑。当前返回占位数据。
"""
import logging
from datetime import datetime, date, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import async_session
from app.models.event import Event, EventStatus, EventLevel
logger = logging.getLogger(__name__)
PLACEHOLDER_EVENTS = [
{
"title": "taiwan Gateway 频繁重启",
"level": EventLevel.A,
"domain": "gateway",
"status": EventStatus.ACTIVE,
"occurred_date": date.today() - timedelta(days=1),
"last_reviewed": datetime.now(timezone.utc) - timedelta(hours=6),
"no_new_info_count": 0,
"next_review": datetime.now(timezone.utc) + timedelta(hours=6),
},
{
"title": "guangxi 爬虫数据延迟",
"level": EventLevel.B,
"domain": "crawler",
"status": EventStatus.ACTIVE,
"occurred_date": date.today() - timedelta(days=2),
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=1),
"no_new_info_count": 1,
"next_review": datetime.now(timezone.utc) + timedelta(hours=12),
},
{
"title": "飞书 Webhook 推送异常",
"level": EventLevel.B,
"domain": "feishu",
"status": EventStatus.FADING,
"occurred_date": date.today() - timedelta(days=5),
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=2),
"no_new_info_count": 3,
"next_review": datetime.now(timezone.utc) + timedelta(days=1),
},
{
"title": "ops-engineer 日报生成失败",
"level": EventLevel.C,
"domain": "reporter",
"status": EventStatus.FADING,
"occurred_date": date.today() - timedelta(days=7),
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=3),
"no_new_info_count": 4,
"next_review": datetime.now(timezone.utc) - timedelta(days=1),
},
{
"title": "hebo 数据库连接超时",
"level": EventLevel.A,
"domain": "database",
"status": EventStatus.DISSIPATING,
"occurred_date": date.today() - timedelta(days=14),
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=7),
"no_new_info_count": 6,
"next_review": datetime.now(timezone.utc) - timedelta(days=3),
},
{
"title": "密钥轮换提醒 (taiwan)",
"level": EventLevel.C,
"domain": "security",
"status": EventStatus.DISSIPATING,
"occurred_date": date.today() - timedelta(days=20),
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=10),
"no_new_info_count": 5,
"next_review": datetime.now(timezone.utc) - timedelta(days=5),
},
{
"title": "Gateway 配置同步冲突",
"level": EventLevel.B,
"domain": "gateway",
"status": EventStatus.CLOSED,
"occurred_date": date.today() - timedelta(days=30),
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=25),
"no_new_info_count": 8,
"next_review": None,
},
{
"title": "网络延迟告警 (overseas node)",
"level": EventLevel.B,
"domain": "network",
"status": EventStatus.ACTIVE,
"occurred_date": date.today() - timedelta(days=3),
"last_reviewed": datetime.now(timezone.utc) - timedelta(hours=12),
"no_new_info_count": 2,
"next_review": datetime.now(timezone.utc) + timedelta(hours=18),
},
{
"title": "存储空间不足告警",
"level": EventLevel.C,
"domain": "storage",
"status": EventStatus.ACTIVE,
"occurred_date": date.today() - timedelta(days=4),
"last_reviewed": None,
"no_new_info_count": 0,
"next_review": datetime.now(timezone.utc) + timedelta(days=1),
},
{
"title": "Monitor 心跳超时",
"level": EventLevel.A,
"domain": "monitor",
"status": EventStatus.ACTIVE,
"occurred_date": date.today() - timedelta(hours=8),
"last_reviewed": datetime.now(timezone.utc) - timedelta(hours=4),
"no_new_info_count": 0,
"next_review": datetime.now(timezone.utc) + timedelta(hours=2),
},
{
"title": "Scheduler 任务积压",
"level": EventLevel.B,
"domain": "scheduler",
"status": EventStatus.FADING,
"occurred_date": date.today() - timedelta(days=6),
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=2),
"no_new_info_count": 3,
"next_review": datetime.now(timezone.utc) + timedelta(hours=10),
},
{
"title": "API 响应延迟 (p95 > 5s)",
"level": EventLevel.A,
"domain": "api",
"status": EventStatus.ACTIVE,
"occurred_date": date.today() - timedelta(hours=12),
"last_reviewed": datetime.now(timezone.utc) - timedelta(hours=3),
"no_new_info_count": 0,
"next_review": datetime.now(timezone.utc) + timedelta(hours=4),
},
]
async def seed_placeholder_events() -> list[Event]:
"""向数据库写入占位事件数据(幂等:清空后重写)。"""
async with async_session() as db:
existing = await db.execute(select(Event).limit(1))
if existing.scalar_one_or_none():
logger.info("events 表已有数据,跳过占位种子")
return []
events = []
for data in PLACEHOLDER_EVENTS:
event = Event(**data)
db.add(event)
events.append(event)
await db.commit()
logger.info("写入 %d 条占位事件", len(events))
return events
async def run_crawl():
"""定时爬取入口(TODO: 真实 HTTP 爬取 hm.zhiweidata.ai/events)。"""
logger.info("Event crawler 执行开始")
await seed_placeholder_events()
logger.info("Event crawler 执行结束")
+341
View File
@@ -0,0 +1,341 @@
"""健康分计算引擎 — 评分规则 + 缓存。
评分维度:
- Gateway 稳定性 40%: 7天在线率 + 日均重启惩罚
- 配置完整性 25%: .env + config.yaml 必填字段检查
- Skill 一致性 20%: 与基准 Skill 清单对比
- Cron 成功率 15%: 24h Cron 成功率
等级: A(80-100) B(60-79) C(40-59) D(0-39)
数据来源暂用占位(TODO: hermes CLI / 文件读取),评分逻辑完整实现。
"""
from __future__ import annotations
import logging
import math
import random
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
logger = logging.getLogger(__name__)
# ── 类型 ────────────────────────────────────────────────────────────────
@dataclass
class DimensionScore:
score: float
max_score: float
deductions: list[str] = field(default_factory=list)
suggestions: list[str] = field(default_factory=list)
@dataclass
class HealthScore:
profile_name: str
total_score: float
grade: str
gateway: DimensionScore
config: DimensionScore
skill: DimensionScore
cron: DimensionScore
deduct_summary: list[str] = field(default_factory=list)
calculated_at: str = ""
GRADE_THRESHOLDS = [
(80, "A"),
(60, "B"),
(40, "C"),
(0, "D"),
]
def _to_grade(score: float) -> str:
for threshold, grade in GRADE_THRESHOLDS:
if score >= threshold:
return grade
return "D"
def _fmt_dt(dt: datetime | None = None) -> str:
return (dt or datetime.now(timezone.utc)).isoformat(timespec="seconds")
# ── 占位数据源(TODO: 对接真实数据) ──────────────────────────────────────
def _placeholder_gateway_data(profile: str) -> dict:
"""返回 Profile 的 Gateway 占位状态数据。"""
# 根据 profile 名 hash 出不同状态以产生多样结果
seed = sum(ord(c) for c in profile)
uptime = 0.85 + (seed % 15) / 100 # 0.85~0.99
restarts = (seed % 7) / 10 # 0.0~0.6
return {
"uptime_7d": min(uptime, 0.999),
"avg_daily_restarts": restarts,
"online": seed % 5 != 0,
}
_REQUIRED_CONFIG_FIELDS = [
"BOT_TOKEN",
"BOT_SECRET",
"APP_ID",
"APP_SECRET",
"PROFILE_NAME",
"LOG_LEVEL",
]
def _placeholder_config_data(profile: str) -> dict:
"""返回 Profile 的配置完整性占位数据。"""
seed = sum(ord(c) for c in profile)
# 每个 Profile 缺失 0~3 个字段
missing_count = seed % 4
rng = random.Random(profile)
missing = rng.sample(_REQUIRED_CONFIG_FIELDS, missing_count) if missing_count else []
return {
"total_required": len(_REQUIRED_CONFIG_FIELDS),
"missing_fields": missing,
"present_fields": [f for f in _REQUIRED_CONFIG_FIELDS if f not in missing],
}
_BASELINE_SKILLS = {"gateway", "feishu", "cron", "kanban", "search", "keys"}
def _placeholder_skill_data(profile: str) -> dict:
"""返回 Skill 一致性占位数据。"""
seed = sum(ord(c) for c in profile)
rng = random.Random(profile)
profile_skills = set(_BASELINE_SKILLS)
# 可能缺失几个
for s in rng.sample(list(_BASELINE_SKILLS), seed % 4):
profile_skills.discard(s)
# 可能多出几个
extras = {"notion", "slack", "jira", "github", "gitlab"}
for s in rng.sample(list(extras), seed % 3):
profile_skills.add(s)
missing = _BASELINE_SKILLS - profile_skills
surplus = profile_skills - _BASELINE_SKILLS
return {
"baseline": list(_BASELINE_SKILLS),
"profile_skills": list(profile_skills),
"missing": list(missing),
"surplus": list(surplus),
}
def _placeholder_cron_data(profile: str) -> dict:
"""返回 Cron 执行状态占位数据。"""
seed = sum(ord(c) for c in profile)
success_rate = 0.7 + (seed % 6) / 20 # 0.7~0.95
return {
"total_executions_24h": 10 + seed % 10,
"success_count": int((10 + seed % 10) * success_rate),
"success_rate": success_rate,
"recent_failures": [f"cron-job-{i}" for i in range(seed % 4)],
}
# ── 评分逻辑 ──────────────────────────────────────────────────────────────
def _score_gateway(profile: str) -> DimensionScore:
data = _placeholder_gateway_data(profile)
deductions: list[str] = []
suggestions: list[str] = []
uptime = data["uptime_7d"]
restarts = data["avg_daily_restarts"]
# 基础分: 7天在线率 × 40
score = uptime * 40
if uptime < 0.99:
deductions.append(f"7天在线率 {uptime:.1%}(目标 ≥99%)")
suggestions.append("检查 Gateway 进程稳定性,考虑设置自动重启")
# 日均重启惩罚: >0.3 次/天 开始扣分
if restarts > 0.3:
penalty = min(score, (restarts - 0.3) * 15)
score -= penalty
deductions.append(f"日均重启 {restarts:.1f} 次(阈值 ≤0.3),扣 {penalty:.1f} 分")
suggestions.append("排查频繁重启原因,检查内存泄漏或日志错误")
if not data["online"]:
score *= 0.5
deductions.append("Gateway 当前离线,得分减半")
suggestions.append("立即启动 Gateway 进程")
return DimensionScore(
score=max(0, round(score, 1)),
max_score=40,
deductions=deductions,
suggestions=suggestions,
)
def _score_config(profile: str) -> DimensionScore:
data = _placeholder_config_data(profile)
deductions: list[str] = []
suggestions: list[str] = []
missing = data["missing_fields"]
total = data["total_required"]
score = 25.0 # 满分 25
if missing:
deduction_pct = len(missing) * 0.1 # 缺 1 个扣 10%
score *= max(0, 1 - deduction_pct)
deductions.append(f"缺 {len(missing)} 个必填字段: {', '.join(missing)}")
suggestions.append(f"在 .env 或 config.yaml 中补充: {', '.join(missing)}")
return DimensionScore(
score=max(0, round(score, 1)),
max_score=25,
deductions=deductions,
suggestions=suggestions,
)
def _score_skill(profile: str) -> DimensionScore:
data = _placeholder_skill_data(profile)
deductions: list[str] = []
suggestions: list[str] = []
missing = data["missing"]
surplus = data["surplus"]
# 基准技能匹配率 → 满分 20
baseline_hit = len(data["baseline"]) - len(missing)
if data["baseline"]:
match_ratio = baseline_hit / len(data["baseline"])
else:
match_ratio = 1.0
score = match_ratio * 20
if missing:
deductions.append(f"缺失基准 Skill: {', '.join(missing)}")
suggestions.append(f"安装缺失 Skill: {', '.join(missing)}")
if surplus:
deductions.append(f"多余未识别 Skill: {', '.join(surplus)}")
suggestions.append(f"检查多余 Skill 是否必要: {', '.join(surplus)}")
return DimensionScore(
score=max(0, round(score, 1)),
max_score=20,
deductions=deductions,
suggestions=suggestions,
)
def _score_cron(profile: str) -> DimensionScore:
data = _placeholder_cron_data(profile)
deductions: list[str] = []
suggestions: list[str] = []
success_rate = data["success_rate"]
score = success_rate * 15 # 成功率 × 15
if success_rate < 0.95:
deductions.append(f"24h Cron 成功率 {success_rate:.1%}(目标 ≥95%)")
suggestions.append("检查失败 Cron 任务的日志,确认是否因超时或配置错误")
if data["recent_failures"]:
deductions.append(f"失败任务: {', '.join(data['recent_failures'])}")
suggestions.append("逐个排查失败任务,优先修复高频失败项")
return DimensionScore(
score=max(0, round(score, 1)),
max_score=15,
deductions=deductions,
suggestions=suggestions,
)
# ── 对外接口 ──────────────────────────────────────────────────────────────
SCORE_CACHE: dict[str, HealthScore] = {}
_LAST_CALCULATED: datetime | None = None
_CACHE_TTL = timedelta(hours=24)
# 占位 Profile 列表(TODO: 从 hermes 配置目录读取)
PLACEHOLDER_PROFILES = [
"hebo", "ops-engineer", "monitor", "visualizer",
"gateway-monitor", "feishu-gw", "taiwan-briefing",
"daily-report", "weekly-digest", "hindsight",
"guangxi-donation", "europe-briefing",
]
def calculate_health_score(profile_name: str) -> HealthScore:
"""计算单个 Profile 的健康分。"""
gw = _score_gateway(profile_name)
cf = _score_config(profile_name)
sk = _score_skill(profile_name)
cr = _score_cron(profile_name)
total = round(gw.score + cf.score + sk.score + cr.score, 1)
grade = _to_grade(total)
deduct_summary = []
for dim, label in [(gw, "Gateway"), (cf, "配置"), (sk, "Skill"), (cr, "Cron")]:
for d in dim.deductions:
deduct_summary.append(f"[{label}] {d}")
return HealthScore(
profile_name=profile_name,
total_score=total,
grade=grade,
gateway=gw,
config=cf,
skill=sk,
cron=cr,
deduct_summary=deduct_summary[:5], # 最多显示 5 条
calculated_at=_fmt_dt(),
)
def _is_cache_stale() -> bool:
if _LAST_CALCULATED is None:
return True
return datetime.now(timezone.utc) - _LAST_CALCULATED > _CACHE_TTL
def calculate_all_scores(profiles: list[str] | None = None) -> list[HealthScore]:
"""计算全部 Profile 的健康分,使用缓存(TTL 24h)。"""
global _LAST_CALCULATED
if not _is_cache_stale() and SCORE_CACHE:
logger.info("健康分缓存命中(%d 条)", len(SCORE_CACHE))
scores = list(SCORE_CACHE.values())
if profiles:
return [s for s in scores if s.profile_name in profiles]
return scores
target_profiles = profiles or PLACEHOLDER_PROFILES
scores: list[HealthScore] = []
for name in target_profiles:
try:
hs = calculate_health_score(name)
SCORE_CACHE[name] = hs
scores.append(hs)
except Exception as e:
logger.error("计算 %s 健康分异常: %s", name, e)
_LAST_CALCULATED = datetime.now(timezone.utc)
logger.info("健康分已刷新(%d 条)", len(scores))
return scores
def invalidate_cache() -> None:
"""强制清除缓存,下次调用重新计算。"""
global _LAST_CALCULATED
SCORE_CACHE.clear()
_LAST_CALCULATED = None
logger.info("健康分缓存已清除")
+43
View File
@@ -0,0 +1,43 @@
"""Hermes CLI 封装 — 通过 subprocess 调用 hermes 命令。"""
import json
import logging
import subprocess
from app.config import settings
logger = logging.getLogger(__name__)
def _run_hermes(*args: str, timeout: int = 15) -> subprocess.CompletedProcess:
"""执行 hermes CLI 命令,返回 CompletedProcess。"""
return subprocess.run(
[settings.hermes_cli_path, *args],
capture_output=True,
text=True,
timeout=timeout,
)
def gateway_status_all() -> list[dict]:
"""执行 `hermes gateway status`,返回解析后的 Gateway 状态列表。
出错时返回空列表并记录告警日志。
"""
try:
proc = _run_hermes("gateway", "status")
if proc.returncode != 0:
logger.warning("hermes gateway status 返回非零: %s", proc.stderr.strip())
return []
data = json.loads(proc.stdout)
if isinstance(data, list):
return data
return data.get("gateways", [])
except FileNotFoundError:
logger.warning("Hermes CLI 未找到: %s", settings.hermes_cli_path)
except json.JSONDecodeError as e:
logger.warning("解析 hermes gateway status 输出失败: %s", e)
except subprocess.TimeoutExpired:
logger.warning("hermes gateway status 执行超时")
except Exception as e:
logger.warning("调用 hermes gateway status 异常: %s", e)
return []
+196
View File
@@ -0,0 +1,196 @@
"""Key Manager 服务 — .env 密钥读写、脱敏、批量操作。"""
import logging
import os
import shutil
from pathlib import Path
from typing import Optional
from app.config import settings
logger = logging.getLogger(__name__)
# 可管理的密钥白名单
WHITELIST_FIELDS = [
"DEEPSEEK_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
]
PROFILES_ROOT = Path(settings.hermes_profiles_root)
def _parse_env(content: str) -> dict[str, str]:
"""解析 .env 文件内容为 key-value 字典。"""
result: dict[str, str] = {}
for line in content.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if "=" not in stripped:
continue
key, _, val = stripped.partition("=")
key = key.strip()
val = val.strip().strip("\"'")
if key in WHITELIST_FIELDS:
result[key] = val
return result
def _mask_value(value: str) -> str:
"""脱敏: 前3字符 + **...** + 后4字符。"""
if len(value) <= 7:
return value[:3] + "**...**" + (value[-4:] if len(value) > 3 else "")
return value[:3] + "**...**" + value[-4:]
def _read_env_file(profile: str) -> dict[str, str]:
"""读取指定 Profile 的 .env 文件中白名单字段。"""
env_path = PROFILES_ROOT / profile / ".env"
if not env_path.exists():
return {}
try:
content = env_path.read_text(encoding="utf-8")
return _parse_env(content)
except OSError as e:
logger.warning("读取 %s/.env 失败: %s", profile, e)
return {}
def _write_env_file(profile: str, updates: dict[str, str]) -> bool:
"""写入指定 Profile 的 .env 文件,操作前自动备份。"""
env_path = PROFILES_ROOT / profile / ".env"
if not env_path.exists():
return False
# 备份
try:
shutil.copy2(env_path, env_path.with_suffix(".env.bak"))
except OSError as e:
logger.warning("备份 %s/.env 失败: %s", profile, e)
try:
content = env_path.read_text(encoding="utf-8")
lines = content.splitlines(keepends=True)
updated_keys = set(updates.keys())
new_lines: list[str] = []
found: set[str] = set()
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
new_lines.append(line)
continue
if "=" not in stripped:
new_lines.append(line)
continue
key = stripped.partition("=")[0].strip()
if key in updated_keys:
new_lines.append(f"{key}={updates[key]}\n")
found.add(key)
else:
new_lines.append(line)
# 追加未找到的字段
for key in updated_keys:
if key not in found:
new_lines.append(f"{key}={updates[key]}\n")
env_path.write_text("".join(new_lines), encoding="utf-8")
logger.info("已写入 %s/.env (%d 个字段)", profile, len(updates))
return True
except OSError as e:
logger.error("写入 %s/.env 失败: %s", profile, e)
return False
def list_profiles() -> list[str]:
"""返回所有包含 .env 文件的 Profile 名称列表。"""
if not PROFILES_ROOT.exists():
return []
return sorted(
d.name
for d in PROFILES_ROOT.iterdir()
if d.is_dir() and (d / ".env").exists()
)
def get_keys(profile_names: Optional[list[str]] = None) -> list[dict]:
"""获取指定 Profile 的密钥列表(脱敏显示)。"""
profiles = profile_names if profile_names else list_profiles()
result: list[dict] = []
for name in profiles:
fields = _read_env_file(name)
masked = {k: _mask_value(v) for k, v in fields.items()}
missing = len(WHITELIST_FIELDS) - len(fields)
result.append({"profile": name, "fields": masked, "missing": missing})
return result
def batch_overwrite(
profile_names: list[str],
key_name: str,
new_value: str,
) -> list[dict]:
"""批量覆盖指定 Profile 的某个密钥字段。"""
results: list[dict] = []
for name in profile_names:
success = _write_env_file(name, {key_name: new_value})
results.append({"profile": name, "success": success})
return results
def batch_scan(profile_names: Optional[list[str]] = None) -> list[dict]:
"""批量检查缺失的密钥字段。"""
profiles = profile_names if profile_names else list_profiles()
results: list[dict] = []
for name in profiles:
fields = _read_env_file(name)
missing = [f for f in WHITELIST_FIELDS if f not in fields]
results.append({
"profile": name,
"missing": missing,
"missing_count": len(missing),
"total": len(WHITELIST_FIELDS),
})
return results
def get_diff(profile_names: list[str]) -> list[dict]:
"""差异视图: 横向 Profile, 纵向字段, 值状态(有/无/长度)。"""
profiles = profile_names if profile_names else list_profiles()
field_map: dict[str, dict[str, str | int]] = {}
for name in profiles:
fields = _read_env_file(name)
for key in WHITELIST_FIELDS:
if key not in field_map:
field_map[key] = {}
if key in fields:
field_map[key][name] = len(fields[key])
else:
field_map[key][name] = 0 # 0 表示缺失
rows: list[dict] = []
for key in WHITELIST_FIELDS:
row: dict[str, str | int] = {"field": key}
for name in profiles:
val = field_map.get(key, {}).get(name, 0)
row[name] = val
rows.append(row)
return rows
def export_csv(profile_names: Optional[list[str]] = None) -> str:
"""CSV 导出批量检查结果。"""
scan = batch_scan(profile_names)
import csv
import io
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["Profile", "缺失字段", "缺失数", "总字段"])
for row in scan:
writer.writerow([
row["profile"],
"; ".join(row["missing"]),
row["missing_count"],
row["total"],
])
return buf.getvalue()
+53
View File
@@ -0,0 +1,53 @@
"""Test fixtures: async client + in-memory SQLite database."""
import asyncio
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base, get_db
from app.main import app
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="session")
async def engine():
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db(engine) -> AsyncGenerator[AsyncSession, None]:
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with session_factory() as session:
yield session
@pytest_asyncio.fixture
async def client(engine) -> AsyncGenerator[AsyncClient, None]:
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def override_get_db():
async with session_factory() as session:
yield session
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
+95
View File
@@ -0,0 +1,95 @@
"""Auth API tests."""
import pytest
from httpx import AsyncClient
class TestRegister:
@pytest.mark.asyncio
async def test_register_success(self, client: AsyncClient):
resp = await client.post("/api/v1/auth/register", json={
"username": "owner1",
"email": "owner@test.local",
"password": "Pass1234",
})
assert resp.status_code == 201
data = resp.json()
assert data["username"] == "owner1"
assert data["role"] == "owner"
assert data["is_approved"] is True
@pytest.mark.asyncio
async def test_register_duplicate(self, client: AsyncClient):
resp = await client.post("/api/v1/auth/register", json={
"username": "owner1",
"email": "another@test.local",
"password": "Pass1234",
})
assert resp.status_code == 409
assert "Username" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_register_second_user_is_viewer(self, client: AsyncClient):
resp = await client.post("/api/v1/auth/register", json={
"username": "viewer1",
"email": "viewer@test.local",
"password": "Pass1234",
})
assert resp.status_code == 201
data = resp.json()
assert data["role"] == "viewer"
assert data["is_approved"] is False
class TestLogin:
@pytest.mark.asyncio
async def test_login_success(self, client: AsyncClient):
resp = await client.post("/api/v1/auth/login", json={
"username": "owner1",
"password": "Pass1234",
})
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
@pytest.mark.asyncio
async def test_login_wrong_password(self, client: AsyncClient):
resp = await client.post("/api/v1/auth/login", json={
"username": "owner1",
"password": "wrongpass",
})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_login_locked(self, client: AsyncClient):
# Fail 4 more times (already failed once above) → total 5 failures
for _ in range(4):
await client.post("/api/v1/auth/login", json={
"username": "owner1",
"password": "wrongpass",
})
resp = await client.post("/api/v1/auth/login", json={
"username": "owner1",
"password": "wrongpass",
})
assert resp.status_code == 423
class TestAuthGuard:
@pytest.mark.asyncio
async def test_require_auth_no_token(self, client: AsyncClient):
resp = await client.post("/api/v1/auth/refresh")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_viewer_cannot_manage_users(self, client: AsyncClient):
# Login as viewer1 → token
login = await client.post("/api/v1/auth/login", json={
"username": "viewer1",
"password": "Pass1234",
})
token = login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
resp = await client.get("/api/v1/users", headers=headers)
assert resp.status_code == 403
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
listen 2053;
server_name hm.zhiweidata.ai;
root /home/enzo/public/hive-manage-v3;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8001;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
@@ -0,0 +1,171 @@
在 /home/enzo/projects/hive-manage-v3/backend/ 下实现完整的 JWT 鉴权 + 用户管理 + 审计日志系统。全部使用 backend/.venv/bin/python 和 backend/.venv/bin/pip。
已有: User + AuditLog ORM 模型, FastAPI 入口, database.py, config.py
## 第一部分: auth_service.py
创建 backend/app/services/__init__.py (空)
创建 backend/app/services/auth_service.py
核心函数:
- hash_password(password: str) -> str — bcrypt (passlib)
- verify_password(plain: str, hashed: str) -> bool
- create_access_token(user_id: str, role: str) -> str — JWT(HS256, config.jwt_secret, expires=config.jwt_expire_minutes)
- decode_access_token(token: str) -> dict — jose.jwt.decode, 捕获 ExpiredSignatureError/JWTError 抛 HTTPException(401)
- get_user_by_id(db, user_id) -> User | None
- get_user_by_username(db, username) -> User | None
- authenticate_user(db, username, password) -> User — 验证密码, 失败检查/递增 login_attempts, >=5 次锁定 15 分钟, 成功后重置 login_attempts
- create_user(db, username, email, password) -> User — 首个用户自动 Owner + is_approved=True, 后续用户 Viewer + is_approved=False
## 第二部分: auth middleware
创建 backend/app/middleware/__init__.py (空)
创建 backend/app/middleware/auth.py
```python
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.auth_service import decode_access_token, get_user_by_id
security = HTTPBearer()
async def require_auth(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
):
payload = decode_access_token(credentials.credentials)
user = await get_user_by_id(db, payload["sub"])
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="Invalid or inactive user")
return user
def require_role(*roles: str):
async def role_check(user = Depends(require_auth)):
if user.role.value not in roles:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user
return role_check
```
## 第三部分: Auth Schemas
创建 backend/app/schemas/__init__.py (空)
创建 backend/app/schemas/auth.py
```python
from pydantic import BaseModel, EmailStr
class LoginRequest(BaseModel):
username: str
password: str
class RegisterRequest(BaseModel):
username: str
email: str # 先用 str,后续升级 EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
```
创建 backend/app/schemas/user.py
```python
from pydantic import BaseModel
from datetime import datetime
class UserResponse(BaseModel):
id: str
username: str
email: str
role: str
is_active: bool
is_approved: bool
created_at: datetime
class Config:
from_attributes = True
class UserUpdateRequest(BaseModel):
role: str | None = None
is_active: bool | None = None
```
## 第四部分: Auth API
创建 backend/app/api/__init__.py (空)
创建 backend/app/api/auth.py
POST /api/v1/auth/register — 注册, 返回 201
POST /api/v1/auth/login — 登录, 返回 TokenResponse
POST /api/v1/auth/refresh — 刷新 token, 需 require_auth
POST /api/v1/auth/change-password — 改密码, 需 require_auth
## 第五部分: User Management API
创建 backend/app/api/users.py
GET /api/v1/users — 用户列表 (需 owner), 支持分页
GET /api/v1/users/{id} — 用户详情 (需 owner)
PATCH /api/v1/users/{id} — 改角色/状态 (需 owner)
POST /api/v1/users/{id}/approve — 审批新用户 (需 owner)
## 第六部分: Audit Middleware + API
创建 backend/app/middleware/audit.py
```python
# 审计中间件: 拦截所有 POST/PUT/PATCH/DELETE 请求
# 从 request.state 获取当前用户 (需 auth middleware 先执行)
# 记录: user_id, username, action, target, detail, ip_address
# 写入 AuditLog 表 (异步)
```
创建 backend/app/api/audit.py
GET /api/v1/audit — 审计日志列表 (需 owner), 支持分页 + 按 action 筛选
## 第七部分: 注册路由
修改 backend/app/main.py, 注册所有 router:
```python
from app.api import auth, users, audit
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(audit.router)
```
## 第八部分: 测试
创建 backend/tests/test_auth.py
- test_register_success (201)
- test_register_duplicate (409)
- test_login_success (200 + JWT)
- test_login_wrong_password (401)
- test_login_locked (423 after 5 failures)
- test_require_auth_no_token (401)
- test_viewer_cannot_manage_users (403)
创建 backend/tests/conftest.py
- async test fixtures: async_client (httpx.AsyncClient + FastAPI app)
- test database (SQLite in-memory 或者用现有的 PG)
注意: 测试使用 pytest + pytest-asyncio + httpx.AsyncClient
## 第九部分: 提交
```bash
cd /home/enzo/projects/hive-manage-v3 && \
git add backend/app/services/ backend/app/middleware/ backend/app/schemas/ backend/app/api/ backend/tests/ backend/app/main.py && \
git commit -m "feat: JWT auth, user management, audit logging system"
```
请按顺序执行以上 9 个部分,每个部分完成后再进入下一部分。pip install 需要时使用 `cd backend && .venv/bin/pip install <package>`。
@@ -0,0 +1,84 @@
在 /home/enzo/projects/hive-manage-v3/frontend/ 下实现前端鉴权体系。
## 1. API 客户端
创建 frontend/src/api/client.ts:
- axios 实例 baseURL='/api/v1'
- 请求拦截器: 从 localStorage('access_token') 读取 JWT, 加到 Authorization: Bearer header
- 响应拦截器: 401 自动清除 token → 跳转 /login
创建 frontend/src/api/auth.ts:
```typescript
import client from './client'
export const login = (username: string, password: string) => client.post('/auth/login', {username, password})
export const register = (data: {username, email, password}) => client.post('/auth/register', data)
export const refresh = () => client.post('/auth/refresh')
export const changePassword = (data: {old_password, new_password}) => client.post('/auth/change-password', data)
```
创建 frontend/src/api/users.ts:
```typescript
import client from './client'
export const getUsers = (params?) => client.get('/users', {params})
export const getUser = (id: string) => client.get(`/users/${id}`)
export const updateUser = (id: string, data) => client.patch(`/users/${id}`, data)
export const approveUser = (id: string) => client.post(`/users/${id}/approve`)
```
创建 frontend/src/api/audit.ts:
```typescript
import client from './client'
export const getAuditLogs = (params?) => client.get('/audit', {params})
```
## 2. useAuth hook
创建/修改 frontend/src/hooks/useAuth.ts:
```typescript
import { useState, useEffect, createContext, useContext } from 'react'
// AuthContext 提供: user, token, login(), logout(), isAuthenticated
// token 存 localStorage('access_token')
// user 从 JWT payload 解码获取 (username, role, user_id)
```
## 3. Login 页面
修改 frontend/src/pages/LoginPage.tsx:
- 居中卡片, 暗色背景
- Logo "🐝 知微蜂巢" + "Hive Manage v3"
- 用户名 + 密码输入框
- 登入按钮 (primary, full width)
- 注册链接 (跳转注册页)
- 错误提示 (用户名/密码错误, 账户锁定 15 分钟等)
- 成功后跳转 /dashboard
## 4. 用户管理页面
修改 frontend/src/pages/UserManagementPage.tsx:
- 使用 Table 组件
- 列: 用户名, 邮箱, 角色(下拉可改), 状态(active/inactive), 审批状态, 创建时间, 操作
- 仅 owner 可见操作按钮
- 角色修改: owner 可改, API PATCH /users/{id}
- 审批: owner 可点, API POST /users/{id}/approve
- 分页: 每页 20 条
## 5. 审计日志页面
修改 frontend/src/pages/AuditLogPage.tsx:
- 使用 Table 组件
- 列: 时间, 用户, 操作(action), 目标(target), 详情(detail), IP
- action 筛选: 下拉列表 (全部/keys.batch_overwrite/gateway.toggle/cron.trigger/...)
- 分页: 每页 20 条
- 仅 owner 可见
## 6. 路由守卫
修改 App.tsx:
- 未登录: 任何页面 → 重定向 /login
- 登录后: 根据角色隐藏/显示导航项和操作按钮
- Viewer 看不到: 用户管理、审计日志、Gateway 启停、密钥覆盖、Cron 操作
完成后提交:
```bash
cd /home/enzo/projects/hive-manage-v3 && git add frontend/src/api/ frontend/src/hooks/useAuth.ts frontend/src/pages/LoginPage.tsx frontend/src/pages/UserManagementPage.tsx frontend/src/pages/AuditLogPage.tsx frontend/src/App.tsx && git commit -m "feat: frontend auth flow (login, user management, audit logs, route guards)"
```
+114
View File
@@ -0,0 +1,114 @@
在 /home/enzo/projects/hive-manage-v3/ 实现 Dashboard 增强(P0-3)。后端 API + 前端页面。
## 后端: Dashboard API
创建 backend/app/api/dashboard.py,所有路由需 require_auth。
### 1. GET /api/v1/dashboard/summary
返回聚合统计数据:
```json
{
"online_count": 12,
"offline_count": 46,
"total_profiles": 58,
"running_tasks": 3,
"cron_failures_today": 1,
"updated_at": "2026-07-22T16:00:00"
}
```
数据来源: hermes CLI (`hermes gateway status` 解析) + Kanban 聚合 + Cron 统计。如需真实数据可先返回 Mock 数据(标注 TODO: replace with CLI)。
### 2. GET /api/v1/dashboard/health-distribution
返回健康分四级分布:
```json
{
"A": 22, "B": 18, "C": 10, "D": 8, "total": 58,
"d_profiles": ["ops-engineer", "monitor", "visualizer", "hebo", "..."]
}
```
暂用占位数据,Phase 4 实现真实计算。
### 3. GET /api/v1/dashboard/gateway-trend
返回最近 24h Gateway 异常事件按小时聚合:
```json
[
{"hour": "2026-07-22T08:00", "restarts": 2, "feishu_disconnects": 1, "heartbeat_timeouts": 0},
...
]
```
暂用占位数据。
### 4. GET /api/v1/dashboard/kanban-summary
返回活跃看板概览:
```json
[
{"board": "europe-briefing", "running": "S4", "ready": "S5"},
{"board": "tech-weekly", "running": "S1", "ready": null}
]
```
暂用占位数据。
### 5. GET /api/v1/dashboard/cron-recent
返回最近 Cron 执行记录:
```json
[
{"job": "taiwan-briefing", "status": "success", "time": "07:15"},
{"job": "hindsight", "status": "failure", "time": "21:13"},
{"job": "guangxi-donation", "status": "paused"}
]
```
暂用占位数据。
### 6. GET /api/v1/search?q=keyword
全局搜索: 搜索 Profile 名、任务名、Cron 名、事件关键词。返回分组结果:
```json
{
"profiles": [{"name": "gateway-monitor", "status": "online"}],
"kanban_tasks": [{"title": "...", "column": "RUNNING"}],
"cron_jobs": [{"job": "...", "status": "active"}],
"events": [{"title": "...", "status": "active"}]
}
```
暂用占位数据,含模糊匹配逻辑。
## 前端: Dashboard 页面
修改 frontend/src/pages/DashboardPage.tsx:
### 统计卡片行 (4 列 grid)
- 在线 Gateway (绿色数值 + "↑ N vs 昨天")
- 离线 Gateway (红色数值 + "↓ N vs 昨天")
- 运行中任务 (蓝色数值)
- 今日 Cron 失败 (红色数值,失败>3 标红)
### 左侧: 蜂群健康分布
- 使用 Card 组件 + 自定义柱状图 (CSS bar chart)
- A(绿) B(蓝) C(黄) D(红) 四柱
- 点击柱跳转 /health?filter=X
### 右侧: Gateway 异常趋势 (24h)
- 使用 Timeline 组件,展示 24h 内事件点分布
- 图例: 🟢正常 🔴心跳超时 🟠飞书断连
### 底部: Kanban 概览 + Cron 最近执行
- 两列并排
- Kanban 概览: 看板名 + RUNNING 任务 + READY 任务
- Cron 最近: Job 名 + 状态(✅/❌/⏸) + 时间
### 搜索框
- 已在 Header 中通过 SearchBox 组件实现
- 输入关键词 → 调 /api/v1/search → 下拉面板分组展示
- 点击结果跳转对应页面
### 四态覆盖
- Loading: Skeleton (card 形状)
- Empty: "暂无数据"
- Error: 错误提示 + 重试按钮
- Normal: 数据正常展示
注册路由: 在 main.py 添加 `from app.api import dashboard; app.include_router(dashboard.router, prefix="/api/v1")`
完成后提交:
```bash
cd /home/enzo/projects/hive-manage-v3 && git add backend/app/api/dashboard.py backend/app/main.py frontend/src/pages/DashboardPage.tsx frontend/src/components/layout/Header.tsx && git commit -m "feat: Dashboard with summary cards, health distribution, gateway trend, global search"
```
+84
View File
@@ -0,0 +1,84 @@
实现 Gateway 健康监控(P0-1)。后端 Hermes CLI 封装 + API + 前端页面。
## 第一部分: Hermes CLI 服务(真实数据源)
创建 backend/app/services/hermes_cli.py:
- 封装 `hermes gateway status` CLI 调用
- 用 subprocess.run 执行,解析 JSON 输出
- 出错时返回空数据 + 日志告警,不抛异常
- 函数: gateway_status_all() -> list[GatewayInfo]
- 数据类型: id, name, status(online/offline/warn), downtime_minutes, restarts_today, last_event
## 第二部分: Gateway API
创建 backend/app/api/gateway.py,全部 require_auth
### GET /api/v1/gateway/status
返回全部 Profile 的 Gateway 状态列表,支持:
- query param `?status=online|offline|warn` 筛选
- 每行: name, status(🟢/🔴/🟡), downtime, restarts_today, last_event, profile_type
- 数据来源: hermes_cli.gateway_status_all()
### GET /api/v1/gateway/events
返回最近 24h 异常事件:
```json
[
{"time": "2026-07-22T18:45", "profile": "monitor", "type": "heartbeat_timeout", "detail": "连续 3 次心跳无响应"},
{"time": "2026-07-22T14:20", "profile": "feishu-gw", "type": "feishu_disconnect", "detail": "WebSocket 断开 8 分钟"},
{"time": "2026-07-22T10:30", "profile": "gateway-monitor", "type": "restart", "detail": "Gateway 重启"}
]
```
数据来源: hermes CLI 日志或系统日志解析。暂用占位数据(TODO 标注)。
### POST /api/v1/gateway/{profile}/start, /stop, /restart
Gateway 启停操作:
- 执行 `hermes gateway start/stop/restart --profile {profile}`
- 需要 operator 或 owner 角色(require_role("owner","operator"))
- 返回操作结果 + 写入审计日志
- 操作前二次确认(前端处理)
## 第三部分: 前端 Gateway 页面
修改 frontend/src/pages/GatewayPage.tsx:
### 顶部: 异常事件时间线
- Timeline 组件,24h 横向展示
- 事件类型: 🔴心跳超时 🟠飞书断连 🔵Gateway 重启
- Hover 显示详情 tooltip
### 筛选栏
- 状态筛选: 全部/在线/离线/异常
- 蜂型筛选: 全部/工蜂/蜂王
- 筛选器即时生效(前端过滤,不重新请求)
### 状态表
- Table 组件,10s 轮询 (usePolling hook)
- 列: Profile 名, 状态 Badge, 掉线时长, 今日重启, 最近异常, 操作
- 异常行标红: Gateway 连续重启≥3次 或 掉线≥30分钟
- 操作按钮: 启动/停止/重启 (二次确认 Modal)
### 四态覆盖
- Loading: Skeleton (table 形状)
- Empty: "全部 Gateway 正常运行 🎉"
- Error: 错误 + 重试
- Normal: 数据展示
### usePolling hook
创建 frontend/src/hooks/usePolling.ts:
```typescript
import { useEffect, useRef } from 'react'
export function usePolling(callback: () => void, intervalMs: number, enabled: boolean = true) {
const savedCallback = useRef(callback)
useEffect(() => { savedCallback.current = callback }, [callback])
useEffect(() => {
if (!enabled) return
savedCallback.current() // 立即执行一次
const id = setInterval(() => savedCallback.current(), intervalMs)
return () => clearInterval(id)
}, [intervalMs, enabled])
}
```
注册路由: main.py 添加 `from app.api import gateway; app.include_router(gateway.router, prefix="/api/v1")`
完成后提交。
+73
View File
@@ -0,0 +1,73 @@
实现 Profile 健康分(P0-2)。评分引擎 + API + 前端页面。
## 第一部分: 健康分计算引擎
创建 backend/app/services/health_scorer.py:
评分规则(PRD §P0-2):
- Gateway 稳定性 40%: 7天在线率×40 + 日均重启≤0.3得满分
- 配置完整性 25%: 必填字段检查(.env+config.yaml),缺1个扣10%
- Skill 一致性 20%: 与基准 Skill 清单对比
- Cron 成功率 15%: 24h Cron 成功率×15
等级: A(80-100) B(60-79) C(40-59) D(0-39)
函数:
- calculate_health_score(profile_name: str) -> HealthScore
- calculate_all_scores() -> list[HealthScore]
- 数据来源: 读取 .env + config.yaml 文件, 调 hermes CLI 获取 Gateway/Cron 状态
- 暂用占位数据(TODO: connect to real data sources),但评分逻辑要完整实现
- 评分结果缓存到 score_results dict, 每天凌晨自动刷新
## 第二部分: Health API
创建 backend/app/api/health.py,全部 require_auth
### GET /api/v1/health/scores
返回全部 Profile 健康分排行列表
- query: ?grade=A|B|C|D&sort=asc|desc
- 默认: 从低到高(D 排最前)
- 每行: rank, name, total_score, grade, gateway_score, config_score, skill_score, cron_score, deduct_summary
### GET /api/v1/health/scores/{profile}
返回单 Profile 详情:
- 四维度得分/满分 + 具体扣分项列表 + 修复建议
### POST /api/v1/health/compare
body: {"profile_names": ["hebo", "ops-engineer"]}
返回对比表: 横向 Profile, 纵向配置/Skill 字段, ✅/❌/⚠
### POST /api/v1/health/calculate
手动触发全量评分计算 (需 owner)
## 第三部分: 前端 Health 页面
修改 frontend/src/pages/HealthPage.tsx:
### 顶部: 四级分布卡片
- 4 个 Card: A(绿) B(蓝) C(黄) D(红)
- 显示数量 + 等级标签
- 点击卡片筛选对应等级
### 筛选栏
- 等级筛选: 全部 A B C D
- 对比按钮: 选中≥2 个 Profile 后启用
- 排序切换: 默认问题优先(D→A)
### 评分列表
- Table 组件
- 列: 排名, Profile名, 总分(颜色编码), 等级 Badge, 四维度分, 主要扣分项, 操作
- 可展开行: 点击展开四维度扣分明细面板(内嵌展开,不跳转)
- D 级行标红
### 批量对比视图
- 勾选 2-20 个 Profile → "对比选中"按钮
- 对比表: 横向 Profile, 纵向字段, ✅有/❌无/⚠版本不同
- 差异行黄色背景高亮
- 支持"只看差异"切换
### 四态覆盖
注册路由: main.py 添加 health router。
完成后提交。
+63
View File
@@ -0,0 +1,63 @@
实现 Cron 管理增强(P0-4b)。后端 API + 前端页面。
## 后端: Cron API
创建 backend/app/api/cron.py,全部 require_auth
### 1. GET /api/v1/cron/jobs
跨 Profile Cron 聚合列表,支持 ?status=active|paused&profile=all|xxx|system
- 每行: job_name, profile, schedule, status, last_run(time+✅/❌), success_rate, has_logs
- 数据来源: hermes CLI `hermes cron list --all` + 系统 Cron 硬编码清单
- 系统 Cron 示例: Hindsight(0 21 * * *), 健康巡检(*/30 * * * *), Skill检查(0 2 * * *), Memory归档(0 3 * * 0)
- 系统 Cron 行有 [system] 标记
- Cron 连续失败≥2次 → 行标红
- 当日有失败时返回 has_failures: true
### 2. GET /api/v1/cron/jobs/{id}/logs
返回最近 8 条运行日志:
```json
{"logs": [{"time": "", "status": "success|failure", "message": "..."}]}
```
数据来源: hermes CLI + 日志文件。暂用占位数据。
### 3. POST /api/v1/cron/jobs/{id}/run, /pause, /resume
操作 Cron (需 operator+):
- run: 手动触发一次
- pause: 暂停 Cron
- resume: 恢复 Cron
- 操作结果写入审计日志
## 前端: Cron 页面
修改 frontend/src/pages/CronPage.tsx:
### 筛选栏
- 状态筛选: 全部/活跃/暂停/今日失败
- Profile 筛选: 全部/各 Profile/仅系统 Cron
- 前端即时过滤
### Cron 列表
- Table 组件, 30s 轮询
- 列: Job名, Profile, Cron表达式(等宽字体), 状态 Badge, 上次运行(时间+✅❌), 成功率(<80%标黄), 操作
- 系统 Cron 灰底 + [system] 标记
- 连续失败≥2 行标红
### 日志面板
- 每行操作列 "📋 日志" 按钮
- 点击弹出居中面板(遮罩+Modal)
- 标题: "{job} — 最近运行日志"
- 列表(最多8条): 时间戳, 状态✅/❌, 日志消息(INFO/WARN/ERROR 按级别着色)
- 失败消息红色背景, 包含完整错误详情
- 关闭: ✕ / 点遮罩 / Esc
### 操作按钮
- 运行(二次确认), 暂停, 恢复
- Toast 结果反馈
### 导航栏 Badge
- Dashboard 今日 Cron 失败计数同步
- 导航栏 Cron 项显示红色 Badge(失败数)
### 四态覆盖
注册路由, 完成后提交。
+82
View File
@@ -0,0 +1,82 @@
实现密钥批量管理(P0-5)。后端 .env 读写 + API + 前端页面。
## 后端: Key Manager 服务
创建 backend/app/services/key_manager.py:
核心功能:
- 读取 Profile 的 .env 文件中指定密钥字段
- 脱敏显示: 前3字符 + "**...**" + 后4字符 (如 "sk-**...**b12c")
- 白名单字段: DEEPSEEK_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY, FEISHU_APP_ID, FEISHU_APP_SECRET
- 批量覆盖: 写入新值到 .env 文件 (操作前自动备份 .env → .env.bak)
- 批量检查: 扫描全部 Profile 的 .env, 返回缺失字段列表
- 差异视图: 对比各 Profile 同一字段的值 (只显示有无和长度, 不显示原文)
- 不写入审计日志原文(只记录 action + profile 名 + 字段名)
## 后端: Keys API
创建 backend/app/api/keys.py, 全部 require_role("owner","operator")
### GET /api/v1/keys
返回密钥字段列表:
```json
[{"profile": "hebo", "fields": {"DEEPSEEK_API_KEY": "sk-**...**b12c", ...}, "missing": 0}]
```
### POST /api/v1/keys/batch-overwrite
批量覆盖密钥:
- body: {profile_names: [...], key_name: "DEEPSEEK_API_KEY", new_value: "sk-xxx", confirmation: "我确认覆盖 N 个 Profile"}
- 验证 confirmation 短语精确匹配
- 逐 Profile 写入 + 返回成功/失败列表
### POST /api/v1/keys/batch-scan
批量检查缺失:
- body: {profile_names: [...]} 或全部 Profile
- 返回缺失统计 + 详情
### GET /api/v1/keys/diff
差异视图:
- query: ?profiles=hebo,ops-engineer
- 返回对比表: 横向 Profile, 纵向字段, 值状态(有/无/长度)
### GET /api/v1/keys/export
CSV 导出批量检查结果
## 前端: Keys 页面
修改 frontend/src/pages/KeysPage.tsx:
### 操作栏
- [批量覆盖] 按钮 → Modal
- [批量检查] 按钮 → 扫描结果展示
- Profile 筛选下拉
- 搜索密钥字段
### 密钥列表
- Table 组件
- 列: ☐复选框, Profile名, 各密钥字段(脱敏显示), 缺失计数
- 缺失字段标红 ❌
- 差异视图切换
### 批量覆盖 Modal
- 下拉选择密钥字段
- 输入新值(密码框, 可切换明文)
- 预览: "将覆盖 N 个 Profile 的 {KEY_NAME}"
- 当前值脱敏预览
- 输入确认短语: "我确认覆盖 N 个 Profile"
- 确认后执行 → 进度提示 → 成功/失败列表
### 批量检查结果
- 显示扫描结果表: Profile, 缺失字段列表, 缺失数
- 支持导出 CSV (下载按钮)
- 缺失行标红
### 差异视图
- 勾选 2+ Profile → "查看差异"
- 对比表: 横向 Profile, 纵向字段, ✅有/❌无
- 差异行黄色高亮
- "只看差异" 切换
### 四态覆盖 + 审计日志
注册路由, 完成后提交。
+84
View File
@@ -0,0 +1,84 @@
实现 Kanban 增强(P0-4)。后端 API + 前端五列看板页面。
核心定位: Kanban 是蜂群任务的电子沙盘——观察全貌、发现异常。不做批量 dispatch/创建任务/goal_mode 配置(这些走 Hermes CLI)。
## 后端: Kanban API
创建 backend/app/api/kanban.py,全部 require_auth
### 1. GET /api/v1/kanban/tasks
跨 Profile 五列任务聚合:
- 数据来源: hermes CLI `hermes kanban list` 解析
- 支持 ?worker=xxx&board=xxx&anomaly=blocked_timeout|unclaimed|retry_failure
- 返回五列数据: {todo: [...], ready: [...], running: [...], blocked: [...], done: [...]}
- 概览条: total_tasks, profile_count, running_count, blocked_count
- 暂用 Mock 数据(TODO: CLI integration)
### 2. GET /api/v1/kanban/tasks/{id}
任务详情:
- Meta: board, worker_profile, worker_type, priority, created_at, updated_at, goal_mode, retries/failures
- Body: 任务描述(Markdown)
- 关联: 依赖任务, 被依赖任务
- 活动: 最近操作时间线
### 3. PATCH /api/v1/kanban/tasks/{id}/move
拖拽移动:
- body: {column: "ready"|"running"|"blocked"|"done", block_reason?: "阻塞原因(≥10字)"}
- 拖入 BLOCKED 需填写阻塞原因
- 拖入 DONE 二次确认(前端)
### 4. 异常检测(内置逻辑)
服务端计算任务异常标记:
- blocked_timeout: BLOCKED 列超 24h → 标红
- unclaimed: TODO 列超 3 天 → 标黄
- retry_failure: 失败重试≥3 次 → 标红
## 前端: Kanban 页面
修改 frontend/src/pages/KanbanPage.tsx:
### 顶部概览条
- "当前 N 个任务 · M 个 Profile · K 个 RUNNING · B 个阻塞"
- RUNNING 蓝色, 阻塞橙色
- 右侧筛选下拉: Worker / 看板 / 异常状态
### 五列看板
- TODO / READY / RUNNING / BLOCKED / DONE
- 每列头: 列名(彩色) + 任务计数
- BLOCKED 列橙色边框 + 左边框
- 列宽 min 240px, 五列一屏可见
- 列内容纵向滚动, 列头 sticky
### 任务卡片
- 标题(粗体, 最多两行) + Worker Profile 名 + 蜂型图标
- 状态标记: 🎯 goal_mode, ⚡ 高优先级
- 创建时间(相对时间 "2h ago")
- BLOCKED 列卡片显示阻塞原因(一行截断)
- 卡片左侧色条: P0=红, goal_mode=金, blocked=橙
- 卡片 hover 边框变 honey
- 异常卡片: 阻塞超时红色, 长期无主黄色, 失败重试红色
- 点击卡片 → 打开详情抽屉
- 卡片 Checkbox (hover 显示)
### 拖拽
- 桌面端支持拖拽卡片到任意列
- 拖入 BLOCKED 弹出阻塞原因输入框(必填 ≥10字)
- 拖入 DONE 二次确认
- 移动端禁用拖拽
### 任务详情抽屉 (Drawer 组件, 480px)
- Header: 状态 Badge + 标题(可编辑) + ✕
- Meta: 看板, Worker, 类型, 优先级, 创建/更新时间, goal_mode, 重试次数
- Body: Markdown 描述, goal_mode 验收标准 checklist(可勾选), 阻塞原因
- 关联: 依赖/被依赖任务链接(点击跳转)
- 活动: 时间线(操作人+时间)
- Footer: [编辑] [标记阻塞] [标记完成] [删除]
### 批量操作栏(底部浮现)
- 勾选≥1 张卡片后底部浮现操作栏
- [标记完成] [标记阻塞] [清除选择]
- 不做 Dispatch(走 CLI)
### 四态覆盖 + 乐观更新(拖拽后即时更新 UI,失败回滚)
注册路由, 完成后提交。
+73
View File
@@ -0,0 +1,73 @@
实现事件档案管理(P0-7)。后端数据爬取 + API + 前端卡片页面。
## 后端: Event Crawler
创建 backend/app/services/event_crawler.py:
- 爬取 hm.zhiweidata.ai/events 数据
- 存入 events 表 (新建 ORM 模型)
- 定时每天凌晨执行
- 暂用占位数据(TODO: real crawler)
## 后端: Event Model
创建 backend/app/models/event.py:
```python
class EventStatus(str, enum.Enum):
ACTIVE = "active"
FADING = "fading"
DISSIPATING = "dissipating"
CLOSED = "closed"
class Event(Base):
__tablename__ = "events"
id: str (UUID)
title: str
level: str (A/B/C)
domain: str (12个领域之一)
status: EventStatus
occurred_date: date
last_reviewed: datetime | None
no_new_info_count: int (连续无新知次数)
next_review: datetime | None
created_at: datetime
updated_at: datetime
```
创建 Alembic 迁移。
## 后端: Events API
创建 backend/app/api/events.py,全部 require_auth
### GET /api/v1/events
事件卡片列表,三维筛选(前端即时过滤):
- query: ?status=active|fading|dissipating|closed&domain=xxx&search=keyword
- 每行含超期标记: overdue_days (下一次复查逾期天数)
- 空结果返回 []
## 前端: Event Archive 页面
修改 frontend/src/pages/EventArchivePage.tsx:
### 筛选工具栏
- 状态筛选: 全部/活跃期/消减期/消散期/已完结
- 领域筛选: 12 个领域下拉
- 搜索框: 按标题模糊搜索
- 前端即时过滤,组合生效
- [+ 新建事件] 按钮(P0 先做占位 Toast)
### 事件卡片网格
- 自适应列数 grid (minmax 340px)
- 每张卡片:
- 标题(粗体)
- 等级 Badge + 领域 Tag (蓝色)
- 状态 Badge(绿/黄/灰)
- Meta: 发生日期, 上次复查, 连续无新知(≥5标黄), 下次复查
- 超期标记: 红色 "超期 N 天"
- Hover: 边框变 honey-dim
- 点击: Toast 显示事件标题(P1 升级详情面板)
### 四态覆盖
- Empty: "无匹配事件"
注册路由 + 迁移 + 提交。
@@ -0,0 +1,154 @@
# Hive Manage v3 前端视觉重写 — 像素级对齐参考图
## 前置要求
- 所有 CSS 使用 `design-tokens.css` 中的变量(已在 `frontend/src/styles/tokens.css`)
- 禁止硬编码任何颜色值
- 间距必须是 4 的倍数
- 表格行高 44px,padding: 10px 14px
- 动画 ≤300ms,页面切换无动画
- 暗色模式下的阴影仅三层:悬浮/弹出/模态
## 1. 登录页 (参考 assets/ref-images/02-login.png)
### 布局
- 页面背景: var(--bg-root),纯暗色无纹理
- 登录卡片: 水平垂直居中,宽约 400px,大圆角(24px),var(--bg-card) 背景
- 卡片阴影: var(--shadow-lg)
### Logo
- 顶部居中: 🐝 蜜蜂图标 + "知微蜂巢" 白色粗体 28px
- 下方: "Hive Manage v3" 浅灰色 14px var(--text-dim)
### 输入框
- 标签: 白色小字,输入框上方
- 输入框: var(--bg-root) 背景,1px solid var(--border),圆角 8px,高 42px
- Placeholder: var(--text-muted),"请输入用户名"/"请输入密码"
- 密码框右侧有眼睛图标(显示/隐藏切换)
### 按钮
- "登入": 全宽,var(--honey) 背景,黑色粗体文字,高 48px,圆角 8px
- 下方 "还没有账号?注册" var(--text-dim) 文字,var(--honey) 链接
## 2. 侧边栏 (参考 01-dashboard-dark.png)
### 分组结构(从上到下)
**蜂群态势**
- 📊 蜂群总览 (active: var(--honey-glow) 背景 + var(--honey) 文字)
- 📡 Gateway 健康 [NEW Badge: 金色小标签]
- 🏥 Profile 健康分 [NEW Badge]
- 🤖 工蜂列表
**任务编排**
- 📋 看板追踪
- ⏰ Cron 管理 [红色计数 Badge]
- 🔔 事件管理 [红色计数 Badge]
**通信频道**
- 📶 频道配置
- 📡 频道监控 [NEW Badge]
**运维工具**
- 🔑 密钥管理
- 📁 文件浏览 [NEW Badge]
- 💻 Web 终端 [NEW Badge]
- 📜 蜂群日志 [NEW Badge]
- 🔌 MCP 接入管理 [NEW Badge]
- 📤 MCP 对外服务 [NEW Badge]
**知识库管理**
- 📚 事件档案管理 [NEW Badge]
**系统**
- 👥 用户管理
- 📋 审计日志
- ⚙ 系统设置
### 样式
- 分组标题: 11px uppercase,var(--text-muted),letter-spacing 0.5px,padding 8px 10px 4px
- NavItem: padding 8px 10px,border-radius 4px
- NavItem 默认: var(--text-dim)
- NavItem hover: var(--bg-hover) 背景,var(--text) 文字
- NavItem active: var(--honey-glow) 背景,var(--honey) 文字,font-weight 600
- Badge: 小圆角标签,红色 Badge 用于计数,金色 Badge 用于 NEW
## 3. Header (参考 01-dashboard-dark.png)
- 高度: 48px,var(--bg-nav) 背景,底边框 1px var(--border)
- 左侧: 🐝 Logo + 面包屑 "/ 蜂群总览" var(--text-dim)
- 搜索框: var(--bg-card) 背景,260px 宽,placeholder "搜索 Profile / 任务 / Cron / 事件..."
- 右侧: 状态标签(在线 12 / 运行中 3)+ 主题切换按钮 + 用户头像
## 4. Dashboard (参考 01-dashboard-dark.png)
### 统计卡片(4列网格)
- 在线 Gateway: 12(绿色 var(--green)), ↑ 绿色箭头 "2 vs 昨天"
- 离线 Gateway: 46(红色 var(--red)), ↓ 红色箭头 "1 vs 昨天"
- 运行中任务: 3(蓝色 var(--blue))
- 今日 Cron 失败: 1(红色,失败>3标红)
卡片: var(--bg-card) 背景,1px var(--border),padding 16px,圆角 8px
### 健康分布
- 横向堆叠柱状图: A(绿22) B(蓝18) C(黄10) D(红8)
- 柱高 8px,圆角 4px
- "查看全部 →" 链接
- D 级 Profile: 逗号分隔列出名称
### Gateway 异常趋势 (24h)
- 48 根迷你竖条(每30分钟),绿/红/橙三色
- 图例: 🟢 正常 🔴 心跳超时 🟠 飞书断连
### Kanban 概览
- 表格: 看板名 | RUNNING | READY
- 如: europe-briefing | S4 质检 | S5 编排
### Cron 最近执行
- 表格: Job | 状态(✅/❌/⏸) | 时间
- 如: 涉台早报 ✅ 07:15
### 最近异常事件
- 完整表格: 时间 | Profile | 类型(Badge) | 摘要 | 状态(Badge)
- 至少 3 行数据
## 5. Gateway 页面 (参考 04-gateway.png)
### 顶部
- 标题 "Gateway 健康监控"
- 统计: 共 N 个 · 在线 N · 离线 N · 异常 N
- 右上角: "最后更新 N 秒前"
### 筛选栏
- 状态筛选: 全部/在线🔵/离线🔴/异常🟡,选中态 var(--honey) 背景
- 右侧: 搜索框 + 蜂型筛选下拉
### 异常时间线 (24h)
- 横向轴线 + 彩色圆点标记事件
- 红=心跳超时,橙=飞书断连,蓝=重启
- 圆点下方标注时间+事件名
### 表格
- 列: Profile名 | 状态Badge | 掉线时长 | 今日重启 | 最新异常 | 操作
- 异常行: row-danger 样式
- 操作按钮: var(--honey) 边框 + var(--honey) 文字,深色背景
---
## 执行计划
1. 先读 `frontend/src/styles/tokens.css` 确认变量名
2. 重写 `LoginPage.tsx` 对齐登录参考图
3. 重写 `Sidebar.tsx` 对齐侧边栏参考图(分组+Badge)
4. 重写 `Header.tsx` 对齐 Header 参考图
5. 重写 `DashboardPage.tsx` 对齐 Dashboard 参考图
6. 重写 `GatewayPage.tsx` 对齐 Gateway 参考图
7. 每完成一个文件运行 `npx tsc --noEmit`
8. 全部完成后 `npm run build`
9. 提交
**质量红线**: 不允许出现以下情况——
- 硬编码颜色(必须用 var(--*))
- 间距不是 4 的倍数
- 表格行高不是 44px
- 动画超过 300ms
- 阴影超过三层
+91
View File
@@ -0,0 +1,91 @@
请创建以下 3 个文件。backend 目录已存在,但 backend/app/ 需要新建。
## 文件 1: backend/app/__init__.py
空文件。
## 文件 2: backend/app/config.py
```python
"""应用配置 — 从环境变量 / .env 读取。"""
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://hive_admin:changeme@localhost:5432/hive_manage_v3"
jwt_secret: str = "change-me"
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 1440
hermes_cli_path: str = "/home/enzo/.local/bin/hermes"
hermes_profiles_root: str = "/home/enzo/.hermes/profiles"
admin_email: str = "admin@zhiwei.local"
admin_password: str = "admin123"
model_config = {"env_file": ".env"}
settings = Settings()
```
## 文件 3: backend/app/database.py
```python
"""异步 SQLAlchemy 引擎 + session 工厂。"""
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.database_url, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with async_session() as session:
yield session
```
## 文件 4: backend/app/main.py
```python
"""FastAPI 应用入口。"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import engine, Base
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
await engine.dispose()
app = FastAPI(
title="Hive Manage v3",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/v1/health")
async def health_check():
return {"status": "ok"}
```
创建完成后,执行以下验证:
1. `cat` 检查每个文件内容
2. `cd backend && pip install -e ".[dev]"` 安装依赖
3. `cd backend && python -c "from app.main import app; print('FastAPI app created OK')"` 验证导入
4. git add + git commit -m "feat: FastAPI skeleton with config, database, health endpoint"
+122
View File
@@ -0,0 +1,122 @@
请完成以下任务。项目在 /home/enzo/projects/hive-manage-v3/,已有 backend/ 目录和 venv (.venv/)。
## 步骤 1: 初始化 Alembic
在 backend/ 目录执行:
```
cd backend && .venv/bin/alembic init alembic
```
## 步骤 2: 修改 backend/alembic/env.py
配置异步引擎,关键改动:
- 导入 `from app.database import Base, engine`
- 导入 `from app.models import * # noqa: F401,F403`
- `target_metadata = Base.metadata`
- 重写 `run_migrations_online` 以使用异步引擎:
```python
from app.config import settings
from app.database import Base, engine
from app.models import * # noqa: F401,F403
target_metadata = Base.metadata
def run_migrations_offline():
url = settings.database_url
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations():
async with engine.begin() as conn:
await conn.run_sync(do_run_migrations)
def run_migrations_online():
import asyncio
asyncio.run(run_async_migrations())
```
## 步骤 3: 创建 backend/app/models/__init__.py
空的 __init__.py。
## 步骤 4: 创建 backend/app/models/user.py
```python
"""用户 ORM 模型。"""
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column
import enum
from app.database import Base
class UserRole(str, enum.Enum):
OWNER = "owner"
OPERATOR = "operator"
VIEWER = "viewer"
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), default=UserRole.VIEWER, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_approved: Mapped[bool] = mapped_column(Boolean, default=False)
login_attempts: Mapped[int] = mapped_column(default=0)
locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
```
## 步骤 5: 创建 backend/app/models/audit.py
```python
"""审计日志 ORM 模型。"""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
username: Mapped[str] = mapped_column(String(50), nullable=False)
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
target: Mapped[str] = mapped_column(String(255), nullable=False)
detail: Mapped[str | None] = mapped_column(Text, nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
```
## 步骤 6: 生成并执行迁移
```bash
cd backend && .venv/bin/alembic revision --autogenerate -m "create users and audit_logs tables"
cd backend && .venv/bin/alembic upgrade head
```
## 步骤 7: 验证并提交
```bash
cd /home/enzo/projects/hive-manage-v3 && git add backend/alembic/ backend/app/models/ && git commit -m "feat: Alembic setup, User and AuditLog models with migration"
```
请严格按照以上步骤执行。遇到 pip/alembic 命令时使用 backend/.venv/bin/ 前缀。
+156
View File
@@ -0,0 +1,156 @@
在 /home/enzo/projects/hive-manage-v3/frontend/ 下创建 React 19 + TypeScript + Vite + Tailwind 前端脚手架。
## 步骤 1: 创建 frontend/package.json
```json
{
"name": "hive-manage-v3-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0",
"react-dom": "^19.0",
"react-router-dom": "^7.0",
"axios": "^1.7",
"lucide-react": "^0.460"
},
"devDependencies": {
"@types/react": "^19.0",
"@types/react-dom": "^19.0",
"@vitejs/plugin-react": "^4.3",
"autoprefixer": "^10.4",
"postcss": "^8.4",
"tailwindcss": "^3.4",
"typescript": "^5.6",
"vite": "^6.0"
}
}
```
## 步骤 2: 创建配置文件
frontend/tsconfig.json:
```json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
```
frontend/vite.config.ts:
```typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:8000',
},
},
})
```
frontend/tailwind.config.ts:
```typescript
import type { Config } from 'tailwindcss'
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
honey: { DEFAULT: '#f0b90b', dim: '#b8970e' },
},
},
},
plugins: [],
} satisfies Config
```
frontend/postcss.config.js:
```js
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
```
## 步骤 3: 创建入口文件
frontend/index.html:
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hive Manage v3</title>
</head>
<body class="bg-[var(--bg-root)] text-[var(--text)]">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
frontend/src/main.tsx:
```tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode><App /></React.StrictMode>
)
```
frontend/src/App.tsx:
```tsx
export default function App() {
return (
<div className="min-h-screen flex items-center justify-center" style={{background:'var(--bg-root)', color:'var(--text)'}}>
<h1 className="text-2xl font-bold">Hive Manage v3</h1>
</div>
)
}
```
## 步骤 4: 安装依赖并验证
```bash
cd frontend && npm install
```
## 步骤 5: 提交
```bash
cd /home/enzo/projects/hive-manage-v3 && git add frontend/ && git commit -m "feat: Vite + React 19 + Tailwind + TypeScript scaffold"
```
+96
View File
@@ -0,0 +1,96 @@
在 /home/enzo/projects/hive-manage-v3/frontend/src/styles/ 下创建 CSS 变量双模体系文件 tokens.css。
内容必须精确如下(这是 UI 设计规范的核心,不可改动):
```css
/* ===== 暗色模式(默认) ===== */
:root {
--bg-root: #0f1117;
--bg-nav: #161822;
--bg-card: #1a1d2e;
--bg-hover: #1e2134;
--bg-elevated: #222639;
--border: #2a2d3e;
--border-light: rgba(42, 45, 62, 0.5);
--honey: #f0b90b;
--honey-dim: #b8970e;
--honey-glow: rgba(240, 185, 11, 0.12);
--green: #00c853;
--red: #ff3d3d;
--yellow: #ffab00;
--blue: #448aff;
--orange: #ff6d00;
--text: #e0e0e0;
--text-dim: #8b8d9a;
--text-muted: #5a5d6a;
--bg-success: #0d2818;
--bg-error: #2d0f0f;
--bg-warn: #2d2000;
--bg-info: #0d1b2d;
--radius: 8px;
--radius-sm: 4px;
--radius-lg: 12px;
--radius-full: 9999px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.55);
--shadow-modal: 0 24px 64px rgba(0, 0, 0, 0.65);
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "SF Mono", "Fira Code", Consolas, monospace;
--header-h: 48px;
--sidebar-w: 220px;
}
/* ===== 浅色模式 ===== */
[data-theme="light"] {
--bg-root: #f5f6fa;
--bg-nav: #ffffff;
--bg-card: #ffffff;
--bg-hover: #edf0f5;
--bg-elevated: #ffffff;
--border: #e2e5ee;
--border-light: rgba(0, 0, 0, 0.06);
--honey: #e5a800;
--honey-dim: #c48f00;
--honey-glow: rgba(229, 168, 0, 0.08);
--green: #008c3a;
--red: #d32f2f;
--yellow: #e65100;
--blue: #1565c0;
--orange: #d84315;
--text: #1a1d2e;
--text-dim: #555a6e;
--text-muted: #8b8f9e;
--bg-success: #e8f5e9;
--bg-error: #fdecea;
--bg-warn: #fff3e0;
--bg-info: #e3f2fd;
--shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
--shadow-modal: 0 16px 48px rgba(0, 0, 0, 0.14);
}
/* ===== 全局基础 ===== */
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font);
font-size: 13px;
background: var(--bg-root);
color: var(--text);
}
/* 主题切换过渡 */
* {
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
}
```
创建后,在 frontend/src/main.tsx 中添加 `import './styles/tokens.css'`。
完成后提交:
```bash
cd /home/enzo/projects/hive-manage-v3 && git add frontend/src/styles/ && git commit -m "feat: CSS variable dual-theme system (dark default + light)"
```
+106
View File
@@ -0,0 +1,106 @@
在 /home/enzo/projects/hive-manage-v3/frontend/src/components/ui/ 下创建 11 个 React + TypeScript 共享 UI 组件。
所有组件必须:
- 使用 CSS 变量(如 var(--bg-card), var(--text), var(--honey) 等,定义在 tokens.css)
- 支持 className prop 扩展
- 导出类型(Props interface)
- 使用 Tailwind 辅助类 + CSS 变量混合
## 组件 1: Button.tsx
Props: variant ('primary'|'danger'|'outline'|'ghost'), size ('sm'|'md'), loading, disabled, children, onClick, className, type
- Primary: bg=var(--honey) color=#000 font-weight:600
- Danger: border=var(--red) color=var(--red)
- Outline: border=var(--border) color=var(--text-dim)
- Ghost: color=var(--text-dim), border:none
- 尺寸: sm padding:4px 10px font-size:12px, md padding:7px 14px font-size:13px
- Loading 态: 文字透明 + 14px spinner
- Disabled: opacity:0.4 cursor:not-allowed
## 组件 2: Card.tsx
Props: label, value, valueColor, change, changeDirection ('up'|'down'|'neutral'), onClick, className
- 渲染: 顶部 label(12px uppercase), 中间 value(28px bold), 底部 change(12px)
- valueColor 支持: 'green','red','yellow','blue' 映射到对应 CSS 变量
- changeDirection: up=var(--green) '↑', down=var(--red) '↓', neutral=var(--text-dim) '→'
## 组件 3: Table.tsx
Props: columns ({key,label,width?}[]), data (record[]), rowKey, onRowClick, rowClassName (record=>string), emptyText
- 表头 sticky top:0, 12px uppercase text-dim
- 行高 44px, hover 时 bg=var(--bg-hover)
- 支持 row-danger 样式(bg=var(--bg-error))
- 空数据: 显示 emptyText 或 "暂无数据"
## 组件 4: Badge.tsx
Props: variant ('success'|'danger'|'warning'|'info'|'honey'|'outline'), children, className
- 胶囊形: padding:2px 8px, border-radius:10px, font-size:11px, font-weight:600
- 每种 variant 对应不同背景色(rgba 半透明)+ 文字色
## 组件 5: Modal.tsx
Props: open, onClose, title, children, footer, confirmText, onConfirm, confirmDanger, width
- 遮罩: rgba(0,0,0,0.6), 居中 flex
- 弹窗: bg=var(--bg-nav), border-radius:8px, max-width:500px
- 关闭方式: ✕ 按钮 / 点遮罩 / Esc 键
- confirmDanger 时确认按钮变红色
- Portal 渲染到 document.body
## 组件 6: Drawer.tsx
Props: open, onClose, title, children, footer, width (默认 480px)
- 右侧滑入: transform translateX(100%→0), transition 250ms ease
- 遮罩 + Esc 关闭
- Header 固定顶部, Body 可滚动, Footer 固定底部
## 组件 7: Toast.tsx
导出: toast 函数(命令式调用),非组件
```typescript
toast.success('操作成功')
toast.error('操作失败')
toast.warn('请注意')
toast.info('提示信息')
```
- 顶部居中, 最大宽度 480px
- success 3s 消失, error 5s 消失, warn 手动关闭
- 堆叠: 最多 3 条
- 动画: translateY + opacity
## 组件 8: Skeleton.tsx
Props: variant ('card'|'table'|'text'), count (行数)
- card: 矩形块占位
- table: 横向条占位
- text: 短条占位
- shimmer 动画: linear-gradient 1500ms infinite
## 组件 9: Toggle.tsx
Props: checked, onChange, label, disabled
- 44×24px, 滑块 20×20px 白色
- Off: bg=var(--bg-hover), On: bg=var(--honey)
- 滑块动画 translateX(20px)
- 标签在右侧, gap 8px, font-size 13px
## 组件 10: Timeline.tsx
Props: events ({time, type, label, color?}[])
- 横向时间线, flex 布局
- 每个点 8px 直径, 线 2px var(--border)
- Hover 点上浮 2px + tooltip
- 颜色: green(心跳恢复), red(超时), orange(飞书断连), blue(重启)
## 组件 11: SearchBox.tsx
Props: onSearch, placeholder, debounceMs (默认 300)
- 输入框: bg=var(--bg-card), border focus 变 var(--honey)
- 下拉面板: 分组结果, bg=var(--bg-elevated)
- 键盘: ↑↓ 导航, Enter 选择, Esc 关闭
- 宽度: 桌面 420px
创建完成后提交:
```bash
cd /home/enzo/projects/hive-manage-v3 && git add frontend/src/components/ui/ && git commit -m "feat: shared UI component library (11 components)"
```
+91
View File
@@ -0,0 +1,91 @@
在 /home/enzo/projects/hive-manage-v3/frontend/src/components/layout/ 下创建布局组件,并在 App.tsx 中集成路由框架。
## 组件 1: Sidebar.tsx
导航项结构(参考原型):
- 蜂群态势: 📊 蜂群总览, 📡 Gateway, 🏥 健康分, 🤖 工蜂列表
- 任务编排: 📋 看板追踪, ⏰ Cron, 🔔 事件管理
- 通信频道: 📶 频道配置
- 运维工具: 🔑 密钥, 📁 文件, 💻 终端, 📜 日志, 🔌 MCP接入, 📤 MCP对外
- 知识库管理: 📚 事件档案
- 系统: ⚙ 设置
Props: activePath, onNavigate(path:string)
- 宽度 220px, bg=var(--bg-nav), 右侧 border
- 顶部 Logo 48px: "🐝 知微蜂巢"
- Section 标题 11px uppercase text-muted
- NavItem: 默认 text-dim, hover bg-hover, active honey-glow + honey 文字 + 600 weight
- Badge: 红色(失败计数)
## 组件 2: Header.tsx
Props: breadcrumb, onlineCount, runningCount, onSearch, theme, onToggleTheme
- 高度 48px, bg=var(--bg-nav), 底部 border
- 左侧: Logo + 面包屑
- 中间: 搜索框 260px, bg=var(--bg-card)
- 右侧: 在线数/运行中 标签 + 主题切换按钮(🌙/☀️) + 用户头像
- useTheme hook: 读取 localStorage 'hive-theme', 默认跟随系统 prefers-color-scheme
## 组件 3/4: MobileNav.tsx + TabBar.tsx
MobileNav: 汉堡菜单 → 左侧抽屉 280px 滑入 + 遮罩
TabBar: 底部固定 56px, 4项(📊总览/📡Gateway/📋Kanban/⏰Cron)等宽分布, 激活态 honey
## 组件 5: AppLayout.tsx
桌面: CSS Grid (sidebar 220px + header 48px + main)
平板: sidebar 180px
移动 (<768px): 顶部 Header + TabBar + 汉堡抽屉
使用 useTheme hook 管理 data-theme 属性
## App.tsx 更新
集成 react-router-dom:
- / → 重定向到 /dashboard
- /dashboard → DashboardPage
- /gateway → GatewayPage
- /health → HealthPage
- /kanban → KanbanPage
- /cron → CronPage
- /keys → KeysPage
- /events → EventArchivePage
- /users → UserManagementPage
- /audit → AuditLogPage
- /login → LoginPage
- 其余 → 404
路由配置用懒加载 React.lazy + Suspense(Skeleton)。
## useTheme hook
```typescript
// hooks/useTheme.ts
import { useState, useEffect } from 'react'
type Theme = 'dark' | 'light' | 'system'
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem('hive-theme') as Theme) || 'system'
})
useEffect(() => {
const root = document.documentElement
if (theme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
} else {
root.setAttribute('data-theme', theme)
}
localStorage.setItem('hive-theme', theme)
}, [theme])
const toggle = () => setTheme(t => t === 'dark' ? 'light' : 'dark')
return { theme, setTheme, toggle }
}
```
创建完成后提交:
```bash
cd /home/enzo/projects/hive-manage-v3 && git add frontend/src/components/layout/ frontend/src/App.tsx frontend/src/hooks/ && git commit -m "feat: layout components (Sidebar, Header, MobileNav, TabBar, AppLayout) with routing"
```
+58
View File
@@ -0,0 +1,58 @@
修复前端视觉质量问题。项目在 /home/enzo/projects/hive-manage-v3/frontend/。
## 问题清单
### 1. 全局:浅色背景
当前 Dashboard 显示白色/浅灰背景,应该是暗色主题。原因:`data-theme` 属性未正确初始化,导致 CSS 变量默认值被 Tailwind 覆盖。
修复:确保 `<html>` 元素初始化时设置 `data-theme="dark"`,且所有页面使用 CSS 变量而非硬编码颜色。
### 2. 侧边栏:导航项 typo
"Cron1" 应为 "Cron 管理"。
### 3. Dashboard 卡片:视觉粗糙
- 在线/离线 Gateway 卡片应该有颜色区分(绿色数值/红色数值),当前只有文字
- "vs 昨天" 的变化指示应该有 ↑/↓ 箭头
- Cron 失败 > 3 的卡片应标红
- 整体卡片间距和 padding 应符合设计规范(gap:14px, padding:16px)
### 4. 蜂群健康分布
柱状图样式简陋,应有:
- 四色柱子(A绿 B蓝 C黄 D红)
- 点击柱子跳转 /health 页面
- D 级 Profile 名称在下方列出
- "共 58 个 Profile" 标题
### 5. Gateway 异常趋势
- Timeline 组件时间轴应无 "R/F/H/OK" 文字,改用彩色圆点
- 每个点 Hover 显示 tooltip(时间和事件详情)
- 图例正确:●正常 ●心跳超时 ●飞书断连 ●重启
### 6. Kanban 概览
- 当前显示静态数据,应调用 GET /api/v1/dashboard/kanban-summary API
- 看板名 + RUNNING/READY 状态显示
### 7. Cron 最近执行
- 调用 GET /api/v1/dashboard/cron-recent API
- 状态正确显示:✅ 成功 / ❌ 失败 / ⏸ 暂停
- 时间显示精确到分钟
### 8. 侧边栏整体样式
- 背景色:var(--bg-nav) 暗色
- Section 标题:11px uppercase letter-spacing
- NavItem 激活态:honey-glow 背景 + honey 文字色
- 各导航项名称应与 PRD/原型一致
### 9. Header
- "在线 0 运行中 0 离线 0" 应为实时数据(从 usePolling 或 context 获取)
- 搜索框 placeholder "搜索 Profile / 任务 / Cron / 事件..."
- 主题切换按钮(🌙/☀️)正常工作
### 10. useTheme hook
确保:
- 初始默认 dark(根元素设置 data-theme="dark")
- localStorage 持久化
- 切换即时生效
---
先读取当前所有相关文件,然后逐个修复上述问题。每修复 2-3 个问题就运行 `npx tsc --noEmit` 确认编译通过。最后 `npm run build` 并提交。
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+132 -28
View File
@@ -1,9 +1,13 @@
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { lazy, Suspense, type ReactNode } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './hooks/useAuth'
import Skeleton from './components/ui/Skeleton'
import AppLayout from './components/layout/AppLayout'
import LoginPage from './pages/LoginPage'
import NotFoundPage from './pages/NotFoundPage'
import { globalSearch } from './api/dashboard'
import type { SearchGroup } from './components/ui/SearchBox'
import Badge from './components/ui/Badge'
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
const GatewayPage = lazy(() => import('./pages/GatewayPage'))
@@ -23,35 +27,135 @@ function Loading() {
)
}
/** Protected route wrapper — redirects to /login if not authenticated */
function ProtectedRoute({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth()
const location = useLocation()
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}
/** Login page wrapper — redirects to / if already authenticated */
function LoginRoute() {
const { isAuthenticated } = useAuth()
if (isAuthenticated) {
return <Navigate to="/" replace />
}
return <LoginPage />
}
function AppRoutes() {
const { user } = useAuth()
const navigate = useNavigate()
const handleSearch = async (query: string): Promise<SearchGroup[]> => {
if (!query.trim()) return []
try {
const res = await globalSearch(query)
const groups: SearchGroup[] = []
if (res.data.profiles.length > 0) {
groups.push({
label: 'Profiles',
items: res.data.profiles.map((p: any) => ({
value: '/gateway',
label: (
<span className="flex items-center gap-2">
{p.name}
<Badge variant={p.status === 'online' ? 'success' : 'danger'}>{p.status}</Badge>
</span>
),
})),
})
}
if (res.data.kanban_tasks.length > 0) {
groups.push({
label: 'Kanban 任务',
items: res.data.kanban_tasks.map((t: any) => ({
value: '/kanban',
label: (
<span className="flex items-center gap-2">
{t.title}
<Badge variant="honey">{t.column}</Badge>
</span>
),
})),
})
}
if (res.data.cron_jobs.length > 0) {
groups.push({
label: 'Cron 任务',
items: res.data.cron_jobs.map((j: any) => ({
value: '/cron',
label: (
<span className="flex items-center gap-2">
{j.job}
<Badge variant={j.status === 'active' ? 'success' : 'honey'}>{j.status}</Badge>
</span>
),
})),
})
}
if (res.data.events.length > 0) {
groups.push({
label: '事件',
items: res.data.events.map((e: any) => ({
value: '/events',
label: (
<span className="flex items-center gap-2">
{e.title}
<Badge variant="honey">{e.status}</Badge>
</span>
),
})),
})
}
return groups
} catch {
return []
}
}
const handleSearchNavigate = (value: string) => {
navigate(value)
}
return (
<Routes>
{/* Public */}
<Route path="/login" element={<LoginRoute />} />
<Route path="/404" element={<NotFoundPage />} />
{/* Protected — each page is its own route (no nested Routes) */}
<Route path="/" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><DashboardPage /></AppLayout></ProtectedRoute>} />
<Route path="/dashboard" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><DashboardPage /></AppLayout></ProtectedRoute>} />
<Route path="/gateway" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><GatewayPage /></AppLayout></ProtectedRoute>} />
<Route path="/health" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><HealthPage /></AppLayout></ProtectedRoute>} />
<Route path="/kanban" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><KanbanPage /></AppLayout></ProtectedRoute>} />
<Route path="/cron" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><CronPage /></AppLayout></ProtectedRoute>} />
<Route path="/keys" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><KeysPage /></AppLayout></ProtectedRoute>} />
<Route path="/events" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><EventArchivePage /></AppLayout></ProtectedRoute>} />
{user?.role === 'owner' && (
<>
<Route path="/users" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><UserManagementPage /></AppLayout></ProtectedRoute>} />
<Route path="/audit" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><AuditLogPage /></AppLayout></ProtectedRoute>} />
</>
)}
{/* Catch-all */}
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
)
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/404" element={<NotFoundPage />} />
<Route
path="/"
element={
<AppLayout>
<Suspense fallback={<Loading />}>
<Routes>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="gateway" element={<GatewayPage />} />
<Route path="health" element={<HealthPage />} />
<Route path="kanban" element={<KanbanPage />} />
<Route path="cron" element={<CronPage />} />
<Route path="keys" element={<KeysPage />} />
<Route path="events" element={<EventArchivePage />} />
<Route path="users" element={<UserManagementPage />} />
<Route path="audit" element={<AuditLogPage />} />
</Routes>
</Suspense>
</AppLayout>
}
/>
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
)
}
+4
View File
@@ -0,0 +1,4 @@
import client from './client'
export const getAuditLogs = (params?: { page?: number; page_size?: number; action?: string }) =>
client.get('/audit', { params })
+12
View File
@@ -0,0 +1,12 @@
import client from './client'
export const login = (username: string, password: string) =>
client.post('/auth/login', { username, password })
export const register = (data: { username: string; email: string; password: string }) =>
client.post('/auth/register', data)
export const refresh = () => client.post('/auth/refresh')
export const changePassword = (data: { old_password: string; new_password: string }) =>
client.post('/auth/change-password', data)
+27
View File
@@ -0,0 +1,27 @@
import axios from 'axios'
const client = axios.create({
baseURL: '/api/v1',
headers: { 'Content-Type': 'application/json' },
})
client.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
client.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('access_token')
window.location.href = '/login'
}
return Promise.reject(err)
},
)
export default client
+52
View File
@@ -0,0 +1,52 @@
import client from './client'
export interface CronJob {
id: string
job_name: string
profile: string
schedule: string
status: string
last_run: string | null
last_run_success: boolean | null
success_rate: number
has_logs: boolean
system: boolean
consecutive_failures: number
}
export interface CronListResponse {
jobs: CronJob[]
has_failures: boolean
}
export interface CronLogItem {
time: string
status: string
message: string
}
export interface CronLogsResponse {
logs: CronLogItem[]
}
export interface CronActionResponse {
success: boolean
action: string
job_id: string
detail: string
}
export const getCronJobs = (params?: { status?: string; profile?: string }) =>
client.get<CronListResponse>('/cron/jobs', { params })
export const getCronLogs = (jobId: string) =>
client.get<CronLogsResponse>(`/cron/jobs/${jobId}/logs`)
export const runCronJob = (jobId: string) =>
client.post<CronActionResponse>(`/cron/jobs/${jobId}/run`)
export const pauseCronJob = (jobId: string) =>
client.post<CronActionResponse>(`/cron/jobs/${jobId}/pause`)
export const resumeCronJob = (jobId: string) =>
client.post<CronActionResponse>(`/cron/jobs/${jobId}/resume`)
+63
View File
@@ -0,0 +1,63 @@
import client from './client'
export interface SummaryData {
online_count: number
offline_count: number
total_profiles: number
running_tasks: number
cron_failures_today: number
updated_at: string
}
export interface HealthDistribution {
A: number
B: number
C: number
D: number
total: number
d_profiles: string[]
}
export interface GatewayTrendItem {
hour: string
restarts: number
feishu_disconnects: number
heartbeat_timeouts: number
}
export interface KanbanSummaryItem {
board: string
running: string | null
ready: string | null
}
export interface CronRecentItem {
job: string
status: string
time?: string
}
export interface SearchResult {
profiles: { name: string; status: string }[]
kanban_tasks: { title: string; column: string }[]
cron_jobs: { job: string; status: string }[]
events: { title: string; status: string }[]
}
export const getDashboardSummary = () =>
client.get<SummaryData>('/dashboard/summary')
export const getHealthDistribution = () =>
client.get<HealthDistribution>('/dashboard/health-distribution')
export const getGatewayTrend = () =>
client.get<GatewayTrendItem[]>('/dashboard/gateway-trend')
export const getKanbanSummary = () =>
client.get<KanbanSummaryItem[]>('/dashboard/kanban-summary')
export const getCronRecent = () =>
client.get<CronRecentItem[]>('/dashboard/cron-recent')
export const globalSearch = (q: string) =>
client.get<SearchResult>('/search', { params: { q } })
+26
View File
@@ -0,0 +1,26 @@
import client from './client'
export interface EventItem {
id: string
title: string
level: 'A' | 'B' | 'C'
domain: string
status: 'active' | 'fading' | 'dissipating' | 'closed'
occurred_date: string
last_reviewed: string | null
no_new_info_count: number
next_review: string | null
overdue_days: number
created_at: string | null
updated_at: string | null
}
export interface EventsResponse {
events: EventItem[]
}
export const getEvents = (params?: {
status?: string
domain?: string
search?: string
}) => client.get<EventsResponse>('/events', { params })
+46
View File
@@ -0,0 +1,46 @@
import client from './client'
export interface GatewayInfo {
id: string
name: string
status: 'online' | 'offline' | 'warn'
downtime_minutes: number
restarts_today: number
last_event: string
profile_type: string
}
export interface GatewayEvent {
time: string
profile: string
type: string
detail: string
}
export interface OperationResult {
success: boolean
action: string
profile: string
detail: string
}
export function getGatewayStatus(status?: string) {
const params = status ? { status } : undefined
return client.get<GatewayInfo[]>('/gateway/status', { params })
}
export function getGatewayEvents() {
return client.get<GatewayEvent[]>('/gateway/events')
}
export function startGateway(profile: string) {
return client.post<OperationResult>(`/gateway/${profile}/start`)
}
export function stopGateway(profile: string) {
return client.post<OperationResult>(`/gateway/${profile}/stop`)
}
export function restartGateway(profile: string) {
return client.post<OperationResult>(`/gateway/${profile}/restart`)
}
+69
View File
@@ -0,0 +1,69 @@
import client from './client'
/* ── types ── */
export interface ScoreRow {
rank: number
name: string
total_score: number
grade: string
gateway_score: number
config_score: number
skill_score: number
cron_score: number
deduct_summary: string[]
}
export interface ScoreListResponse {
total: number
distribution: { A: number; B: number; C: number; D: number }
scores: ScoreRow[]
}
export interface DimensionDetail {
name: string
weight: string
score: number
max_score: number
deductions: string[]
suggestions: string[]
}
export interface ProfileScoreDetail {
profile_name: string
total_score: number
grade: string
calculated_at: string
dimensions: DimensionDetail[]
}
export interface CompareRow {
field: string
category: string
has_diff: boolean
[profileName: string]: unknown
}
export interface CompareResponse {
profiles: string[]
rows: CompareRow[]
}
export interface CalculateResponse {
message: string
count: number
}
/* ── API functions ── */
export const getHealthScores = (params?: { grade?: string; sort?: string }) =>
client.get<ScoreListResponse>('/health/scores', { params })
export const getProfileScore = (profile: string) =>
client.get<ProfileScoreDetail>(`/health/scores/${encodeURIComponent(profile)}`)
export const compareProfiles = (profile_names: string[]) =>
client.post<CompareResponse>('/health/compare', { profile_names })
export const recalculateScores = () =>
client.post<CalculateResponse>('/health/calculate')
+55
View File
@@ -0,0 +1,55 @@
import client from './client'
export interface KanbanTask {
id: string
title: string
board: string
worker_profile: string
worker_type: string
priority: number
column: string
created_at: string
updated_at: string
goal_mode: boolean
retries: number
failures: number
description: string
block_reason: string | null
dependencies: string[]
dependents: string[]
activities: { time: string; user: string; action: string }[]
anomalies: string[]
dependency_tasks?: { id: string; title: string; column: string }[]
dependent_tasks?: { id: string; title: string; column: string }[]
}
export interface KanbanOverview {
total_tasks: number
profile_count: number
board_count: number
running_count: number
blocked_count: number
}
export interface KanbanResponse {
todo: KanbanTask[]
ready: KanbanTask[]
running: KanbanTask[]
blocked: KanbanTask[]
done: KanbanTask[]
overview: KanbanOverview
}
export interface MoveResponse {
success: boolean
task: KanbanTask
}
export const getKanbanTasks = (params?: { worker?: string; board?: string; anomaly?: string }) =>
client.get<KanbanResponse>('/kanban/tasks', { params })
export const getKanbanTaskDetail = (taskId: string) =>
client.get<KanbanTask>(`/kanban/tasks/${taskId}`)
export const moveKanbanTask = (taskId: string, column: string, blockReason?: string) =>
client.patch<MoveResponse>(`/kanban/tasks/${taskId}/move`, { column, block_reason: blockReason })
+72
View File
@@ -0,0 +1,72 @@
import client from './client'
export interface KeyFieldEntry {
profile: string
fields: Record<string, string>
missing: number
}
export interface KeyListResponse {
data: KeyFieldEntry[]
}
export interface OverwriteResult {
profile: string
success: boolean
}
export interface OverwriteResponse {
results: OverwriteResult[]
}
export interface ScanResult {
profile: string
missing: string[]
missing_count: number
total: number
}
export interface ScanResponse {
results: ScanResult[]
}
export interface DiffRow {
field: string
[profile: string]: string | number
}
export interface DiffResponse {
profiles: string[]
rows: DiffRow[]
}
export const getKeys = (profiles?: string) =>
client.get<KeyListResponse>('/keys', { params: profiles ? { profiles } : {} })
export const batchOverwrite = (body: {
profile_names: string[]
key_name: string
new_value: string
confirmation: string
}) => client.post<OverwriteResponse>('/keys/batch-overwrite', body)
export const batchScan = (profile_names?: string[]) =>
client.post<ScanResponse>('/keys/batch-scan', { profile_names })
export const getDiff = (profiles: string) =>
client.get<DiffResponse>('/keys/diff', { params: { profiles } })
export const exportCsv = (profiles?: string) =>
client.get<string>('/keys/export', {
params: profiles ? { profiles } : {},
responseType: 'text',
})
export const WHITELIST_FIELDS = [
'DEEPSEEK_API_KEY',
'OPENAI_API_KEY',
'ANTHROPIC_API_KEY',
'GOOGLE_API_KEY',
'FEISHU_APP_ID',
'FEISHU_APP_SECRET',
]
+11
View File
@@ -0,0 +1,11 @@
import client from './client'
export const getUsers = (params?: { page?: number; page_size?: number }) =>
client.get('/users', { params })
export const getUser = (id: string) => client.get(`/users/${id}`)
export const updateUser = (id: string, data: { role?: string; is_active?: boolean }) =>
client.patch(`/users/${id}`, data)
export const approveUser = (id: string) => client.post(`/users/${id}/approve`)
+27 -2
View File
@@ -1,6 +1,8 @@
import { useState, type ReactNode } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useTheme } from '../../hooks/useTheme'
import { usePolling } from '../../hooks/usePolling'
import { getDashboardSummary } from '../../api/dashboard'
import Sidebar from './Sidebar'
import Header from './Header'
import MobileNav from './MobileNav'
@@ -27,7 +29,9 @@ export interface AppLayoutProps {
breadcrumb?: string
onlineCount?: number
runningCount?: number
offlineCount?: number
onSearch?: (query: string) => any
onSearchNavigate?: (value: string) => void
}
export default function AppLayout({
@@ -35,7 +39,9 @@ export default function AppLayout({
breadcrumb,
onlineCount = 0,
runningCount = 0,
offlineCount = 0,
onSearch,
onSearchNavigate,
}: AppLayoutProps) {
const { theme, toggle } = useTheme()
const navigate = useNavigate()
@@ -50,6 +56,23 @@ export default function AppLayout({
const sidebarWidth = isTablet ? 180 : 220
/* ---- live header counts ---- */
const [liveOnline, setLiveOnline] = useState(0)
const [liveRunning, setLiveRunning] = useState(0)
const [liveOffline, setLiveOffline] = useState(0)
usePolling(async () => {
try {
const res = await getDashboardSummary()
setLiveOnline(res.data.online_count)
setLiveRunning(res.data.running_tasks)
setLiveOffline(res.data.offline_count)
} catch { /* ignore polling errors */ }
}, 30_000)
const hOnline = onlineCount || liveOnline
const hRunning = runningCount || liveRunning
const hOffline = offlineCount || liveOffline
if (isMobile) {
return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--bg-root)' }}>
@@ -118,9 +141,11 @@ export default function AppLayout({
<div style={{ gridArea: 'header' }}>
<Header
breadcrumb={breadcrumb}
onlineCount={onlineCount}
runningCount={runningCount}
onlineCount={hOnline}
runningCount={hRunning}
offlineCount={hOffline}
onSearch={onSearch}
onSearchNavigate={onSearchNavigate}
theme={theme}
onToggleTheme={toggle}
/>
+192 -28
View File
@@ -1,12 +1,15 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import Badge from '../ui/Badge'
import SearchBox from '../ui/SearchBox'
import { useAuth } from '../../hooks/useAuth'
import { Search } from 'lucide-react'
export interface HeaderProps {
breadcrumb?: string
onlineCount?: number
runningCount?: number
offlineCount?: number
onSearch?: (query: string) => any
onSearchNavigate?: (value: string) => void
theme: string
onToggleTheme: () => void
}
@@ -16,64 +19,225 @@ export default function Header({
onlineCount = 0,
runningCount = 0,
onSearch,
onSearchNavigate,
theme,
onToggleTheme,
}: HeaderProps) {
const navigate = useNavigate()
const { user, logout } = useAuth()
const initial = user?.id ? user.id.charAt(0).toUpperCase() : '?'
const [menuOpen, setMenuOpen] = useState(false)
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const value = (e.target as HTMLInputElement).value.trim()
if (e.key === 'Enter' && onSearchNavigate) {
onSearchNavigate(value)
}
if (e.key === 'Enter' && onSearch) {
onSearch(value)
}
}
return (
<header
className="flex items-center gap-3 px-4 shrink-0"
style={{
gridArea: 'header',
height: 'var(--header-h)',
background: 'var(--bg-nav)',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
padding: '0 20px',
gap: 16,
zIndex: 10,
}}
>
{/* Logo */}
<span
style={{
fontWeight: 700,
fontSize: 14,
color: 'var(--honey)',
whiteSpace: 'nowrap',
}}
>
🐝 知微蜂巢
</span>
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-[13px] min-w-0" style={{ color: 'var(--text-dim)' }}>
{breadcrumb && (
<>
<span
className="cursor-pointer hover:text-[var(--text)] transition-colors"
onClick={() => navigate('/dashboard')}
>
首页
</span>
<span style={{ color: 'var(--text-muted)' }}>/</span>
<span style={{ color: 'var(--text)', fontWeight: 500 }}>{breadcrumb}</span>
</>
)}
</div>
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>
/{' '}
<span style={{ color: 'var(--text)' }}>
{breadcrumb || '蜂群总览'}
</span>
</span>
{/* Spacer */}
<div className="flex-1" />
<div style={{ flex: 1 }} />
{/* Search */}
{onSearch && (
<SearchBox onSearch={onSearch} placeholder="搜索功能、任务、日志..." />
)}
<div style={{ position: 'relative' }}>
<Search
size={14}
style={{
position: 'absolute',
left: 10,
top: '50%',
transform: 'translateY(-50%)',
color: 'var(--text-muted)',
}}
/>
<input
placeholder="搜索 Profile / 任务 / Cron / 事件..."
onKeyDown={handleSearchKeyDown}
style={{
width: 260,
height: 32,
padding: '0 12px 0 30px',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border)',
background: 'var(--bg-card)',
color: 'var(--text)',
fontSize: 13,
outline: 'none',
fontFamily: 'inherit',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--honey)'
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--border)'
}}
/>
</div>
{/* Status badges */}
<Badge variant="success">在线 {onlineCount}</Badge>
<Badge variant="honey">运行中 {runningCount}</Badge>
<span
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: '4px 10px',
fontSize: 12,
color: 'var(--text-dim)',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<span style={{ color: 'var(--green)' }}>●</span>
{onlineCount} online
</span>
<span
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: '4px 10px',
fontSize: 12,
color: 'var(--text-dim)',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<span style={{ color: 'var(--yellow)' }}>●</span>
{runningCount} running
</span>
{/* Theme toggle */}
<button
onClick={onToggleTheme}
className="cursor-pointer bg-transparent border-none text-[18px] leading-none p-1 rounded hover:bg-[var(--bg-hover)] transition-colors"
title={theme === 'dark' ? '切换浅色模式' : '切换深色模式'}
style={{
background: 'transparent',
border: 'none',
fontSize: 18,
cursor: 'pointer',
padding: 4,
borderRadius: 'var(--radius-sm)',
color: 'var(--text-dim)',
lineHeight: 1,
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-hover)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
}}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
{/* User avatar */}
<div
className="w-[30px] h-[30px] rounded-full flex items-center justify-center text-[13px] font-semibold cursor-pointer shrink-0"
style={{ background: 'var(--honey-glow)', color: 'var(--honey)' }}
title="管理员"
style={{ position: 'relative', cursor: 'pointer', flexShrink: 0 }}
onMouseEnter={() => setMenuOpen(true)}
onMouseLeave={() => setMenuOpen(false)}
>
A
<div
style={{
width: 30,
height: 30,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 13,
fontWeight: 600,
background: 'var(--honey-glow)',
color: 'var(--honey)',
}}
title={user?.role ?? ''}
>
{initial}
</div>
{menuOpen && (
<div
style={{
position: 'absolute',
right: 0,
top: '100%',
marginTop: 4,
width: 120,
borderRadius: 'var(--radius)',
boxShadow: 'var(--shadow)',
padding: '4px 0',
background: 'var(--bg-nav)',
border: '1px solid var(--border)',
zIndex: 50,
}}
>
<button
onClick={() => {
logout()
navigate('/login')
}}
style={{
width: '100%',
textAlign: 'left',
padding: '6px 12px',
fontSize: 13,
border: 'none',
background: 'transparent',
color: 'var(--red)',
cursor: 'pointer',
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-hover)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
}}
>
退出登录
</button>
</div>
)}
</div>
</header>
)
+182 -62
View File
@@ -1,11 +1,14 @@
import { type ReactNode } from 'react'
import Badge from '../ui/Badge'
import { useEffect, useState } from 'react'
import { useAuth } from '../../hooks/useAuth'
import { getDashboardSummary } from '../../api/dashboard'
interface NavItem {
label: string
icon: string
path: string
badge?: number
isNew?: boolean
requiredRole?: string
}
interface NavSection {
@@ -18,8 +21,8 @@ const sections: NavSection[] = [
title: '蜂群态势',
items: [
{ label: '蜂群总览', icon: '📊', path: '/dashboard' },
{ label: 'Gateway', icon: '📡', path: '/gateway' },
{ label: '健康分', icon: '🏥', path: '/health' },
{ label: 'Gateway 健康', icon: '📡', path: '/gateway', isNew: true },
{ label: 'Profile 健康分', icon: '🏥', path: '/health', isNew: true },
{ label: '工蜂列表', icon: '🤖', path: '/workers' },
],
},
@@ -27,32 +30,41 @@ const sections: NavSection[] = [
title: '任务编排',
items: [
{ label: '看板追踪', icon: '📋', path: '/kanban' },
{ label: 'Cron', icon: '⏰', path: '/cron' },
{ label: '事件管理', icon: '🔔', path: '/events' },
{ label: 'Cron 管理', icon: '⏰', path: '/cron', badge: 1 },
{ label: '事件管理', icon: '🔔', path: '/events', badge: 3 },
],
},
{
title: '通信频道',
items: [{ label: '频道配置', icon: '📶', path: '/channels' }],
items: [
{ label: '频道配置', icon: '📡', path: '/channels' },
{ label: '频道监控', icon: '📶', path: '/channel-monitor', isNew: true },
],
},
{
title: '运维工具',
items: [
{ label: '密钥', icon: '🔑', path: '/keys' },
{ label: '文件', icon: '📁', path: '/files' },
{ label: '终端', icon: '💻', path: '/terminal' },
{ label: '日志', icon: '📜', path: '/logs' },
{ label: 'MCP接入', icon: '🔌', path: '/mcp-in' },
{ label: 'MCP对外', icon: '📤', path: '/mcp-out' },
{ label: '密钥管理', icon: '🔑', path: '/keys' },
{ label: '文件浏览', icon: '📁', path: '/files', isNew: true },
{ label: 'Web 终端', icon: '💻', path: '/terminal', isNew: true },
{ label: '蜂群日志', icon: '📜', path: '/logs', isNew: true },
{ label: 'MCP 接入管理', icon: '🔌', path: '/mcp-in', isNew: true },
{ label: 'MCP 对外服务', icon: '📤', path: '/mcp-out', isNew: true },
],
},
{
title: '知识库管理',
items: [{ label: '事件档案', icon: '📚', path: '/archive' }],
items: [
{ label: '事件档案管理', icon: '📚', path: '/archive', isNew: true },
],
},
{
title: '系统',
items: [{ label: '设置', icon: '⚙', path: '/settings' }],
items: [
{ label: '用户管理', icon: '👥', path: '/users', requiredRole: 'owner' },
{ label: '审计日志', icon: '📋', path: '/audit', requiredRole: 'owner' },
{ label: '系统设置', icon: '⚙', path: '/settings' },
],
},
]
@@ -61,62 +73,170 @@ export interface SidebarProps {
onNavigate: (path: string) => void
}
function hasAccess(userRole: string | undefined, requiredRole?: string): boolean {
if (!requiredRole) return true
const hierarchy: Record<string, number> = { viewer: 0, operator: 1, owner: 2 }
const userLevel = hierarchy[userRole ?? 'viewer'] ?? 0
const requiredLevel = hierarchy[requiredRole] ?? 0
return userLevel >= requiredLevel
}
export default function Sidebar({ activePath, onNavigate }: SidebarProps) {
const { user } = useAuth()
const [cronFailures, setCronFailures] = useState(0)
useEffect(() => {
const fetch = async () => {
try {
const res = await getDashboardSummary()
setCronFailures(res.data.cron_failures_today)
} catch { /* ignore */ }
}
fetch()
const id = setInterval(fetch, 60_000)
return () => clearInterval(id)
}, [])
return (
<nav className="flex flex-col h-full overflow-y-auto" style={{ background: 'var(--bg-nav)', borderRight: '1px solid var(--border)' }}>
{/* Logo */}
<nav
style={{
background: 'var(--bg-nav)',
borderRight: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
height: '100%',
}}
>
{/* Nav header / logo */}
<div
className="flex items-center gap-2 shrink-0 cursor-pointer px-5"
style={{ height: 'var(--header-h)' }}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
height: 'var(--header-h)',
padding: '0 14px',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
cursor: 'pointer',
}}
onClick={() => onNavigate('/dashboard')}
>
<span style={{ fontSize: 20 }}>🐝</span>
<span style={{ color: 'var(--honey)', fontWeight: 700, fontSize: 15, letterSpacing: 0.3 }}>
知微蜂巢
<span style={{ fontSize: 16 }}>🐝</span>
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--honey)' }}>
Hive Manage v3
</span>
</div>
{/* Nav sections */}
<div className="flex-1 overflow-y-auto px-3 pb-4">
{sections.map(section => (
<div key={section.title} className="mt-4">
<div
className="px-2 mb-1 text-[11px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--text-muted)' }}
>
{section.title}
{/* Nav body */}
<div
style={{
flex: 1,
padding: 8,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{sections.map((section) => {
const visible = section.items.filter((item) => hasAccess(user?.role, item.requiredRole))
if (visible.length === 0) return null
return (
<div key={section.title}>
{/* Section title */}
<div
style={{
color: 'var(--text-muted)',
fontSize: 11,
textTransform: 'uppercase',
letterSpacing: 0.5,
padding: '8px 10px 4px',
}}
>
{section.title}
</div>
{/* Section items */}
{visible.map((item) => {
const isActive = activePath === item.path
const finalBadge =
item.path === '/cron'
? cronFailures > 0
? cronFailures
: item.badge
: item.badge
return (
<button
key={item.path}
onClick={() => onNavigate(item.path)}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 10px',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
fontSize: 13,
border: 'none',
background: isActive ? 'var(--honey-glow)' : 'transparent',
color: isActive ? 'var(--honey)' : 'var(--text-dim)',
fontWeight: isActive ? 600 : 400,
width: '100%',
textAlign: 'left',
fontFamily: 'inherit',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.background = 'var(--bg-hover)'
e.currentTarget.style.color = 'var(--text)'
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = 'var(--text-dim)'
}
}}
>
<span>{item.icon}</span>
<span style={{ flex: 1 }}>{item.label}</span>
{item.isNew && (
<span
style={{
fontSize: 10,
padding: '1px 6px',
borderRadius: 10,
background: 'var(--honey)',
color: '#000',
fontWeight: 600,
}}
>
NEW
</span>
)}
{finalBadge != null && finalBadge > 0 && (
<span
style={{
fontSize: 10,
padding: '1px 6px',
borderRadius: 10,
background: 'var(--red)',
color: '#fff',
}}
>
{finalBadge}
</span>
)}
</button>
)
})}
</div>
{section.items.map(item => {
const isActive = activePath === item.path
return (
<div
key={item.path}
className="flex items-center justify-between rounded-[var(--radius-sm)] cursor-pointer px-2 py-[7px] text-[13px] transition-colors duration-100"
style={{
color: isActive ? 'var(--honey)' : 'var(--text-dim)',
fontWeight: isActive ? 600 : 400,
background: isActive ? 'var(--honey-glow)' : undefined,
}}
onMouseEnter={e => {
if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'
}}
onMouseLeave={e => {
if (!isActive) e.currentTarget.style.background = 'transparent'
}}
onClick={() => onNavigate(item.path)}
>
<span>
<span style={{ marginRight: 8 }}>{item.icon}</span>
{item.label}
</span>
{item.badge != null && item.badge > 0 && (
<Badge variant="danger">{item.badge}</Badge>
)}
</div>
)
})}
</div>
))}
)
})}
</div>
</nav>
)
+1 -1
View File
@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom'
export interface DrawerProps {
open: boolean
onClose: () => void
title?: string
title?: ReactNode
children: ReactNode
footer?: ReactNode
width?: string | number
+4
View File
@@ -10,6 +10,7 @@ export interface SearchBoxProps {
placeholder?: string
debounceMs?: number
className?: string
onItemClick?: (value: string) => void
}
export default function SearchBox({
@@ -17,6 +18,7 @@ export default function SearchBox({
placeholder = "搜索...",
debounceMs = 300,
className = "",
onItemClick,
}: SearchBoxProps) {
const [query, setQuery] = useState("")
const [groups, setGroups] = useState<SearchGroup[]>([])
@@ -87,6 +89,7 @@ export default function SearchBox({
e.preventDefault()
setQuery(String(flatItems[activeIdx].label))
setOpen(false)
onItemClick?.(flatItems[activeIdx].value)
inputRef.current?.blur()
} else if (e.key === "Escape") {
setOpen(false)
@@ -144,6 +147,7 @@ export default function SearchBox({
onMouseDown={() => {
setQuery(String(item.label))
setOpen(false)
onItemClick?.(item.value)
}}
>
{item.label}
+3 -5
View File
@@ -1,9 +1,7 @@
import type { ReactNode } from "react"
export interface TimelineEvent {
time: string
type: string
label: ReactNode
tooltip?: string
color?: string
}
@@ -35,10 +33,10 @@ export default function Timeline({ events, className = "" }: TimelineProps) {
>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-[11px] whitespace-nowrap rounded-[var(--radius-sm)] opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10"
style={{ backgroundColor: "var(--bg-elevated)", color: "var(--text)" }}>
{evt.time}
<div>{evt.time}</div>
{evt.tooltip && <div style={{ color: "var(--text-dim)" }}>{evt.tooltip}</div>}
</div>
</div>
<div className="text-[11px] text-[var(--text-dim)] mt-1">{evt.label}</div>
</div>
{!isLast && (
<div
+65
View File
@@ -0,0 +1,65 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
import * as authApi from '../api/auth'
export interface AuthUser {
id: string
role: string
}
export interface AuthContextType {
user: AuthUser | null
token: string | null
isAuthenticated: boolean
login: (username: string, password: string) => Promise<void>
logout: () => void
}
function decodeToken(token: string): AuthUser | null {
try {
const payloadBase64 = token.split('.')[1]
const payload = JSON.parse(atob(payloadBase64))
return { id: payload.sub, role: payload.role }
} catch {
return null
}
}
export const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() => localStorage.getItem('access_token'))
const [user, setUser] = useState<AuthUser | null>(() => {
const t = localStorage.getItem('access_token')
return t ? decodeToken(t) : null
})
const login = useCallback(async (username: string, password: string) => {
const res = await authApi.login(username, password)
const t: string = res.data.access_token
localStorage.setItem('access_token', t)
setToken(t)
setUser(decodeToken(t))
}, [])
const logout = useCallback(() => {
localStorage.removeItem('access_token')
setToken(null)
setUser(null)
}, [])
const value: AuthContextType = {
user,
token,
isAuthenticated: !!token && !!user,
login,
logout,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth(): AuthContextType {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}
+19
View File
@@ -0,0 +1,19 @@
import { useEffect, useRef } from 'react'
export function usePolling(
callback: () => void,
intervalMs: number,
enabled: boolean = true,
) {
const savedCallback = useRef(callback)
useEffect(() => {
savedCallback.current = callback
}, [callback])
useEffect(() => {
if (!enabled) return
savedCallback.current()
const id = setInterval(() => savedCallback.current(), intervalMs)
return () => clearInterval(id)
}, [intervalMs, enabled])
}
+3 -8
View File
@@ -1,20 +1,15 @@
import { useState, useEffect } from 'react'
type Theme = 'dark' | 'light' | 'system'
type Theme = 'dark' | 'light'
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem('hive-theme') as Theme) || 'system'
return (localStorage.getItem('hive-theme') as Theme) || 'dark'
})
useEffect(() => {
const root = document.documentElement
if (theme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
} else {
root.setAttribute('data-theme', theme)
}
root.setAttribute('data-theme', theme)
localStorage.setItem('hive-theme', theme)
}, [theme])
+138 -2
View File
@@ -1,3 +1,139 @@
export default function AuditLogPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>审计日志</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>操作审计记录</p></div>
import { useEffect, useState, useCallback } from 'react'
import { getAuditLogs } from '../api/audit'
import Table, { type TableColumn } from '../components/ui/Table'
import Button from '../components/ui/Button'
import toast from '../components/ui/Toast'
interface AuditRecord {
id: string
username: string
action: string
target: string
detail: string | null
ip_address: string | null
created_at: string
}
const ACTION_OPTIONS = [
'',
'keys.batch_overwrite',
'gateway.toggle',
'cron.trigger',
'user.login',
'user.update_role',
'user.approve',
'keys.generate',
'kaban.create',
'kaban.update',
]
export default function AuditLogPage() {
const [logs, setLogs] = useState<AuditRecord[]>([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(false)
const [actionFilter, setActionFilter] = useState('')
const fetchLogs = useCallback(async (p: number, action?: string) => {
setLoading(true)
try {
const params: Record<string, unknown> = { page: p, page_size: 20 }
if (action) params.action = action
const res = await getAuditLogs(params)
const data: AuditRecord[] = res.data
setLogs(data)
setHasMore(data.length === 20)
} catch {
toast.error('加载审计日志失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchLogs(page, actionFilter)
}, [page, fetchLogs])
const handleFilterChange = (val: string) => {
setActionFilter(val)
setPage(1)
}
const columns: TableColumn[] = [
{ key: 'created_at', label: '时间', width: 180 },
{ key: 'username', label: '用户', width: 100 },
{ key: 'action', label: '操作', width: 160 },
{ key: 'target', label: '目标', width: 200 },
{ key: 'detail', label: '详情' },
{ key: 'ip_address', label: 'IP', width: 140 },
]
const data = logs.map((l) => ({
created_at: new Date(l.created_at).toLocaleString('zh-CN'),
username: l.username,
action: l.action,
target: l.target,
detail: l.detail ?? '-',
ip_address: l.ip_address ?? '-',
}))
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>审计日志</h2>
<p style={{ color: 'var(--text-dim)', marginTop: 4, marginBottom: 16, fontSize: 13 }}>
操作审计记录
</p>
{/* Filter */}
<div className="flex items-center gap-2 mb-4">
<label className="text-[12px] font-semibold" style={{ color: 'var(--text-dim)' }}>
操作筛选:
</label>
<select
value={actionFilter}
onChange={(e) => handleFilterChange(e.target.value)}
className="px-3 py-[7px] rounded-[var(--radius-sm)] text-[13px] outline-none"
style={{
background: 'var(--bg-root)',
color: 'var(--text)',
border: '1px solid var(--border)',
}}
>
<option value="">全部</option>
{ACTION_OPTIONS.filter(Boolean).map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
<Table columns={columns} data={data} rowKey="created_at" />
{/* Pagination */}
<div className="flex items-center justify-between mt-4">
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>
第 {page} 页
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
上一页
</Button>
<Button
size="sm"
variant="outline"
disabled={!hasMore || loading}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
</div>
)
}
+464 -2
View File
@@ -1,3 +1,465 @@
export default function CronPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Cron</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Cron 任务管理</p></div>
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useAuth } from '../hooks/useAuth'
import Table, { type TableColumn } from '../components/ui/Table'
import Badge from '../components/ui/Badge'
import Button from '../components/ui/Button'
import Modal from '../components/ui/Modal'
import Skeleton from '../components/ui/Skeleton'
import toast from '../components/ui/Toast'
import {
getCronJobs,
getCronLogs,
runCronJob,
pauseCronJob,
resumeCronJob,
type CronJob,
type CronLogItem,
} from '../api/cron'
/* ---------- helpers ---------- */
const isToday = (iso: string | null) => {
if (!iso) return false
return iso.startsWith(new Date().toISOString().slice(0, 10))
}
const canAct = (role: string | undefined) => role === 'owner' || role === 'operator'
/* ---------- component ---------- */
export default function CronPage() {
const { user } = useAuth()
/* data */
const [jobs, setJobs] = useState<CronJob[]>([])
const [hasFailures, setHasFailures] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
/* filters */
const [statusFilter, setStatusFilter] = useState<string>('all')
const [profileFilter, setProfileFilter] = useState<string>('all')
/* log modal */
const [logJob, setLogJob] = useState<CronJob | null>(null)
const [logs, setLogs] = useState<CronLogItem[]>([])
const [logsLoading, setLogsLoading] = useState(false)
/* confirm modal */
const [confirmJob, setConfirmJob] = useState<CronJob | null>(null)
const [confirmAction, setConfirmAction] = useState<string>('')
const [acting, setActing] = useState<string | null>(null)
/* ---------- fetch ---------- */
const fetchJobs = useCallback(async () => {
try {
const params: Record<string, string> = {}
if (statusFilter !== 'all') params.status = statusFilter
if (profileFilter !== 'all') params.profile = profileFilter
const res = await getCronJobs(params)
setJobs(res.data.jobs)
setHasFailures(res.data.has_failures)
setError(null)
} catch {
setError('加载 Cron 列表失败')
} finally {
setLoading(false)
}
}, [statusFilter, profileFilter])
useEffect(() => {
setLoading(true)
fetchJobs()
const id = setInterval(fetchJobs, 30_000)
return () => clearInterval(id)
}, [fetchJobs])
/* ---------- log modal ---------- */
const openLog = async (job: CronJob) => {
setLogJob(job)
setLogsLoading(true)
try {
const res = await getCronLogs(job.id)
setLogs(res.data.logs)
} catch {
setLogs([])
toast.error('加载日志失败')
} finally {
setLogsLoading(false)
}
}
/* ---------- actions ---------- */
const handleRun = (job: CronJob) => {
setConfirmJob(job)
setConfirmAction('run')
}
const handlePause = async (job: CronJob) => {
setActing(job.id)
try {
await pauseCronJob(job.id)
toast.success(`已暂停 ${job.job_name}`)
fetchJobs()
} catch {
toast.error(`暂停 ${job.job_name} 失败`)
} finally {
setActing(null)
}
}
const handleResume = async (job: CronJob) => {
setActing(job.id)
try {
await resumeCronJob(job.id)
toast.success(`已恢复 ${job.job_name}`)
fetchJobs()
} catch {
toast.error(`恢复 ${job.job_name} 失败`)
} finally {
setActing(null)
}
}
const confirmRun = async () => {
if (!confirmJob) return
setActing(confirmJob.id)
setConfirmJob(null)
try {
await runCronJob(confirmJob.id)
toast.success(`已触发 ${confirmJob.job_name}`)
fetchJobs()
} catch {
toast.error(`触发 ${confirmJob.job_name} 失败`)
} finally {
setActing(null)
setConfirmAction('')
}
}
/* ---------- profile options ---------- */
const profileOptions = useMemo(() => {
const seen = new Set<string>()
const list: { value: string; label: string }[] = []
for (const j of jobs) {
const p = j.system ? 'system' : j.profile
if (!seen.has(p)) {
seen.add(p)
list.push({ value: p, label: j.system ? '系统 Cron' : p })
}
}
list.sort((a, b) => a.label.localeCompare(b.label))
return list
}, [jobs])
/* ---------- derived ---------- */
const filteredJobs = useMemo(() => {
if (statusFilter !== 'today-fail') return jobs
return jobs.filter((j) => isToday(j.last_run) && j.last_run_success === false)
}, [jobs, statusFilter])
/* ---------- render: loading ---------- */
if (loading) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
Cron 任务管理
</h2>
<Skeleton variant="card" count={4} />
</div>
)
}
/* ---------- render: error ---------- */
if (error) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
Cron 任务管理
</h2>
<div
className="flex flex-col items-center justify-center py-12 gap-3"
style={{ color: 'var(--text-dim)' }}
>
<span style={{ fontSize: 32, color: 'var(--red)' }}>⚠</span>
<p>{error}</p>
<Button variant="outline" onClick={fetchJobs}>
重试
</Button>
</div>
</div>
)
}
/* ---------- render ---------- */
const columns: TableColumn[] = [
{ key: 'job_name', label: 'Job 名' },
{ key: 'profile', label: 'Profile' },
{ key: 'schedule', label: 'Cron 表达式' },
{ key: 'status', label: '状态' },
{ key: 'last_run', label: '上次运行' },
{ key: 'success_rate', label: '成功率' },
{ key: 'actions', label: '操作' },
]
const dangerKeys = useMemo(() => {
const s = new Set<string>()
for (const j of filteredJobs) {
if (j.consecutive_failures >= 2) s.add(j.id)
}
return s
}, [filteredJobs])
const rowClassName = (record: Record<string, unknown>) => {
if (dangerKeys.has(record.key as string)) return 'row-danger'
return ''
}
const tableData = filteredJobs.map((j) => {
const lastRunStr = j.last_run
? (() => {
const d = new Date(j.last_run)
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
})()
: '-'
const successPct = `${j.success_rate}%`
const successRateStyle =
j.success_rate < 80 ? { color: 'var(--yellow)', fontWeight: 600 } : undefined
const rowBg = j.system ? { background: 'var(--bg-hover)' } : undefined
return {
key: j.id,
job_name: (
<span className="flex items-center gap-2" style={rowBg}>
{j.job_name}
{j.system && (
<Badge variant="outline" className="!text-[10px]">system</Badge>
)}
</span>
),
profile: (
<span style={rowBg}>
{j.system ? (
<Badge variant="info" className="!text-[10px]">系统 Cron</Badge>
) : (
j.profile
)}
</span>
),
schedule: (
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 12, ...rowBg }}>
{j.schedule}
</code>
),
status: (
<span style={rowBg}>
<Badge variant={j.status === 'active' ? 'success' : 'warning'}>
{j.status === 'active' ? '活跃' : '暂停'}
</Badge>
</span>
),
last_run: (
<span className="flex items-center gap-1" style={rowBg}>
{lastRunStr}
{j.last_run_success === true && <span style={{ fontSize: 12 }}>✅</span>}
{j.last_run_success === false && (
<span style={{ fontSize: 12, color: 'var(--red)' }}>❌</span>
)}
</span>
),
success_rate: (
<span style={{ ...successRateStyle, ...rowBg }}>{successPct}</span>
),
actions: (
<span className="flex items-center gap-1" style={rowBg}>
{canAct(user?.role) && (
<>
{j.status === 'active' ? (
<Button
size="sm"
variant="outline"
loading={acting === j.id}
onClick={(e) => { e.stopPropagation(); handlePause(j) }}
>
暂停
</Button>
) : (
<Button
size="sm"
variant="primary"
loading={acting === j.id}
onClick={(e) => { e.stopPropagation(); handleResume(j) }}
>
恢复
</Button>
)}
<Button
size="sm"
variant="ghost"
loading={acting === j.id}
onClick={(e) => { e.stopPropagation(); handleRun(j) }}
>
运行
</Button>
</>
)}
{j.has_logs && (
<Button
size="sm"
variant="ghost"
onClick={(e) => { e.stopPropagation(); openLog(j) }}
>
📋 日志
</Button>
)}
</span>
),
}
})
const statusTabs = [
{ value: 'all', label: '全部' },
{ value: 'active', label: '活跃' },
{ value: 'paused', label: '暂停' },
{ value: 'today-fail', label: '今日失败' },
]
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
Cron 任务管理
</h2>
{/* Filter bar */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
{statusTabs.map((t) => (
<button
key={t.value}
onClick={() => setStatusFilter(t.value)}
className="cursor-pointer bg-transparent border-none text-[13px] font-medium px-3 py-1.5 rounded-[var(--radius-sm)] transition-colors"
style={{
color: statusFilter === t.value ? 'var(--honey)' : 'var(--text-dim)',
background: statusFilter === t.value ? 'var(--honey-glow)' : undefined,
}}
>
{t.label}
</button>
))}
<div className="w-[1px] h-5 bg-[var(--border)] mx-1" />
<select
value={profileFilter}
onChange={(e) => setProfileFilter(e.target.value)}
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)' }}
>
<option value="all">全部 Profile</option>
{profileOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
<option value="system">仅系统 Cron</option>
</select>
</div>
{/* Table */}
{tableData.length === 0 ? (
<div
className="flex flex-col items-center justify-center py-12"
style={{ color: 'var(--text-dim)' }}
>
<span style={{ fontSize: 32, marginBottom: 8 }}>📭</span>
<p>暂无 Cron 任务</p>
</div>
) : (
<Table
columns={columns}
data={tableData}
rowKey="key"
rowClassName={rowClassName}
/>
)}
{/* Log modal */}
<Modal
open={!!logJob}
onClose={() => setLogJob(null)}
title={logJob ? `${logJob.job_name} — 最近运行日志` : ''}
width={640}
>
{logsLoading ? (
<Skeleton variant="card" count={3} />
) : logs.length === 0 ? (
<p style={{ color: 'var(--text-dim)', textAlign: 'center', padding: '24px 0' }}>
暂无日志
</p>
) : (
<div className="space-y-2">
{logs.map((log, i) => {
const isErr = log.status === 'failure'
const levelColor = (msg: string) => {
if (msg.startsWith('ERROR')) return 'var(--red)'
if (msg.startsWith('WARN')) return 'var(--yellow)'
return 'var(--green)'
}
return (
<div
key={i}
className="p-3 rounded-[var(--radius-sm)] text-[13px] leading-relaxed"
style={{
background: isErr ? 'var(--bg-error)' : 'var(--bg-card)',
borderLeft: `3px solid ${isErr ? 'var(--red)' : 'var(--border)'}`,
}}
>
<div className="flex items-center gap-2 mb-1">
<span style={{ color: 'var(--text-dim)', fontSize: 12 }}>
{log.time}
</span>
<span>{log.status === 'success' ? '✅' : '❌'}</span>
</div>
<div style={{ color: levelColor(log.message), whiteSpace: 'pre-wrap' }}>
{log.message}
</div>
</div>
)
})}
</div>
)}
</Modal>
{/* Run confirmation modal */}
<Modal
open={!!confirmJob}
onClose={() => { setConfirmJob(null); setConfirmAction('') }}
title="确认执行"
confirmText="确认运行"
confirmDanger
onConfirm={confirmRun}
>
<p style={{ color: 'var(--text-dim)' }}>
确认要手动触发 <strong style={{ color: 'var(--text)' }}>{confirmJob?.job_name}</strong> 执行吗?
</p>
{confirmJob && (
<div className="mt-3 text-[13px]" style={{ color: 'var(--text-dim)' }}>
<div>Profile: {confirmJob.system ? '系统 Cron' : confirmJob.profile}</div>
<div>Cron 表达式: <code style={{ fontFamily: 'var(--font-mono)' }}>{confirmJob.schedule}</code></div>
</div>
)}
</Modal>
</div>
)
}
+607 -2
View File
@@ -1,3 +1,608 @@
export default function DashboardPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>蜂群总览</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Dashboard 仪表板</p></div>
import { useEffect, useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArrowUp, ArrowDown } from 'lucide-react'
import Skeleton from '../components/ui/Skeleton'
import {
getDashboardSummary,
getHealthDistribution,
getGatewayTrend,
getKanbanSummary,
getCronRecent,
type SummaryData,
type HealthDistribution,
type GatewayTrendItem,
type KanbanSummaryItem,
type CronRecentItem,
} from '../api/dashboard'
/* ---------- helpers ---------- */
function timeAgo(iso: string): string {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
if (diff < 5) return '刚刚'
if (diff < 60) return diff + ' 秒前'
const m = Math.floor(diff / 60)
if (m < 60) return m + ' 分钟前'
return Math.floor(m / 60) + ' 小时前'
}
interface RecentEvent {
time: string
profile: string
type: string
typeClass: string
summary: string
status: string
statusClass: string
}
const MOCK_EVENTS: RecentEvent[] = [
{ time: '21:13', profile: 'hindsight', type: 'Cron 失败', typeClass: 'badge-offline', summary: 'Hindsight index 更新超时', status: '待处理', statusClass: 'badge-warn' },
{ time: '18:45', profile: 'monitor', type: '心跳超时', typeClass: 'badge-warn', summary: 'Gateway 心跳 3 次无响应', status: '待处理', statusClass: 'badge-warn' },
{ time: '14:20', profile: 'feishu-gateway', type: '飞书断连', typeClass: 'badge-warn', summary: 'WebSocket 断开 8 分钟后恢复', status: '已恢复', statusClass: 'badge-a' },
]
/* ---------- Stat card ---------- */
function StatCard({
label, value, valueColor, change, changeDirection,
}: {
label: string; value: string | number; valueColor?: string
change?: string; changeDirection?: 'up' | 'down'
}) {
const iconColor = changeDirection === 'up' ? 'var(--green)' : 'var(--red)'
return (
<div
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 16,
}}
>
<div
style={{
fontSize: 12,
color: 'var(--text-dim)',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 8,
}}
>
{label}
</div>
<div
style={{
fontSize: 28,
fontWeight: 700,
color: valueColor ? `var(--${valueColor})` : 'var(--text)',
}}
>
{value}
</div>
{change && (
<div
style={{
fontSize: 12,
marginTop: 4,
display: 'flex',
alignItems: 'center',
gap: 2,
color: changeDirection ? iconColor : 'var(--text-dim)',
}}
>
{changeDirection === 'up' && <ArrowUp size={12} />}
{changeDirection === 'down' && <ArrowDown size={12} />}
{change}
</div>
)}
</div>
)
}
/* ---------- Health stacked bar chart (horizontal) ---------- */
const GRADE_META = [
{ key: 'A', color: 'var(--green)' },
{ key: 'B', color: 'var(--blue)' },
{ key: 'C', color: 'var(--yellow)' },
{ key: 'D', color: 'var(--red)' },
] as const
function HealthStackedBar({ dist, onBarClick }: { dist: HealthDistribution; onBarClick: (g: string) => void }) {
if (!dist || dist.total === 0) {
return <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
}
const total = dist.A + dist.B + dist.C + dist.D
return (
<div>
<div
style={{
display: 'flex',
height: 8,
borderRadius: 4,
overflow: 'hidden',
marginBottom: 12,
}}
>
{GRADE_META.map((meta) => {
const val = dist[meta.key as keyof HealthDistribution] as number
if (val === 0) return null
const pct = total > 0 ? (val / total) * 100 : 0
return (
<div
key={meta.key}
onClick={() => onBarClick(meta.key)}
style={{
width: pct + '%',
height: '100%',
backgroundColor: meta.color,
cursor: 'pointer',
transition: 'opacity 0.15s',
}}
title={meta.key + '级: ' + val}
/>
)
})}
</div>
<div style={{ display: 'flex', gap: 20, fontSize: 12 }}>
{GRADE_META.map((meta) => {
const val = dist[meta.key as keyof HealthDistribution] as number
return (
<div key={meta.key} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ color: meta.color }}>●</span>
<span style={{ color: 'var(--text-dim)' }}>{meta.key} {val}</span>
</div>
)
})}
</div>
</div>
)
}
/* ---------- Gateway 48-bar timeline ---------- */
function barColor(item: GatewayTrendItem): string {
if (item.heartbeat_timeouts > 0) return 'var(--red)'
if (item.feishu_disconnects > 0) return 'var(--orange)'
return 'var(--green)'
}
function GatewayTimeline({ trend }: { trend: GatewayTrendItem[] }) {
const maxIssues = Math.max(1, ...trend.map(
i => i.heartbeat_timeouts + i.feishu_disconnects + i.restarts,
))
return (
<div>
<div
style={{
display: 'flex',
gap: 2,
height: 40,
alignItems: 'flex-end',
marginBottom: 12,
}}
>
{trend.map((item, i) => {
const col = barColor(item)
const total = item.heartbeat_timeouts + item.feishu_disconnects + item.restarts
const pct = total / maxIssues
const height = total === 0 ? 3 : Math.max(3, Math.round(pct * 36))
const details: string[] = []
if (item.heartbeat_timeouts > 0) details.push('心跳超时 ' + item.heartbeat_timeouts)
if (item.feishu_disconnects > 0) details.push('飞书断连 ' + item.feishu_disconnects)
if (item.restarts > 0) details.push('重启 ' + item.restarts)
const tip = details.length > 0 ? details.join(' / ') : '正常运行'
return (
<div
key={i}
title={item.hour + ': ' + tip}
style={{
flex: 1,
height,
borderRadius: '2px 2px 0 0',
minWidth: 3,
cursor: 'pointer',
backgroundColor: col,
}}
/>
)
})}
</div>
<div
style={{
display: 'flex',
gap: 16,
fontSize: 12,
color: 'var(--text-dim)',
}}
>
<span><span style={{ color: 'var(--green)' }}>●</span> 正常运行</span>
<span><span style={{ color: 'var(--red)' }}>●</span> 心跳超时</span>
<span><span style={{ color: 'var(--orange)' }}>●</span> 飞书断连</span>
</div>
</div>
)
}
/* ---------- Badge helpers for events table ---------- */
function BadgeSpan({ cls, children }: { cls: string; children: React.ReactNode }) {
const styleMap: Record<string, React.CSSProperties> = {
'badge-offline': { background: 'rgba(255,61,61,.15)', color: 'var(--red)' },
'badge-warn': { background: 'rgba(255,171,0,.15)', color: 'var(--yellow)' },
'badge-a': { background: 'rgba(0,200,83,.15)', color: 'var(--green)' },
}
const baseStyle: React.CSSProperties = styleMap[cls] || { background: 'var(--bg-hover)', color: 'var(--text-dim)' }
return (
<span
style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: 10,
fontSize: 11,
fontWeight: 600,
...baseStyle,
}}
>
{children}
</span>
)
}
/* ---------- main page ---------- */
export default function DashboardPage() {
const navigate = useNavigate()
const [summary, setSummary] = useState<SummaryData | null>(null)
const [health, setHealth] = useState<HealthDistribution | null>(null)
const [trend, setTrend] = useState<GatewayTrendItem[]>([])
const [kanban, setKanban] = useState<KanbanSummaryItem[]>([])
const [cron, setCron] = useState<CronRecentItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchAll = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await Promise.all([
getDashboardSummary(),
getHealthDistribution(),
getGatewayTrend(),
getKanbanSummary(),
getCronRecent(),
])
setSummary(res[0].data)
setHealth(res[1].data)
setTrend(res[2].data)
setKanban(res[3].data)
setCron(res[4].data)
} catch {
setError('加载仪表盘数据失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchAll() }, [fetchAll])
/* loading */
if (loading) {
return (
<div>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
<Skeleton variant='card' count={4} className='mt-4' />
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 20, marginTop: 20 }}>
<Skeleton variant='card' count={1} />
<Skeleton variant='card' count={1} />
</div>
</div>
)
}
/* error */
if (error) {
return (
<div>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
<div
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 20,
textAlign: 'center',
marginTop: 20,
}}
>
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
<button
onClick={fetchAll}
style={{
background: 'var(--honey)',
color: '#000',
border: 'none',
borderRadius: 'var(--radius-sm)',
padding: '6px 14px',
fontSize: 13,
cursor: 'pointer',
fontWeight: 600,
}}
>
重试
</button>
</div>
</div>
)
}
/* empty */
if (!summary) {
return (
<div>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
<p style={{ color: 'var(--text-dim)', fontSize: 13, marginTop: 20 }}>暂无数据</p>
</div>
)
}
const subtitle = summary.total_profiles + ' 个 Profile' + (summary.updated_at ? ' · 最后更新 ' + timeAgo(summary.updated_at) : '')
return (
<div>
{/* page header */}
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
<div style={{ color: 'var(--text-dim)', fontSize: 13, marginBottom: 20 }}>{subtitle}</div>
{/* stat cards: 4-column grid */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 16,
marginBottom: 20,
}}
>
<StatCard label='在线 Gateway' value={summary.online_count} valueColor='green' change={'2 vs 昨天'} changeDirection='up' />
<StatCard label='离线 Gateway' value={summary.offline_count} valueColor='red' change={'1 vs 昨天'} changeDirection='down' />
<StatCard label='运行中任务' value={summary.running_tasks} valueColor='blue' change={'Kanban · ' + kanban.length + ' 个看板活跃'} />
<StatCard label='今日 Cron 失败' value={summary.cron_failures_today} valueColor='red' />
</div>
{/* health + trend: 3-column layout (2fr 1fr) */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 20, marginBottom: 20 }}>
{/* health distribution */}
<div
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
}}
>
<div
style={{
padding: '14px 16px',
borderBottom: '1px solid var(--border)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: 14,
fontWeight: 600,
color: 'var(--text)',
}}
>
<span>蜂群健康分布</span>
<span
onClick={() => navigate('/health')}
style={{ color: 'var(--text-dim)', fontSize: 12, cursor: 'pointer' }}
>
查看全部 →
</span>
</div>
<div style={{ padding: 16 }}>
{health ? (
<div>
<HealthStackedBar dist={health} onBarClick={(g) => navigate('/health?filter=' + g)} />
{health.d_profiles && health.d_profiles.length > 0 ? (
<div style={{ marginTop: 16, fontSize: 12, color: 'var(--text-dim)' }}>
<span>⚠ {health.d_profiles.length} 个 D 级 Profile:</span>
<span style={{ color: 'var(--red)' }}>{health.d_profiles.join(' · ')}</span>
</div>
) : null}
</div>
) : (
<p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
)}
</div>
</div>
{/* gateway trend */}
<div
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
}}
>
<div
style={{
padding: '14px 16px',
borderBottom: '1px solid var(--border)',
fontSize: 14,
fontWeight: 600,
color: 'var(--text)',
}}
>
Gateway 异常趋势 (24h)
</div>
<div style={{ padding: 16 }}>
{trend.length > 0 ? <GatewayTimeline trend={trend} /> : <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>}
</div>
</div>
</div>
{/* kanban + cron: 2-column grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 20 }}>
{/* kanban overview */}
<div
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
}}
>
<div
style={{
padding: '14px 16px',
borderBottom: '1px solid var(--border)',
fontSize: 14,
fontWeight: 600,
color: 'var(--text)',
}}
>
Kanban 任务概览
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>看板</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>RUNNING</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>READY</th>
</tr>
</thead>
<tbody>
{kanban.length > 0 ? kanban.map((item) => (
<tr
key={item.board}
onClick={() => navigate('/kanban')}
style={{ cursor: 'pointer', height: 44 }}
>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>{item.board}</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--green)' }}>{item.running || '—'}</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--blue)' }}>{item.ready || '—'}</td>
</tr>
)) : (
<tr>
<td colSpan={3} style={{ textAlign: 'center', padding: 20, color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* cron recent */}
<div
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
}}
>
<div
style={{
padding: '14px 16px',
borderBottom: '1px solid var(--border)',
fontSize: 14,
fontWeight: 600,
color: 'var(--text)',
}}
>
Cron 最近执行
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>Job</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>状态</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>时间</th>
</tr>
</thead>
<tbody>
{cron.length > 0 ? cron.map((item) => (
<tr
key={item.job}
onClick={() => navigate('/cron')}
style={{ cursor: 'pointer', height: 44 }}
>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>{item.job}</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>
{item.status === 'success' ? '✅' : item.status === 'failure' ? '❌' : item.status === 'paused' ? '⏸' : '❓'}
</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-muted)' }}>{item.time || '—'}</td>
</tr>
)) : (
<tr>
<td colSpan={3} style={{ textAlign: 'center', padding: 20, color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* recent events table */}
<div
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
}}
>
<div
style={{
padding: '14px 16px',
borderBottom: '1px solid var(--border)',
fontSize: 14,
fontWeight: 600,
color: 'var(--text)',
}}
>
最近异常事件
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>时间</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>Profile</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>类型</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>摘要</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>状态</th>
</tr>
</thead>
<tbody>
{MOCK_EVENTS.map((evt, i) => (
<tr
key={i}
style={{
height: 44,
...(evt.status === '待处理' ? { background: 'rgba(255,61,61,.06)' } : {}),
}}
>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text)' }}>{evt.time}</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--blue)' }}>{evt.profile}</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)' }}>
<BadgeSpan cls={evt.typeClass}>{evt.type}</BadgeSpan>
</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>{evt.summary}</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)' }}>
<BadgeSpan cls={evt.statusClass}>{evt.status}</BadgeSpan>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
+266 -2
View File
@@ -1,3 +1,267 @@
export default function EventArchivePage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>事件档案</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>事件归档查询</p></div>
import { useEffect, useState, useCallback } from 'react'
import Badge from '../components/ui/Badge'
import Button from '../components/ui/Button'
import Skeleton from '../components/ui/Skeleton'
import toast from '../components/ui/Toast'
import { getEvents, type EventItem } from '../api/events'
/* ---------- constants ---------- */
const DOMAINS = [
'gateway', 'crawler', 'reporter', 'feishu', 'database', 'security',
'network', 'storage', 'monitor', 'scheduler', 'api', 'other',
]
const STATUS_OPTIONS = [
{ value: '', label: '全部' },
{ value: 'active', label: '活跃期' },
{ value: 'fading', label: '消减期' },
{ value: 'dissipating', label: '消散期' },
{ value: 'closed', label: '已完结' },
] as const
const LEVEL_VARIANTS: Record<string, 'danger' | 'warning' | 'info'> = {
A: 'danger',
B: 'warning',
C: 'info',
}
const STATUS_VARIANTS: Record<string, 'success' | 'warning' | 'honey' | 'outline'> = {
active: 'success',
fading: 'warning',
dissipating: 'honey',
closed: 'outline',
}
const STATUS_LABELS: Record<string, string> = {
active: '活跃期',
fading: '消减期',
dissipating: '消散期',
closed: '已完结',
}
/* ---------- component ---------- */
export default function EventArchivePage() {
/* data */
const [events, setEvents] = useState<EventItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
/* filters */
const [statusFilter, setStatusFilter] = useState('')
const [domainFilter, setDomainFilter] = useState('')
const [searchText, setSearchText] = useState('')
/* ---------- fetch ---------- */
const fetchEvents = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params: Record<string, string> = {}
if (statusFilter) params.status = statusFilter
if (domainFilter) params.domain = domainFilter
if (searchText.trim()) params.search = searchText.trim()
const res = await getEvents(params)
setEvents(res.data.events)
} catch {
setError('加载事件列表失败')
} finally {
setLoading(false)
}
}, [statusFilter, domainFilter, searchText])
useEffect(() => {
fetchEvents()
}, [fetchEvents])
/* ---------- handlers ---------- */
const handleNewEvent = () => {
toast.info('新建事件功能即将上线')
}
const handleCardClick = (event: EventItem) => {
toast.info(`事件: ${event.title}`)
}
/* ---------- render: loading ---------- */
if (loading && events.length === 0) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
事件档案
</h2>
<Skeleton variant="card" count={6} />
</div>
)
}
/* ---------- render: error ---------- */
if (error) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
事件档案
</h2>
<div
className="flex flex-col items-center justify-center py-12 gap-3"
style={{ color: 'var(--text-dim)' }}
>
<span style={{ fontSize: 32, color: 'var(--red)' }}>!</span>
<p>{error}</p>
<Button variant="outline" onClick={fetchEvents}>重试</Button>
</div>
</div>
)
}
/* ---------- render ---------- */
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
事件档案
</h2>
{/* Filter toolbar */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)' }}
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<select
value={domainFilter}
onChange={(e) => setDomainFilter(e.target.value)}
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)' }}
>
<option value="">全部领域</option>
{DOMAINS.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
<input
type="text"
placeholder="搜索事件标题..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)', width: 200 }}
/>
<Button size="sm" variant="primary" onClick={handleNewEvent}>
+ 新建事件
</Button>
</div>
{/* Event cards */}
{events.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12" style={{ color: 'var(--text-dim)' }}>
<span style={{ fontSize: 28, marginBottom: 8, fontWeight: 600 }}>0</span>
<p>无匹配事件</p>
</div>
) : (
<div
className="grid gap-4"
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
}}
>
{events.map((event) => (
<div
key={event.id}
onClick={() => handleCardClick(event)}
className="rounded-[var(--radius)] p-4 cursor-pointer transition-all duration-150"
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--honey-dim)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border)'
}}
>
{/* Title */}
<h3
className="mb-3 truncate"
style={{ color: 'var(--text)', fontSize: 15, fontWeight: 700, lineHeight: 1.4 }}
title={event.title}
>
{event.title}
</h3>
{/* Level badge + Domain tag */}
<div className="flex items-center gap-2 mb-3">
<Badge variant={LEVEL_VARIANTS[event.level]}>
{event.level}
</Badge>
<Badge variant="info">{event.domain}</Badge>
<Badge variant={STATUS_VARIANTS[event.status]}>
{STATUS_LABELS[event.status]}
</Badge>
</div>
{/* Meta */}
<div className="space-y-1.5 text-[12px]" style={{ color: 'var(--text-dim)' }}>
<div className="flex justify-between">
<span>发生日期</span>
<span style={{ color: 'var(--text-secondary)' }}>{event.occurred_date}</span>
</div>
<div className="flex justify-between">
<span>上次复查</span>
<span style={{ color: 'var(--text-secondary)' }}>
{event.last_reviewed ? new Date(event.last_reviewed).toLocaleString('zh-CN') : '未复查'}
</span>
</div>
<div className="flex justify-between">
<span>连续无新知</span>
<span
style={{
color: event.no_new_info_count >= 5 ? 'var(--yellow)' : 'var(--text-secondary)',
fontWeight: event.no_new_info_count >= 5 ? 600 : 400,
}}
>
{event.no_new_info_count} 次
</span>
</div>
<div className="flex justify-between">
<span>下次复查</span>
<span style={{ color: 'var(--text-secondary)' }}>
{event.next_review ? new Date(event.next_review).toLocaleString('zh-CN') : '无'}
</span>
</div>
</div>
{/* Overdue badge */}
{event.overdue_days > 0 && (
<div className="mt-3">
<span
className="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-semibold"
style={{ background: 'rgba(255, 61, 61, 0.15)', color: 'var(--red)' }}
>
超期 {event.overdue_days} 天
</span>
</div>
)}
</div>
))}
</div>
)}
</div>
)
}
+459 -2
View File
@@ -1,3 +1,460 @@
export default function GatewayPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Gateway 管理</p></div>
import { useState, useCallback, useEffect } from 'react'
import { RotateCcw, Play, Square, AlertTriangle, Search } from 'lucide-react'
import Table, { type TableColumn } from '../components/ui/Table'
import Badge from '../components/ui/Badge'
import Modal from '../components/ui/Modal'
import Skeleton from '../components/ui/Skeleton'
import Timeline, { type TimelineEvent } from '../components/ui/Timeline'
import toast from '../components/ui/Toast'
import { useAuth } from '../hooks/useAuth'
import { usePolling } from '../hooks/usePolling'
import {
getGatewayStatus,
getGatewayEvents,
startGateway,
stopGateway,
restartGateway,
type GatewayInfo,
type GatewayEvent,
} from '../api/gateway'
/* ---------- helpers ---------- */
const statBadge = (s: string) => {
if (s === 'online') return <Badge variant="success">在线</Badge>
if (s === 'offline') return <Badge variant="danger">离线</Badge>
if (s === 'warn') return <Badge variant="warning">异常</Badge>
return <Badge variant="outline">{s}</Badge>
}
const eventToTimeline = (e: GatewayEvent): TimelineEvent => {
const colorMap: Record<string, string> = {
heartbeat_timeout: 'var(--red)',
feishu_disconnect: 'var(--orange)',
restart: 'var(--blue)',
}
const labelMap: Record<string, string> = {
heartbeat_timeout: '心跳超时',
feishu_disconnect: '飞书断连',
restart: 'Gateway 重启',
}
return {
time: e.time,
type: e.type,
tooltip: `${labelMap[e.type] || e.type} — ${e.profile}`,
color: colorMap[e.type],
}
}
const isDangerRow = (gw: GatewayInfo) =>
gw.restarts_today >= 3 || gw.downtime_minutes >= 30
/* ---------- page ---------- */
export default function GatewayPage() {
const { user } = useAuth()
const canOperate = user?.role === 'owner' || user?.role === 'operator'
const [gateways, setGateways] = useState<GatewayInfo[]>([])
const [events, setEvents] = useState<GatewayEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastUpdate, setLastUpdate] = useState<Date>(new Date())
const [statusFilter, setStatusFilter] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState('')
const [typeFilter, setTypeFilter] = useState<string>('all')
const [operating, setOperating] = useState<string | null>(null)
const [modalAction, setModalAction] = useState<{ profile: string; action: 'start' | 'stop' | 'restart' } | null>(null)
const fetchStatus = useCallback(async () => {
try {
const res = await getGatewayStatus()
setGateways(res.data)
setLastUpdate(new Date())
setError(null)
} catch {
setError('加载 Gateway 状态失败')
} finally {
setLoading(false)
}
}, [])
const fetchEvents = useCallback(async () => {
try {
const res = await getGatewayEvents()
setEvents(res.data)
} catch {
// event fetch failure is non-critical
}
}, [])
useEffect(() => { fetchEvents() }, [fetchEvents])
usePolling(fetchStatus, 10_000)
/* ---- operate ---- */
const handleOperate = useCallback(async () => {
if (!modalAction) return
const { profile, action } = modalAction
setOperating(profile)
setModalAction(null)
try {
if (action === 'start') await startGateway(profile)
else if (action === 'stop') await stopGateway(profile)
else await restartGateway(profile)
toast.success(`Gateway "${profile}" ${action === 'start' ? '启动' : action === 'stop' ? '停止' : '重启'}成功`)
fetchStatus()
} catch {
toast.error(`Gateway "${profile}" ${action === 'start' ? '启动' : action === 'stop' ? '停止' : '重启'}失败`)
} finally {
setOperating(null)
}
}, [modalAction, fetchStatus])
/* ---- stats ---- */
const totalCount = gateways.length
const onlineCount = gateways.filter(g => g.status === 'online').length
const offlineCount = gateways.filter(g => g.status === 'offline').length
const warnCount = gateways.filter(g => g.status === 'warn').length
/* ---- filter ---- */
const filtered = gateways.filter((g) => {
if (statusFilter !== 'all' && g.status !== statusFilter) return false
if (typeFilter !== 'all' && g.profile_type !== typeFilter) return false
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase()
if (!g.name.toLowerCase().includes(q)) return false
}
return true
})
const cols: TableColumn[] = [
{ key: 'name', label: 'Profile 名' },
{ key: 'status', label: '状态' },
{ key: 'downtime', label: '掉线时长' },
{ key: 'restarts', label: '今日重启' },
{ key: 'last_event', label: '最近异常' },
]
if (canOperate) cols.push({ key: 'actions', label: '操作' })
const tableData = filtered.map((gw) => ({
key: gw.id || gw.name,
name: <span style={{ fontWeight: 500, color: 'var(--text)' }}>{gw.name}</span>,
status: statBadge(gw.status),
downtime: gw.downtime_minutes > 0 ? `${gw.downtime_minutes} 分钟` : '-',
restarts: gw.restarts_today > 0 ? (
<span style={{ color: gw.restarts_today >= 3 ? 'var(--red)' : 'var(--yellow)' }}>
{gw.restarts_today} 次
</span>
) : '-',
last_event: gw.last_event || '-',
actions: canOperate ? (
<div style={{ display: 'flex', gap: 4 }}>
<button
disabled={operating === gw.name}
onClick={() => setModalAction({ profile: gw.name, action: 'restart' })}
title="重启"
style={{
background: 'transparent',
border: '1px solid var(--honey)',
borderRadius: 'var(--radius-sm)',
color: 'var(--honey)',
cursor: 'pointer',
padding: '4px 8px',
display: 'flex',
alignItems: 'center',
fontSize: 12,
fontFamily: 'inherit',
opacity: operating === gw.name ? 0.5 : 1,
}}
>
<RotateCcw size={13} />
</button>
{gw.status === 'online' ? (
<button
disabled={operating === gw.name}
onClick={() => setModalAction({ profile: gw.name, action: 'stop' })}
title="停止"
style={{
background: 'transparent',
border: '1px solid var(--honey)',
borderRadius: 'var(--radius-sm)',
color: 'var(--honey)',
cursor: 'pointer',
padding: '4px 8px',
display: 'flex',
alignItems: 'center',
fontSize: 12,
fontFamily: 'inherit',
opacity: operating === gw.name ? 0.5 : 1,
}}
>
<Square size={13} />
</button>
) : (
<button
disabled={operating === gw.name}
onClick={() => setModalAction({ profile: gw.name, action: 'start' })}
title="启动"
style={{
background: 'transparent',
border: '1px solid var(--honey)',
borderRadius: 'var(--radius-sm)',
color: 'var(--honey)',
cursor: 'pointer',
padding: '4px 8px',
display: 'flex',
alignItems: 'center',
fontSize: 12,
fontFamily: 'inherit',
opacity: operating === gw.name ? 0.5 : 1,
}}
>
<Play size={13} />
</button>
)}
</div>
) : null,
}))
const rowClassName = (record: Record<string, unknown>) => {
const gw = gateways.find((g) => g.name === record.name)
return gw && isDangerRow(gw) ? 'row-danger' : ''
}
const timelineEvents = events.map(eventToTimeline)
/* ---- last update text ---- */
const secondsAgo = Math.floor((Date.now() - lastUpdate.getTime()) / 1000)
/* ---- status filter buttons ---- */
const statusFilters = [
{ key: 'all', label: '全部' },
{ key: 'online', label: '在线\u{1F535}' },
{ key: 'offline', label: '离线\u{1F534}' },
{ key: 'warn', label: '异常\u{1F7E0}' },
]
/* ---------- render ---------- */
if (loading) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway 健康监控</h2>
<Skeleton variant="card" count={1} className="mt-4" />
<Skeleton variant="table" count={5} className="mt-4" />
</div>
)
}
if (error && gateways.length === 0) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway 健康监控</h2>
<div
className="mt-4 p-5 rounded-[var(--radius)] text-center"
style={{ background: 'var(--bg-card)' }}
>
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
<button
onClick={fetchStatus}
style={{
background: 'var(--honey)',
color: '#000',
border: 'none',
borderRadius: 'var(--radius-sm)',
padding: '6px 14px',
fontSize: 13,
cursor: 'pointer',
fontWeight: 600,
}}
>
重试
</button>
</div>
</div>
)
}
return (
<div>
{/* Title + stats */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>
Gateway 健康监控
</h2>
<div style={{ color: 'var(--text-dim)', fontSize: 13, marginTop: 4 }}>
共 {totalCount} 个 · 在线 {onlineCount} · 离线 {offlineCount} · 异常 {warnCount}
</div>
</div>
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>
最后更新 {secondsAgo} 秒前
</div>
</div>
{/* ---- event timeline ---- */}
{timelineEvents.length > 0 && (
<div
className="mb-4 rounded-[var(--radius)] p-4"
style={{ background: 'var(--bg-card)' }}
>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
异常事件时间线 (24h)
</h3>
<div className="overflow-x-auto pb-2">
<Timeline events={timelineEvents} className="min-w-[600px]" />
</div>
<div className="flex gap-4 mt-3 text-[12px]" style={{ color: 'var(--text-dim)' }}>
<span><span style={{ color: 'var(--red)' }}>●</span> 心跳超时</span>
<span><span style={{ color: 'var(--orange)' }}>●</span> 飞书断连</span>
<span><span style={{ color: 'var(--blue)' }}>●</span> Gateway 重启</span>
</div>
</div>
)}
{/* ---- filter bar ---- */}
<div
className="mb-3"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 12,
}}
>
{/* status filter buttons */}
<div style={{ display: 'flex', gap: 4 }}>
{statusFilters.map((f) => (
<button
key={f.key}
onClick={() => setStatusFilter(f.key)}
style={{
padding: '6px 14px',
borderRadius: 'var(--radius-sm)',
border: 'none',
fontSize: 13,
cursor: 'pointer',
fontFamily: 'inherit',
background: statusFilter === f.key ? 'var(--honey)' : 'var(--bg-card)',
color: statusFilter === f.key ? '#000' : 'var(--text-dim)',
fontWeight: statusFilter === f.key ? 600 : 400,
transition: 'all 0.15s',
}}
>
{f.label}
</button>
))}
</div>
{/* search + type filter */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div style={{ position: 'relative' }}>
<Search
size={14}
style={{
position: 'absolute',
left: 10,
top: '50%',
transform: 'translateY(-50%)',
color: 'var(--text-muted)',
}}
/>
<input
placeholder="搜索 Gateway..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: 200,
height: 32,
padding: '0 12px 0 30px',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border)',
background: 'var(--bg-card)',
color: 'var(--text)',
fontSize: 13,
outline: 'none',
fontFamily: 'inherit',
}}
/>
</div>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
style={{
height: 32,
padding: '0 10px',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border)',
background: 'var(--bg-card)',
color: 'var(--text)',
fontSize: 13,
outline: 'none',
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
<option value="all">全部蜂型</option>
<option value="worker">工蜂</option>
<option value="queen">蜂王</option>
</select>
</div>
</div>
{/* ---- table ---- */}
<div>
{filtered.length === 0 && !loading ? (
<div
className="rounded-[var(--radius)] p-8 text-center"
style={{ background: 'var(--bg-card)' }}
>
<p style={{ color: 'var(--text-dim)', fontSize: 14 }}>全部 Gateway 正常运行</p>
</div>
) : (
<Table
columns={cols}
data={tableData}
rowKey="key"
rowClassName={rowClassName}
/>
)}
</div>
{/* ---- confirm modal ---- */}
<Modal
open={!!modalAction}
onClose={() => setModalAction(null)}
title="确认操作"
confirmText={modalAction?.action === 'start' ? '启动' : modalAction?.action === 'stop' ? '停止' : '重启'}
confirmDanger
onConfirm={handleOperate}
>
<div className="flex items-start gap-3">
<AlertTriangle size={20} style={{ color: 'var(--red)', flexShrink: 0, marginTop: 2 }} />
<div>
<p style={{ color: 'var(--text)', fontSize: 14, marginBottom: 8 }}>
确认{modalAction?.action === 'start' ? '启动' : modalAction?.action === 'stop' ? '停止' : '重启'}
<strong> {modalAction?.profile}</strong> Gateway?
</p>
<p style={{ color: 'var(--text-dim)', fontSize: 12 }}>
{modalAction?.action === 'stop'
? '停止后该 Gateway 上的所有连接将立即断开。'
: modalAction?.action === 'restart'
? '重启期间服务将暂时不可用。'
: '启动后将恢复服务。'}
</p>
</div>
</div>
</Modal>
</div>
)
}
+294 -2
View File
@@ -1,3 +1,295 @@
export default function HealthPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>系统健康评分</p></div>
import { useEffect, useState, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import Badge from '../components/ui/Badge'
import Button from '../components/ui/Button'
import Table, { type TableColumn } from '../components/ui/Table'
import Skeleton from '../components/ui/Skeleton'
import Modal from '../components/ui/Modal'
import toast from '../components/ui/Toast'
import { useAuth } from '../hooks/useAuth'
import {
getHealthScores, getProfileScore, compareProfiles, recalculateScores,
type ScoreListResponse, type ProfileScoreDetail, type CompareResponse,
} from '../api/health'
const GRADE_META: Record<string, { color: string; bg: string; label: string }> = {
A: { color: 'var(--green)', bg: 'var(--bg-success)', label: '优秀' },
B: { color: 'var(--blue)', bg: 'var(--bg-info)', label: '良好' },
C: { color: 'var(--yellow)', bg: 'var(--bg-warn)', label: '一般' },
D: { color: 'var(--red)', bg: 'var(--bg-error)', label: '危险' },
}
function GradeBadge({ grade }: { grade: string }) {
const m = GRADE_META[grade]
if (!m) return <Badge variant="outline">{grade}</Badge>
return (
<span className="inline-flex items-center gap-1 px-[8px] py-[2px] rounded-[10px] text-[11px] font-semibold"
style={{ backgroundColor: m.bg, color: m.color }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: m.color, display: 'inline-block' }} />
{grade}
</span>
)
}
function GradeCard({ grade, count, active, onClick }: {
grade: string; count: number; active: boolean; onClick: () => void
}) {
const m = GRADE_META[grade]
const as = active ? { boxShadow: '0 0 0 2px ' + m.color } : {}
return (
<div onClick={onClick} className="rounded-[var(--radius)] p-4 cursor-pointer transition-all duration-150"
style={{ background: m.bg, ...as }}>
<div className="text-[12px] uppercase tracking-wider" style={{ color: m.color }}>{grade}</div>
<div className="text-[28px] font-bold leading-tight mt-1" style={{ color: m.color }}>{count}</div>
<div className="text-[12px] mt-1" style={{ color: m.color, opacity: 0.7 }}>{m.label}</div>
</div>
)
}
function DetailPanel({ profile }: { profile: string }) {
const [detail, setDetail] = useState<ProfileScoreDetail | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
getProfileScore(profile).then(r => setDetail(r.data))
.catch(() => toast.error('加载详情失败'))
.finally(() => setLoading(false))
}, [profile])
if (loading) return <div className="p-4"><Skeleton variant="text" count={4} /></div>
if (!detail) return <div className="p-4 text-[var(--text-dim)]">暂无数据</div>
return (
<div className="p-4 space-y-4">
{detail.dimensions.map((dim, i) => {
const ok = dim.score / dim.max_score >= 0.7
return (
<div key={i} className="rounded-[var(--radius-sm)] p-3" style={{ background: "var(--bg-hover)" }}>
<div className="flex items-center justify-between mb-2">
<span className="text-[13px] font-semibold" style={{ color: "var(--text)" }}>{dim.name}</span>
<span className="text-[12px]" style={{ color: ok ? "var(--green)" : "var(--red)" }}>
{dim.score}/{dim.max_score} ({dim.weight})
</span>
</div>
{dim.deductions.length > 0 && (
<div className="space-y-1 mb-2">
<div className="text-[11px] font-semibold" style={{ color: "var(--red)" }}>扣分项:</div>
{dim.deductions.map((d, j) => <div key={j} className="text-[12px] pl-2" style={{ color: "var(--text-dim)" }}>\u2022 {d}</div>)}
</div>
)}
{dim.suggestions.length > 0 && (
<div className="space-y-1">
<div className="text-[11px] font-semibold" style={{ color: "var(--honey)" }}>修复建议:</div>
{dim.suggestions.map((s, j) => <div key={j} className="text-[12px] pl-2" style={{ color: "var(--text-dim)" }}>\u2192 {s}</div>)}
</div>
)}
{dim.deductions.length === 0 && <div className="text-[12px]" style={{ color: "var(--green)" }}>\u2713 满分</div>}
</div>
)
})}
</div>
)
}
function CompareTable({ data, showDiffOnly, profiles: p }: {
data: CompareResponse; showDiffOnly: boolean; profiles: string[]
}) {
const rows = showDiffOnly ? data.rows.filter(r => r.has_diff) : data.rows
if (rows.length === 0) {
return <div className="p-4 text-center text-[var(--text-dim)]">没有差异</div>
}
return (
<div className="w-full overflow-x-auto rounded-[var(--radius)] border border-[var(--border)]">
<table className="w-full border-collapse text-[12px]">
<thead><tr>
<th className="sticky top-0 z-10 bg-[var(--bg-nav)] text-left px-3 h-[36px] uppercase text-[var(--text-dim)] font-semibold">字段</th>
{p.map(n =>
<th key={n} className="sticky top-0 z-10 bg-[var(--bg-nav)] text-center px-3 h-[36px] text-[var(--text-dim)] font-semibold">{n}</th>
)}
</tr></thead>
<tbody>
{rows.map((row, i) => {
const vals = p.map(n => String(row[n] || ''))
const hasDiff = new Set(vals).size > 1
return (
<tr key={i} className="border-t border-[var(--border)]" style={hasDiff ? { background: "var(--bg-warn)" } : {}}>
<td className="px-3 font-medium h-[36px]" style={{ color: "var(--text)" }}>{row.field}</td>
{p.map(n => {
const v = String(row[n] || '')
const c = v === '\u2705' ? 'var(--green)' : v === '\u274c' ? 'var(--red)' : 'var(--honey)'
return <td key={n} className="px-3 text-center" style={{ color: c }}>{v}</td>
})}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
export default function HealthPage() {
const [searchParams, setSearchParams] = useSearchParams()
const { user } = useAuth()
const [data, setData] = useState<ScoreListResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const gradeFilter = searchParams.get('filter') || ''
const [sortAsc, setSortAsc] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [compareData, setCompareData] = useState<CompareResponse | null>(null)
const [compareLoading, setCompareLoading] = useState(false)
const [showCompare, setShowCompare] = useState(false)
const [showDiffOnly, setShowDiffOnly] = useState(false)
const [expandedRow, setExpandedRow] = useState<string | null>(null)
const [recalcLoading, setRecalcLoading] = useState(false)
const fetchData = useCallback(async () => {
setLoading(true); setError(null)
try {
const params: Record<string, string> = { sort: sortAsc ? 'asc' : 'desc' }
if (gradeFilter) params.grade = gradeFilter
const r = await getHealthScores(params)
setData(r.data)
} catch { setError('加载健康分数据失败') }
finally { setLoading(false) }
}, [gradeFilter, sortAsc])
useEffect(() => { fetchData() }, [fetchData])
const toggleSelect = (name: string) => {
setSelected(prev => { const n = new Set(prev); if (n.has(name)) n.delete(name); else n.add(name); return n })
}
const handleCompare = async () => {
if (selected.size < 2) { toast.error('至少选择 2 个 Profile'); return }
setCompareLoading(true)
try { const r = await compareProfiles(Array.from(selected)); setCompareData(r.data); setShowCompare(true) }
catch { toast.error('对比失败') } finally { setCompareLoading(false) }
}
const handleRecalculate = async () => {
setRecalcLoading(true)
try { await recalculateScores(); toast.success('已重新计算'); fetchData() }
catch { toast.error('重算失败') } finally { setRecalcLoading(false) }
}
const handleGradeFilter = (g: string) => {
const p = new URLSearchParams(searchParams)
if (g && g !== gradeFilter) p.set('filter', g); else p.delete('filter')
setSearchParams(p)
}
const columns: TableColumn[] = [
{ key: '_select', label: '', width: 40 },
{ key: 'rank', label: '排名', width: 50 },
{ key: 'name', label: 'Profile' },
{ key: 'total', label: '总分', width: 70 },
{ key: 'grade', label: '等级', width: 60 },
{ key: 'dims', label: '四维度得分', width: 200 },
{ key: 'deduct', label: '主要扣分' },
]
const dist = data?.distribution ?? { A: 0, B: 0, C: 0, D: 0 }
const scores = data?.scores ?? []
/* loading */
if (loading) {
return (
<div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4">
{['A','B','C','D'].map(g => <div key={g} className="rounded-[var(--radius)] p-4" style={{ background: 'var(--bg-card)' }}><Skeleton variant="text" count={2} /></div>)}
</div>
<Skeleton variant="table" count={8} className="mt-4" />
</div>
)
}
/* error */
if (error) {
return (
<div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2>
<div className="mt-4 p-5 rounded-[var(--radius)] text-center" style={{ background: "var(--bg-card)" }}>
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
<Button onClick={fetchData}>重试</Button>
</div>
</div>
)
}
/* table rows */
const tableData = scores.map(s => {
const tc = s.total_score >= 80 ? 'var(--green)' : s.total_score >= 60 ? 'var(--blue)' : s.total_score >= 40 ? 'var(--yellow)' : 'var(--red)'
return {
key: s.name,
_select: <input type="checkbox" checked={selected.has(s.name)} onChange={() => toggleSelect(s.name)} className="cursor-pointer" style={{ accentColor: 'var(--honey)' }} />,
rank: <span style={{ color: 'var(--text-dim)' }}>{s.rank}</span>,
name: <span style={{ color: 'var(--text)', fontWeight: 500 }}>{s.name}</span>,
total: <span style={{ color: tc, fontWeight: 700, fontSize: 15 }}>{s.total_score}</span>,
grade: <GradeBadge grade={s.grade} />,
dims: (
<div className="flex gap-3 text-[12px]">
<span style={{ color: 'var(--text-dim)' }}>G:<span style={{ color: 'var(--green)' }}>{s.gateway_score}</span></span>
<span style={{ color: 'var(--text-dim)' }}>C:<span style={{ color: 'var(--blue)' }}>{s.config_score}</span></span>
<span style={{ color: 'var(--text-dim)' }}>S:<span style={{ color: 'var(--honey)' }}>{s.skill_score}</span></span>
<span style={{ color: 'var(--text-dim)' }}>R:<span style={{ color: 'var(--yellow)' }}>{s.cron_score}</span></span>
</div>
),
deduct: <div className="text-[12px] truncate max-w-[200px]" style={{ color: 'var(--text-dim)' }}>{s.deduct_summary[0] || '-'}</div>,
}
})
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2>
{/* grade cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4">
{Object.entries(GRADE_META).map(([g]) =>
<GradeCard key={g} grade={g} count={dist[g as keyof typeof dist]} active={gradeFilter === g} onClick={() => handleGradeFilter(g)} />)
}
</div>
{/* filter bar */}
<div className="flex flex-wrap items-center gap-3 mt-4 mb-3">
<div className="flex gap-1">
{['', 'A', 'B', 'C', 'D'].map(g =>
<button key={g} onClick={() => handleGradeFilter(g)}
className="px-3 py-1.5 text-[12px] rounded-[var(--radius-sm)] font-semibold cursor-pointer border-none transition-colors"
style={{ background: gradeFilter === g ? 'var(--honey)' : 'var(--bg-hover)', color: gradeFilter === g ? '#000' : 'var(--text-dim)' }}>
{g || '全部'}
</button>
)}
</div>
<Button size="sm" variant="outline" disabled={selected.size < 2} loading={compareLoading} onClick={handleCompare}>对比选中 ({selected.size})</Button>
<Button size="sm" variant="ghost" onClick={() => { setSortAsc(!sortAsc) }}>{sortAsc ? '\u2191 低到高' : '\u2193 高到低'}</Button>
{user?.role === 'owner' && <Button size="sm" variant="outline" loading={recalcLoading} onClick={handleRecalculate}>重新计算</Button>}
<span className="text-[12px]" style={{ color: 'var(--text-dim)' }}>共 {scores.length} 个 Profile</span>
</div>
{/* table */}
<Table columns={columns} data={tableData as any} rowKey="key"
onRowClick={(record) => setExpandedRow(expandedRow === record.key ? null : String(record.key))}
rowClassName={(record: any) => String(record._grade || '') === 'D' ? 'row-danger' : ''} />
{/* expanded detail */}
{expandedRow && (
<div className="mt-2 mb-4 rounded-[var(--radius)] border border-[var(--border)] overflow-hidden"
style={{ background: "var(--bg-card)" }}>
<DetailPanel profile={expandedRow} />
</div>
)}
{/* compare modal */}
<Modal open={showCompare} onClose={() => setShowCompare(false)} title="Profile \u5bf9\u6bd4"
footer={
<label className="flex items-center gap-2 text-[13px] cursor-pointer" style={{ color: "var(--text-dim)" }}>
<input type="checkbox" checked={showDiffOnly} onChange={() => setShowDiffOnly(!showDiffOnly)} style={{ accentColor: 'var(--honey)' }} />
只看差异
</label>
}>
{compareData && <CompareTable data={compareData} showDiffOnly={showDiffOnly} profiles={Array.from(selected)} />}
</Modal>
</div>
)
}
+857 -2
View File
@@ -1,3 +1,858 @@
export default function KanbanPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>看板追踪</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Kanban 任务看板</p></div>
import { useEffect, useState, useCallback, useRef } from 'react'
import Badge from '../components/ui/Badge'
import Button from '../components/ui/Button'
import Drawer from '../components/ui/Drawer'
import Skeleton from '../components/ui/Skeleton'
import toast from '../components/ui/Toast'
import {
getKanbanTasks,
getKanbanTaskDetail,
moveKanbanTask,
type KanbanTask,
type KanbanOverview,
} from '../api/kanban'
/* ===== Helpers ===== */
const COLUMN_META: Record<string, { label: string; color: string; bg: string; borderColor: string }> = {
todo: { label: 'TODO', color: 'var(--text-dim)', bg: 'transparent', borderColor: 'var(--border)' },
ready: { label: 'READY', color: 'var(--blue)', bg: 'rgba(68,138,255,0.06)', borderColor: 'var(--blue)' },
running: { label: 'RUNNING', color: 'var(--green)', bg: 'rgba(0,200,83,0.06)', borderColor: 'var(--green)' },
blocked: { label: 'BLOCKED', color: 'var(--orange)', bg: 'rgba(255,109,0,0.08)', borderColor: 'var(--orange)' },
done: { label: 'DONE', color: 'var(--text-muted)', bg: 'rgba(90,93,106,0.04)', borderColor: 'var(--text-muted)' },
}
const COLUMN_ORDER = ['todo', 'ready', 'running', 'blocked', 'done'] as const
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return mins + 'm ago'
const hrs = Math.floor(mins / 60)
if (hrs < 24) return hrs + 'h ago'
const days = Math.floor(hrs / 24)
if (days < 30) return days + 'd ago'
return Math.floor(days / 30) + 'mo ago'
}
function priorityLabel(p: number): string {
if (p === 0) return 'P0'
if (p === 1) return 'P1'
if (p === 2) return 'P2'
return 'P3'
}
function priorityColor(p: number): string {
if (p === 0) return 'var(--red)'
if (p === 1) return 'var(--yellow)'
return 'var(--text-dim)'
}
function leftBarColor(task: KanbanTask): string {
if (task.priority === 0) return 'var(--red)'
if (task.goal_mode) return 'var(--honey)'
if (task.column === 'blocked') return 'var(--orange)'
return 'transparent'
}
function anomalyStyle(anomalies: string[]): React.CSSProperties | undefined {
if (anomalies.includes('blocked_timeout')) return { border: '1px solid var(--red)', boxShadow: '0 0 0 1px var(--red)' }
if (anomalies.includes('unclaimed')) return { border: '1px solid var(--yellow)' }
if (anomalies.includes('retry_failure')) return { border: '1px solid var(--red)', boxShadow: '0 0 0 1px var(--red)' }
return undefined
}
function isTouchDevice(): boolean {
return typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
}
/* ===== TaskCard ===== */
function TaskCard({
task,
onDragStart,
onCardClick,
checked,
onCheckChange,
}: {
task: KanbanTask
onDragStart: (e: React.DragEvent, t: KanbanTask) => void
onCardClick: (t: KanbanTask) => void
checked: boolean
onCheckChange: (id: string, v: boolean) => void
}) {
const lColor = leftBarColor(task)
const aStyle = anomalyStyle(task.anomalies)
return (
<div
draggable
onDragStart={(e) => onDragStart(e, task)}
onClick={() => onCardClick(task)}
className="rounded-[var(--radius)] bg-[var(--bg-card)] cursor-grab active:cursor-grabbing transition-all duration-150 group relative"
style={{
padding: '10px 12px',
border: '1px solid var(--border)',
borderLeft: lColor !== 'transparent' ? '3px solid ' + lColor : '1px solid var(--border)',
...(aStyle || {}),
}}
onMouseEnter={(e) => {
if (!aStyle) (e.currentTarget as HTMLElement).style.borderColor = 'var(--honey)'
}}
onMouseLeave={(e) => {
if (!aStyle) (e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
}}
>
<div
className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity z-10"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={checked}
onChange={(e) => onCheckChange(task.id, e.target.checked)}
style={{ accentColor: 'var(--honey)', width: 14, height: 14, cursor: 'pointer' }}
/>
</div>
<div
className="font-semibold leading-snug mb-1.5"
style={{
color: 'var(--text)',
fontSize: 13,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{task.title}
</div>
<div className="flex items-center gap-2 text-[11px]" style={{ color: 'var(--text-dim)' }}>
<span>{task.worker_profile}</span>
<span>{'\u{1F41D}'}</span>
{task.goal_mode && <span title="goal_mode">{'\u{1F3AF}'}</span>}
{task.priority === 0 && <span style={{ color: 'var(--red)', fontWeight: 600, fontSize: 10 }}>{'⚡'}P0</span>}
{(task.retries > 0 || task.failures > 0) && (
<span style={{ color: task.failures > 0 ? 'var(--red)' : 'var(--yellow)', fontSize: 10 }}>
{'↻'}{task.retries}/{task.failures}
</span>
)}
</div>
<div className="text-[10px] mt-1" style={{ color: 'var(--text-muted)' }}>
{relativeTime(task.created_at)}
</div>
{task.column === 'blocked' && task.block_reason && (
<div
className="text-[11px] mt-1.5 truncate"
style={{ color: 'var(--orange)' }}
title={task.block_reason}
>
{'⏸'} {task.block_reason}
</div>
)}
{task.anomalies.length > 0 && (
<div className="flex gap-1 mt-1.5 flex-wrap">
{task.anomalies.includes('blocked_timeout') && (
<Badge variant="danger" className="!text-[9px]">阻塞超时</Badge>
)}
{task.anomalies.includes('unclaimed') && (
<Badge variant="warning" className="!text-[9px]">长期无主</Badge>
)}
{task.anomalies.includes('retry_failure') && (
<Badge variant="danger" className="!text-[9px]">失败重试</Badge>
)}
</div>
)}
</div>
)
}
/* ===== Kanban Page Component ===== */
export default function KanbanPage() {
const isDesktop = !isTouchDevice()
/* data */
const [tasks, setTasks] = useState<KanbanTask[]>([])
const [overview, setOverview] = useState<KanbanOverview | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
/* filters */
const [workerFilter, setWorkerFilter] = useState('')
const [boardFilter, setBoardFilter] = useState('')
const [anomalyFilter, setAnomalyFilter] = useState('')
/* drawer */
const [drawerTask, setDrawerTask] = useState<KanbanTask | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false)
/* drag state */
const dragTask = useRef<KanbanTask | null>(null)
const [blockReasonModal, setBlockReasonModal] = useState<{ task: KanbanTask; toColumn: string } | null>(null)
const [blockReason, setBlockReason] = useState('')
const [confirmDone, setConfirmDone] = useState<KanbanTask | null>(null)
/* batch */
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set())
const [batchActing, setBatchActing] = useState(false)
/* filter options */
const [profiles, setProfiles] = useState<string[]>([])
const [boards, setBoards] = useState<string[]>([])
/* ---------- fetch ---------- */
const fetchTasks = useCallback(async () => {
try {
const params: Record<string, string> = {}
if (workerFilter) params.worker = workerFilter
if (boardFilter) params.board = boardFilter
if (anomalyFilter) params.anomaly = anomalyFilter
const res = await getKanbanTasks(params)
const data = res.data
const all = [...data.todo, ...data.ready, ...data.running, ...data.blocked, ...data.done]
setTasks(all)
setOverview(data.overview)
setProfiles(Array.from(new Set(all.map(t => t.worker_profile))))
setBoards(Array.from(new Set(all.map(t => t.board))))
setError(null)
} catch {
setError('加载看板数据失败')
} finally {
setLoading(false)
}
}, [workerFilter, boardFilter, anomalyFilter])
useEffect(() => {
setLoading(true)
fetchTasks()
const id = setInterval(fetchTasks, 30_000)
return () => clearInterval(id)
}, [fetchTasks])
/* ---------- helpers: get column tasks ---------- */
const getColumnTasks = (col: string): KanbanTask[] =>
tasks.filter(t => t.column === col).sort((a, b) => a.priority - b.priority)
/* ---------- drag & drop ---------- */
const handleDragStart = (e: React.DragEvent, task: KanbanTask) => {
if (!isDesktop) { e.preventDefault(); return }
dragTask.current = task
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', task.id)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
const handleDrop = async (e: React.DragEvent, column: string) => {
e.preventDefault()
const task = dragTask.current
if (!task || task.column === column) return
dragTask.current = null
if (column === 'blocked') {
setBlockReasonModal({ task, toColumn: column })
setBlockReason('')
return
}
if (column === 'done') {
setConfirmDone(task)
return
}
await doMove(task, column)
}
const doMove = async (task: KanbanTask, column: string, reason?: string) => {
const prevTasks = [...tasks]
// optimistic update
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, column, block_reason: reason || null } : t))
try {
await moveKanbanTask(task.id, column, reason)
toast.success('已移动到 ' + COLUMN_META[column].label)
} catch {
setTasks(prevTasks)
toast.error('移动失败,已回滚')
}
}
const confirmBlockReason = async () => {
if (!blockReasonModal) return
if (blockReason.trim().length < 10) {
toast.warn('阻塞原因至少 10 字')
return
}
await doMove(blockReasonModal.task, blockReasonModal.toColumn, blockReason.trim())
setBlockReasonModal(null)
setBlockReason('')
}
const confirmDoneMove = async () => {
if (!confirmDone) return
await doMove(confirmDone, 'done')
setConfirmDone(null)
}
/* ---------- drawer ---------- */
const openDrawer = async (task: KanbanTask) => {
setDrawerTask(task)
setDrawerOpen(true)
// fetch fresh detail from API
try {
const res = await getKanbanTaskDetail(task.id)
setDrawerTask(res.data)
} catch {
// use existing data
}
}
const closeDrawer = () => {
setDrawerOpen(false)
setTimeout(() => setDrawerTask(null), 250)
}
/* ---------- batch ---------- */
const handleCheckChange = (id: string, v: boolean) => {
setCheckedIds(prev => {
const next = new Set(prev)
if (v) next.add(id); else next.delete(id)
return next
})
}
const clearSelection = () => setCheckedIds(new Set())
const batchMove = async (column: string, reason?: string) => {
if (checkedIds.size === 0) return
setBatchActing(true)
const ids = Array.from(checkedIds)
let successCount = 0
for (const id of ids) {
try {
await moveKanbanTask(id, column, reason)
successCount++
} catch {
// continue
}
}
if (successCount > 0) {
toast.success('已完成 ' + successCount + ' 个任务')
setCheckedIds(new Set())
fetchTasks()
} else {
toast.error('批量操作失败')
}
setBatchActing(false)
}
const batchBlocked = () => {
const reason = prompt('请输入阻塞原因(至少 10 字):')
if (!reason || reason.trim().length < 10) {
toast.warn('阻塞原因至少 10 字')
return
}
batchMove('blocked', reason.trim())
}
/* ---------- render: loading ---------- */
if (loading) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
看板追踪
</h2>
<Skeleton variant="card" count={4} />
</div>
)
}
/* ---------- render: error ---------- */
if (error) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
看板追踪
</h2>
<div className="flex flex-col items-center justify-center py-12 gap-3" style={{ color: 'var(--text-dim)' }}>
<span style={{ fontSize: 32, color: 'var(--red)' }}>⚠</span>
<p>{error}</p>
<Button variant="outline" onClick={fetchTasks}>重试</Button>
</div>
</div>
)
}
/* ---------- render ---------- */
const checkedCount = checkedIds.size
return (
<div className="flex flex-col h-full">
{/* ===== Top Summary Bar ===== */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-2 flex-wrap">
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginRight: 8 }}>
看板追踪
</h2>
{overview && (
<div className="flex items-center gap-3 text-[13px]" style={{ color: 'var(--text-dim)' }}>
<span>{overview.total_tasks} 个任务</span>
<span className="w-[1px] h-3 bg-[var(--border)]" />
<span>{overview.profile_count} 个 Profile</span>
<span className="w-[1px] h-3 bg-[var(--border)]" />
<span style={{ color: 'var(--blue)' }}>{overview.running_count} 个 RUNNING</span>
{overview.blocked_count > 0 && (
<>
<span className="w-[1px] h-3 bg-[var(--border)]" />
<span style={{ color: 'var(--orange)' }}>{overview.blocked_count} 个阻塞</span>
</>
)}
</div>
)}
</div>
{/* Filters */}
<div className="flex items-center gap-2">
<select
value={workerFilter}
onChange={(e) => setWorkerFilter(e.target.value)}
className="text-[12px] px-2 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)', maxWidth: 130 }}
>
<option value="">全部 Worker</option>
{profiles.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
<select
value={boardFilter}
onChange={(e) => setBoardFilter(e.target.value)}
className="text-[12px] px-2 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)', maxWidth: 130 }}
>
<option value="">全部看板</option>
{boards.map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
<select
value={anomalyFilter}
onChange={(e) => setAnomalyFilter(e.target.value)}
className="text-[12px] px-2 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)', maxWidth: 140 }}
>
<option value="">全部状态</option>
<option value="blocked_timeout">阻塞超时</option>
<option value="unclaimed">长期无主</option>
<option value="retry_failure">失败重试</option>
</select>
</div>
</div>
{/* ===== Five-column Board ===== */}
<div
className="flex gap-3 flex-1 overflow-x-auto pb-4"
style={{ minHeight: 0 }}
>
{COLUMN_ORDER.map((colKey) => {
const colTasks = getColumnTasks(colKey)
const meta = COLUMN_META[colKey]
const isBlocked = colKey === 'blocked'
return (
<div
key={colKey}
className="flex flex-col rounded-[var(--radius)] shrink-0"
style={{
minWidth: 240,
width: 0,
flex: '1 1 0',
backgroundColor: meta.bg,
border: isBlocked ? '1px solid var(--orange)' : '1px solid var(--border)',
borderLeft: isBlocked ? '3px solid var(--orange)' : '1px solid var(--border)',
}}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, colKey)}
>
{/* Column Header */}
<div
className="sticky top-0 z-10 px-3 py-2.5 flex items-center justify-between"
style={{
backgroundColor: isBlocked ? 'rgba(255,109,0,0.04)' : 'transparent',
borderBottom: '1px solid var(--border)',
}}
>
<span style={{ color: meta.color, fontSize: 12, fontWeight: 700, letterSpacing: '0.5px' }}>
{meta.label}
</span>
<span
className="text-[11px] font-semibold px-2 py-0.5 rounded-[10px]"
style={{
backgroundColor: 'var(--bg-hover)',
color: 'var(--text-dim)',
}}
>
{colTasks.length}
</span>
</div>
{/* Column Content */}
<div className="flex-1 overflow-y-auto p-2 space-y-2" style={{ minHeight: 120 }}>
{colTasks.length === 0 ? (
<div className="flex items-center justify-center h-24 text-[12px]" style={{ color: 'var(--text-muted)' }}>
暂无任务
</div>
) : (
colTasks.map((t) => (
<TaskCard
key={t.id}
task={t}
onDragStart={handleDragStart}
onCardClick={openDrawer}
checked={checkedIds.has(t.id)}
onCheckChange={handleCheckChange}
/>
))
)}
</div>
</div>
)
})}
</div>
{/* ===== Detail Drawer ===== */}
<Drawer
open={drawerOpen}
onClose={closeDrawer}
width={480}
title={
drawerTask ? (
<span className="flex items-center gap-2">
<Badge variant={
drawerTask.column === 'running' ? 'success' :
drawerTask.column === 'blocked' ? 'warning' :
drawerTask.column === 'done' ? 'outline' : 'info'
}>
{COLUMN_META[drawerTask.column]?.label || drawerTask.column}
</Badge>
{drawerTask.title}
</span>
) : undefined
}
footer={
drawerTask ? (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm">编辑</Button>
{drawerTask.column !== 'blocked' && (
<Button
variant="outline"
size="sm"
onClick={() => {
closeDrawer()
setBlockReasonModal({ task: drawerTask, toColumn: 'blocked' })
setBlockReason('')
}}
>
标记阻塞
</Button>
)}
{drawerTask.column !== 'done' && (
<Button
variant="primary"
size="sm"
onClick={async () => {
await doMove(drawerTask, 'done')
closeDrawer()
}}
>
标记完成
</Button>
)}
<Button variant="danger" size="sm" style={{ marginLeft: 'auto' }}>
删除
</Button>
</div>
) : undefined
}
>
{drawerTask && (
<div className="space-y-5 text-[13px]">
{/* Meta Section */}
<div className="grid grid-cols-2 gap-3">
<MetaItem label="看板" value={drawerTask.board} />
<MetaItem label="Worker" value={drawerTask.worker_profile} />
<MetaItem label="类型" value={drawerTask.worker_type} />
<MetaItem
label="优先级"
value={
<span style={{ color: priorityColor(drawerTask.priority), fontWeight: 600 }}>
{priorityLabel(drawerTask.priority)}
</span>
}
/>
<MetaItem label="创建时间" value={relativeTime(drawerTask.created_at)} />
<MetaItem label="更新时间" value={relativeTime(drawerTask.updated_at)} />
<MetaItem
label="Goal Mode"
value={drawerTask.goal_mode ? '🎯 启用' : '未启用'}
/>
<MetaItem
label="重试/失败"
value={`${drawerTask.retries}/${drawerTask.failures}`}
valueColor={drawerTask.failures > 0 ? 'var(--red)' : undefined}
/>
</div>
{/* Anomalies */}
{drawerTask.anomalies.length > 0 && (
<div>
<SectionTitle>异常标记</SectionTitle>
<div className="flex gap-2">
{drawerTask.anomalies.map((a) => (
<Badge key={a} variant={a === 'unclaimed' ? 'warning' : 'danger'}>
{a === 'blocked_timeout' ? '阻塞超时' : a === 'unclaimed' ? '长期无主' : '失败重试'}
</Badge>
))}
</div>
</div>
)}
{/* Block Reason */}
{drawerTask.block_reason && (
<div>
<SectionTitle>阻塞原因</SectionTitle>
<div
className="p-3 rounded-[var(--radius-sm)]"
style={{ backgroundColor: 'var(--bg-warn)', color: 'var(--orange)' }}
>
{drawerTask.block_reason}
</div>
</div>
)}
{/* Description */}
<div>
<SectionTitle>任务描述</SectionTitle>
<div
className="p-3 rounded-[var(--radius-sm)] leading-relaxed whitespace-pre-wrap"
style={{ backgroundColor: 'var(--bg-card)', color: 'var(--text)', fontFamily: 'var(--font-mono)', fontSize: 12 }}
>
{drawerTask.description}
</div>
</div>
{/* Dependencies */}
{(drawerTask.dependency_tasks && drawerTask.dependency_tasks.length > 0) && (
<div>
<SectionTitle>依赖任务</SectionTitle>
<div className="space-y-1">
{drawerTask.dependency_tasks.map((dep) => (
<div
key={dep.id}
className="flex items-center gap-2 p-2 rounded-[var(--radius-sm)] cursor-pointer"
style={{ backgroundColor: 'var(--bg-hover)' }}
onClick={() => {
const found = tasks.find(t => t.id === dep.id)
if (found) { closeDrawer(); setTimeout(() => openDrawer(found), 300) }
}}
>
<Badge variant="outline" className="!text-[10px]">{dep.column}</Badge>
<span style={{ color: 'var(--text)' }}>{dep.title}</span>
</div>
))}
</div>
</div>
)}
{/* Dependents */}
{(drawerTask.dependent_tasks && drawerTask.dependent_tasks.length > 0) && (
<div>
<SectionTitle>被依赖任务</SectionTitle>
<div className="space-y-1">
{drawerTask.dependent_tasks.map((dep) => (
<div
key={dep.id}
className="flex items-center gap-2 p-2 rounded-[var(--radius-sm)] cursor-pointer"
style={{ backgroundColor: 'var(--bg-hover)' }}
onClick={() => {
const found = tasks.find(t => t.id === dep.id)
if (found) { closeDrawer(); setTimeout(() => openDrawer(found), 300) }
}}
>
<Badge variant="outline" className="!text-[10px]">{dep.column}</Badge>
<span style={{ color: 'var(--text)' }}>{dep.title}</span>
</div>
))}
</div>
</div>
)}
{/* Activity Timeline */}
{drawerTask.activities && drawerTask.activities.length > 0 && (
<div>
<SectionTitle>活动</SectionTitle>
<div className="space-y-2">
{drawerTask.activities.map((act, i) => (
<div key={i} className="flex items-start gap-3 text-[12px]">
<div
className="w-[6px] h-[6px] rounded-full mt-1.5 shrink-0"
style={{ backgroundColor: i === 0 ? 'var(--honey)' : 'var(--text-muted)' }}
/>
<div>
<span style={{ color: 'var(--text)' }}>{act.action}</span>
<div style={{ color: 'var(--text-muted)' }}>
{act.user} · {relativeTime(act.time)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</Drawer>
{/* ===== Block Reason Modal ===== */}
{blockReasonModal && (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={() => setBlockReasonModal(null)}
>
<div
className="bg-[var(--bg-nav)] rounded-[8px] p-5 w-full mx-4"
style={{ maxWidth: 400 }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
移动到 BLOCKED
</h3>
<p className="text-[12px] mb-3" style={{ color: 'var(--text-dim)' }}>
请填写阻塞原因(至少 10 字)
</p>
<textarea
value={blockReason}
onChange={(e) => setBlockReason(e.target.value)}
className="w-full p-2.5 rounded-[var(--radius-sm)] border text-[13px]"
style={{
borderColor: 'var(--border)',
backgroundColor: 'var(--bg-card)',
color: 'var(--text)',
minHeight: 80,
resize: 'vertical',
}}
placeholder="阻塞原因..."
autoFocus
/>
<div className="flex items-center justify-end gap-2 mt-4">
<Button variant="ghost" onClick={() => setBlockReasonModal(null)}>取消</Button>
<Button variant="primary" onClick={confirmBlockReason}>确认移动</Button>
</div>
</div>
</div>
)}
{/* ===== Done Confirm Modal ===== */}
{confirmDone && (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={() => setConfirmDone(null)}
>
<div
className="bg-[var(--bg-nav)] rounded-[8px] p-5 w-full mx-4"
style={{ maxWidth: 400 }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
确认完成
</h3>
<p className="text-[13px]" style={{ color: 'var(--text-dim)' }}>
确认将 <strong style={{ color: 'var(--text)' }}>{confirmDone.title}</strong> 移入 DONE 列?
</p>
<div className="flex items-center justify-end gap-2 mt-4">
<Button variant="ghost" onClick={() => setConfirmDone(null)}>取消</Button>
<Button variant="primary" onClick={confirmDoneMove}>确认</Button>
</div>
</div>
</div>
)}
{/* ===== Batch Operations Bar ===== */}
{checkedCount > 0 && (
<div
className="fixed bottom-0 left-1/2 -translate-x-1/2 z-[999] flex items-center gap-3 px-5 py-3 rounded-t-[var(--radius)] shadow-[var(--shadow-lg)]"
style={{
backgroundColor: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderBottom: 'none',
minWidth: 400,
justifyContent: 'center',
}}
>
<span className="text-[13px]" style={{ color: 'var(--text-dim)' }}>
已选择 {checkedCount} 个任务
</span>
<div className="w-[1px] h-4 bg-[var(--border)]" />
<Button
variant="primary"
size="sm"
loading={batchActing}
onClick={() => batchMove('done')}
>
标记完成
</Button>
<Button
variant="outline"
size="sm"
loading={batchActing}
onClick={batchBlocked}
>
标记阻塞
</Button>
<Button variant="ghost" size="sm" onClick={clearSelection}>
清除选择
</Button>
</div>
)}
</div>
)
}
/* ===== Sub-components ===== */
function MetaItem({ label, value, valueColor }: { label: string; value: React.ReactNode; valueColor?: string }) {
return (
<div>
<div className="text-[11px] mb-0.5" style={{ color: 'var(--text-muted)' }}>{label}</div>
<div style={{ color: valueColor || 'var(--text)', fontWeight: 500 }}>{value}</div>
</div>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="text-[12px] font-semibold mb-2" style={{ color: 'var(--text-dim)', letterSpacing: '0.3px' }}>
{children}
</div>
)
}
+622 -2
View File
@@ -1,3 +1,623 @@
export default function KeysPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>密钥管理</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>API 密钥管理</p></div>
import { useEffect, useState, useCallback, useMemo, type ReactNode } from 'react'
import { useAuth } from '../hooks/useAuth'
import Table, { type TableColumn } from '../components/ui/Table'
import Button from '../components/ui/Button'
import Modal from '../components/ui/Modal'
import Skeleton from '../components/ui/Skeleton'
import Toggle from '../components/ui/Toggle'
import toast from '../components/ui/Toast'
import {
getKeys,
batchOverwrite,
batchScan,
getDiff,
exportCsv,
WHITELIST_FIELDS,
type KeyFieldEntry,
type ScanResult,
type OverwriteResult,
type DiffRow,
} from '../api/keys'
/* ---------- helpers ---------- */
function downloadBlob(content: string, filename: string, mime = 'text/csv') {
const blob = new Blob([content], { type: mime })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
/* ---------- component ---------- */
export default function KeysPage() {
const { user } = useAuth()
const isOperator = user?.role === 'owner' || user?.role === 'operator'
/* data */
const [entries, setEntries] = useState<KeyFieldEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
/* filters */
const [profileFilter, setProfileFilter] = useState('all')
const [searchText, setSearchText] = useState('')
/* selection */
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set())
/* overwrite modal */
const [showOverwrite, setShowOverwrite] = useState(false)
const [owField, setOwField] = useState(WHITELIST_FIELDS[0])
const [owValue, setOwValue] = useState('')
const [owShowPlain, setOwShowPlain] = useState(false)
const [owConfirmation, setOwConfirmation] = useState('')
const [owActing, setOwActing] = useState(false)
const [owResults, setOwResults] = useState<OverwriteResult[] | null>(null)
/* scan results */
const [scanResults, setScanResults] = useState<ScanResult[] | null>(null)
const [scanning, setScanning] = useState(false)
/* diff view */
const [diffRows, setDiffRows] = useState<DiffRow[] | null>(null)
const [diffProfiles, setDiffProfiles] = useState<string[]>([])
const [diffOnly, setDiffOnly] = useState(false)
const [diffLoading, setDiffLoading] = useState(false)
/* ---------- fetch keys ---------- */
const fetchKeys = useCallback(async () => {
try {
const profiles = profileFilter === 'all' ? undefined : profileFilter
const res = await getKeys(profiles)
setEntries(res.data.data)
setError(null)
} catch {
setError('加载密钥列表失败')
} finally {
setLoading(false)
}
}, [profileFilter])
useEffect(() => {
setLoading(true)
fetchKeys()
const id = setInterval(fetchKeys, 30_000)
return () => clearInterval(id)
}, [fetchKeys])
/* ---------- selection ---------- */
const allProfiles = useMemo(() => entries.map((e) => e.profile), [entries])
const toggleSelect = (profile: string) => {
setSelectedProfiles((prev) => {
const next = new Set(prev)
if (next.has(profile)) next.delete(profile)
else next.add(profile)
return next
})
}
const toggleSelectAll = () => {
if (selectedProfiles.size === allProfiles.length) {
setSelectedProfiles(new Set())
} else {
setSelectedProfiles(new Set(allProfiles))
}
}
/* ---------- overwrite ---------- */
const openOverwrite = () => {
setOwField(WHITELIST_FIELDS[0])
setOwValue('')
setOwShowPlain(false)
setOwConfirmation('')
setOwResults(null)
setShowOverwrite(true)
}
const handleOverwrite = async () => {
const profileNames = Array.from(selectedProfiles)
setOwActing(true)
setOwResults(null)
try {
const res = await batchOverwrite({
profile_names: profileNames,
key_name: owField,
new_value: owValue,
confirmation: owConfirmation,
})
setOwResults(res.data.results)
toast.success('批量覆盖完成')
fetchKeys()
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '批量覆盖失败'
toast.error(msg)
} finally {
setOwActing(false)
}
}
/* ---------- scan ---------- */
const handleScan = async () => {
setScanning(true)
setScanResults(null)
try {
const profiles = profileFilter === 'all' ? undefined : [profileFilter]
const res = await batchScan(profiles)
setScanResults(res.data.results)
} catch {
toast.error('批量检查失败')
} finally {
setScanning(false)
}
}
const handleExportCsv = async () => {
try {
const profiles = profileFilter === 'all' ? undefined : profileFilter
const res = await exportCsv(profiles)
downloadBlob(res.data as unknown as string, 'keys-scan.csv')
toast.success('CSV 已导出')
} catch {
toast.error('导出 CSV 失败')
}
}
/* ---------- diff ---------- */
const handleViewDiff = async () => {
const profiles = Array.from(selectedProfiles).join(',')
setDiffLoading(true)
try {
const res = await getDiff(profiles)
setDiffRows(res.data.rows)
setDiffProfiles(res.data.profiles)
} catch {
toast.error('加载差异视图失败')
} finally {
setDiffLoading(false)
}
}
/* ---------- filtered & derived ---------- */
const filteredEntries = useMemo(() => {
if (!searchText.trim()) return entries
const q = searchText.toLowerCase()
return entries.filter((e) => {
for (const key of Object.keys(e.fields)) {
if (key.toLowerCase().includes(q)) return true
}
return e.profile.toLowerCase().includes(q)
})
}, [entries, searchText])
const profileOptions = useMemo(() => {
const seen = new Set<string>()
for (const e of entries) {
if (!seen.has(e.profile)) {
seen.add(e.profile)
}
}
return Array.from(seen).sort()
}, [entries])
/* ---------- render: loading ---------- */
if (loading) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
密钥管理
</h2>
<Skeleton variant="card" count={4} />
</div>
)
}
/* ---------- render: error ---------- */
if (error) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
密钥管理
</h2>
<div
className="flex flex-col items-center justify-center py-12 gap-3"
style={{ color: 'var(--text-dim)' }}
>
<span style={{ fontSize: 32, color: 'var(--red)' }}>!</span>
<p>{error}</p>
<Button variant="outline" onClick={fetchKeys}>重试</Button>
</div>
</div>
)
}
/* ---------- table columns ---------- */
const checkCol: TableColumn = {
key: '_check',
label: '',
width: 40,
}
const fieldColumns: TableColumn[] = WHITELIST_FIELDS.map((f) => ({
key: f,
label: f,
}))
const columns: TableColumn[] = [
checkCol,
{ key: 'profile', label: 'Profile' },
...fieldColumns,
{ key: 'missing', label: '缺失' },
]
const missingSet = new Set<string>()
for (const e of filteredEntries) {
if (e.missing > 0) missingSet.add(e.profile)
}
const rowClassName = (record: Record<string, unknown>) => {
if (missingSet.has(record.key as string)) return 'row-danger'
return ''
}
const tableData: Record<string, ReactNode>[] = filteredEntries.map((e) => {
const row: Record<string, ReactNode> = {
key: e.profile,
_check: (
<input
type="checkbox"
checked={selectedProfiles.has(e.profile)}
onChange={() => toggleSelect(e.profile)}
className="cursor-pointer accent-[var(--honey)]"
/>
),
profile: e.profile,
missing: e.missing > 0 ? (
<span style={{ color: 'var(--red)', fontWeight: 600 }}>{e.missing} ❌</span>
) : (
<span style={{ color: 'var(--green)' }}>0</span>
),
}
for (const f of WHITELIST_FIELDS) {
if (e.fields[f]) {
row[f] = <span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{e.fields[f]}</span>
} else {
row[f] = <span style={{ color: 'var(--red)' }}>❌</span>
}
}
return row
})
/* ---------- diff table ---------- */
const diffTableData = useMemo(() => {
if (!diffRows) return []
let rows = diffRows
if (diffOnly) {
rows = rows.filter((r) => {
const vals = diffProfiles.map((p) => r[p] as number)
return vals.some((v) => v !== vals[0])
})
}
return rows.map((r) => {
const row: Record<string, ReactNode> = { key: r.field, field: r.field }
for (const p of diffProfiles) {
const val = r[p] as number
row[p] = val > 0 ? (
<span style={{ color: 'var(--green)' }}>✅ ({val} 字符)</span>
) : (
<span style={{ color: 'var(--red)' }}>❌</span>
)
}
return row
})
}, [diffRows, diffProfiles, diffOnly])
const diffColumns: TableColumn[] = [
{ key: 'field', label: '字段' },
...diffProfiles.map((p) => ({ key: p, label: p })),
]
const diffRowClassName = (record: Record<string, unknown>) => {
const vals = diffProfiles.map((p) => record[p] as string)
if (vals.some((v) => v !== vals[0])) return 'row-diff-highlight'
return ''
}
/* ---------- render: scan results ---------- */
const scanColumns: TableColumn[] = [
{ key: 'profile', label: 'Profile' },
{ key: 'missing', label: '缺失字段' },
{ key: 'missing_count', label: '缺失数' },
]
const scanTableData: Record<string, ReactNode>[] = (scanResults ?? []).map((r) => ({
key: r.profile,
profile: r.profile,
missing: r.missing.length > 0 ? (
<span style={{ color: 'var(--red)' }}>{r.missing.join(', ')}</span>
) : (
<span style={{ color: 'var(--green)' }}>无</span>
),
missing_count: r.missing_count > 0 ? (
<span style={{ color: 'var(--red)', fontWeight: 600 }}>{r.missing_count}/{r.total}</span>
) : (
<span style={{ color: 'var(--green)' }}>{r.missing_count}/{r.total}</span>
),
}))
const scanRowClassName = (record: Record<string, unknown>) => {
if ((record.missing_count as string)?.startsWith('0/')) return ''
return 'row-danger'
}
/* ---------- render: overwrite results ---------- */
const owResultColumns: TableColumn[] = [
{ key: 'profile', label: 'Profile' },
{ key: 'status', label: '结果' },
]
const owResultData: Record<string, ReactNode>[] = (owResults ?? []).map((r) => ({
key: r.profile,
profile: r.profile,
status: r.success ? (
<span style={{ color: 'var(--green)' }}>✅ 成功</span>
) : (
<span style={{ color: 'var(--red)' }}>❌ 失败</span>
),
}))
/* ---------- render ---------- */
const expectedConfirmation = `我确认覆盖 ${selectedProfiles.size} 个 Profile`
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
密钥管理
</h2>
{/* Operation bar */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
{isOperator && (
<>
<Button
size="sm"
variant="primary"
disabled={selectedProfiles.size === 0}
onClick={openOverwrite}
>
批量覆盖
</Button>
<Button
size="sm"
variant="outline"
loading={scanning}
onClick={handleScan}
>
批量检查
</Button>
</>
)}
{selectedProfiles.size >= 2 && (
<Button
size="sm"
variant="outline"
loading={diffLoading}
onClick={handleViewDiff}
>
查看差异
</Button>
)}
<div className="w-[1px] h-5 bg-[var(--border)] mx-1" />
<select
value={profileFilter}
onChange={(e) => setProfileFilter(e.target.value)}
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)' }}
>
<option value="all">全部 Profile</option>
{profileOptions.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
<input
type="text"
placeholder="搜索密钥字段..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)', width: 180 }}
/>
{tableData.length > 0 && (
<label className="flex items-center gap-1.5 text-[13px] cursor-pointer" style={{ color: 'var(--text-dim)' }}>
<input
type="checkbox"
checked={selectedProfiles.size === allProfiles.length && allProfiles.length > 0}
onChange={toggleSelectAll}
className="cursor-pointer accent-[var(--honey)]"
/>
全选
</label>
)}
</div>
{/* Key list table */}
{tableData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12" style={{ color: 'var(--text-dim)' }}>
<span style={{ fontSize: 32, marginBottom: 8 }}>🔑</span>
<p>暂无密钥数据</p>
</div>
) : (
<Table columns={columns} data={tableData} rowKey="key" rowClassName={rowClassName} />
)}
{/* Scan results */}
{scanResults && (
<div className="mt-6">
<div className="flex items-center justify-between mb-3">
<h3 style={{ color: 'var(--text)', fontSize: 15, fontWeight: 600 }}>
批量检查结果
</h3>
<Button size="sm" variant="outline" onClick={handleExportCsv}>
导出 CSV
</Button>
</div>
{scanTableData.length === 0 ? (
<p style={{ color: 'var(--text-dim)', textAlign: 'center', padding: 24 }}>暂无结果</p>
) : (
<Table columns={scanColumns} data={scanTableData} rowKey="key" rowClassName={scanRowClassName} />
)}
</div>
)}
{/* Diff view */}
{diffRows && (
<div className="mt-6">
<div className="flex items-center justify-between mb-3">
<h3 style={{ color: 'var(--text)', fontSize: 15, fontWeight: 600 }}>
差异视图
</h3>
<div className="flex items-center gap-3">
<Toggle checked={diffOnly} onChange={setDiffOnly} label="只看差异" />
<Button size="sm" variant="ghost" onClick={() => { setDiffRows(null); setDiffProfiles([]) }}>
关闭
</Button>
</div>
</div>
{diffTableData.length === 0 ? (
<p style={{ color: 'var(--text-dim)', textAlign: 'center', padding: 24 }}>无差异数据</p>
) : (
<Table columns={diffColumns} data={diffTableData} rowKey="key" rowClassName={diffRowClassName} />
)}
</div>
)}
{/* ── Overwrite Modal ── */}
<Modal
open={showOverwrite}
onClose={() => { setShowOverwrite(false); setOwResults(null) }}
title="批量覆盖密钥"
width={560}
onConfirm={owResults ? undefined : handleOverwrite}
confirmText={owResults ? undefined : '确认覆盖'}
confirmDanger
>
{owResults ? (
<div>
<p className="mb-3" style={{ color: 'var(--text-dim)' }}>执行结果:</p>
<Table columns={owResultColumns} data={owResultData} rowKey="key" />
</div>
) : (
<div className="space-y-4">
{/* Field selector */}
<div>
<label className="block text-[13px] font-medium mb-1" style={{ color: 'var(--text-dim)' }}>
密钥字段
</label>
<select
value={owField}
onChange={(e) => setOwField(e.target.value)}
className="w-full text-[13px] px-3 py-2 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)' }}
>
{WHITELIST_FIELDS.map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
</div>
{/* New value */}
<div>
<label className="block text-[13px] font-medium mb-1" style={{ color: 'var(--text-dim)' }}>
新值
</label>
<div className="flex items-center gap-2">
<input
type={owShowPlain ? 'text' : 'password'}
value={owValue}
onChange={(e) => setOwValue(e.target.value)}
className="flex-1 text-[13px] px-3 py-2 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)', fontFamily: 'var(--font-mono)' }}
/>
<Button size="sm" variant="ghost" onClick={() => setOwShowPlain(!owShowPlain)}>
{owShowPlain ? '隐藏' : '显示'}
</Button>
</div>
</div>
{/* Preview */}
<div className="p-3 rounded-[var(--radius-sm)]" style={{ background: 'var(--bg-hover)' }}>
<p className="text-[13px] mb-2" style={{ color: 'var(--text-dim)' }}>
将覆盖 <strong style={{ color: 'var(--honey)' }}>{selectedProfiles.size}</strong> 个 Profile 的 <code style={{ fontFamily: 'var(--font-mono)' }}>{owField}</code>
</p>
<div className="text-[12px]" style={{ color: 'var(--text-dim)' }}>
{Array.from(selectedProfiles).sort().map((p) => {
const entry = entries.find((e) => e.profile === p)
const current = entry?.fields[owField]
return (
<div key={p} className="flex items-center gap-2">
<span>{p}:</span>
{current ? (
<span style={{ fontFamily: 'var(--font-mono)' }}>{current}</span>
) : (
<span style={{ color: 'var(--red)' }}>当前无值</span>
)}
</div>
)
})}
</div>
</div>
{/* Confirmation */}
<div>
<label className="block text-[13px] font-medium mb-1" style={{ color: 'var(--text-dim)' }}>
确认短语
</label>
<input
type="text"
placeholder={expectedConfirmation}
value={owConfirmation}
onChange={(e) => setOwConfirmation(e.target.value)}
className="w-full text-[13px] px-3 py-2 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)' }}
/>
<p className="text-[12px] mt-1" style={{ color: 'var(--text-dim)' }}>
请输入: <code style={{ fontFamily: 'var(--font-mono)', color: 'var(--honey)' }}>{expectedConfirmation}</code>
</p>
</div>
</div>
)}
</Modal>
{/* Diff highlight style */}
<style>{`
.row-diff-highlight td {
background: var(--bg-warn) !important;
}
`}</style>
</div>
)
}
+203 -7
View File
@@ -1,14 +1,210 @@
import { type FormEvent, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { Eye, EyeOff } from 'lucide-react'
import { useAuth } from '../hooks/useAuth'
import toast from '../components/ui/Toast'
export default function LoginPage() {
const { login } = useAuth()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPwd, setShowPwd] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(username, password)
navigate('/', { replace: true })
} catch (err: any) {
const status = err?.response?.status
const detail = err?.response?.data?.detail ?? '登录失败'
if (status === 423) {
setError('账户已锁定,请 15 分钟后再试')
} else if (status === 401) {
setError('用户名或密码错误')
} else {
setError(detail)
}
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-root)' }}>
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg-root)',
}}
>
<div
className="p-8 rounded-[var(--radius)] w-[360px]"
style={{ background: 'var(--bg-card)', boxShadow: 'var(--shadow-lg)' }}
style={{
width: 400,
padding: 32,
borderRadius: 24,
background: 'var(--bg-card)',
boxShadow: 'var(--shadow-lg)',
}}
>
<h1 className="text-center mb-6" style={{ color: 'var(--honey)', fontSize: 20, fontWeight: 700 }}>
🐝 知微蜂巢
</h1>
<p className="text-center" style={{ color: 'var(--text-dim)', fontSize: 13 }}>登录页面(待实现)</p>
{/* Logo */}
<div style={{ textAlign: 'center', marginBottom: 4 }}>
<div style={{ fontSize: 28, fontWeight: 700, color: '#fff' }}>
🐝 知微蜂巢
</div>
<div style={{ fontSize: 14, color: 'var(--text-dim)', marginTop: 4 }}>
Hive Manage v3
</div>
</div>
{/* Error */}
{error && (
<div
style={{
marginTop: 16,
padding: '8px 12px',
borderRadius: 'var(--radius)',
background: 'var(--bg-error)',
color: 'var(--red)',
fontSize: 13,
fontWeight: 500,
}}
>
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} style={{ marginTop: 24, display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<label
style={{
display: 'block',
fontSize: 12,
fontWeight: 600,
color: '#fff',
marginBottom: 6,
}}
>
用户名
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
required
autoFocus
style={{
width: '100%',
height: 42,
padding: '0 12px',
borderRadius: 8,
border: '1px solid var(--border)',
background: 'var(--bg-root)',
color: 'var(--text)',
fontSize: 13,
outline: 'none',
fontFamily: 'inherit',
}}
/>
</div>
<div>
<label
style={{
display: 'block',
fontSize: 12,
fontWeight: 600,
color: '#fff',
marginBottom: 6,
}}
>
密码
</label>
<div style={{ position: 'relative' }}>
<input
type={showPwd ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
required
style={{
width: '100%',
height: 42,
padding: '0 36px 0 12px',
borderRadius: 8,
border: '1px solid var(--border)',
background: 'var(--bg-root)',
color: 'var(--text)',
fontSize: 13,
outline: 'none',
fontFamily: 'inherit',
}}
/>
<button
type="button"
onClick={() => setShowPwd(!showPwd)}
style={{
position: 'absolute',
right: 10,
top: '50%',
transform: 'translateY(-50%)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: 'var(--text-muted)',
padding: 4,
display: 'flex',
alignItems: 'center',
}}
tabIndex={-1}
>
{showPwd ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
height: 48,
borderRadius: 8,
border: 'none',
background: 'var(--honey)',
color: '#000',
fontSize: 14,
fontWeight: 700,
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1,
fontFamily: 'inherit',
}}
>
{loading ? '登录中...' : '登入'}
</button>
</form>
{/* Register link */}
<p
style={{
textAlign: 'center',
marginTop: 20,
fontSize: 13,
color: 'var(--text-dim)',
}}
>
还没有账号?{' '}
<Link to="/register" style={{ color: 'var(--honey)', fontWeight: 600 }}>
注册
</Link>
</p>
</div>
</div>
)
+159 -2
View File
@@ -1,3 +1,160 @@
export default function UserManagementPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>用户管理</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>用户管理与权限</p></div>
import { useEffect, useState, useCallback } from 'react'
import { useAuth } from '../hooks/useAuth'
import { getUsers, updateUser, approveUser } from '../api/users'
import Table, { type TableColumn } from '../components/ui/Table'
import Button from '../components/ui/Button'
import Badge from '../components/ui/Badge'
import toast from '../components/ui/Toast'
interface UserRecord {
id: string
username: string
email: string
role: string
is_active: boolean
is_approved: boolean
created_at: string
}
const ROLE_OPTIONS = ['owner', 'operator', 'viewer']
export default function UserManagementPage() {
const { user: me } = useAuth()
const [users, setUsers] = useState<UserRecord[]>([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(false)
const fetchUsers = useCallback(async (p: number) => {
setLoading(true)
try {
const res = await getUsers({ page: p, page_size: 20 })
const data: UserRecord[] = res.data
setUsers(data)
setHasMore(data.length === 20)
} catch {
toast.error('加载用户列表失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchUsers(page)
}, [page, fetchUsers])
const handleRoleChange = async (userId: string, newRole: string) => {
try {
await updateUser(userId, { role: newRole })
toast.success('角色已更新')
fetchUsers(page)
} catch {
toast.error('更新角色失败')
}
}
const handleApprove = async (userId: string) => {
try {
await approveUser(userId)
toast.success('用户已审批通过')
fetchUsers(page)
} catch {
toast.error('审批失败')
}
}
const isOwner = me?.role === 'owner'
const columns: TableColumn[] = [
{ key: 'username', label: '用户名' },
{ key: 'email', label: '邮箱' },
{ key: 'role', label: '角色' },
{ key: 'status', label: '状态' },
{ key: 'approve', label: '审批状态' },
{ key: 'created_at', label: '创建时间' },
...(isOwner ? [{ key: 'actions', label: '操作' }] : []),
]
const data = users.map((u) => ({
username: u.username,
email: u.email,
role: isOwner ? (
<select
value={u.role}
onChange={(e) => handleRoleChange(u.id, e.target.value)}
className="px-2 py-1 rounded text-[12px] outline-none cursor-pointer"
style={{
background: 'var(--bg-root)',
color: 'var(--text)',
border: '1px solid var(--border)',
}}
>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
) : (
<Badge variant={u.role === 'owner' ? 'honey' : u.role === 'operator' ? 'info' : 'outline'}>
{u.role}
</Badge>
),
status: u.is_active ? (
<Badge variant="success">active</Badge>
) : (
<Badge variant="danger">inactive</Badge>
),
approve: u.is_approved ? (
<Badge variant="success">已审批</Badge>
) : (
<Badge variant="warning">待审批</Badge>
),
created_at: new Date(u.created_at).toLocaleString('zh-CN'),
actions: isOwner ? (
<div className="flex items-center gap-2">
{!u.is_approved && (
<Button size="sm" onClick={() => handleApprove(u.id)}>
审批
</Button>
)}
</div>
) : null,
}))
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>用户管理</h2>
<p style={{ color: 'var(--text-dim)', marginTop: 4, marginBottom: 16, fontSize: 13 }}>
用户管理与权限
</p>
<Table columns={columns} data={data} rowKey="username" />
{/* Pagination */}
<div className="flex items-center justify-between mt-4">
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>
第 {page} 页
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
上一页
</Button>
<Button
size="sm"
variant="outline"
disabled={!hasMore || loading}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
</div>
)
}
+145 -67
View File
@@ -1,83 +1,161 @@
/* ===== 暗色模式(默认) ===== */
/* =============================================================
* Hive Manage v2 — Design Tokens (CSS Variables)
* 基于 UI-design-spec.md v2.1
*
* 使用方式:
* 暗色模式(默认): 直接引入本文件
* 浅色模式: 在 <html> 上加 data-theme="light"
* 跟随系统: 用 @media (prefers-color-scheme: light) 覆盖
* ============================================================= */
/* === 暗色模式(默认) === */
:root {
--bg-root: #0f1117;
--bg-nav: #161822;
--bg-card: #1a1d2e;
--bg-hover: #1e2134;
--bg-elevated: #222639;
--border: #2a2d3e;
--border-light: rgba(42, 45, 62, 0.5);
--honey: #f0b90b;
--honey-dim: #b8970e;
--honey-glow: rgba(240, 185, 11, 0.12);
--green: #00c853;
--red: #ff3d3d;
--yellow: #ffab00;
--blue: #448aff;
--orange: #ff6d00;
--text: #e0e0e0;
--text-dim: #8b8d9a;
--text-muted: #5a5d6a;
--bg-success: #0d2818;
--bg-error: #2d0f0f;
--bg-warn: #2d2000;
--bg-info: #0d1b2d;
--radius: 8px;
--radius-sm: 4px;
--radius-lg: 12px;
/* 背景层级 — 对齐参考图实际色值 */
--bg-root: #03050a;
--bg-nav: #060810;
--bg-card: #0b0e17;
--bg-hover: #111522;
--bg-elevated: #151b28;
/* 边框 */
--border: #2a2d3e;
--border-light: rgba(42,45,62,0.5);
/* 品牌色 */
--honey: #e9ab01;
--honey-dim: #c48f00;
--honey-glow: rgba(233,171,1,0.12);
/* 功能色 */
--green: #23ad3f;
--red: #e04343;
--yellow: #e9ab01;
--blue: #3b82f6;
--orange: #f59e0b;
/* 文字 */
--text: #e0e0e0;
--text-dim: #8b8d9a;
--text-muted: #5a5d6a;
/* 语义背景 */
--bg-success: #0d2818;
--bg-error: #2d0f0f;
--bg-warn: #2d2000;
--bg-info: #0d1b2d;
/* 圆角 */
--radius: 8px;
--radius-sm: 4px;
--radius-lg: 12px;
--radius-full: 9999px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.55);
--shadow-modal: 0 24px 64px rgba(0, 0, 0, 0.65);
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "SF Mono", "Fira Code", Consolas, monospace;
--header-h: 48px;
--sidebar-w: 220px;
/* 阴影 */
--shadow: 0 2px 8px rgba(0,0,0,0.35);
--shadow-lg: 0 12px 32px rgba(0,0,0,0.55);
--shadow-modal: 0 24px 64px rgba(0,0,0,0.65);
/* 间距 */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 20px;
--space-2xl: 24px;
--space-3xl: 32px;
/* 字体 */
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "SF Mono", "Fira Code", Consolas, monospace;
}
/* ===== 浅色模式 ===== */
/* === 浅色模式 === */
[data-theme="light"] {
--bg-root: #f5f6fa;
--bg-nav: #ffffff;
--bg-card: #ffffff;
--bg-hover: #edf0f5;
--bg-root: #f5f6fa;
--bg-nav: #ffffff;
--bg-card: #ffffff;
--bg-hover: #edf0f5;
--bg-elevated: #ffffff;
--border: #e2e5ee;
--border-light: rgba(0, 0, 0, 0.06);
--honey: #e5a800;
--honey-dim: #c48f00;
--honey-glow: rgba(229, 168, 0, 0.08);
--green: #008c3a;
--red: #d32f2f;
--yellow: #e65100;
--blue: #1565c0;
--orange: #d84315;
--text: #1a1d2e;
--text-dim: #555a6e;
--text-muted: #8b8f9e;
--bg-success: #e8f5e9;
--bg-error: #fdecea;
--bg-warn: #fff3e0;
--bg-info: #e3f2fd;
--shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
--shadow-modal: 0 16px 48px rgba(0, 0, 0, 0.14);
--border: #e2e5ee;
--border-light: rgba(0,0,0,0.06);
--honey: #e5a800;
--honey-dim: #c48f00;
--honey-glow: rgba(229,168,0,0.08);
--green: #008c3a;
--red: #d32f2f;
--yellow: #e65100;
--blue: #1565c0;
--orange: #d84315;
--text: #1a1d2e;
--text-dim: #555a6e;
--text-muted: #8b8f9e;
--bg-success: #e8f5e9;
--bg-error: #fdecea;
--bg-warn: #fff3e0;
--bg-info: #e3f2fd;
--shadow: 0 1px 4px rgba(0,0,0,0.08);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.10);
--shadow-modal: 0 16px 48px rgba(0,0,0,0.14);
}
/* ===== 全局基础 ===== */
/* === 自动跟随系统 === */
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg-root: #f5f6fa;
--bg-nav: #ffffff;
--bg-card: #ffffff;
--bg-hover: #edf0f5;
--bg-elevated: #ffffff;
--border: #e2e5ee;
--border-light: rgba(0,0,0,0.06);
--honey: #e5a800;
--honey-dim: #c48f00;
--honey-glow: rgba(229,168,0,0.08);
--green: #008c3a;
--red: #d32f2f;
--yellow: #e65100;
--blue: #1565c0;
--orange: #d84315;
--text: #1a1d2e;
--text-dim: #555a6e;
--text-muted: #8b8f9e;
--bg-success: #e8f5e9;
--bg-error: #fdecea;
--bg-warn: #fff3e0;
--bg-info: #e3f2fd;
--shadow: 0 1px 4px rgba(0,0,0,0.08);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.10);
--shadow-modal: 0 16px 48px rgba(0,0,0,0.14);
}
}
/* === 主题切换过渡 === */
* {
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
/* === 全局 Reset === */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font);
font-size: 13px;
html, body {
font-family: var(--font-sans);
background: var(--bg-root);
color: var(--text);
font-size: 13px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 主题切换过渡 */
* {
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
}
/* Backward compat aliases */
:root { --font: var(--font-sans); --header-h: 48px; --sidebar-w: 220px; }