297 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|