feat: Key batch management (Key Manager service, API, frontend) (Phase 6)
Backend service for reading/writing profile .env files with masking, auto-backup, batch overwrite, batch scan, diff view, and CSV export. Owner/operator-only API endpoints. Frontend KeysPage with batch-overwrite modal, batch-scan results, diff view with highlight, and CSV download. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
+2
-1
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.database import engine, Base
|
||||
from app.api import auth, users, audit, dashboard, gateway, health, cron
|
||||
from app.api import auth, users, audit, dashboard, gateway, health, cron, keys
|
||||
from app.middleware.audit import AuditMiddleware
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ 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(keys.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
|
||||
@@ -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,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',
|
||||
]
|
||||
@@ -1,3 +1,623 @@
|
||||
export default function KeysPage() {
|
||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>密钥管理</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>API 密钥管理</p></div>
|
||||
import { useEffect, useState, useCallback, useMemo, type ReactNode } from 'react'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import Table, { type TableColumn } from '../components/ui/Table'
|
||||
import Button from '../components/ui/Button'
|
||||
import Modal from '../components/ui/Modal'
|
||||
import Skeleton from '../components/ui/Skeleton'
|
||||
import Toggle from '../components/ui/Toggle'
|
||||
import toast from '../components/ui/Toast'
|
||||
|
||||
import {
|
||||
getKeys,
|
||||
batchOverwrite,
|
||||
batchScan,
|
||||
getDiff,
|
||||
exportCsv,
|
||||
WHITELIST_FIELDS,
|
||||
type KeyFieldEntry,
|
||||
type ScanResult,
|
||||
type OverwriteResult,
|
||||
type DiffRow,
|
||||
} from '../api/keys'
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
function downloadBlob(content: string, filename: string, mime = 'text/csv') {
|
||||
const blob = new Blob([content], { type: mime })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/* ---------- component ---------- */
|
||||
|
||||
export default function KeysPage() {
|
||||
const { user } = useAuth()
|
||||
const isOperator = user?.role === 'owner' || user?.role === 'operator'
|
||||
|
||||
/* data */
|
||||
const [entries, setEntries] = useState<KeyFieldEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
/* filters */
|
||||
const [profileFilter, setProfileFilter] = useState('all')
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
/* selection */
|
||||
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set())
|
||||
|
||||
/* overwrite modal */
|
||||
const [showOverwrite, setShowOverwrite] = useState(false)
|
||||
const [owField, setOwField] = useState(WHITELIST_FIELDS[0])
|
||||
const [owValue, setOwValue] = useState('')
|
||||
const [owShowPlain, setOwShowPlain] = useState(false)
|
||||
const [owConfirmation, setOwConfirmation] = useState('')
|
||||
const [owActing, setOwActing] = useState(false)
|
||||
const [owResults, setOwResults] = useState<OverwriteResult[] | null>(null)
|
||||
|
||||
|
||||
/* scan results */
|
||||
const [scanResults, setScanResults] = useState<ScanResult[] | null>(null)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
|
||||
/* diff view */
|
||||
const [diffRows, setDiffRows] = useState<DiffRow[] | null>(null)
|
||||
const [diffProfiles, setDiffProfiles] = useState<string[]>([])
|
||||
const [diffOnly, setDiffOnly] = useState(false)
|
||||
const [diffLoading, setDiffLoading] = useState(false)
|
||||
|
||||
/* ---------- fetch keys ---------- */
|
||||
|
||||
const fetchKeys = useCallback(async () => {
|
||||
try {
|
||||
const profiles = profileFilter === 'all' ? undefined : profileFilter
|
||||
const res = await getKeys(profiles)
|
||||
setEntries(res.data.data)
|
||||
setError(null)
|
||||
} catch {
|
||||
setError('加载密钥列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [profileFilter])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchKeys()
|
||||
const id = setInterval(fetchKeys, 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [fetchKeys])
|
||||
|
||||
/* ---------- selection ---------- */
|
||||
|
||||
const allProfiles = useMemo(() => entries.map((e) => e.profile), [entries])
|
||||
|
||||
const toggleSelect = (profile: string) => {
|
||||
setSelectedProfiles((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(profile)) next.delete(profile)
|
||||
else next.add(profile)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedProfiles.size === allProfiles.length) {
|
||||
setSelectedProfiles(new Set())
|
||||
} else {
|
||||
setSelectedProfiles(new Set(allProfiles))
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- overwrite ---------- */
|
||||
|
||||
const openOverwrite = () => {
|
||||
setOwField(WHITELIST_FIELDS[0])
|
||||
setOwValue('')
|
||||
setOwShowPlain(false)
|
||||
setOwConfirmation('')
|
||||
setOwResults(null)
|
||||
setShowOverwrite(true)
|
||||
}
|
||||
|
||||
const handleOverwrite = async () => {
|
||||
const profileNames = Array.from(selectedProfiles)
|
||||
setOwActing(true)
|
||||
setOwResults(null)
|
||||
try {
|
||||
const res = await batchOverwrite({
|
||||
profile_names: profileNames,
|
||||
key_name: owField,
|
||||
new_value: owValue,
|
||||
confirmation: owConfirmation,
|
||||
})
|
||||
setOwResults(res.data.results)
|
||||
toast.success('批量覆盖完成')
|
||||
fetchKeys()
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '批量覆盖失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setOwActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- scan ---------- */
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true)
|
||||
setScanResults(null)
|
||||
try {
|
||||
const profiles = profileFilter === 'all' ? undefined : [profileFilter]
|
||||
const res = await batchScan(profiles)
|
||||
setScanResults(res.data.results)
|
||||
} catch {
|
||||
toast.error('批量检查失败')
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportCsv = async () => {
|
||||
try {
|
||||
const profiles = profileFilter === 'all' ? undefined : profileFilter
|
||||
const res = await exportCsv(profiles)
|
||||
downloadBlob(res.data as unknown as string, 'keys-scan.csv')
|
||||
toast.success('CSV 已导出')
|
||||
} catch {
|
||||
toast.error('导出 CSV 失败')
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- diff ---------- */
|
||||
|
||||
const handleViewDiff = async () => {
|
||||
const profiles = Array.from(selectedProfiles).join(',')
|
||||
setDiffLoading(true)
|
||||
try {
|
||||
const res = await getDiff(profiles)
|
||||
setDiffRows(res.data.rows)
|
||||
setDiffProfiles(res.data.profiles)
|
||||
} catch {
|
||||
toast.error('加载差异视图失败')
|
||||
} finally {
|
||||
setDiffLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- filtered & derived ---------- */
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
if (!searchText.trim()) return entries
|
||||
const q = searchText.toLowerCase()
|
||||
return entries.filter((e) => {
|
||||
for (const key of Object.keys(e.fields)) {
|
||||
if (key.toLowerCase().includes(q)) return true
|
||||
}
|
||||
return e.profile.toLowerCase().includes(q)
|
||||
})
|
||||
}, [entries, searchText])
|
||||
|
||||
const profileOptions = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
for (const e of entries) {
|
||||
if (!seen.has(e.profile)) {
|
||||
seen.add(e.profile)
|
||||
}
|
||||
}
|
||||
return Array.from(seen).sort()
|
||||
}, [entries])
|
||||
|
||||
/* ---------- render: loading ---------- */
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
|
||||
密钥管理
|
||||
</h2>
|
||||
<Skeleton variant="card" count={4} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- render: error ---------- */
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
|
||||
密钥管理
|
||||
</h2>
|
||||
<div
|
||||
className="flex flex-col items-center justify-center py-12 gap-3"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
<span style={{ fontSize: 32, color: 'var(--red)' }}>!</span>
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" onClick={fetchKeys}>重试</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- table columns ---------- */
|
||||
|
||||
const checkCol: TableColumn = {
|
||||
key: '_check',
|
||||
label: '',
|
||||
width: 40,
|
||||
}
|
||||
|
||||
const fieldColumns: TableColumn[] = WHITELIST_FIELDS.map((f) => ({
|
||||
key: f,
|
||||
label: f,
|
||||
}))
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
checkCol,
|
||||
{ key: 'profile', label: 'Profile' },
|
||||
...fieldColumns,
|
||||
{ key: 'missing', label: '缺失' },
|
||||
]
|
||||
|
||||
const missingSet = new Set<string>()
|
||||
for (const e of filteredEntries) {
|
||||
if (e.missing > 0) missingSet.add(e.profile)
|
||||
}
|
||||
|
||||
const rowClassName = (record: Record<string, unknown>) => {
|
||||
if (missingSet.has(record.key as string)) return 'row-danger'
|
||||
return ''
|
||||
}
|
||||
|
||||
const tableData: Record<string, ReactNode>[] = filteredEntries.map((e) => {
|
||||
const row: Record<string, ReactNode> = {
|
||||
key: e.profile,
|
||||
_check: (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProfiles.has(e.profile)}
|
||||
onChange={() => toggleSelect(e.profile)}
|
||||
className="cursor-pointer accent-[var(--honey)]"
|
||||
/>
|
||||
),
|
||||
profile: e.profile,
|
||||
missing: e.missing > 0 ? (
|
||||
<span style={{ color: 'var(--red)', fontWeight: 600 }}>{e.missing} ❌</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--green)' }}>0</span>
|
||||
),
|
||||
}
|
||||
for (const f of WHITELIST_FIELDS) {
|
||||
if (e.fields[f]) {
|
||||
row[f] = <span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{e.fields[f]}</span>
|
||||
} else {
|
||||
row[f] = <span style={{ color: 'var(--red)' }}>❌</span>
|
||||
}
|
||||
}
|
||||
return row
|
||||
})
|
||||
|
||||
/* ---------- diff table ---------- */
|
||||
|
||||
const diffTableData = useMemo(() => {
|
||||
if (!diffRows) return []
|
||||
let rows = diffRows
|
||||
if (diffOnly) {
|
||||
rows = rows.filter((r) => {
|
||||
const vals = diffProfiles.map((p) => r[p] as number)
|
||||
return vals.some((v) => v !== vals[0])
|
||||
})
|
||||
}
|
||||
return rows.map((r) => {
|
||||
const row: Record<string, ReactNode> = { key: r.field, field: r.field }
|
||||
for (const p of diffProfiles) {
|
||||
const val = r[p] as number
|
||||
row[p] = val > 0 ? (
|
||||
<span style={{ color: 'var(--green)' }}>✅ ({val} 字符)</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--red)' }}>❌</span>
|
||||
)
|
||||
}
|
||||
return row
|
||||
})
|
||||
}, [diffRows, diffProfiles, diffOnly])
|
||||
|
||||
const diffColumns: TableColumn[] = [
|
||||
{ key: 'field', label: '字段' },
|
||||
...diffProfiles.map((p) => ({ key: p, label: p })),
|
||||
]
|
||||
|
||||
const diffRowClassName = (record: Record<string, unknown>) => {
|
||||
const vals = diffProfiles.map((p) => record[p] as string)
|
||||
if (vals.some((v) => v !== vals[0])) return 'row-diff-highlight'
|
||||
return ''
|
||||
}
|
||||
|
||||
/* ---------- render: scan results ---------- */
|
||||
|
||||
const scanColumns: TableColumn[] = [
|
||||
{ key: 'profile', label: 'Profile' },
|
||||
{ key: 'missing', label: '缺失字段' },
|
||||
{ key: 'missing_count', label: '缺失数' },
|
||||
]
|
||||
|
||||
const scanTableData: Record<string, ReactNode>[] = (scanResults ?? []).map((r) => ({
|
||||
key: r.profile,
|
||||
profile: r.profile,
|
||||
missing: r.missing.length > 0 ? (
|
||||
<span style={{ color: 'var(--red)' }}>{r.missing.join(', ')}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--green)' }}>无</span>
|
||||
),
|
||||
missing_count: r.missing_count > 0 ? (
|
||||
<span style={{ color: 'var(--red)', fontWeight: 600 }}>{r.missing_count}/{r.total}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--green)' }}>{r.missing_count}/{r.total}</span>
|
||||
),
|
||||
}))
|
||||
|
||||
const scanRowClassName = (record: Record<string, unknown>) => {
|
||||
if ((record.missing_count as string)?.startsWith('0/')) return ''
|
||||
return 'row-danger'
|
||||
}
|
||||
|
||||
/* ---------- render: overwrite results ---------- */
|
||||
|
||||
const owResultColumns: TableColumn[] = [
|
||||
{ key: 'profile', label: 'Profile' },
|
||||
{ key: 'status', label: '结果' },
|
||||
]
|
||||
|
||||
const owResultData: Record<string, ReactNode>[] = (owResults ?? []).map((r) => ({
|
||||
key: r.profile,
|
||||
profile: r.profile,
|
||||
status: r.success ? (
|
||||
<span style={{ color: 'var(--green)' }}>✅ 成功</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--red)' }}>❌ 失败</span>
|
||||
),
|
||||
}))
|
||||
|
||||
/* ---------- render ---------- */
|
||||
|
||||
const expectedConfirmation = `我确认覆盖 ${selectedProfiles.size} 个 Profile`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
|
||||
密钥管理
|
||||
</h2>
|
||||
|
||||
{/* Operation bar */}
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||
{isOperator && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={selectedProfiles.size === 0}
|
||||
onClick={openOverwrite}
|
||||
>
|
||||
批量覆盖
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
loading={scanning}
|
||||
onClick={handleScan}
|
||||
>
|
||||
批量检查
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedProfiles.size >= 2 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
loading={diffLoading}
|
||||
onClick={handleViewDiff}
|
||||
>
|
||||
查看差异
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="w-[1px] h-5 bg-[var(--border)] mx-1" />
|
||||
|
||||
<select
|
||||
value={profileFilter}
|
||||
onChange={(e) => setProfileFilter(e.target.value)}
|
||||
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
|
||||
style={{ color: 'var(--text)' }}
|
||||
>
|
||||
<option value="all">全部 Profile</option>
|
||||
{profileOptions.map((o) => (
|
||||
<option key={o} value={o}>{o}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索密钥字段..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
|
||||
style={{ color: 'var(--text)', width: 180 }}
|
||||
/>
|
||||
|
||||
{tableData.length > 0 && (
|
||||
<label className="flex items-center gap-1.5 text-[13px] cursor-pointer" style={{ color: 'var(--text-dim)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProfiles.size === allProfiles.length && allProfiles.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="cursor-pointer accent-[var(--honey)]"
|
||||
/>
|
||||
全选
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Key list table */}
|
||||
{tableData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12" style={{ color: 'var(--text-dim)' }}>
|
||||
<span style={{ fontSize: 32, marginBottom: 8 }}>🔑</span>
|
||||
<p>暂无密钥数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table columns={columns} data={tableData} rowKey="key" rowClassName={rowClassName} />
|
||||
)}
|
||||
|
||||
{/* Scan results */}
|
||||
{scanResults && (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 style={{ color: 'var(--text)', fontSize: 15, fontWeight: 600 }}>
|
||||
批量检查结果
|
||||
</h3>
|
||||
<Button size="sm" variant="outline" onClick={handleExportCsv}>
|
||||
导出 CSV
|
||||
</Button>
|
||||
</div>
|
||||
{scanTableData.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-dim)', textAlign: 'center', padding: 24 }}>暂无结果</p>
|
||||
) : (
|
||||
<Table columns={scanColumns} data={scanTableData} rowKey="key" rowClassName={scanRowClassName} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff view */}
|
||||
{diffRows && (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 style={{ color: 'var(--text)', fontSize: 15, fontWeight: 600 }}>
|
||||
差异视图
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<Toggle checked={diffOnly} onChange={setDiffOnly} label="只看差异" />
|
||||
<Button size="sm" variant="ghost" onClick={() => { setDiffRows(null); setDiffProfiles([]) }}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{diffTableData.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-dim)', textAlign: 'center', padding: 24 }}>无差异数据</p>
|
||||
) : (
|
||||
<Table columns={diffColumns} data={diffTableData} rowKey="key" rowClassName={diffRowClassName} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Overwrite Modal ── */}
|
||||
<Modal
|
||||
open={showOverwrite}
|
||||
onClose={() => { setShowOverwrite(false); setOwResults(null) }}
|
||||
title="批量覆盖密钥"
|
||||
width={560}
|
||||
onConfirm={owResults ? undefined : handleOverwrite}
|
||||
confirmText={owResults ? undefined : '确认覆盖'}
|
||||
confirmDanger
|
||||
>
|
||||
{owResults ? (
|
||||
<div>
|
||||
<p className="mb-3" style={{ color: 'var(--text-dim)' }}>执行结果:</p>
|
||||
<Table columns={owResultColumns} data={owResultData} rowKey="key" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Field selector */}
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium mb-1" style={{ color: 'var(--text-dim)' }}>
|
||||
密钥字段
|
||||
</label>
|
||||
<select
|
||||
value={owField}
|
||||
onChange={(e) => setOwField(e.target.value)}
|
||||
className="w-full text-[13px] px-3 py-2 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
|
||||
style={{ color: 'var(--text)' }}
|
||||
>
|
||||
{WHITELIST_FIELDS.map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* New value */}
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium mb-1" style={{ color: 'var(--text-dim)' }}>
|
||||
新值
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type={owShowPlain ? 'text' : 'password'}
|
||||
value={owValue}
|
||||
onChange={(e) => setOwValue(e.target.value)}
|
||||
className="flex-1 text-[13px] px-3 py-2 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
|
||||
style={{ color: 'var(--text)', fontFamily: 'var(--font-mono)' }}
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={() => setOwShowPlain(!owShowPlain)}>
|
||||
{owShowPlain ? '隐藏' : '显示'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="p-3 rounded-[var(--radius-sm)]" style={{ background: 'var(--bg-hover)' }}>
|
||||
<p className="text-[13px] mb-2" style={{ color: 'var(--text-dim)' }}>
|
||||
将覆盖 <strong style={{ color: 'var(--honey)' }}>{selectedProfiles.size}</strong> 个 Profile 的 <code style={{ fontFamily: 'var(--font-mono)' }}>{owField}</code>
|
||||
</p>
|
||||
<div className="text-[12px]" style={{ color: 'var(--text-dim)' }}>
|
||||
{Array.from(selectedProfiles).sort().map((p) => {
|
||||
const entry = entries.find((e) => e.profile === p)
|
||||
const current = entry?.fields[owField]
|
||||
return (
|
||||
<div key={p} className="flex items-center gap-2">
|
||||
<span>{p}:</span>
|
||||
{current ? (
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{current}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--red)' }}>当前无值</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation */}
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium mb-1" style={{ color: 'var(--text-dim)' }}>
|
||||
确认短语
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={expectedConfirmation}
|
||||
value={owConfirmation}
|
||||
onChange={(e) => setOwConfirmation(e.target.value)}
|
||||
className="w-full text-[13px] px-3 py-2 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
|
||||
style={{ color: 'var(--text)' }}
|
||||
/>
|
||||
<p className="text-[12px] mt-1" style={{ color: 'var(--text-dim)' }}>
|
||||
请输入: <code style={{ fontFamily: 'var(--font-mono)', color: 'var(--honey)' }}>{expectedConfirmation}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Diff highlight style */}
|
||||
<style>{`
|
||||
.row-diff-highlight td {
|
||||
background: var(--bg-warn) !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user