Initial commit: Sproutlink short URL system
This commit is contained in:
296
web/src/components/AdminPanel.tsx
Normal file
296
web/src/components/AdminPanel.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user