feat: clash/ss suport
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user