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
+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() {
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>
)
}