fix:增面
This commit is contained in:
131
src/api/edit.ts
Normal file
131
src/api/edit.ts
Normal 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
552
src/pages/EditDashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user