From 3d905b606ab7218297fad8186e843e68ba85d9ab Mon Sep 17 00:00:00 2001 From: enzo Date: Wed, 22 Jul 2026 17:24:53 +0800 Subject: [PATCH] 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 --- backend/app/api/keys.py | 106 +++++ backend/app/main.py | 3 +- backend/app/services/key_manager.py | 196 +++++++++ frontend/src/api/keys.ts | 72 ++++ frontend/src/pages/KeysPage.tsx | 624 +++++++++++++++++++++++++++- 5 files changed, 998 insertions(+), 3 deletions(-) create mode 100644 backend/app/api/keys.py create mode 100644 backend/app/services/key_manager.py create mode 100644 frontend/src/api/keys.ts diff --git a/backend/app/api/keys.py b/backend/app/api/keys.py new file mode 100644 index 0000000..8d9f4c8 --- /dev/null +++ b/backend/app/api/keys.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 51c6eb9..54051d9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/services/key_manager.py b/backend/app/services/key_manager.py new file mode 100644 index 0000000..d530b1d --- /dev/null +++ b/backend/app/services/key_manager.py @@ -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() diff --git a/frontend/src/api/keys.ts b/frontend/src/api/keys.ts new file mode 100644 index 0000000..4b47bc9 --- /dev/null +++ b/frontend/src/api/keys.ts @@ -0,0 +1,72 @@ +import client from './client' + +export interface KeyFieldEntry { + profile: string + fields: Record + 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('/keys', { params: profiles ? { profiles } : {} }) + +export const batchOverwrite = (body: { + profile_names: string[] + key_name: string + new_value: string + confirmation: string +}) => client.post('/keys/batch-overwrite', body) + +export const batchScan = (profile_names?: string[]) => + client.post('/keys/batch-scan', { profile_names }) + +export const getDiff = (profiles: string) => + client.get('/keys/diff', { params: { profiles } }) + +export const exportCsv = (profiles?: string) => + client.get('/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', +] diff --git a/frontend/src/pages/KeysPage.tsx b/frontend/src/pages/KeysPage.tsx index 3f23d17..bf2d79a 100644 --- a/frontend/src/pages/KeysPage.tsx +++ b/frontend/src/pages/KeysPage.tsx @@ -1,3 +1,623 @@ -export default function KeysPage() { - return

密钥管理

API 密钥管理

+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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + /* filters */ + const [profileFilter, setProfileFilter] = useState('all') + const [searchText, setSearchText] = useState('') + + /* selection */ + const [selectedProfiles, setSelectedProfiles] = useState>(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(null) + + + /* scan results */ + const [scanResults, setScanResults] = useState(null) + const [scanning, setScanning] = useState(false) + + /* diff view */ + const [diffRows, setDiffRows] = useState(null) + const [diffProfiles, setDiffProfiles] = useState([]) + 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() + 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 ( +
+

+ 密钥管理 +

+ +
+ ) + } + + /* ---------- render: error ---------- */ + + if (error) { + return ( +
+

+ 密钥管理 +

+
+ ! +

{error}

+ +
+
+ ) + } + + /* ---------- 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() + for (const e of filteredEntries) { + if (e.missing > 0) missingSet.add(e.profile) + } + + const rowClassName = (record: Record) => { + if (missingSet.has(record.key as string)) return 'row-danger' + return '' + } + + const tableData: Record[] = filteredEntries.map((e) => { + const row: Record = { + key: e.profile, + _check: ( + toggleSelect(e.profile)} + className="cursor-pointer accent-[var(--honey)]" + /> + ), + profile: e.profile, + missing: e.missing > 0 ? ( + {e.missing} ❌ + ) : ( + 0 + ), + } + for (const f of WHITELIST_FIELDS) { + if (e.fields[f]) { + row[f] = {e.fields[f]} + } else { + row[f] = ❌ + } + } + 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 = { key: r.field, field: r.field } + for (const p of diffProfiles) { + const val = r[p] as number + row[p] = val > 0 ? ( + ✅ ({val} 字符) + ) : ( + ❌ + ) + } + return row + }) + }, [diffRows, diffProfiles, diffOnly]) + + const diffColumns: TableColumn[] = [ + { key: 'field', label: '字段' }, + ...diffProfiles.map((p) => ({ key: p, label: p })), + ] + + const diffRowClassName = (record: Record) => { + 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[] = (scanResults ?? []).map((r) => ({ + key: r.profile, + profile: r.profile, + missing: r.missing.length > 0 ? ( + {r.missing.join(', ')} + ) : ( + 无 + ), + missing_count: r.missing_count > 0 ? ( + {r.missing_count}/{r.total} + ) : ( + {r.missing_count}/{r.total} + ), + })) + + const scanRowClassName = (record: Record) => { + 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[] = (owResults ?? []).map((r) => ({ + key: r.profile, + profile: r.profile, + status: r.success ? ( + ✅ 成功 + ) : ( + ❌ 失败 + ), + })) + + /* ---------- render ---------- */ + + const expectedConfirmation = `我确认覆盖 ${selectedProfiles.size} 个 Profile` + + return ( +
+

+ 密钥管理 +

+ + {/* Operation bar */} +
+ {isOperator && ( + <> + + + + )} + + {selectedProfiles.size >= 2 && ( + + )} + +
+ + + + 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 && ( + + )} +
+ + {/* Key list table */} + {tableData.length === 0 ? ( +
+ 🔑 +

暂无密钥数据

+
+ ) : ( + + )} + + {/* Scan results */} + {scanResults && ( +
+
+

+ 批量检查结果 +

+ +
+ {scanTableData.length === 0 ? ( +

暂无结果

+ ) : ( +
+ )} + + )} + + {/* Diff view */} + {diffRows && ( +
+
+

+ 差异视图 +

+
+ + +
+
+ {diffTableData.length === 0 ? ( +

无差异数据

+ ) : ( +
+ )} + + )} + + {/* ── Overwrite Modal ── */} + { setShowOverwrite(false); setOwResults(null) }} + title="批量覆盖密钥" + width={560} + onConfirm={owResults ? undefined : handleOverwrite} + confirmText={owResults ? undefined : '确认覆盖'} + confirmDanger + > + {owResults ? ( +
+

执行结果:

+
+ + ) : ( +
+ {/* Field selector */} +
+ + +
+ + {/* New value */} +
+ +
+ 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)' }} + /> + +
+
+ + {/* Preview */} +
+

+ 将覆盖 {selectedProfiles.size} 个 Profile 的 {owField} +

+
+ {Array.from(selectedProfiles).sort().map((p) => { + const entry = entries.find((e) => e.profile === p) + const current = entry?.fields[owField] + return ( +
+ {p}: + {current ? ( + {current} + ) : ( + 当前无值 + )} +
+ ) + })} +
+
+ + {/* Confirmation */} +
+ + 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)' }} + /> +

+ 请输入: {expectedConfirmation} +

+
+
+ )} + + + {/* Diff highlight style */} + + + ) }