fix:增面

This commit is contained in:
Daniel
2026-03-03 22:42:21 +08:00
parent 09ec2e3a69
commit 86e50debec
13 changed files with 1486 additions and 0 deletions

131
src/api/edit.ts Normal file
View File

@@ -0,0 +1,131 @@
/** 手动修正看板数据 API */
export interface CombatLossesRow {
side: string
bases_destroyed: number
bases_damaged: number
personnel_killed: number
personnel_wounded: number
civilian_killed?: number
civilian_wounded?: number
aircraft: number
warships: number
armor: number
vehicles: number
drones?: number
missiles?: number
helicopters?: number
submarines?: number
tanks?: number
carriers?: number
civilian_ships?: number
airport_port?: number
}
export interface KeyLocationRow {
id: number
side: string
name: string
lat: number
lng: number
type?: string | null
region?: string | null
status?: string | null
damage_level?: number | null
}
export interface SituationUpdateRow {
id: string
timestamp: string
category: string
summary: string
severity: string
}
export interface ForceSummaryRow {
side: string
total_assets: number
personnel: number
naval_ships: number
aircraft: number
ground_units: number
uav: number
missile_consumed: number
missile_stock: number
}
export interface EditRawData {
combatLosses: { us: CombatLossesRow | null; iran: CombatLossesRow | null }
keyLocations: { us: KeyLocationRow[]; iran: KeyLocationRow[] }
situationUpdates: SituationUpdateRow[]
forceSummary: { us: ForceSummaryRow | null; iran: ForceSummaryRow | null }
}
export async function fetchEditRaw(): Promise<EditRawData> {
const res = await fetch('/api/edit/raw', { cache: 'no-store' })
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}
export async function putCombatLosses(side: 'us' | 'iran', body: Partial<CombatLossesRow>): Promise<void> {
const res = await fetch('/api/edit/combat-losses', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ side, ...body }),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}
export async function patchKeyLocation(id: number, body: Partial<KeyLocationRow>): Promise<void> {
const res = await fetch(`/api/edit/key-location/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}
export async function postSituationUpdate(body: {
id?: string
timestamp?: string
category: string
summary: string
severity?: string
}): Promise<{ id: string }> {
const res = await fetch('/api/edit/situation-update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
return res.json()
}
export async function deleteSituationUpdate(id: string): Promise<void> {
const res = await fetch(`/api/edit/situation-update/${encodeURIComponent(id)}`, { method: 'DELETE' })
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}
export async function putForceSummary(side: 'us' | 'iran', body: Partial<ForceSummaryRow>): Promise<void> {
const res = await fetch('/api/edit/force-summary', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ side, ...body }),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}

552
src/pages/EditDashboard.tsx Normal file
View File

@@ -0,0 +1,552 @@
import { useEffect, useState, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeft, RefreshCw, Save, Trash2, Plus, ChevronDown, ChevronRight } from 'lucide-react'
import {
fetchEditRaw,
putCombatLosses,
patchKeyLocation,
postSituationUpdate,
deleteSituationUpdate,
putForceSummary,
type EditRawData,
type CombatLossesRow,
type KeyLocationRow,
type SituationUpdateRow,
type ForceSummaryRow,
} from '@/api/edit'
import { fetchAndSetSituation } from '@/store/situationStore'
const LOSS_FIELDS: { key: keyof CombatLossesRow; label: string }[] = [
{ key: 'bases_destroyed', label: '基地摧毁' },
{ key: 'bases_damaged', label: '基地受损' },
{ key: 'personnel_killed', label: '人员阵亡' },
{ key: 'personnel_wounded', label: '人员受伤' },
{ key: 'civilian_killed', label: '平民死亡' },
{ key: 'civilian_wounded', label: '平民受伤' },
{ key: 'aircraft', label: '飞机' },
{ key: 'warships', label: '军舰' },
{ key: 'armor', label: '装甲' },
{ key: 'vehicles', label: '车辆' },
{ key: 'drones', label: '无人机' },
{ key: 'missiles', label: '导弹' },
{ key: 'helicopters', label: '直升机' },
{ key: 'submarines', label: '潜艇' },
{ key: 'carriers', label: '航母' },
{ key: 'civilian_ships', label: '民船' },
{ key: 'airport_port', label: '机场/港口' },
]
const SUMMARY_FIELDS: { key: keyof ForceSummaryRow; label: string }[] = [
{ key: 'total_assets', label: '总资产' },
{ key: 'personnel', label: '人员' },
{ key: 'naval_ships', label: '舰艇' },
{ key: 'aircraft', label: '飞机' },
{ key: 'ground_units', label: '地面单位' },
{ key: 'uav', label: '无人机' },
{ key: 'missile_consumed', label: '导弹消耗' },
{ key: 'missile_stock', label: '导弹库存' },
]
const CATEGORIES = ['deployment', 'alert', 'intel', 'diplomatic', 'other'] as const
const SEVERITIES = ['low', 'medium', 'high', 'critical'] as const
const STATUS_OPTIONS = ['operational', 'damaged', 'attacked'] as const
function num(v: unknown): number {
if (v === null || v === undefined) return 0
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
export function EditDashboard() {
const [data, setData] = useState<EditRawData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState<string | null>(null)
const [openSections, setOpenSections] = useState<Set<string>>(new Set(['losses', 'updates']))
const [newUpdate, setNewUpdate] = useState({ category: 'other', summary: '', severity: 'medium' })
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const raw = await fetchEditRaw()
setData(raw)
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
const toggleSection = (id: string) => {
setOpenSections((s) => {
const next = new Set(s)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const afterSave = async () => {
await load()
await fetchAndSetSituation()
}
const handleSaveLosses = async (side: 'us' | 'iran', row: CombatLossesRow | null) => {
if (!row) return
setSaving(`losses-${side}`)
try {
await putCombatLosses(side, row)
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
const handleSaveKeyLocation = async (loc: KeyLocationRow, patch: Partial<KeyLocationRow>) => {
setSaving(`loc-${loc.id}`)
try {
await patchKeyLocation(loc.id, patch)
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
const handleAddUpdate = async () => {
if (!newUpdate.summary.trim()) return
setSaving('add-update')
try {
await postSituationUpdate({
timestamp: new Date().toISOString(),
category: newUpdate.category,
summary: newUpdate.summary.trim(),
severity: newUpdate.severity,
})
setNewUpdate({ category: 'other', summary: '', severity: 'medium' })
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '添加失败')
} finally {
setSaving(null)
}
}
const handleDeleteUpdate = async (id: string) => {
if (!confirm('确定删除这条事件?')) return
setSaving(`del-${id}`)
try {
await deleteSituationUpdate(id)
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '删除失败')
} finally {
setSaving(null)
}
}
const handleSaveForceSummary = async (side: 'us' | 'iran', row: ForceSummaryRow | null) => {
if (!row) return
setSaving(`summary-${side}`)
try {
await putForceSummary(side, row)
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
if (loading && !data) {
return (
<div className="flex min-h-screen items-center justify-center bg-military-dark text-military-text-secondary">
<RefreshCw className="h-6 w-6 animate-spin" />
</div>
)
}
return (
<div className="min-h-screen bg-military-dark font-orbitron text-military-text-primary">
<header className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-2 border-b border-military-border bg-military-panel/95 px-4 py-3">
<div className="flex items-center gap-4">
<Link
to="/"
className="flex items-center gap-2 rounded border border-military-border px-3 py-1.5 text-sm text-military-text-secondary hover:bg-military-border/30 hover:text-military-text-primary"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<span className="flex items-center gap-2 text-lg text-cyan-400">
</span>
</div>
<button
onClick={load}
disabled={loading}
className="flex items-center gap-2 rounded border border-military-border px-3 py-1.5 text-sm text-military-text-secondary hover:bg-military-border/30 disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</header>
{error && (
<div className="mx-4 mt-4 rounded border border-amber-600/50 bg-amber-950/30 px-4 py-2 text-amber-400">
{error}
<button type="button" className="ml-2 underline" onClick={() => setError(null)}></button>
</div>
)}
<main className="max-w-4xl space-y-2 p-4">
{/* 战损 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('losses')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-cyan-400"> / </span>
{openSections.has('losses') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('losses') && data && (
<div className="border-t border-military-border p-4 space-y-6">
{(['us', 'iran'] as const).map((side) => {
const row = data.combatLosses[side]
if (!row) return <p key={side} className="text-military-text-secondary text-sm"></p>
return (
<LossForm
key={side}
side={side}
row={row}
onSave={(updated) => handleSaveLosses(side, updated)}
saving={saving === `losses-${side}`}
/>
)
})}
</div>
)}
</section>
{/* 美军据点 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('loc-us')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-military-us"></span>
{openSections.has('loc-us') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('loc-us') && data && (
<div className="border-t border-military-border p-4">
<KeyLocationList
list={data.keyLocations.us}
onSave={handleSaveKeyLocation}
savingId={saving}
/>
</div>
)}
</section>
{/* 伊朗据点 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('loc-iran')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-military-iran"></span>
{openSections.has('loc-iran') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('loc-iran') && data && (
<div className="border-t border-military-border p-4">
<KeyLocationList
list={data.keyLocations.iran}
onSave={handleSaveKeyLocation}
savingId={saving}
/>
</div>
)}
</section>
{/* 事件脉络 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('updates')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-cyan-400"></span>
{openSections.has('updates') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('updates') && data && (
<div className="border-t border-military-border p-4 space-y-4">
<div className="flex flex-wrap gap-2 items-end rounded border border-military-border/50 bg-military-dark/50 p-3">
<select
value={newUpdate.category}
onChange={(e) => setNewUpdate((u) => ({ ...u, category: e.target.value }))}
className="rounded border border-military-border bg-military-panel px-2 py-1.5 text-sm"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<select
value={newUpdate.severity}
onChange={(e) => setNewUpdate((u) => ({ ...u, severity: e.target.value }))}
className="rounded border border-military-border bg-military-panel px-2 py-1.5 text-sm"
>
{SEVERITIES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<input
type="text"
placeholder="摘要"
value={newUpdate.summary}
onChange={(e) => setNewUpdate((u) => ({ ...u, summary: e.target.value }))}
className="min-w-[200px] flex-1 rounded border border-military-border bg-military-panel px-2 py-1.5 text-sm"
/>
<button
type="button"
onClick={handleAddUpdate}
disabled={saving === 'add-update' || !newUpdate.summary.trim()}
className="flex items-center gap-1 rounded border border-cyan-600 bg-cyan-950/50 px-3 py-1.5 text-sm text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-50"
>
<Plus className="h-4 w-4" />
</button>
</div>
<ul className="space-y-2 max-h-64 overflow-y-auto">
{data.situationUpdates.map((u) => (
<li
key={u.id}
className="flex items-start gap-2 rounded border border-military-border/50 bg-military-dark/30 px-3 py-2 text-sm"
>
<span className="shrink-0 text-military-text-secondary text-xs">
{u.timestamp.slice(0, 19).replace('T', ' ')}
</span>
<span className="shrink-0 rounded bg-military-border/50 px-1.5 py-0.5 text-xs">{u.category}</span>
<span className="shrink-0 rounded bg-amber-900/40 px-1.5 py-0.5 text-xs">{u.severity}</span>
<span className="min-w-0 flex-1 truncate" title={u.summary}>{u.summary}</span>
<button
type="button"
onClick={() => handleDeleteUpdate(u.id)}
disabled={String(saving).startsWith('del-')}
className="shrink-0 rounded p-1 text-red-400 hover:bg-red-950/50 disabled:opacity-50"
title="删除"
>
<Trash2 className="h-4 w-4" />
</button>
</li>
))}
</ul>
</div>
)}
</section>
{/* 军力概要 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('summary')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-cyan-400"> / </span>
{openSections.has('summary') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('summary') && data && (
<div className="border-t border-military-border p-4 space-y-6">
{(['us', 'iran'] as const).map((side) => {
const row = data.forceSummary[side]
if (!row) return <p key={side} className="text-military-text-secondary text-sm"></p>
return (
<ForceSummaryForm
key={side}
side={side}
row={row}
onSave={(updated) => handleSaveForceSummary(side, updated)}
saving={saving === `summary-${side}`}
/>
)
})}
</div>
)}
</section>
</main>
</div>
)
}
function LossForm({
side,
row,
onSave,
saving,
}: {
side: 'us' | 'iran'
row: CombatLossesRow
onSave: (row: CombatLossesRow) => void
saving: boolean
}) {
const [edit, setEdit] = useState<CombatLossesRow>({ ...row })
useEffect(() => {
setEdit({ ...row })
}, [row])
const sideLabel = side === 'us' ? '美军' : '伊朗'
return (
<div className={`rounded border ${side === 'us' ? 'border-military-us/40' : 'border-military-iran/40'} bg-military-dark/50 p-4`}>
<div className="mb-3 font-medium">{sideLabel}</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-3">
{LOSS_FIELDS.map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 text-sm">
<span className="w-24 shrink-0 text-military-text-secondary">{label}</span>
<input
type="number"
min={0}
value={edit[key] ?? 0}
onChange={(e) => setEdit((r) => ({ ...r, [key]: num(e.target.value) }))}
className="w-20 rounded border border-military-border bg-military-panel px-2 py-1 text-right tabular-nums"
/>
</label>
))}
</div>
<button
type="button"
onClick={() => onSave(edit)}
disabled={saving}
className="mt-3 flex items-center gap-1 rounded border border-cyan-600 bg-cyan-950/50 px-3 py-1.5 text-sm text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-50"
>
<Save className="h-4 w-4" />
</button>
</div>
)
}
function KeyLocationList({
list,
onSave,
savingId,
}: {
list: KeyLocationRow[]
onSave: (loc: KeyLocationRow, patch: Partial<KeyLocationRow>) => void
savingId: string | null
}) {
if (list.length === 0) return <p className="text-military-text-secondary text-sm"></p>
return (
<ul className="space-y-3">
{list.map((loc) => (
<KeyLocationRowEdit
key={loc.id}
loc={loc}
onSave={onSave}
saving={savingId === `loc-${loc.id}`}
/>
))}
</ul>
)
}
function KeyLocationRowEdit({
loc,
onSave,
saving,
}: {
loc: KeyLocationRow
onSave: (loc: KeyLocationRow, patch: Partial<KeyLocationRow>) => void
saving: boolean
}) {
const [status, setStatus] = useState(loc.status ?? 'operational')
const [damageLevel, setDamageLevel] = useState(num(loc.damage_level))
useEffect(() => {
setStatus(loc.status ?? 'operational')
setDamageLevel(num(loc.damage_level))
}, [loc.id, loc.status, loc.damage_level])
const hasChange = status !== (loc.status ?? 'operational') || damageLevel !== num(loc.damage_level)
return (
<li className="flex flex-wrap items-center gap-2 rounded border border-military-border/50 bg-military-dark/30 px-3 py-2">
<span className="font-medium min-w-0 truncate">{loc.name}</span>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="rounded border border-military-border bg-military-panel px-2 py-1 text-sm"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<label className="flex items-center gap-1 text-sm">
<span className="text-military-text-secondary"></span>
<input
type="number"
min={0}
max={3}
value={damageLevel}
onChange={(e) => setDamageLevel(num(e.target.value))}
className="w-14 rounded border border-military-border bg-military-panel px-2 py-1 text-right"
/>
</label>
<button
type="button"
onClick={() => onSave(loc, { status, damage_level: damageLevel })}
disabled={saving || !hasChange}
className="flex items-center gap-1 rounded border border-cyan-600 bg-cyan-950/50 px-2 py-1 text-xs text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-50"
>
<Save className="h-3 w-3" />
</button>
{saving && <span className="text-cyan-400 text-xs"></span>}
</li>
)
}
function ForceSummaryForm({
side,
row,
onSave,
saving,
}: {
side: 'us' | 'iran'
row: ForceSummaryRow
onSave: (row: ForceSummaryRow) => void
saving: boolean
}) {
const [edit, setEdit] = useState<ForceSummaryRow>({ ...row })
useEffect(() => {
setEdit({ ...row })
}, [row])
const sideLabel = side === 'us' ? '美军' : '伊朗'
return (
<div className={`rounded border ${side === 'us' ? 'border-military-us/40' : 'border-military-iran/40'} bg-military-dark/50 p-4`}>
<div className="mb-3 font-medium">{sideLabel}</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
{SUMMARY_FIELDS.map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 text-sm">
<span className="w-24 shrink-0 text-military-text-secondary">{label}</span>
<input
type="number"
min={0}
value={edit[key] ?? 0}
onChange={(e) => setEdit((r) => ({ ...r, [key]: num(e.target.value) }))}
className="w-20 rounded border border-military-border bg-military-panel px-2 py-1 text-right tabular-nums"
/>
</label>
))}
</div>
<button
type="button"
onClick={() => onSave(edit)}
disabled={saving}
className="mt-3 flex items-center gap-1 rounded border border-cyan-600 bg-cyan-950/50 px-3 py-1.5 text-sm text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-50"
>
<Save className="h-4 w-4" />
</button>
</div>
)
}