Files
sproutlink/web/src/components/AdminPanel.tsx

297 lines
10 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { listLinks, updateLink, deleteLink, type ClientRule, type IpRule, type LinkItem, type GeoRule } from '../api';
import EditDrawer from './EditDrawer';
const PAGE_SIZE = 20;
function formatDate(ts: number | null): string {
if (!ts) return '—';
return new Date(ts * 1000).toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
function geoBadge(item: LinkItem): JSX.Element | null {
if (!item.geo_rule) return null;
let rule: GeoRule;
try { rule = JSON.parse(item.geo_rule); } catch { return null; }
const label = rule.mode === 'allow'
? `${rule.countries.join('/')}`
: `屏蔽 ${rule.countries.join('/')}`;
return <span className="tag tag-mute" style={{ marginLeft: '0.3rem' }} title={label}>{label}</span>;
}
function clientBadge(item: LinkItem): JSX.Element | null {
if (!item.client_rule) return null;
let r: ClientRule;
try { r = JSON.parse(item.client_rule); } catch { return null; }
const parts: string[] = [];
if (r.device === 'mobile') parts.push('仅手机');
if (r.device === 'desktop') parts.push('仅电脑');
if (r.browsers && r.browsers.length) {
const m: Record<string, string> = { chrome: 'Chrome', edge: 'Edge', safari: 'Safari', firefox: 'Fx', opera: 'Op', other: '其他' };
parts.push(r.browsers.map(b => m[b] || b).join('+'));
}
if (!parts.length) return null;
const label = [r.device && (r.device === 'mobile' ? '手机' : '电脑'), r.browsers?.length ? `浏览器: ${r.browsers.join(',')}` : ''].filter(Boolean).join(' · ');
return (
<span
className="tag tag-mute"
style={{ marginLeft: '0.3rem', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={label}
>
{parts.join(' · ')}
</span>
);
}
function ipBadge(item: LinkItem): JSX.Element | null {
if (!item.ip_rule) return null;
let r: IpRule;
try { r = JSON.parse(item.ip_rule); } catch { return null; }
const n = r.ips?.length ?? 0;
if (!n) return null;
const short = r.mode === 'allow' ? `${n}` : `${n}`;
return (
<span
className="tag tag-mute"
style={{ marginLeft: '0.3rem' }}
title={r.mode === 'allow' ? 'IP 白名单' : 'IP 黑名单'}
>
{short}
</span>
);
}
function expiredBadge(item: LinkItem): JSX.Element {
const now = Math.floor(Date.now() / 1000);
if (item.expires_at && now > item.expires_at) {
return <span className="tag tag-void"></span>;
}
if (item.max_clicks !== null && item.clicks >= item.max_clicks) {
return <span className="tag tag-void"></span>;
}
if (item.deleted) return <span className="tag tag-mute"></span>;
return <span className="tag tag-ok"></span>;
}
export default function AdminPanel() {
const [items, setItems] = useState<LinkItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [editing, setEditing] = useState<LinkItem | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const load = useCallback(async (p: number) => {
setLoading(true);
setError('');
try {
const data = await listLinks(p, PAGE_SIZE);
setItems(data.items);
setTotal(data.total);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(page); }, [page, load]);
const handleDelete = async (code: string) => {
try {
await deleteLink(code);
setConfirmDelete(null);
load(page);
} catch (e) {
setError(String(e));
}
};
const handleToggleDeleted = async (item: LinkItem) => {
try {
await updateLink(item.code, { deleted: item.deleted ? 0 : 1 });
load(page);
} catch (e) {
setError(String(e));
}
};
const handleSave = async () => {
setEditing(null);
load(page);
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<div>
<div className="admin-toolbar">{total}</div>
{error && <div className="error-msg" style={{ marginBottom: '1rem' }}>{error}</div>}
{loading && <p className="link-muted" style={{ fontSize: '0.8rem', marginBottom: '0.75rem' }}></p>}
{editing && (
<EditDrawer
item={editing}
onSave={handleSave}
onClose={() => setEditing(null)}
/>
)}
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div className="link-table-wrap">
<table className="link-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{items.length === 0 && !loading && (
<tr>
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--muted)', padding: '2rem' }}>
</td>
</tr>
)}
{items.map(item => (
<tr key={item.id}>
<td style={{ maxWidth: 200, wordBreak: 'break-all' }}>
<code style={{ fontFamily: 'ui-monospace, monospace', fontSize: '0.88rem', fontWeight: 500, color: 'var(--ink)' }}>
{item.code}
</code>
{item.has_password ? (
<span className="tag tag-mute" style={{ marginLeft: '0.35rem' }}></span>
) : null}
{geoBadge(item)}
{clientBadge(item)}
{ipBadge(item)}
</td>
<td>
<a
href={item.target_url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '0.82rem', color: 'var(--muted)', wordBreak: 'break-all' }}
>
{item.target_url.length > 45 ? item.target_url.slice(0, 45) + '…' : item.target_url}
</a>
</td>
<td>{expiredBadge(item)}</td>
<td style={{ fontSize: '0.85rem' }}>
{item.clicks}
{item.max_clicks !== null && (
<span style={{ color: 'var(--muted)' }}> / {item.max_clicks}</span>
)}
</td>
<td style={{ fontSize: '0.8rem', color: 'var(--muted)', whiteSpace: 'nowrap' }}>
{formatDate(item.expires_at)}
</td>
<td style={{ fontSize: '0.8rem', color: 'var(--muted)', whiteSpace: 'nowrap' }}>
{formatDate(item.created_at)}
</td>
<td>
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap' }}>
<button
className="btn-ghost"
onClick={() => setEditing(item)}
disabled={!!editing}
>
</button>
<button
className="btn-ghost"
onClick={() => handleToggleDeleted(item)}
style={{ fontSize: '0.78rem' }}
>
{item.deleted ? '恢复' : '禁用'}
</button>
{confirmDelete === item.code ? (
<>
<button
className="btn-danger"
onClick={() => handleDelete(item.code)}
>
</button>
<button
className="btn-ghost"
onClick={() => setConfirmDelete(null)}
>
</button>
</>
) : (
<button
className="btn-danger"
onClick={() => setConfirmDelete(item.code)}
>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{totalPages > 1 && (
<div className="pagination">
<button
type="button"
className="btn-ghost"
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
>
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => Math.abs(p - page) <= 2 || p === 1 || p === totalPages)
.reduce<(number | '...')[]>((acc, p, idx, arr) => {
if (idx > 0 && (arr[idx - 1] as number) < p - 1) acc.push('...');
acc.push(p);
return acc;
}, [])
.map((p, i) =>
p === '...'
? <span key={`ellipsis-${i}`} style={{ padding: '0.3rem 0.5rem', color: 'var(--muted)' }}></span>
: (
<button
type="button"
key={p}
className={`btn-ghost page-num ${p === page ? 'is-active' : ''}`}
onClick={() => setPage(p as number)}
>
{p}
</button>
)
)
}
<button
type="button"
className="btn-ghost"
disabled={page >= totalPages}
onClick={() => setPage(p => p + 1)}
>
</button>
</div>
)}
</div>
);
}