feat: 完善服务 CRUD 表单(添加/编辑/删除服务)

This commit is contained in:
aiyimickey 2026-07-02 09:32:07 +08:00
parent 51cfcab11e
commit 57174fd26a
1 changed files with 142 additions and 19 deletions

View File

@ -1,9 +1,12 @@
'use client' 'use client'
// src/app/services/page.tsx — 服务管理页面(容器操作) // src/app/services/page.tsx — 服务管理页面(CRUD + 容器操作)
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Server, Plus, RotateCw, Square, ScrollText } from 'lucide-react' import { Plus, RotateCw, Square, ScrollText, X, Save, Trash2, Edit3 } from 'lucide-react'
interface Service { id: number; name: string; category: string; alert_level: string; checks: Array<{type: string; containerName?: string}>; check_interval: number; enabled: number; current_status: string } interface Check { type: string; containerName?: string; url?: string }
interface Service { id: number; name: string; category: string; alert_level: string; checks: Check[]; check_interval: number; enabled: number; current_status: string }
const emptyService = { name: '', category: 'endpoint', alert_level: 'warning', checks: [] as Check[], check_interval: 30, check_timeout: 10, enabled: 1 }
export default function ServicesPage() { export default function ServicesPage() {
const [services, setServices] = useState<Service[]>([]) const [services, setServices] = useState<Service[]>([])
@ -11,6 +14,9 @@ export default function ServicesPage() {
const [opLoading, setOpLoading] = useState<number | null>(null) const [opLoading, setOpLoading] = useState<number | null>(null)
const [logs, setLogs] = useState<{ serviceId: number; output: string } | null>(null) const [logs, setLogs] = useState<{ serviceId: number; output: string } | null>(null)
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null) const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
const [editing, setEditing] = useState<Record<string, unknown> | null>(null)
const [isNew, setIsNew] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => { fetchServices() }, []) useEffect(() => { fetchServices() }, [])
@ -29,7 +35,6 @@ export default function ServicesPage() {
async function containerAction(s: Service, action: 'restart' | 'stop' | 'start' | 'logs') { async function containerAction(s: Service, action: 'restart' | 'stop' | 'start' | 'logs') {
const confirmMsg = { restart: `确定重启 ${s.name}`, stop: `确定停止 ${s.name}`, start: `确定启动 ${s.name}`, logs: '' } const confirmMsg = { restart: `确定重启 ${s.name}`, stop: `确定停止 ${s.name}`, start: `确定启动 ${s.name}`, logs: '' }
if (action !== 'logs' && !confirm(confirmMsg[action])) return if (action !== 'logs' && !confirm(confirmMsg[action])) return
setOpLoading(s.id) setOpLoading(s.id)
try { try {
const res = await fetch(`/api/services/${s.id}/ops`, { const res = await fetch(`/api/services/${s.id}/ops`, {
@ -38,19 +43,55 @@ export default function ServicesPage() {
}) })
const data = await res.json() const data = await res.json()
if (data.success) { if (data.success) {
if (action === 'logs') { if (action === 'logs') setLogs({ serviceId: s.id, output: data.output || '(空)' })
setLogs({ serviceId: s.id, output: data.output || '(空)' }) else { showToast('ok', `${s.name} 操作成功`); fetchServices() }
} else { } else showToast('err', data.error || '操作失败')
showToast('ok', `${s.name} ${action === 'restart' ? '重启' : action === 'stop' ? '停止' : '启动'}成功`)
fetchServices()
}
} else {
showToast('err', data.error || '操作失败')
}
} catch { showToast('err', '网络错误') } } catch { showToast('err', '网络错误') }
setOpLoading(null) setOpLoading(null)
} }
const update = (f: string, v: unknown) => setEditing(prev => prev ? { ...prev, [f]: v } : null)
const addCheck = () => {
const checks = (editing?.checks as Check[]) || []
setEditing(prev => prev ? { ...prev, checks: [...checks, { type: 'http', url: '' }] } : null)
}
const updateCheck = (i: number, f: string, v: string) => {
const checks = [...((editing?.checks as Check[]) || [])]
if (checks[i]) (checks[i] as Record<string, string>)[f] = v
update('checks', checks)
}
const removeCheck = (i: number) => {
const checks = [...((editing?.checks as Check[]) || [])]
checks.splice(i, 1)
update('checks', checks)
}
async function handleSave() {
if (!editing?.name) { showToast('err', '请输入服务名称'); return }
setSaving(true)
try {
const url = isNew ? '/api/services' : `/api/services/${editing.id}`
const method = isNew ? 'POST' : 'PUT'
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(editing) })
if (res.ok) { showToast('ok', isNew ? '服务已添加' : '服务已更新'); setEditing(null); fetchServices() }
else { const d = await res.json(); showToast('err', d.error || '保存失败') }
} catch { showToast('err', '网络错误') }
setSaving(false)
}
async function handleDelete(id: number) {
if (!confirm('确定删除此服务?')) return
try {
const res = await fetch(`/api/services/${id}`, { method: 'DELETE' })
if (res.ok) { showToast('ok', '服务已删除'); fetchServices() }
else { const d = await res.json(); showToast('err', d.error || '删除失败') }
} catch { showToast('err', '网络错误') }
}
const startNew = () => { setEditing({ ...emptyService }); setIsNew(true) }
const startEdit = (s: Service) => { setEditing({ ...s }); setIsNew(false) }
if (loading) return <div className="p-12 text-center text-slate-400">...</div> if (loading) return <div className="p-12 text-center text-slate-400">...</div>
return ( return (
@ -63,8 +104,7 @@ export default function ServicesPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]"></h1> <h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]"></h1>
<button className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg px-4 py-2 text-sm font-medium" <button onClick={startNew} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg px-4 py-2 text-sm font-medium">
onClick={() => showToast('err', '添加服务功能即将上线')}>
<Plus size={16} /> <Plus size={16} />
</button> </button>
</div> </div>
@ -79,6 +119,7 @@ export default function ServicesPage() {
<th className="text-left px-4 py-3 font-medium text-slate-500"></th> <th className="text-left px-4 py-3 font-medium text-slate-500"></th>
<th className="text-left px-4 py-3 font-medium text-slate-500"></th> <th className="text-left px-4 py-3 font-medium text-slate-500"></th>
<th className="text-center px-4 py-3 font-medium text-slate-500"></th> <th className="text-center px-4 py-3 font-medium text-slate-500"></th>
<th className="text-center px-4 py-3 font-medium text-slate-500"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -91,7 +132,7 @@ export default function ServicesPage() {
{s.alert_level} {s.alert_level}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-slate-500">{s.checks.map(c => c.type).join(' + ')}</td> <td className="px-4 py-3 text-slate-500">{s.checks?.map(c => c.type).join(' + ') || '—'}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 ${s.current_status === 'normal' ? 'text-green-600' : s.current_status === 'abnormal' ? 'text-red-600' : 'text-amber-600'}`}> <span className={`inline-flex items-center gap-1.5 ${s.current_status === 'normal' ? 'text-green-600' : s.current_status === 'abnormal' ? 'text-red-600' : 'text-amber-600'}`}>
<span className="w-2 h-2 rounded-full bg-current" /> <span className="w-2 h-2 rounded-full bg-current" />
@ -118,6 +159,14 @@ export default function ServicesPage() {
<span className="text-xs text-slate-400"></span> <span className="text-xs text-slate-400"></span>
)} )}
</td> </td>
<td className="px-4 py-3 text-center">
<button onClick={() => startEdit(s)} className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded text-slate-400" title="编辑">
<Edit3 size={14} />
</button>
<button onClick={() => handleDelete(s.id)} className="p-1.5 hover:bg-red-50 dark:hover:bg-red-500/10 rounded text-red-400 ml-1" title="删除">
<Trash2 size={14} />
</button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -133,9 +182,83 @@ export default function ServicesPage() {
<h2 className="font-semibold"> {services.find(s => s.id === logs.serviceId)?.name}</h2> <h2 className="font-semibold"> {services.find(s => s.id === logs.serviceId)?.name}</h2>
<button onClick={() => setLogs(null)} className="text-slate-400 hover:text-slate-600 text-lg">&times;</button> <button onClick={() => setLogs(null)} className="text-slate-400 hover:text-slate-600 text-lg">&times;</button>
</div> </div>
<pre className="text-xs font-mono text-slate-300 bg-slate-950 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap max-h-96"> <pre className="text-xs font-mono text-slate-300 bg-slate-950 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap max-h-96">{logs.output}</pre>
{logs.output} </div>
</pre> </div>
)}
{/* 添加/编辑服务 Dialog */}
{editing && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-slate-900 rounded-xl p-6 w-full max-w-lg shadow-xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">{isNew ? '添加服务' : '编辑服务'}</h2>
<button onClick={() => setEditing(null)} className="p-1 text-slate-400 hover:text-slate-600"><X size={18} /></button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"> *</label>
<input value={String(editing.name || '')} onChange={e => update('name', e.target.value)}
placeholder="如Authelia SSO" className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<select value={String(editing.category || 'endpoint')} onChange={e => update('category', e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm">
<option value="endpoint"></option><option value="container"></option><option value="custom"></option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<select value={String(editing.alert_level || 'warning')} onChange={e => update('alert_level', e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm">
<option value="critical">Critical</option><option value="warning">Warning</option><option value="info">Info</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<input type="number" value={Number(editing.check_interval) || 30} onChange={e => update('check_interval', Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<input type="number" value={Number(editing.check_timeout) || 10} onChange={e => update('check_timeout', Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm" />
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300"></label>
<button onClick={addCheck} className="text-xs text-indigo-600 hover:text-indigo-700">+ </button>
</div>
{((editing.checks as Check[]) || []).map((c, i) => (
<div key={i} className="flex items-center gap-2 mb-2">
<select value={c.type} onChange={e => updateCheck(i, 'type', e.target.value)}
className="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-xs w-20">
<option value="http">HTTP</option><option value="docker">Docker</option>
</select>
{c.type === 'http' ? (
<input value={c.url || ''} onChange={e => updateCheck(i, 'url', e.target.value)} placeholder="https://example.com/api/health"
className="flex-1 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-xs" />
) : (
<input value={c.containerName || ''} onChange={e => updateCheck(i, 'containerName', e.target.value)} placeholder="容器名,如 authelia"
className="flex-1 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-xs" />
)}
<button onClick={() => removeCheck(i)} className="text-red-400 hover:text-red-600 p-1"><X size={14} /></button>
</div>
))}
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={() => setEditing(null)} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800"></button>
<button onClick={handleSave} disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white rounded-lg text-sm font-medium">
<Save size={15} /> {saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div> </div>
</div> </div>
)} )}