feat: clash/ss suport

This commit is contained in:
2026-04-13 22:19:30 +08:00
parent 699b038a6c
commit 35e95ee777
11 changed files with 665 additions and 129 deletions

View File

@@ -50,9 +50,9 @@ export const auth = {
// Subscriptions
export const subscriptions = {
list: () => request<any[]>('/subscriptions'),
create: (name: string, url: string) => request<{ id: number }>('/subscriptions', {
create: (name: string, url: string, urlClash?: string, urlSsr?: string) => request<{ id: number }>('/subscriptions', {
method: 'POST',
body: JSON.stringify({ name, url }),
body: JSON.stringify({ name, url, url_clash: urlClash || null, url_ssr: urlSsr || null }),
}),
update: (id: number, data: any) => request<{ ok: boolean }>(`/subscriptions/${id}`, {
method: 'PUT',

View File

@@ -4,10 +4,14 @@ import { config as configApi } from '../api';
export default function Output() {
const [preview, setPreview] = useState('');
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [copiedSurge, setCopiedSurge] = useState(false);
const [copiedClash, setCopiedClash] = useState(false);
const [copiedSsr, setCopiedSsr] = useState(false);
const [surgeToken, setSurgeToken] = useState('');
const surgeUrl = surgeToken ? `${window.location.origin}/surge/${surgeToken}` : '';
const clashUrl = surgeToken ? `${window.location.origin}/clash/${surgeToken}` : '';
const ssrUrl = surgeToken ? `${window.location.origin}/ssr/${surgeToken}` : '';
const loadToken = async () => {
try {
@@ -17,7 +21,7 @@ export default function Output() {
};
const handleRegenerate = async () => {
if (!confirm('重新生成后,旧的订阅链接将失效Surge 客户端需要更新订阅地址。确定继续?')) return;
if (!confirm('重新生成后,旧的订阅链接将失效Surge / Clash / SSR 三条链接同时更新)。确定继续?')) return;
try {
const data = await configApi.regenerateSurgeToken();
setSurgeToken(data.token);
@@ -42,93 +46,88 @@ export default function Output() {
loadPreview();
}, []);
const handleCopy = () => {
navigator.clipboard.writeText(surgeUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
const makeCopyHandler = (url: string, setter: (v: boolean) => void) => () => {
navigator.clipboard.writeText(url);
setter(true);
setTimeout(() => setter(false), 2000);
};
return (
<div>
<h2 style={styles.title}></h2>
<p style={styles.subtitle}>Surge </p>
<p style={styles.subtitle}>Surge / Clash / SSR </p>
{/* Subscription URL */}
<div style={{
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 16,
marginBottom: 20,
}}>
<div style={{
fontSize: 10,
fontFamily: 'var(--font-mono)',
color: 'var(--text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginBottom: 8,
}}>
Surge
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<code style={{
flex: 1,
fontFamily: 'var(--font-mono)',
fontSize: 13,
color: 'var(--accent)',
userSelect: 'all',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{surgeUrl || '加载中...'}
</code>
<button className="small" onClick={handleCopy} disabled={!surgeUrl}>
{copied ? '已复制' : '复制'}
</button>
<button className="small" onClick={handleRegenerate} disabled={!surgeUrl}>
</button>
</div>
</div>
{/* Surge URL */}
<UrlCard
label="SURGE 订阅链接"
url={surgeUrl}
copied={copiedSurge}
onCopy={makeCopyHandler(surgeUrl, setCopiedSurge)}
onRegenerate={handleRegenerate}
/>
{/* Clash URL */}
<UrlCard
label="CLASH / STASH 订阅链接"
url={clashUrl}
copied={copiedClash}
onCopy={makeCopyHandler(clashUrl, setCopiedClash)}
/>
{/* SSR URL */}
<UrlCard
label="SSR / QX / 小火箭 订阅链接"
url={ssrUrl}
copied={copiedSsr}
onCopy={makeCopyHandler(ssrUrl, setCopiedSsr)}
/>
{/* Preview */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{
fontSize: 10,
fontFamily: 'var(--font-mono)',
color: 'var(--text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
}}>
</span>
<span style={styles.sectionLabel}>SURGE </span>
<button className="small" onClick={loadPreview} disabled={loading}>
{loading ? '加载中...' : '刷新预览'}
</button>
</div>
<pre style={{
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 16,
fontFamily: 'var(--font-mono)',
fontSize: 11,
lineHeight: 1.6,
color: 'var(--text-secondary)',
overflow: 'auto',
maxHeight: 'calc(100vh - 360px)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}>
<pre style={styles.pre}>
{preview || '(empty)'}
</pre>
</div>
);
}
function UrlCard({
label,
url,
copied,
onCopy,
onRegenerate,
}: {
label: string;
url: string;
copied: boolean;
onCopy: () => void;
onRegenerate?: () => void;
}) {
return (
<div style={styles.card}>
<div style={styles.cardLabel}>{label}</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<code style={styles.urlCode}>{url || '加载中...'}</code>
<button className="small" onClick={onCopy} disabled={!url}>
{copied ? '已复制' : '复制'}
</button>
{onRegenerate && (
<button className="small" onClick={onRegenerate} disabled={!url}>
</button>
)}
</div>
</div>
);
}
const styles = {
title: {
fontFamily: 'var(--font-mono)' as const,
@@ -142,4 +141,50 @@ const styles = {
color: 'var(--text-secondary)',
marginBottom: 20,
},
card: {
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 16,
marginBottom: 12,
},
cardLabel: {
fontSize: 10,
fontFamily: 'var(--font-mono)' as const,
color: 'var(--text-muted)',
textTransform: 'uppercase' as const,
letterSpacing: '0.1em',
marginBottom: 8,
},
urlCode: {
flex: 1,
fontFamily: 'var(--font-mono)' as const,
fontSize: 13,
color: 'var(--accent)',
userSelect: 'all' as const,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap' as const,
},
sectionLabel: {
fontSize: 10,
fontFamily: 'var(--font-mono)' as const,
color: 'var(--text-muted)',
textTransform: 'uppercase' as const,
letterSpacing: '0.1em',
},
pre: {
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 16,
fontFamily: 'var(--font-mono)' as const,
fontSize: 11,
lineHeight: 1.6,
color: 'var(--text-secondary)',
overflow: 'auto',
maxHeight: 'calc(100vh - 480px)',
whiteSpace: 'pre-wrap' as const,
wordBreak: 'break-all' as const,
},
};

View File

@@ -3,18 +3,46 @@ import { subscriptions as api } from '../api';
export default function Subscriptions() {
const [subs, setSubs] = useState<any[]>([]);
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [fetching, setFetching] = useState<number | null>(null);
// Form state
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formName, setFormName] = useState('');
const [formUrl, setFormUrl] = useState('');
const [formUrlClash, setFormUrlClash] = useState('');
const [formUrlSsr, setFormUrlSsr] = useState('');
const load = () => api.list().then(setSubs).catch(console.error);
useEffect(() => { load(); }, []);
const handleAdd = async () => {
if (!name.trim() || !url.trim()) return;
await api.create(name.trim(), url.trim());
setName('');
setUrl('');
const openForm = (sub?: any) => {
setEditingId(sub?.id ?? null);
setFormName(sub?.name ?? '');
setFormUrl(sub?.url ?? '');
setFormUrlClash(sub?.url_clash ?? '');
setFormUrlSsr(sub?.url_ssr ?? '');
setFormOpen(true);
};
const closeForm = () => {
setFormOpen(false);
setEditingId(null);
};
const handleSubmit = async () => {
if (!formName.trim() || !formUrl.trim()) return;
if (editingId !== null) {
await api.update(editingId, {
name: formName.trim(),
url: formUrl.trim(),
url_clash: formUrlClash.trim() || null,
url_ssr: formUrlSsr.trim() || null,
});
} else {
await api.create(formName.trim(), formUrl.trim(), formUrlClash.trim(), formUrlSsr.trim());
}
closeForm();
load();
};
@@ -46,22 +74,63 @@ export default function Subscriptions() {
<h2 style={styles.title}></h2>
<p style={styles.subtitle}></p>
{/* Add form */}
<div style={styles.form}>
<input
placeholder="名称"
value={name}
onChange={e => setName(e.target.value)}
style={{ width: 160 }}
/>
<input
placeholder="订阅 URL"
value={url}
onChange={e => setUrl(e.target.value)}
style={{ flex: 1 }}
/>
<button className="primary" onClick={handleAdd}></button>
</div>
{/* Add button */}
{!formOpen && (
<button className="primary" style={{ marginBottom: 16 }} onClick={() => openForm()}>
+
</button>
)}
{/* Add / Edit form */}
{formOpen && (
<div style={styles.formCard}>
<div style={styles.formTitle}>
{editingId !== null ? '编辑订阅' : '添加订阅'}
</div>
<div style={styles.fieldGroup}>
<label style={styles.label}></label>
<input
placeholder="名称"
value={formName}
onChange={e => setFormName(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div style={styles.fieldGroup}>
<label style={styles.label}>Surge URL <span style={{ color: 'var(--accent)' }}>*</span></label>
<input
placeholder="https://..."
value={formUrl}
onChange={e => setFormUrl(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div style={styles.fieldGroup}>
<label style={styles.label}>Clash / Stash URL <span style={styles.optional}></span></label>
<input
placeholder="https://..."
value={formUrlClash}
onChange={e => setFormUrlClash(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div style={styles.fieldGroup}>
<label style={styles.label}>SSR / QX / URL <span style={styles.optional}></span></label>
<input
placeholder="https://..."
value={formUrlSsr}
onChange={e => setFormUrlSsr(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
<button onClick={closeForm}></button>
<button className="primary" onClick={handleSubmit}>
{editingId !== null ? '保存' : '添加'}
</button>
</div>
</div>
)}
{/* Table */}
<table>
@@ -69,10 +138,10 @@ export default function Subscriptions() {
<tr>
<th style={{ width: 50 }}></th>
<th></th>
<th>URL</th>
<th style={{ width: 120 }}></th>
<th style={{ width: 80 }}></th>
<th style={{ width: 140 }}></th>
<th style={{ width: 140 }}></th>
<th style={{ width: 170 }}></th>
</tr>
</thead>
<tbody>
@@ -90,9 +159,15 @@ export default function Subscriptions() {
{sub.name}
</td>
<td>
<span className="ellipsis" style={{ maxWidth: 300, display: 'inline-block' }}>
{sub.url}
</span>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<span style={styles.badge} title={sub.url}>SURGE</span>
{sub.url_clash && (
<span style={{ ...styles.badge, ...styles.badgeClash }} title={sub.url_clash}>CLASH</span>
)}
{sub.url_ssr && (
<span style={{ ...styles.badge, ...styles.badgeSsr }} title={sub.url_ssr}>SSR</span>
)}
</div>
</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>
{sub.node_count || '—'}
@@ -102,6 +177,9 @@ export default function Subscriptions() {
</td>
<td>
<div style={{ display: 'flex', gap: 6 }}>
<button className="small" onClick={() => openForm(sub)}>
</button>
<button
className="small"
onClick={() => handleFetch(sub.id)}
@@ -142,9 +220,54 @@ const styles = {
color: 'var(--text-secondary)',
marginBottom: 20,
},
form: {
display: 'flex' as const,
gap: 8,
formCard: {
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 16,
marginBottom: 20,
display: 'flex' as const,
flexDirection: 'column' as const,
gap: 12,
},
formTitle: {
fontFamily: 'var(--font-mono)' as const,
fontSize: 12,
color: 'var(--text-muted)',
textTransform: 'uppercase' as const,
letterSpacing: '0.08em',
},
fieldGroup: {
display: 'flex' as const,
flexDirection: 'column' as const,
gap: 4,
},
label: {
fontSize: 11,
color: 'var(--text-secondary)',
fontFamily: 'var(--font-mono)' as const,
},
optional: {
color: 'var(--text-muted)',
fontSize: 10,
},
badge: {
fontSize: 10,
fontFamily: 'var(--font-mono)' as const,
padding: '1px 5px',
borderRadius: 3,
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
color: 'var(--text-secondary)',
cursor: 'default' as const,
userSelect: 'none' as const,
},
badgeClash: {
color: '#4ade80',
borderColor: '#4ade8040',
},
badgeSsr: {
color: '#60a5fa',
borderColor: '#60a5fa40',
},
};