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:
enzo
2026-07-22 17:24:53 +08:00
parent 670d222665
commit 3d905b606a
5 changed files with 998 additions and 3 deletions
+106
View File
@@ -0,0 +1,106 @@
"""Keys API — 密钥批量管理。"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import PlainTextResponse
from app.database import get_db
from app.middleware.auth import require_role
from app.models.audit import AuditLog
from app.models.user import User
from app.services.key_manager import (
WHITELIST_FIELDS,
get_keys,
batch_overwrite,
batch_scan as scan_profiles,
get_diff,
export_csv,
list_profiles,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/keys", tags=["keys"])
@router.get("")
async def list_keys(
profiles: str = Query("", description="逗号分隔的 Profile 名"),
_user: User = Depends(require_role("owner", "operator")),
):
"""返回密钥字段列表(脱敏显示)。"""
profile_list = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else None
data = get_keys(profile_list)
return {"data": data}
@router.post("/batch-overwrite")
async def batch_overwrite_keys(
body: dict,
user: User = Depends(require_role("owner", "operator")),
db: AsyncSession = Depends(get_db),
):
"""批量覆盖密钥。"""
profile_names = body.get("profile_names", [])
key_name = body.get("key_name", "")
new_value = body.get("new_value", "")
confirmation = body.get("confirmation", "")
if key_name not in WHITELIST_FIELDS:
raise HTTPException(status_code=400, detail=f"不支持的密钥字段: {key_name}")
# 验证确认短语
expected = f"我确认覆盖 {len(profile_names)} 个 Profile"
if confirmation != expected:
raise HTTPException(status_code=400, detail="确认短语不匹配")
results = batch_overwrite(profile_names, key_name, new_value)
# 审计日志(不记录原文)
audit = AuditLog(
user_id=user.id,
username=user.username,
action="keys:batch-overwrite",
target=f"profiles:{','.join(profile_names)}",
detail=f"key={key_name}, profiles={','.join(profile_names)}, "
f"success={sum(1 for r in results if r['success'])}/{len(results)}",
)
db.add(audit)
await db.commit()
return {"results": results}
@router.post("/batch-scan")
async def batch_scan_keys(
body: dict,
_user: User = Depends(require_role("owner", "operator")),
):
"""批量检查缺失密钥。"""
profile_names = body.get("profile_names")
if profile_names is not None and not isinstance(profile_names, list):
raise HTTPException(status_code=400, detail="profile_names 必须为数组")
results = scan_profiles(profile_names)
return {"results": results}
@router.get("/diff")
async def diff_keys(
profiles: str = Query("", description="逗号分隔的 Profile 名"),
_user: User = Depends(require_role("owner", "operator")),
):
"""差异视图。"""
profile_list = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else list_profiles()
rows = get_diff(profile_list)
return {"profiles": profile_list, "rows": rows}
@router.get("/export", response_class=PlainTextResponse)
async def export_keys_csv(
profiles: str = Query("", description="逗号分隔的 Profile 名"),
_user: User = Depends(require_role("owner", "operator")),
):
"""CSV 导出批量检查结果。"""
profile_list = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else None
return export_csv(profile_list)
+2 -1
View File
@@ -4,7 +4,7 @@ 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 from app.api import auth, users, audit, dashboard, gateway, health, cron, keys
from app.middleware.audit import AuditMiddleware 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(gateway.router, prefix="/api/v1")
app.include_router(health.router, prefix="/api/v1") app.include_router(health.router, prefix="/api/v1")
app.include_router(cron.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") @app.get("/api/v1/health")
+196
View File
@@ -0,0 +1,196 @@
"""Key Manager 服务 — .env 密钥读写、脱敏、批量操作。"""
import logging
import os
import shutil
from pathlib import Path
from typing import Optional
from app.config import settings
logger = logging.getLogger(__name__)
# 可管理的密钥白名单
WHITELIST_FIELDS = [
"DEEPSEEK_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
]
PROFILES_ROOT = Path(settings.hermes_profiles_root)
def _parse_env(content: str) -> dict[str, str]:
"""解析 .env 文件内容为 key-value 字典。"""
result: dict[str, str] = {}
for line in content.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if "=" not in stripped:
continue
key, _, val = stripped.partition("=")
key = key.strip()
val = val.strip().strip("\"'")
if key in WHITELIST_FIELDS:
result[key] = val
return result
def _mask_value(value: str) -> str:
"""脱敏: 前3字符 + **...** + 后4字符。"""
if len(value) <= 7:
return value[:3] + "**...**" + (value[-4:] if len(value) > 3 else "")
return value[:3] + "**...**" + value[-4:]
def _read_env_file(profile: str) -> dict[str, str]:
"""读取指定 Profile 的 .env 文件中白名单字段。"""
env_path = PROFILES_ROOT / profile / ".env"
if not env_path.exists():
return {}
try:
content = env_path.read_text(encoding="utf-8")
return _parse_env(content)
except OSError as e:
logger.warning("读取 %s/.env 失败: %s", profile, e)
return {}
def _write_env_file(profile: str, updates: dict[str, str]) -> bool:
"""写入指定 Profile 的 .env 文件,操作前自动备份。"""
env_path = PROFILES_ROOT / profile / ".env"
if not env_path.exists():
return False
# 备份
try:
shutil.copy2(env_path, env_path.with_suffix(".env.bak"))
except OSError as e:
logger.warning("备份 %s/.env 失败: %s", profile, e)
try:
content = env_path.read_text(encoding="utf-8")
lines = content.splitlines(keepends=True)
updated_keys = set(updates.keys())
new_lines: list[str] = []
found: set[str] = set()
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
new_lines.append(line)
continue
if "=" not in stripped:
new_lines.append(line)
continue
key = stripped.partition("=")[0].strip()
if key in updated_keys:
new_lines.append(f"{key}={updates[key]}\n")
found.add(key)
else:
new_lines.append(line)
# 追加未找到的字段
for key in updated_keys:
if key not in found:
new_lines.append(f"{key}={updates[key]}\n")
env_path.write_text("".join(new_lines), encoding="utf-8")
logger.info("已写入 %s/.env (%d 个字段)", profile, len(updates))
return True
except OSError as e:
logger.error("写入 %s/.env 失败: %s", profile, e)
return False
def list_profiles() -> list[str]:
"""返回所有包含 .env 文件的 Profile 名称列表。"""
if not PROFILES_ROOT.exists():
return []
return sorted(
d.name
for d in PROFILES_ROOT.iterdir()
if d.is_dir() and (d / ".env").exists()
)
def get_keys(profile_names: Optional[list[str]] = None) -> list[dict]:
"""获取指定 Profile 的密钥列表(脱敏显示)。"""
profiles = profile_names if profile_names else list_profiles()
result: list[dict] = []
for name in profiles:
fields = _read_env_file(name)
masked = {k: _mask_value(v) for k, v in fields.items()}
missing = len(WHITELIST_FIELDS) - len(fields)
result.append({"profile": name, "fields": masked, "missing": missing})
return result
def batch_overwrite(
profile_names: list[str],
key_name: str,
new_value: str,
) -> list[dict]:
"""批量覆盖指定 Profile 的某个密钥字段。"""
results: list[dict] = []
for name in profile_names:
success = _write_env_file(name, {key_name: new_value})
results.append({"profile": name, "success": success})
return results
def batch_scan(profile_names: Optional[list[str]] = None) -> list[dict]:
"""批量检查缺失的密钥字段。"""
profiles = profile_names if profile_names else list_profiles()
results: list[dict] = []
for name in profiles:
fields = _read_env_file(name)
missing = [f for f in WHITELIST_FIELDS if f not in fields]
results.append({
"profile": name,
"missing": missing,
"missing_count": len(missing),
"total": len(WHITELIST_FIELDS),
})
return results
def get_diff(profile_names: list[str]) -> list[dict]:
"""差异视图: 横向 Profile, 纵向字段, 值状态(有/无/长度)。"""
profiles = profile_names if profile_names else list_profiles()
field_map: dict[str, dict[str, str | int]] = {}
for name in profiles:
fields = _read_env_file(name)
for key in WHITELIST_FIELDS:
if key not in field_map:
field_map[key] = {}
if key in fields:
field_map[key][name] = len(fields[key])
else:
field_map[key][name] = 0 # 0 表示缺失
rows: list[dict] = []
for key in WHITELIST_FIELDS:
row: dict[str, str | int] = {"field": key}
for name in profiles:
val = field_map.get(key, {}).get(name, 0)
row[name] = val
rows.append(row)
return rows
def export_csv(profile_names: Optional[list[str]] = None) -> str:
"""CSV 导出批量检查结果。"""
scan = batch_scan(profile_names)
import csv
import io
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["Profile", "缺失字段", "缺失数", "总字段"])
for row in scan:
writer.writerow([
row["profile"],
"; ".join(row["missing"]),
row["missing_count"],
row["total"],
])
return buf.getvalue()
+72
View File
@@ -0,0 +1,72 @@
import client from './client'
export interface KeyFieldEntry {
profile: string
fields: Record<string, string>
missing: number
}
export interface KeyListResponse {
data: KeyFieldEntry[]
}
export interface OverwriteResult {
profile: string
success: boolean
}
export interface OverwriteResponse {
results: OverwriteResult[]
}
export interface ScanResult {
profile: string
missing: string[]
missing_count: number
total: number
}
export interface ScanResponse {
results: ScanResult[]
}
export interface DiffRow {
field: string
[profile: string]: string | number
}
export interface DiffResponse {
profiles: string[]
rows: DiffRow[]
}
export const getKeys = (profiles?: string) =>
client.get<KeyListResponse>('/keys', { params: profiles ? { profiles } : {} })
export const batchOverwrite = (body: {
profile_names: string[]
key_name: string
new_value: string
confirmation: string
}) => client.post<OverwriteResponse>('/keys/batch-overwrite', body)
export const batchScan = (profile_names?: string[]) =>
client.post<ScanResponse>('/keys/batch-scan', { profile_names })
export const getDiff = (profiles: string) =>
client.get<DiffResponse>('/keys/diff', { params: { profiles } })
export const exportCsv = (profiles?: string) =>
client.get<string>('/keys/export', {
params: profiles ? { profiles } : {},
responseType: 'text',
})
export const WHITELIST_FIELDS = [
'DEEPSEEK_API_KEY',
'OPENAI_API_KEY',
'ANTHROPIC_API_KEY',
'GOOGLE_API_KEY',
'FEISHU_APP_ID',
'FEISHU_APP_SECRET',
]
+622 -2
View File
@@ -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>
)
} }