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/
|
.pytest_cache/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
venv/
|
venv/
|
||||||
|
.venv/
|
||||||
.DS_Store
|
.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 fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.database import engine, Base
|
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
|
@asynccontextmanager
|
||||||
@@ -27,6 +29,18 @@ app.add_middleware(
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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")
|
@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):
|
class UserRole(str, enum.Enum):
|
||||||
OWNER = "owner"
|
owner = "owner"
|
||||||
OPERATOR = "operator"
|
operator = "operator"
|
||||||
VIEWER = "viewer"
|
viewer = "viewer"
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
@@ -21,7 +21,7 @@ class User(Base):
|
|||||||
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
hashed_password: Mapped[str] = mapped_column(String(255), 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_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
is_approved: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_approved: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
login_attempts: Mapped[int] = mapped_column(default=0)
|
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>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
+132
-28
@@ -1,9 +1,13 @@
|
|||||||
import { lazy, Suspense } from 'react'
|
import { lazy, Suspense, type ReactNode } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { AuthProvider, useAuth } from './hooks/useAuth'
|
||||||
import Skeleton from './components/ui/Skeleton'
|
import Skeleton from './components/ui/Skeleton'
|
||||||
import AppLayout from './components/layout/AppLayout'
|
import AppLayout from './components/layout/AppLayout'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import NotFoundPage from './pages/NotFoundPage'
|
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 DashboardPage = lazy(() => import('./pages/DashboardPage'))
|
||||||
const GatewayPage = lazy(() => import('./pages/GatewayPage'))
|
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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<AuthProvider>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<AppRoutes />
|
||||||
<Route path="/404" element={<NotFoundPage />} />
|
</AuthProvider>
|
||||||
<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>
|
|
||||||
</BrowserRouter>
|
</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 { useState, type ReactNode } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useTheme } from '../../hooks/useTheme'
|
import { useTheme } from '../../hooks/useTheme'
|
||||||
|
import { usePolling } from '../../hooks/usePolling'
|
||||||
|
import { getDashboardSummary } from '../../api/dashboard'
|
||||||
import Sidebar from './Sidebar'
|
import Sidebar from './Sidebar'
|
||||||
import Header from './Header'
|
import Header from './Header'
|
||||||
import MobileNav from './MobileNav'
|
import MobileNav from './MobileNav'
|
||||||
@@ -27,7 +29,9 @@ export interface AppLayoutProps {
|
|||||||
breadcrumb?: string
|
breadcrumb?: string
|
||||||
onlineCount?: number
|
onlineCount?: number
|
||||||
runningCount?: number
|
runningCount?: number
|
||||||
|
offlineCount?: number
|
||||||
onSearch?: (query: string) => any
|
onSearch?: (query: string) => any
|
||||||
|
onSearchNavigate?: (value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AppLayout({
|
export default function AppLayout({
|
||||||
@@ -35,7 +39,9 @@ export default function AppLayout({
|
|||||||
breadcrumb,
|
breadcrumb,
|
||||||
onlineCount = 0,
|
onlineCount = 0,
|
||||||
runningCount = 0,
|
runningCount = 0,
|
||||||
|
offlineCount = 0,
|
||||||
onSearch,
|
onSearch,
|
||||||
|
onSearchNavigate,
|
||||||
}: AppLayoutProps) {
|
}: AppLayoutProps) {
|
||||||
const { theme, toggle } = useTheme()
|
const { theme, toggle } = useTheme()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -50,6 +56,23 @@ export default function AppLayout({
|
|||||||
|
|
||||||
const sidebarWidth = isTablet ? 180 : 220
|
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) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col" style={{ background: 'var(--bg-root)' }}>
|
<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' }}>
|
<div style={{ gridArea: 'header' }}>
|
||||||
<Header
|
<Header
|
||||||
breadcrumb={breadcrumb}
|
breadcrumb={breadcrumb}
|
||||||
onlineCount={onlineCount}
|
onlineCount={hOnline}
|
||||||
runningCount={runningCount}
|
runningCount={hRunning}
|
||||||
|
offlineCount={hOffline}
|
||||||
onSearch={onSearch}
|
onSearch={onSearch}
|
||||||
|
onSearchNavigate={onSearchNavigate}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={toggle}
|
onToggleTheme={toggle}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import Badge from '../ui/Badge'
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
import SearchBox from '../ui/SearchBox'
|
import { Search } from 'lucide-react'
|
||||||
|
|
||||||
export interface HeaderProps {
|
export interface HeaderProps {
|
||||||
breadcrumb?: string
|
breadcrumb?: string
|
||||||
onlineCount?: number
|
onlineCount?: number
|
||||||
runningCount?: number
|
runningCount?: number
|
||||||
|
offlineCount?: number
|
||||||
onSearch?: (query: string) => any
|
onSearch?: (query: string) => any
|
||||||
|
onSearchNavigate?: (value: string) => void
|
||||||
theme: string
|
theme: string
|
||||||
onToggleTheme: () => void
|
onToggleTheme: () => void
|
||||||
}
|
}
|
||||||
@@ -16,64 +19,225 @@ export default function Header({
|
|||||||
onlineCount = 0,
|
onlineCount = 0,
|
||||||
runningCount = 0,
|
runningCount = 0,
|
||||||
onSearch,
|
onSearch,
|
||||||
|
onSearchNavigate,
|
||||||
theme,
|
theme,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const navigate = useNavigate()
|
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 (
|
return (
|
||||||
<header
|
<header
|
||||||
className="flex items-center gap-3 px-4 shrink-0"
|
|
||||||
style={{
|
style={{
|
||||||
|
gridArea: 'header',
|
||||||
height: 'var(--header-h)',
|
height: 'var(--header-h)',
|
||||||
background: 'var(--bg-nav)',
|
background: 'var(--bg-nav)',
|
||||||
borderBottom: '1px solid var(--border)',
|
borderBottom: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 20px',
|
||||||
|
gap: 16,
|
||||||
|
zIndex: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Breadcrumb */}
|
{/* Logo */}
|
||||||
<div className="flex items-center gap-2 text-[13px] min-w-0" style={{ color: 'var(--text-dim)' }}>
|
|
||||||
{breadcrumb && (
|
|
||||||
<>
|
|
||||||
<span
|
<span
|
||||||
className="cursor-pointer hover:text-[var(--text)] transition-colors"
|
style={{
|
||||||
onClick={() => navigate('/dashboard')}
|
fontWeight: 700,
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--honey)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
首页
|
🐝 知微蜂巢
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>
|
||||||
|
/{' '}
|
||||||
|
<span style={{ color: 'var(--text)' }}>
|
||||||
|
{breadcrumb || '蜂群总览'}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: 'var(--text-muted)' }}>/</span>
|
|
||||||
<span style={{ color: 'var(--text)', fontWeight: 500 }}>{breadcrumb}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div className="flex-1" />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
{onSearch && (
|
<div style={{ position: 'relative' }}>
|
||||||
<SearchBox onSearch={onSearch} placeholder="搜索功能、任务、日志..." />
|
<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 */}
|
{/* Status badges */}
|
||||||
<Badge variant="success">在线 {onlineCount}</Badge>
|
<span
|
||||||
<Badge variant="honey">运行中 {runningCount}</Badge>
|
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 */}
|
{/* Theme toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={onToggleTheme}
|
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' ? '切换浅色模式' : '切换深色模式'}
|
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' ? '☀️' : '🌙'}
|
{theme === 'dark' ? '☀️' : '🌙'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* User avatar */}
|
{/* User avatar */}
|
||||||
<div
|
<div
|
||||||
className="w-[30px] h-[30px] rounded-full flex items-center justify-center text-[13px] font-semibold cursor-pointer shrink-0"
|
style={{ position: 'relative', cursor: 'pointer', flexShrink: 0 }}
|
||||||
style={{ background: 'var(--honey-glow)', color: 'var(--honey)' }}
|
onMouseEnter={() => setMenuOpen(true)}
|
||||||
title="管理员"
|
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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { type ReactNode } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Badge from '../ui/Badge'
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
|
import { getDashboardSummary } from '../../api/dashboard'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
label: string
|
label: string
|
||||||
icon: string
|
icon: string
|
||||||
path: string
|
path: string
|
||||||
badge?: number
|
badge?: number
|
||||||
|
isNew?: boolean
|
||||||
|
requiredRole?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavSection {
|
interface NavSection {
|
||||||
@@ -18,8 +21,8 @@ const sections: NavSection[] = [
|
|||||||
title: '蜂群态势',
|
title: '蜂群态势',
|
||||||
items: [
|
items: [
|
||||||
{ label: '蜂群总览', icon: '📊', path: '/dashboard' },
|
{ label: '蜂群总览', icon: '📊', path: '/dashboard' },
|
||||||
{ label: 'Gateway', icon: '📡', path: '/gateway' },
|
{ label: 'Gateway 健康', icon: '📡', path: '/gateway', isNew: true },
|
||||||
{ label: '健康分', icon: '🏥', path: '/health' },
|
{ label: 'Profile 健康分', icon: '🏥', path: '/health', isNew: true },
|
||||||
{ label: '工蜂列表', icon: '🤖', path: '/workers' },
|
{ label: '工蜂列表', icon: '🤖', path: '/workers' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -27,32 +30,41 @@ const sections: NavSection[] = [
|
|||||||
title: '任务编排',
|
title: '任务编排',
|
||||||
items: [
|
items: [
|
||||||
{ label: '看板追踪', icon: '📋', path: '/kanban' },
|
{ label: '看板追踪', icon: '📋', path: '/kanban' },
|
||||||
{ label: 'Cron', icon: '⏰', path: '/cron' },
|
{ label: 'Cron 管理', icon: '⏰', path: '/cron', badge: 1 },
|
||||||
{ label: '事件管理', icon: '🔔', path: '/events' },
|
{ label: '事件管理', icon: '🔔', path: '/events', badge: 3 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '通信频道',
|
title: '通信频道',
|
||||||
items: [{ label: '频道配置', icon: '📶', path: '/channels' }],
|
items: [
|
||||||
|
{ label: '频道配置', icon: '📡', path: '/channels' },
|
||||||
|
{ label: '频道监控', icon: '📶', path: '/channel-monitor', isNew: true },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '运维工具',
|
title: '运维工具',
|
||||||
items: [
|
items: [
|
||||||
{ label: '密钥', icon: '🔑', path: '/keys' },
|
{ label: '密钥管理', icon: '🔑', path: '/keys' },
|
||||||
{ label: '文件', icon: '📁', path: '/files' },
|
{ label: '文件浏览', icon: '📁', path: '/files', isNew: true },
|
||||||
{ label: '终端', icon: '💻', path: '/terminal' },
|
{ label: 'Web 终端', icon: '💻', path: '/terminal', isNew: true },
|
||||||
{ label: '日志', icon: '📜', path: '/logs' },
|
{ label: '蜂群日志', icon: '📜', path: '/logs', isNew: true },
|
||||||
{ label: 'MCP接入', icon: '🔌', path: '/mcp-in' },
|
{ label: 'MCP 接入管理', icon: '🔌', path: '/mcp-in', isNew: true },
|
||||||
{ label: 'MCP对外', icon: '📤', path: '/mcp-out' },
|
{ label: 'MCP 对外服务', icon: '📤', path: '/mcp-out', isNew: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '知识库管理',
|
title: '知识库管理',
|
||||||
items: [{ label: '事件档案', icon: '📚', path: '/archive' }],
|
items: [
|
||||||
|
{ label: '事件档案管理', icon: '📚', path: '/archive', isNew: true },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '系统',
|
title: '系统',
|
||||||
items: [{ label: '设置', icon: '⚙', path: '/settings' }],
|
items: [
|
||||||
|
{ label: '用户管理', icon: '👥', path: '/users', requiredRole: 'owner' },
|
||||||
|
{ label: '审计日志', icon: '📋', path: '/audit', requiredRole: 'owner' },
|
||||||
|
{ label: '系统设置', icon: '⚙', path: '/settings' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -61,63 +73,171 @@ export interface SidebarProps {
|
|||||||
onNavigate: (path: string) => void
|
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) {
|
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 (
|
return (
|
||||||
<nav className="flex flex-col h-full overflow-y-auto" style={{ background: 'var(--bg-nav)', borderRight: '1px solid var(--border)' }}>
|
<nav
|
||||||
{/* Logo */}
|
style={{
|
||||||
|
background: 'var(--bg-nav)',
|
||||||
|
borderRight: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Nav header / logo */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 shrink-0 cursor-pointer px-5"
|
style={{
|
||||||
style={{ height: 'var(--header-h)' }}
|
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')}
|
onClick={() => onNavigate('/dashboard')}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 20 }}>🐝</span>
|
<span style={{ fontSize: 16 }}>🐝</span>
|
||||||
<span style={{ color: 'var(--honey)', fontWeight: 700, fontSize: 15, letterSpacing: 0.3 }}>
|
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--honey)' }}>
|
||||||
知微蜂巢
|
Hive Manage v3
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav sections */}
|
{/* Nav body */}
|
||||||
<div className="flex-1 overflow-y-auto px-3 pb-4">
|
|
||||||
{sections.map(section => (
|
|
||||||
<div key={section.title} className="mt-4">
|
|
||||||
<div
|
<div
|
||||||
className="px-2 mb-1 text-[11px] font-semibold uppercase tracking-wider"
|
style={{
|
||||||
style={{ color: 'var(--text-muted)' }}
|
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}
|
{section.title}
|
||||||
</div>
|
</div>
|
||||||
{section.items.map(item => {
|
|
||||||
|
{/* Section items */}
|
||||||
|
{visible.map((item) => {
|
||||||
const isActive = activePath === item.path
|
const isActive = activePath === item.path
|
||||||
|
const finalBadge =
|
||||||
|
item.path === '/cron'
|
||||||
|
? cronFailures > 0
|
||||||
|
? cronFailures
|
||||||
|
: item.badge
|
||||||
|
: item.badge
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={item.path}
|
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"
|
onClick={() => onNavigate(item.path)}
|
||||||
style={{
|
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)',
|
color: isActive ? 'var(--honey)' : 'var(--text-dim)',
|
||||||
fontWeight: isActive ? 600 : 400,
|
fontWeight: isActive ? 600 : 400,
|
||||||
background: isActive ? 'var(--honey-glow)' : undefined,
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => {
|
onMouseEnter={(e) => {
|
||||||
if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'
|
if (!isActive) {
|
||||||
|
e.currentTarget.style.background = 'var(--bg-hover)'
|
||||||
|
e.currentTarget.style.color = 'var(--text)'
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={e => {
|
onMouseLeave={(e) => {
|
||||||
if (!isActive) e.currentTarget.style.background = 'transparent'
|
if (!isActive) {
|
||||||
|
e.currentTarget.style.background = 'transparent'
|
||||||
|
e.currentTarget.style.color = 'var(--text-dim)'
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onClick={() => onNavigate(item.path)}
|
|
||||||
>
|
>
|
||||||
<span>
|
<span>{item.icon}</span>
|
||||||
<span style={{ marginRight: 8 }}>{item.icon}</span>
|
<span style={{ flex: 1 }}>{item.label}</span>
|
||||||
{item.label}
|
{item.isNew && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 10,
|
||||||
|
background: 'var(--honey)',
|
||||||
|
color: '#000',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
NEW
|
||||||
</span>
|
</span>
|
||||||
{item.badge != null && item.badge > 0 && (
|
|
||||||
<Badge variant="danger">{item.badge}</Badge>
|
|
||||||
)}
|
)}
|
||||||
|
{finalBadge != null && finalBadge > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 10,
|
||||||
|
background: 'var(--red)',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{finalBadge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom'
|
|||||||
export interface DrawerProps {
|
export interface DrawerProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
title?: string
|
title?: ReactNode
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
footer?: ReactNode
|
footer?: ReactNode
|
||||||
width?: string | number
|
width?: string | number
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface SearchBoxProps {
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
debounceMs?: number
|
debounceMs?: number
|
||||||
className?: string
|
className?: string
|
||||||
|
onItemClick?: (value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchBox({
|
export default function SearchBox({
|
||||||
@@ -17,6 +18,7 @@ export default function SearchBox({
|
|||||||
placeholder = "搜索...",
|
placeholder = "搜索...",
|
||||||
debounceMs = 300,
|
debounceMs = 300,
|
||||||
className = "",
|
className = "",
|
||||||
|
onItemClick,
|
||||||
}: SearchBoxProps) {
|
}: SearchBoxProps) {
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [groups, setGroups] = useState<SearchGroup[]>([])
|
const [groups, setGroups] = useState<SearchGroup[]>([])
|
||||||
@@ -87,6 +89,7 @@ export default function SearchBox({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setQuery(String(flatItems[activeIdx].label))
|
setQuery(String(flatItems[activeIdx].label))
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
onItemClick?.(flatItems[activeIdx].value)
|
||||||
inputRef.current?.blur()
|
inputRef.current?.blur()
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
@@ -144,6 +147,7 @@ export default function SearchBox({
|
|||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
setQuery(String(item.label))
|
setQuery(String(item.label))
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
onItemClick?.(item.value)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { ReactNode } from "react"
|
|
||||||
|
|
||||||
export interface TimelineEvent {
|
export interface TimelineEvent {
|
||||||
time: string
|
time: string
|
||||||
type: string
|
type: string
|
||||||
label: ReactNode
|
tooltip?: string
|
||||||
color?: 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"
|
<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)" }}>
|
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>
|
</div>
|
||||||
<div className="text-[11px] text-[var(--text-dim)] mt-1">{evt.label}</div>
|
|
||||||
</div>
|
</div>
|
||||||
{!isLast && (
|
{!isLast && (
|
||||||
<div
|
<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'
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
type Theme = 'dark' | 'light' | 'system'
|
type Theme = 'dark' | 'light'
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
const [theme, setTheme] = useState<Theme>(() => {
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
return (localStorage.getItem('hive-theme') as Theme) || 'system'
|
return (localStorage.getItem('hive-theme') as Theme) || 'dark'
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement
|
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)
|
localStorage.setItem('hive-theme', theme)
|
||||||
}, [theme])
|
}, [theme])
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,139 @@
|
|||||||
export default function AuditLogPage() {
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>审计日志</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>操作审计记录</p></div>
|
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() {
|
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
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 { 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() {
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>蜂群总览</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Dashboard 仪表板</p></div>
|
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() {
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>事件档案</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>事件归档查询</p></div>
|
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() {
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
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 { 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() {
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>系统健康评分</p></div>
|
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() {
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>看板追踪</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Kanban 任务看板</p></div>
|
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() {
|
import { useEffect, useState, useCallback, useMemo, type ReactNode } from 'react'
|
||||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>密钥管理</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>API 密钥管理</p></div>
|
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() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-root)' }}>
|
|
||||||
<div
|
<div
|
||||||
className="p-8 rounded-[var(--radius)] w-[360px]"
|
style={{
|
||||||
style={{ background: 'var(--bg-card)', boxShadow: 'var(--shadow-lg)' }}
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'var(--bg-root)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<h1 className="text-center mb-6" style={{ color: 'var(--honey)', fontSize: 20, fontWeight: 700 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
width: 400,
|
||||||
|
padding: 32,
|
||||||
|
borderRadius: 24,
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 4 }}>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, color: '#fff' }}>
|
||||||
🐝 知微蜂巢
|
🐝 知微蜂巢
|
||||||
</h1>
|
</div>
|
||||||
<p className="text-center" style={{ color: 'var(--text-dim)', fontSize: 13 }}>登录页面(待实现)</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,160 @@
|
|||||||
export default function UserManagementPage() {
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>用户管理</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>用户管理与权限</p></div>
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-28
@@ -1,47 +1,117 @@
|
|||||||
/* ===== 暗色模式(默认) ===== */
|
/* =============================================================
|
||||||
|
* Hive Manage v2 — Design Tokens (CSS Variables)
|
||||||
|
* 基于 UI-design-spec.md v2.1
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* 暗色模式(默认): 直接引入本文件
|
||||||
|
* 浅色模式: 在 <html> 上加 data-theme="light"
|
||||||
|
* 跟随系统: 用 @media (prefers-color-scheme: light) 覆盖
|
||||||
|
* ============================================================= */
|
||||||
|
|
||||||
|
/* === 暗色模式(默认) === */
|
||||||
:root {
|
:root {
|
||||||
--bg-root: #0f1117;
|
/* 背景层级 — 对齐参考图实际色值 */
|
||||||
--bg-nav: #161822;
|
--bg-root: #03050a;
|
||||||
--bg-card: #1a1d2e;
|
--bg-nav: #060810;
|
||||||
--bg-hover: #1e2134;
|
--bg-card: #0b0e17;
|
||||||
--bg-elevated: #222639;
|
--bg-hover: #111522;
|
||||||
|
--bg-elevated: #151b28;
|
||||||
|
|
||||||
|
/* 边框 */
|
||||||
--border: #2a2d3e;
|
--border: #2a2d3e;
|
||||||
--border-light: rgba(42,45,62,0.5);
|
--border-light: rgba(42,45,62,0.5);
|
||||||
--honey: #f0b90b;
|
|
||||||
--honey-dim: #b8970e;
|
/* 品牌色 */
|
||||||
--honey-glow: rgba(240, 185, 11, 0.12);
|
--honey: #e9ab01;
|
||||||
--green: #00c853;
|
--honey-dim: #c48f00;
|
||||||
--red: #ff3d3d;
|
--honey-glow: rgba(233,171,1,0.12);
|
||||||
--yellow: #ffab00;
|
|
||||||
--blue: #448aff;
|
/* 功能色 */
|
||||||
--orange: #ff6d00;
|
--green: #23ad3f;
|
||||||
|
--red: #e04343;
|
||||||
|
--yellow: #e9ab01;
|
||||||
|
--blue: #3b82f6;
|
||||||
|
--orange: #f59e0b;
|
||||||
|
|
||||||
|
/* 文字 */
|
||||||
--text: #e0e0e0;
|
--text: #e0e0e0;
|
||||||
--text-dim: #8b8d9a;
|
--text-dim: #8b8d9a;
|
||||||
--text-muted: #5a5d6a;
|
--text-muted: #5a5d6a;
|
||||||
|
|
||||||
|
/* 语义背景 */
|
||||||
--bg-success: #0d2818;
|
--bg-success: #0d2818;
|
||||||
--bg-error: #2d0f0f;
|
--bg-error: #2d0f0f;
|
||||||
--bg-warn: #2d2000;
|
--bg-warn: #2d2000;
|
||||||
--bg-info: #0d1b2d;
|
--bg-info: #0d1b2d;
|
||||||
|
|
||||||
|
/* 圆角 */
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 12px;
|
||||||
--radius-full: 9999px;
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* 阴影 */
|
||||||
--shadow: 0 2px 8px rgba(0,0,0,0.35);
|
--shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||||
--shadow-lg: 0 12px 32px rgba(0,0,0,0.55);
|
--shadow-lg: 0 12px 32px rgba(0,0,0,0.55);
|
||||||
--shadow-modal: 0 24px 64px rgba(0,0,0,0.65);
|
--shadow-modal: 0 24px 64px rgba(0,0,0,0.65);
|
||||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
|
/* 间距 */
|
||||||
|
--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;
|
--font-mono: "SF Mono", "Fira Code", Consolas, monospace;
|
||||||
--header-h: 48px;
|
|
||||||
--sidebar-w: 220px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 浅色模式 ===== */
|
/* === 浅色模式 === */
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--bg-root: #f5f6fa;
|
--bg-root: #f5f6fa;
|
||||||
--bg-nav: #ffffff;
|
--bg-nav: #ffffff;
|
||||||
--bg-card: #ffffff;
|
--bg-card: #ffffff;
|
||||||
--bg-hover: #edf0f5;
|
--bg-hover: #edf0f5;
|
||||||
--bg-elevated: #ffffff;
|
--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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 自动跟随系统 === */
|
||||||
|
@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: #e2e5ee;
|
||||||
--border-light: rgba(0,0,0,0.06);
|
--border-light: rgba(0,0,0,0.06);
|
||||||
--honey: #e5a800;
|
--honey: #e5a800;
|
||||||
@@ -60,24 +130,32 @@
|
|||||||
--bg-warn: #fff3e0;
|
--bg-warn: #fff3e0;
|
||||||
--bg-info: #e3f2fd;
|
--bg-info: #e3f2fd;
|
||||||
--shadow: 0 1px 4px rgba(0,0,0,0.08);
|
--shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
|
--shadow-lg: 0 8px 24px rgba(0,0,0,0.10);
|
||||||
--shadow-modal: 0 16px 48px rgba(0,0,0,0.14);
|
--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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
html, body {
|
||||||
margin: 0;
|
font-family: var(--font-sans);
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 13px;
|
|
||||||
background: var(--bg-root);
|
background: var(--bg-root);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 主题切换过渡 */
|
/* Backward compat aliases */
|
||||||
* {
|
:root { --font: var(--font-sans); --header-h: 48px; --sidebar-w: 220px; }
|
||||||
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user