Compare commits
22
Commits
7441546804
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e393f265d | ||
|
|
ae04ffa9a0 | ||
|
|
c79b1af392 | ||
|
|
cbd8b347db | ||
|
|
a97b885081 | ||
|
|
db11548414 | ||
|
|
e5bb7ec49a | ||
|
|
cb296ba09e | ||
|
|
1de5fcf698 | ||
|
|
07ff8c2a12 | ||
|
|
681e4b3a7d | ||
|
|
c7563ede47 | ||
|
|
ff813824fa | ||
|
|
eaf13e5f51 | ||
|
|
5c0352c768 | ||
|
|
3d905b606a | ||
|
|
670d222665 | ||
|
|
8bfefd199c | ||
|
|
6d4a5c955b | ||
|
|
76045538bb | ||
|
|
10404be62d | ||
|
|
e13be8eb1c |
@@ -6,4 +6,5 @@ dist/
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
venv/
|
||||
.venv/
|
||||
.DS_Store
|
||||
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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="任务不存在")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1 +1,3 @@
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.event import Event
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 执行结束")
|
||||
@@ -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("健康分缓存已清除")
|
||||
@@ -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 []
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
```
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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")`
|
||||
|
||||
完成后提交。
|
||||
@@ -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。
|
||||
|
||||
完成后提交。
|
||||
@@ -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(失败数)
|
||||
|
||||
### 四态覆盖
|
||||
|
||||
注册路由, 完成后提交。
|
||||
@@ -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, 纵向字段, ✅有/❌无
|
||||
- 差异行黄色高亮
|
||||
- "只看差异" 切换
|
||||
|
||||
### 四态覆盖 + 审计日志
|
||||
|
||||
注册路由, 完成后提交。
|
||||
@@ -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,失败回滚)
|
||||
|
||||
注册路由, 完成后提交。
|
||||
@@ -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
|
||||
- 阴影超过三层
|
||||
@@ -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"
|
||||
@@ -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/ 前缀。
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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)"
|
||||
```
|
||||
@@ -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)"
|
||||
```
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import client from './client'
|
||||
|
||||
export const getAuditLogs = (params?: { page?: number; page_size?: number; action?: string }) =>
|
||||
client.get('/audit', { params })
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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`)
|
||||
@@ -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 } })
|
||||
@@ -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 })
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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 })
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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`)
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user