Show UI drift marker for env draft vs Vercel config

This commit is contained in:
CJACK.
2026-03-21 17:08:43 +08:00
parent 1e7e0b2ae3
commit 43a6e6712f
12 changed files with 131 additions and 15 deletions

View File

@@ -36,6 +36,7 @@ func RegisterRoutes(r chi.Router, h *Handler) {
pr.Post("/test", h.testAPI)
pr.Post("/vercel/sync", h.syncVercel)
pr.Get("/vercel/status", h.vercelStatus)
pr.Post("/vercel/status", h.vercelStatus)
pr.Get("/export", h.exportConfig)
pr.Get("/dev/captures", h.getDevCaptures)
pr.Delete("/dev/captures", h.clearDevCaptures)

View File

@@ -8,8 +8,9 @@ import (
func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
snap := h.Store.Snapshot()
safe := map[string]any{
"keys": snap.Keys,
"accounts": []map[string]any{},
"keys": snap.Keys,
"accounts": []map[string]any{},
"env_backed": h.Store.IsEnvBacked(),
"claude_mapping": func() map[string]string {
if len(snap.ClaudeMapping) > 0 {
return snap.ClaudeMapping

View File

@@ -3,6 +3,8 @@ package admin
import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -11,6 +13,8 @@ import (
"os"
"strings"
"time"
"ds2api/internal/config"
)
func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
@@ -25,7 +29,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
return
}
validated, failed := h.validateAccountsForVercelSync(r.Context(), opts.AutoValidate)
_, cfgB64, err := h.Store.ExportJSONAndBase64()
cfgJSON, cfgB64, err := h.exportSyncConfig(req)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
@@ -47,7 +51,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
}
savedCreds := h.saveVercelProjectCredentials(r.Context(), client, opts, params, headers, envs)
manual, deployURL := triggerVercelDeployment(r.Context(), client, opts.ProjectID, params, headers)
_ = h.Store.SetVercelSync(h.computeSyncHash(), time.Now().Unix())
_ = h.Store.SetVercelSync(syncHashForJSON(cfgJSON), time.Now().Unix())
result := map[string]any{"success": true, "validated_accounts": validated}
if manual {
result["message"] = "配置已同步到 Vercel请手动触发重新部署"
@@ -209,11 +213,71 @@ func triggerVercelDeployment(ctx context.Context, client *http.Client, projectID
return false, deployURL
}
func (h *Handler) vercelStatus(w http.ResponseWriter, _ *http.Request) {
func (h *Handler) vercelStatus(w http.ResponseWriter, r *http.Request) {
snap := h.Store.Snapshot()
current := h.computeSyncHash()
synced := snap.VercelSyncHash != "" && snap.VercelSyncHash == current
writeJSON(w, http.StatusOK, map[string]any{"synced": synced, "last_sync_time": nilIfZero(snap.VercelSyncTime), "has_synced_before": snap.VercelSyncHash != ""})
draftHash := ""
draftDiffers := false
if r != nil && r.Method == http.MethodPost && r.Body != nil {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
if cfgJSON, _, err := h.exportSyncConfig(req); err == nil {
draftHash = syncHashForJSON(cfgJSON)
draftDiffers = draftHash != "" && draftHash != current
}
}
}
writeJSON(w, http.StatusOK, map[string]any{
"synced": synced,
"last_sync_time": nilIfZero(snap.VercelSyncTime),
"has_synced_before": snap.VercelSyncHash != "",
"env_backed": h.Store.IsEnvBacked(),
"config_hash": current,
"last_synced_hash": snap.VercelSyncHash,
"draft_hash": draftHash,
"draft_differs": draftDiffers,
})
}
func (h *Handler) exportSyncConfig(req map[string]any) (string, string, error) {
override, ok := req["config_override"]
if !ok || override == nil {
return h.Store.ExportJSONAndBase64()
}
raw, err := json.Marshal(override)
if err != nil {
return "", "", err
}
var cfg config.Config
if err := json.Unmarshal(raw, &cfg); err != nil {
return "", "", err
}
cfg.DropInvalidAccounts()
cfg.ClearAccountTokens()
cfg.VercelSyncHash = ""
cfg.VercelSyncTime = 0
b, err := json.Marshal(cfg)
if err != nil {
return "", "", err
}
return string(b), base64.StdEncoding.EncodeToString(b), nil
}
func syncHashForJSON(s string) string {
var cfg config.Config
if err := json.Unmarshal([]byte(s), &cfg); err != nil {
return ""
}
cfg.VercelSyncHash = ""
cfg.VercelSyncTime = 0
cfg.ClearAccountTokens()
b, err := json.Marshal(cfg)
if err != nil {
return ""
}
sum := md5.Sum(b)
return fmt.Sprintf("%x", sum)
}
func vercelRequest(ctx context.Context, client *http.Client, method, endpoint string, params url.Values, headers map[string]string, body any) (map[string]any, int, error) {

View File

@@ -1,5 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
const ENV_DRAFT_KEY = 'ds2api_env_config_draft_v1'
export function useAdminConfig({ token, showMessage, t }) {
const [config, setConfig] = useState({ keys: [], accounts: [] })
@@ -11,6 +13,21 @@ export function useAdminConfig({ token, showMessage, t }) {
})
if (res.ok) {
const data = await res.json()
if (data?.env_backed) {
const rawDraft = localStorage.getItem(ENV_DRAFT_KEY)
if (rawDraft) {
try {
const draft = JSON.parse(rawDraft)
setConfig({ ...draft, env_backed: true })
return
} catch (_e) {
localStorage.removeItem(ENV_DRAFT_KEY)
}
}
localStorage.setItem(ENV_DRAFT_KEY, JSON.stringify(data))
} else {
localStorage.removeItem(ENV_DRAFT_KEY)
}
setConfig(data)
}
} catch (e) {
@@ -21,6 +38,17 @@ export function useAdminConfig({ token, showMessage, t }) {
useEffect(() => {
if (token) {
const rawDraft = localStorage.getItem(ENV_DRAFT_KEY)
if (rawDraft) {
try {
const draft = JSON.parse(rawDraft)
if (draft?.env_backed) {
setConfig(draft)
}
} catch (_e) {
localStorage.removeItem(ENV_DRAFT_KEY)
}
}
fetchConfig()
}
}, [fetchConfig, token])

View File

@@ -101,6 +101,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
onPageSizeChange={changePageSize}
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
envBacked={Boolean(config?.env_backed)}
/>
<AddKeyModal

View File

@@ -26,6 +26,7 @@ export default function AccountsTable({
onPageSizeChange,
searchQuery,
onSearchChange,
envBacked = false,
}) {
const [copiedId, setCopiedId] = useState(null)
@@ -101,14 +102,16 @@ export default function AccountsTable({
) : accounts.length > 0 ? (
accounts.map((acc, i) => {
const id = resolveAccountIdentifier(acc)
const runtimeUnknown = envBacked && !acc.test_status
const isActive = acc.test_status === 'ok' || acc.has_token
return (
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className={clsx(
"w-2 h-2 rounded-full shrink-0",
acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
(acc.test_status === 'ok' || acc.has_token) ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
"bg-amber-500"
isActive ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
runtimeUnknown ? "bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]" : "bg-amber-500"
)} />
<div className="min-w-0">
<div
@@ -122,7 +125,7 @@ export default function AccountsTable({
}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span>
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')}</span>
{acc.token_preview && (
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
{acc.token_preview}

View File

@@ -4,7 +4,7 @@ import VercelSyncForm from './VercelSyncForm'
import VercelSyncStatus from './VercelSyncStatus'
import VercelGuide from './VercelGuide'
export default function VercelSyncContainer({ onMessage, authFetch, isVercel = false }) {
export default function VercelSyncContainer({ onMessage, authFetch, isVercel = false, config = null }) {
const { t } = useI18n()
const apiFetch = authFetch || fetch
@@ -28,6 +28,7 @@ export default function VercelSyncContainer({ onMessage, authFetch, isVercel = f
onMessage,
t,
isVercel,
config,
})
return (

View File

@@ -69,6 +69,11 @@ export default function VercelSyncForm({
{t('vercel.lastSyncTime', { time: new Date(syncStatus.last_sync_time * 1000).toLocaleString() })}
</p>
)}
{syncStatus?.draft_differs && (
<p className="text-xs text-amber-500 mt-2">
{t('vercel.draftDiffers')}
</p>
)}
</div>
<div className="space-y-4">

View File

@@ -8,7 +8,7 @@ function pollDelayMs(attempt) {
return 60000
}
export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false }) {
export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false, config = null }) {
const [vercelToken, setVercelToken] = useState('')
const [projectId, setProjectId] = useState('')
const [teamId, setTeamId] = useState('')
@@ -22,7 +22,14 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
const fetchSyncStatus = useCallback(async ({ manual = false } = {}) => {
try {
const res = await apiFetch('/admin/vercel/status')
const hasConfig = Boolean(config && (Array.isArray(config?.keys) || Array.isArray(config?.accounts)))
const res = await apiFetch('/admin/vercel/status', hasConfig ? {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config_override: { keys: config?.keys || [], accounts: config?.accounts || [] },
}),
} : undefined)
if (!res.ok) {
throw new Error(`status ${res.status}`)
}
@@ -50,7 +57,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
// eslint-disable-next-line no-console
console.error('Failed to fetch sync status:', e)
}
}, [apiFetch, isVercel, onMessage, t])
}, [apiFetch, config, isVercel, onMessage, t])
useEffect(() => {
const loadPreconfig = async () => {
@@ -117,6 +124,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
vercel_token: tokenToUse,
project_id: projectId,
team_id: teamId || undefined,
config_override: config ? { keys: config.keys || [], accounts: config.accounts || [] } : undefined,
}),
})
const data = await res.json()
@@ -133,7 +141,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
} finally {
setLoading(false)
}
}, [apiFetch, fetchSyncStatus, onMessage, preconfig?.has_token, projectId, t, teamId, vercelToken])
}, [apiFetch, config, fetchSyncStatus, onMessage, preconfig?.has_token, projectId, t, teamId, vercelToken])
return {
vercelToken,

View File

@@ -79,7 +79,7 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s
case 'import':
return <BatchImport onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
case 'vercel':
return <VercelSync onMessage={showMessage} authFetch={authFetch} isVercel={isVercel} />
return <VercelSync onMessage={showMessage} authFetch={authFetch} isVercel={isVercel} config={config} />
case 'settings':
return <Settings onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} onForceLogout={onForceLogout} isVercel={isVercel} />
default:

View File

@@ -115,6 +115,7 @@
"testingAllAccounts": "Refreshing tokens for all accounts...",
"sessionActive": "Session active",
"reauthRequired": "Re-auth required",
"runtimeStatusUnknown": "Will be determined after sync",
"testStatusFailed": "Last test failed",
"noAccounts": "No accounts found.",
"modalAddKeyTitle": "Add API key",
@@ -294,6 +295,7 @@
"statusNotSynced": "Not synced",
"statusNeverSynced": "Never synced",
"lastSyncTime": "Last sync: {time}",
"draftDiffers": "Frontend draft differs from env config. Click Sync & redeploy.",
"pollPaused": "Status polling paused after {count} failures.",
"manualRefresh": "Refresh manually",
"howItWorks": "How it works",

View File

@@ -115,6 +115,7 @@
"testingAllAccounts": "正在刷新所有账号 Token...",
"sessionActive": "已建立会话",
"reauthRequired": "需重新登录",
"runtimeStatusUnknown": "状态以同步后为准",
"testStatusFailed": "上次测试失败",
"noAccounts": "未找到任何账号",
"modalAddKeyTitle": "添加 API 密钥",
@@ -294,6 +295,7 @@
"statusNotSynced": "未同步",
"statusNeverSynced": "从未同步",
"lastSyncTime": "上次同步: {time}",
"draftDiffers": "检测到前端草稿与环境变量配置不一致,请点击“同步并重新部署”。",
"pollPaused": "状态轮询已暂停:连续失败 {count} 次。",
"manualRefresh": "手动刷新",
"howItWorks": "工作原理",