Update App.jsx via upload script - 2026-08-13 11:00:02

This commit is contained in:
mike 2026-08-13 11:00:22 +08:00
parent 8ef989ade4
commit 52dd9237ac
1 changed files with 74 additions and 230 deletions

292
App.jsx
View File

@ -5,15 +5,13 @@ import {
Building2, Globe, Phone, Save, RefreshCw, LayoutDashboard, Users, Building2, Globe, Phone, Save, RefreshCw, LayoutDashboard, Users,
Package, Layers, Zap, Tag, Inbox, Paperclip, Package, Layers, Zap, Tag, Inbox, Paperclip,
Menu, LogOut, GripVertical, BookOpen, FileText, FolderTree, Lock, Crown, Sparkles, Printer, Image as ImageIcon, Menu, LogOut, GripVertical, BookOpen, FileText, FolderTree, Lock, Crown, Sparkles, Printer, Image as ImageIcon,
ShieldCheck, Settings2, Boxes, Download, Wifi, Radio, QrCode, Send ShieldCheck, Settings2, Boxes, Download, Wifi, Radio, QrCode
} from 'lucide-react'; } from 'lucide-react';
import { import {
storageGet, storageSet, uploadFile, deleteFile, storageGet, storageSet, uploadFile, deleteFile,
authCheck, authLogin, authLogout, authChangePassword, authCheck, authLogin, authLogout, authChangePassword,
getAiConfig, saveAiConfig, deleteAiConfig, testAiConfig, generateAiDraft, getAiConfig, saveAiConfig, deleteAiConfig, testAiConfig, generateAiDraft,
parseCatalogFiles, translateTerms, parseCatalogFiles, translateTerms,
getTelegramConfig, saveTelegramConfig, deleteTelegramConfig, testTelegramConfig,
checkJmsLine,
} from './storage'; } from './storage';
/* ----------------------------- constants ----------------------------- */ /* ----------------------------- constants ----------------------------- */
@ -1600,26 +1598,11 @@ const Styles = () => (
.jms-card-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; } .jms-card-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.jms-card-title { display: flex; align-items: center; gap: 8px; min-width: 0; } .jms-card-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
.jms-card-name { font-weight: 700; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .jms-card-name { font-weight: 700; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.jms-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.jms-dot-normal { background: var(--led-green); box-shadow: 0 0 0 3px var(--led-green-bg); }
.jms-dot-abnormal { background: var(--led-red); box-shadow: 0 0 0 3px var(--led-red-bg); animation: jms-pulse-red 1.6s ease-in-out infinite; }
.jms-dot-checking { background: var(--led-amber); box-shadow: 0 0 0 3px var(--led-amber-bg); animation: jms-pulse-amber 1s ease-in-out infinite; }
@keyframes jms-pulse-red { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
@keyframes jms-pulse-amber { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.jms-proto-chip { font-size: 10px; font-weight: 700; letter-spacing: 0.03em; padding: 3px 9px; border-radius: 100px; text-transform: uppercase; flex-shrink: 0; } .jms-proto-chip { font-size: 10px; font-weight: 700; letter-spacing: 0.03em; padding: 3px 9px; border-radius: 100px; text-transform: uppercase; flex-shrink: 0; }
.jms-proto-ss { background: var(--led-green-bg); color: var(--led-green); } .jms-proto-ss { background: var(--led-green-bg); color: var(--led-green); }
.jms-proto-vmess { background: var(--led-blue-bg); color: var(--blue); }
.jms-proto-vless-cdn { background: var(--copper-soft); color: var(--copper); }
.jms-proto-vless-reality { background: var(--led-red-bg); color: var(--led-red); } .jms-proto-vless-reality { background: var(--led-red-bg); color: var(--led-red); }
.jms-monitor-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 11.5px; color: var(--ink-soft); }
.jms-status-abnormal { color: var(--led-red); font-weight: 600; }
.jms-check-btn { background: none; border: none; color: var(--copper); font-size: 11.5px; font-weight: 600; cursor: pointer; padding: 2px 4px; font-family: inherit; }
.jms-check-btn:hover { text-decoration: underline; }
.jms-check-btn:disabled { color: var(--ink-soft); cursor: default; text-decoration: none; }
.jms-check-note { font-size: 10.5px; color: var(--ink-soft); background: var(--bg); border: 1px dashed var(--line); border-radius: 6px; padding: 6px 8px; }
.jms-terminal { background: var(--ink); border-radius: 9px; padding: 12px 14px; font-family: 'IBM Plex Mono', monospace; font-size: 12px; line-height: 1.85; } .jms-terminal { background: var(--ink); border-radius: 9px; padding: 12px 14px; font-family: 'IBM Plex Mono', monospace; font-size: 12px; line-height: 1.85; }
.jms-terminal-row { display: flex; gap: 10px; align-items: baseline; } .jms-terminal-row { display: flex; gap: 10px; align-items: baseline; }
.jms-terminal-key { color: rgba(255,255,255,0.5); flex-shrink: 0; width: 56px; } .jms-terminal-key { color: rgba(255,255,255,0.5); flex-shrink: 0; width: 56px; }
@ -1634,14 +1617,9 @@ const Styles = () => (
.jms-card-actions { display: flex; gap: 8px; flex-wrap: wrap; } .jms-card-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.jms-card-actions .btn { flex: 1; justify-content: center; } .jms-card-actions .btn { flex: 1; justify-content: center; }
.jms-group-select { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .jms-proto-select { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.jms-group-opt { border: 1.5px solid var(--line); border-radius: 9px; padding: 10px 8px; text-align: center; font-size: 12.5px; font-weight: 700; cursor: pointer; color: var(--ink-soft); }
.jms-group-selected.jms-group-selfbuilt { border-color: var(--copper); background: var(--copper-soft); color: var(--copper); }
.jms-group-selected.jms-group-jms { border-color: var(--blue); background: var(--led-blue-bg); color: var(--blue); }
.jms-proto-select { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.jms-proto-opt { border: 1.5px solid var(--line); border-radius: 9px; padding: 9px 4px; text-align: center; font-size: 11px; font-weight: 700; cursor: pointer; letter-spacing: 0.02em; text-transform: uppercase; color: var(--ink-soft); } .jms-proto-opt { border: 1.5px solid var(--line); border-radius: 9px; padding: 9px 4px; text-align: center; font-size: 11px; font-weight: 700; cursor: pointer; letter-spacing: 0.02em; text-transform: uppercase; color: var(--ink-soft); }
.jms-proto-selected.jms-proto-opt-ss { border-color: var(--led-green); background: var(--led-green-bg); color: var(--led-green); } .jms-proto-selected.jms-proto-opt-ss { border-color: var(--led-green); background: var(--led-green-bg); color: var(--led-green); }
.jms-proto-selected.jms-proto-opt-vmess { border-color: var(--blue); background: var(--led-blue-bg); color: var(--blue); }
.jms-proto-selected.jms-proto-opt-vless-reality { border-color: var(--led-red); background: var(--led-red-bg); color: var(--led-red); } .jms-proto-selected.jms-proto-opt-vless-reality { border-color: var(--led-red); background: var(--led-red-bg); color: var(--led-red); }
.jms-qr-link { font-family: 'IBM Plex Mono', monospace; font-size: 11px; word-break: break-all; color: var(--ink-soft); background: var(--bg); padding: 10px; border-radius: 8px; max-height: 90px; overflow-y: auto; text-align: left; margin: 12px 0; } .jms-qr-link { font-family: 'IBM Plex Mono', monospace; font-size: 11px; word-break: break-all; color: var(--ink-soft); background: var(--bg); padding: 10px; border-radius: 8px; max-height: 90px; overflow-y: auto; text-align: left; margin: 12px 0; }
@ -2099,10 +2077,6 @@ function MainApp({ username, onLogout }) {
const [aiKeyInput, setAiKeyInput] = useState(''); const [aiKeyInput, setAiKeyInput] = useState('');
const [aiSaving, setAiSaving] = useState(false); const [aiSaving, setAiSaving] = useState(false);
const [aiTesting, setAiTesting] = useState(false); const [aiTesting, setAiTesting] = useState(false);
const [tgTokenInput, setTgTokenInput] = useState('');
const [tgChatIdInput, setTgChatIdInput] = useState('');
const [tgSaving, setTgSaving] = useState(false);
const [tgTesting, setTgTesting] = useState(false);
const [aiGenerating, setAiGenerating] = useState(false); const [aiGenerating, setAiGenerating] = useState(false);
const [aiDraftModal, setAiDraftModal] = useState(null); // { channel, customerId, subject, body } const [aiDraftModal, setAiDraftModal] = useState(null); // { channel, customerId, subject, body }
@ -2134,8 +2108,6 @@ function MainApp({ username, onLogout }) {
const [catalogCategoryFilter, setCatalogCategoryFilter] = useState('all'); const [catalogCategoryFilter, setCatalogCategoryFilter] = useState('all');
const [jmsLines, setJmsLines] = useState([]); const [jmsLines, setJmsLines] = useState([]);
const [backPanelTemplates, setBackPanelTemplates] = useState([]); const [backPanelTemplates, setBackPanelTemplates] = useState([]);
const [telegramConfigHasToken, setTelegramConfigHasToken] = useState(false);
const [telegramConfigChatId, setTelegramConfigChatId] = useState('');
useEffect(() => { useEffect(() => {
getAiConfig().then((res) => setAiConfigHasKey(!!res.hasKey)).catch(() => {}); getAiConfig().then((res) => setAiConfigHasKey(!!res.hasKey)).catch(() => {});
@ -2164,12 +2136,6 @@ function MainApp({ username, onLogout }) {
setLoaded(true); setLoaded(true);
})(); })();
}, []); }, []);
useEffect(() => {
getTelegramConfig().then(res => {
setTelegramConfigHasToken(!!res.hasToken);
setTelegramConfigChatId(res.chatId || '');
}).catch(() => {});
}, []);
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.customers, JSON.stringify(customers)).catch(() => {}); }, [customers, loaded]); useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.customers, JSON.stringify(customers)).catch(() => {}); }, [customers, loaded]);
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.products, JSON.stringify(products)).catch(() => {}); }, [products, loaded]); useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.products, JSON.stringify(products)).catch(() => {}); }, [products, loaded]);
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.settings, JSON.stringify(settings)).catch(() => {}); }, [settings, loaded]); useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.settings, JSON.stringify(settings)).catch(() => {}); }, [settings, loaded]);
@ -2401,24 +2367,6 @@ function MainApp({ username, onLogout }) {
setAiConfigHasKey(false); setAiConfigHasKey(false);
notify('已清除'); notify('已清除');
} }
async function handleSaveTelegram() {
if (!tgTokenInput.trim() || !tgChatIdInput.trim()) { notify('Bot Token和Chat ID都要填'); return; }
setTgSaving(true);
try {
await saveTelegramSettings(tgTokenInput.trim(), tgChatIdInput.trim());
setTgTokenInput('');
} finally {
setTgSaving(false);
}
}
async function handleTestTelegram() {
setTgTesting(true);
try { await testTelegramSettings(); } finally { setTgTesting(false); }
}
async function handleClearTelegram() {
if (!window.confirm('确定清除已保存的Telegram配置清除后线路异常将不再发送报警。')) return;
await removeTelegramSettings();
}
async function handleGenerateAi(customerId, channel) { async function handleGenerateAi(customerId, channel) {
if (!aiConfigHasKey) { notify('请先在设置中配置DeepSeek API Key'); return; } if (!aiConfigHasKey) { notify('请先在设置中配置DeepSeek API Key'); return; }
setAiGenerating(true); setAiGenerating(true);
@ -2575,49 +2523,7 @@ function MainApp({ username, onLogout }) {
notify('已删除'); notify('已删除');
} }
async function checkJmsLineNow(id) {
const line = jmsLines.find(l => l.id === id);
if (!line) return;
setJmsLines(prev => prev.map(l => (l.id === id ? { ...l, monitorStatus: 'checking' } : l)));
try {
const res = await checkJmsLine(line);
setJmsLines(prev => prev.map(l => (l.id === id ? { ...l, monitorStatus: res.status, lastChecked: res.checkedAt } : l)));
if (res.status === 'abnormal') notify(`${line.name || line.addr} 检测异常`);
else notify(`${line.name || line.addr} 检测正常`);
} catch (e) {
setJmsLines(prev => prev.map(l => (l.id === id ? { ...l, monitorStatus: line.monitorStatus } : l)));
notify('检测失败:' + e.message);
}
}
async function saveTelegramSettings(botToken, chatId) {
try {
await saveTelegramConfig(botToken, chatId);
setTelegramConfigHasToken(true);
setTelegramConfigChatId(chatId);
notify('Telegram配置已保存');
} catch (e) {
notify('保存失败:' + e.message);
}
}
async function removeTelegramSettings() {
try {
await deleteTelegramConfig();
setTelegramConfigHasToken(false);
setTelegramConfigChatId('');
notify('已清除Telegram配置');
} catch (e) {
notify('清除失败:' + e.message);
}
}
async function testTelegramSettings() {
try {
await testTelegramConfig();
notify('测试消息已发送去Telegram看看');
} catch (e) {
notify('测试失败:' + e.message);
}
}
async function handleExportProductPdf() { async function handleExportProductPdf() {
if (!selectedProduct) return; if (!selectedProduct) return;
@ -3337,7 +3243,6 @@ function MainApp({ username, onLogout }) {
lines={jmsLines} lines={jmsLines}
onSave={saveJmsLine} onSave={saveJmsLine}
onDelete={deleteJmsLine} onDelete={deleteJmsLine}
onCheck={checkJmsLineNow}
onNotify={notify} onNotify={notify}
/> />
)} )}
@ -3821,35 +3726,6 @@ function MainApp({ username, onLogout }) {
</div> </div>
</div> </div>
<div className="panel">
<div className="panel-header"><div className="panel-title"><Send size={15} />Telegram 报警Just My Socks线路监控</div></div>
<div className="panel-body" style={{ padding: 18 }}>
<div style={{ fontSize: 12.5, color: 'var(--ink-soft)', marginBottom: 12 }}>
状态{telegramConfigHasToken ? <span style={{ color: 'var(--led-green)', fontWeight: 600 }}>已配置Chat ID: {telegramConfigChatId}</span> : <span>未配置</span>}
</div>
<div className="grid-2">
<div className="field">
<span className="field-label">Bot Token</span>
<input
type="password" className="input" value={tgTokenInput} onChange={e => setTgTokenInput(e.target.value)}
placeholder={telegramConfigHasToken ? '已设置,重新填写可覆盖' : '找 @BotFather 申请'}
/>
</div>
<div className="field">
<span className="field-label">Chat ID</span>
<input className="input" value={tgChatIdInput} onChange={e => setTgChatIdInput(e.target.value)} placeholder="接收报警的对话ID" />
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" disabled={tgSaving} onClick={handleSaveTelegram}><Save size={13} />保存</button>
<button className="btn" disabled={!telegramConfigHasToken || tgTesting} onClick={handleTestTelegram}>{tgTesting ? '发送中…' : '发送测试消息'}</button>
{telegramConfigHasToken && <button className="btn btn-danger" onClick={handleClearTelegram}>清除</button>}
</div>
<div className="disclaimer" style={{ marginTop: 14 }}>
Just My Socks页面的线路每10分钟自动巡检一次只在状态发生变化正常异常时才发消息不会每次检测都刷屏Bot Token加密保存在服务器本地
</div>
</div>
</div>
</> </>
)} )}
@ -4426,21 +4302,23 @@ function CustomerDetail({
const JMS_PROTO_META = { const JMS_PROTO_META = {
'ss': { label: 'SS' }, 'ss': { label: 'SS' },
'vmess': { label: 'VMess' },
'vless-reality': { label: 'VLESS Reality' }, 'vless-reality': { label: 'VLESS Reality' },
'vless-cdn': { label: 'VLESS CDN' },
}; };
const JMS_PROTOCOLS = ['ss', 'vmess', 'vless-reality']; const JMS_PROTOCOLS = ['ss', 'vless-reality'];
// Shadowsocks2022-blake3-*AEAD-2022aes/chacha
// justmysocks2.net
const JMS_SS_METHODS = [
'aes-256-gcm',
'aes-128-gcm',
'chacha20-ietf-poly1305',
'2022-blake3-aes-128-gcm',
'2022-blake3-aes-256-gcm',
'2022-blake3-chacha20-poly1305',
];
function jmsTimeAgo(ts) { function jmsMask(len = 8) { return '•'.repeat(Math.max(6, Math.min(len, 16))); }
if (!ts) return '尚未检测';
const sec = Math.floor((Date.now() - ts) / 1000);
if (sec < 60) return `${sec}秒前`;
if (sec < 3600) return `${Math.floor(sec / 60)}分钟前`;
return `${Math.floor(sec / 3600)}小时前`;
}
// ss:// / vmess:// / vless:// // ss:// / vless://
function jmsBuildLink(line) { function jmsBuildLink(line) {
const { protocol, addr, port, name } = line; const { protocol, addr, port, name } = line;
const tag = encodeURIComponent(name || addr); const tag = encodeURIComponent(name || addr);
@ -4448,20 +4326,6 @@ function jmsBuildLink(line) {
const userinfo = btoa(`${line.method}:${line.password}`).replace(/=+$/, ''); const userinfo = btoa(`${line.method}:${line.password}`).replace(/=+$/, '');
return `ss://${userinfo}@${addr}:${port}#${tag}`; return `ss://${userinfo}@${addr}:${port}#${tag}`;
} }
if (protocol === 'vmess') {
const obj = {
v: '2', ps: name || addr, add: addr, port: String(port), id: line.uuid, aid: line.alterId || '0',
net: 'ws', type: 'none', host: line.host || addr, path: '/', tls: 'tls',
};
return 'vmess://' + btoa(JSON.stringify(obj));
}
if (protocol === 'vless-cdn') {
const q = new URLSearchParams({
encryption: 'none', security: 'tls', type: 'ws', fp: 'chrome',
host: line.host || addr, path: line.ws_path || '/stickrouter', sni: line.sni || addr,
});
return `vless://${line.uuid}@${addr}:${port}?${q.toString()}#${tag}`;
}
if (protocol === 'vless-reality') { if (protocol === 'vless-reality') {
const q = new URLSearchParams({ const q = new URLSearchParams({
encryption: 'none', security: 'reality', sni: line.sni, fp: line.fingerprint || 'chrome', encryption: 'none', security: 'reality', sni: line.sni, fp: line.fingerprint || 'chrome',
@ -4481,35 +4345,34 @@ async function jmsResolveIp(domain) {
return answer ? answer.data : null; return answer ? answer.data : null;
} }
function JmsLineCard({ line, ip, onRefreshIp, onCheck, onCopy, onQr, onDuplicate, onEdit, onDelete }) { function jmsCopyText(text, label, onNotify) {
const meta = JMS_PROTO_META[line.protocol]; if (!text) { onNotify && onNotify('没有可复制的内容'); return; }
const status = line.monitorStatus || 'normal'; navigator.clipboard.writeText(text)
const dotClass = status === 'abnormal' ? 'jms-dot-abnormal' : status === 'checking' ? 'jms-dot-checking' : 'jms-dot-normal'; .then(() => onNotify && onNotify(`${label}已复制`))
const statusLabel = status === 'abnormal' ? '异常,无法连接' : status === 'checking' ? '检测中…' : '正常'; .catch(() => onNotify && onNotify('复制失败,请手动选择'));
const isVlessCdn = line.protocol === 'vless-cdn'; }
const checkNote = isVlessCdn
? '检测方式请求WS路径看是否返回520-524源站不可达错误并做真实WebSocket握手不是单纯测TCP' function JmsLineCard({ line, ip, onRefreshIp, onCopy, onQr, onDuplicate, onEdit, onDelete, onNotify }) {
: '检测方式TCP端口连通性测试'; const meta = JMS_PROTO_META[line.protocol] || { label: line.protocol };
const ipDisplay = ip === 'resolving' ? '解析中…' : (ip || '点击右侧刷新解析'); const ipDisplay = ip === 'resolving' ? '解析中…' : (ip || '解析中…');
const hasPassword = line.protocol === 'ss' && !!line.password;
return ( return (
<div className="jms-card"> <div className="jms-card">
<div className="jms-card-top"> <div className="jms-card-top">
<div className="jms-card-title"> <div className="jms-card-title">
<span className={`jms-dot ${dotClass}`} />
<span className="jms-card-name">{line.name || line.addr}</span> <span className="jms-card-name">{line.name || line.addr}</span>
</div> </div>
<span className={`jms-proto-chip jms-proto-${line.protocol}`}>{meta.label}</span> <span className={`jms-proto-chip jms-proto-${line.protocol}`}>{meta.label}</span>
</div> </div>
<div className="jms-monitor-row"> <div className="jms-terminal">
<span className={`jms-status-text ${status === 'abnormal' ? 'jms-status-abnormal' : ''}`}> {statusLabel} · {jmsTimeAgo(line.lastChecked)}</span> <div className="jms-terminal-row">
<button className="jms-check-btn" disabled={status === 'checking'} onClick={() => onCheck(line.id)}> <span className="jms-terminal-key">域名</span>
{status === 'checking' ? '检测中' : '立即检测'} <span className="jms-terminal-val">{line.addr}</span>
<button className="jms-refresh-btn" onClick={() => jmsCopyText(line.addr, '域名', onNotify)} title="复制域名">
<Copy size={12} />
</button> </button>
</div> </div>
<div className="jms-check-note">{checkNote}</div>
<div className="jms-terminal">
<div className="jms-terminal-row"><span className="jms-terminal-key">域名</span><span className="jms-terminal-val">{line.addr}</span></div>
<div className="jms-terminal-row"> <div className="jms-terminal-row">
<span className="jms-terminal-key">解析IP</span> <span className="jms-terminal-key">解析IP</span>
<span className={`jms-terminal-val jms-ip ${!ip || ip === 'resolving' ? 'jms-ip-pending' : ''}`}>{ipDisplay}</span> <span className={`jms-terminal-val jms-ip ${!ip || ip === 'resolving' ? 'jms-ip-pending' : ''}`}>{ipDisplay}</span>
@ -4518,6 +4381,15 @@ function JmsLineCard({ line, ip, onRefreshIp, onCheck, onCopy, onQr, onDuplicate
</button> </button>
</div> </div>
<div className="jms-terminal-row"><span className="jms-terminal-key">端口</span><span className="jms-terminal-val">{line.port}</span></div> <div className="jms-terminal-row"><span className="jms-terminal-key">端口</span><span className="jms-terminal-val">{line.port}</span></div>
{hasPassword && (
<div className="jms-terminal-row">
<span className="jms-terminal-key">密码</span>
<span className="jms-terminal-val">{jmsMask(line.password.length)}</span>
<button className="jms-refresh-btn" onClick={() => jmsCopyText(line.password, '密码', onNotify)} title="复制密码">
<Copy size={12} />
</button>
</div>
)}
</div> </div>
<div className="jms-card-actions"> <div className="jms-card-actions">
<button className="btn btn-sm" onClick={() => onCopy(line)}><Copy size={13} />复制链接</button> <button className="btn btn-sm" onClick={() => onCopy(line)}><Copy size={13} />复制链接</button>
@ -4530,33 +4402,18 @@ function JmsLineCard({ line, ip, onRefreshIp, onCheck, onCopy, onQr, onDuplicate
); );
} }
function JmsLineFormFields({ group, protocol, draft, setDraft }) { function JmsLineFormFields({ protocol, draft, setDraft }) {
const set = (patch) => setDraft(d => ({ ...d, ...patch })); const set = (patch) => setDraft(d => ({ ...d, ...patch }));
if (group === 'selfbuilt') {
return (
<>
<div className="field"><span className="field-label">UUID</span><input className="input" value={draft.uuid || ''} onChange={e => set({ uuid: e.target.value })} placeholder="用户UUID" /></div>
<div className="grid-2">
<div className="field"><span className="field-label">WS路径</span><input className="input" value={draft.ws_path || ''} onChange={e => set({ ws_path: e.target.value })} placeholder="/stickrouter" /></div>
<div className="field"><span className="field-label">SNI / Host</span><input className="input" value={draft.sni || ''} onChange={e => set({ sni: e.target.value })} placeholder="留空则用服务器地址" /></div>
</div>
<div className="disclaimer">* vless加密none · 勾选tls · 指纹chrome固定不需要手动填</div>
</>
);
}
if (protocol === 'ss') { if (protocol === 'ss') {
return ( return (
<div className="grid-2"> <div className="grid-2">
<div className="field"><span className="field-label">加密方式</span><input className="input" value={draft.method || 'aes-256-gcm'} onChange={e => set({ method: e.target.value })} /></div> <div className="field">
<div className="field"><span className="field-label">密码</span><input className="input" value={draft.password || ''} onChange={e => set({ password: e.target.value })} placeholder="连接密码" /></div> <span className="field-label">加密方式</span>
<select className="input" value={draft.method || 'aes-256-gcm'} onChange={e => set({ method: e.target.value })}>
{JMS_SS_METHODS.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div> </div>
); <div className="field"><span className="field-label">密码</span><input className="input" value={draft.password || ''} onChange={e => set({ password: e.target.value })} placeholder="连接密码" /></div>
}
if (protocol === 'vmess') {
return (
<div className="grid-2">
<div className="field"><span className="field-label">UUID</span><input className="input" value={draft.uuid || ''} onChange={e => set({ uuid: e.target.value })} placeholder="用户UUID" /></div>
<div className="field"><span className="field-label">AlterId</span><input className="input" value={draft.alterId || '0'} onChange={e => set({ alterId: e.target.value })} /></div>
</div> </div>
); );
} }
@ -4579,20 +4436,18 @@ function JmsLineFormFields({ group, protocol, draft, setDraft }) {
return null; return null;
} }
function JmsPage({ lines, onSave, onDelete, onCheck, onNotify }) { function JmsPage({ lines, onSave, onDelete, onNotify }) {
const [ipMap, setIpMap] = useState({}); const [ipMap, setIpMap] = useState({});
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [group, setGroup] = useState('selfbuilt'); const [protocol, setProtocol] = useState('ss');
const [protocol, setProtocol] = useState('vless-cdn');
const [draft, setDraft] = useState({}); const [draft, setDraft] = useState({});
const [qrLine, setQrLine] = useState(null); const [qrLine, setQrLine] = useState(null);
const [qrDataUrl, setQrDataUrl] = useState(''); const [qrDataUrl, setQrDataUrl] = useState('');
const total = lines.length; const total = lines.length;
const ssCount = lines.filter(l => l.protocol === 'ss').length; const ssCount = lines.filter(l => l.protocol === 'ss').length;
const vmessCount = lines.filter(l => l.protocol === 'vmess').length; const vlessCount = lines.filter(l => l.protocol === 'vless-reality').length;
const vlessCount = lines.filter(l => l.protocol.startsWith('vless')).length;
async function refreshIp(id, addr) { async function refreshIp(id, addr) {
setIpMap(m => ({ ...m, [id]: 'resolving' })); setIpMap(m => ({ ...m, [id]: 'resolving' }));
@ -4604,6 +4459,14 @@ function JmsPage({ lines, onSave, onDelete, onCheck, onNotify }) {
} }
} }
// IP线/线DNS
useEffect(() => {
lines.forEach(line => {
if (line.addr && ipMap[line.id] === undefined) refreshIp(line.id, line.addr);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lines]);
function copyLink(line) { function copyLink(line) {
const link = jmsBuildLink(line); const link = jmsBuildLink(line);
navigator.clipboard.writeText(link).then(() => onNotify('订阅链接已复制')).catch(() => onNotify('复制失败,请手动选择')); navigator.clipboard.writeText(link).then(() => onNotify('订阅链接已复制')).catch(() => onNotify('复制失败,请手动选择'));
@ -4625,21 +4488,18 @@ function JmsPage({ lines, onSave, onDelete, onCheck, onNotify }) {
function openAddModal() { function openAddModal() {
setEditingId(null); setEditingId(null);
setGroup('selfbuilt'); setProtocol('ss');
setProtocol('vless-cdn'); setDraft({ name: '', addr: '', port: '' });
setDraft({ name: '', addr: 'pb.impromx.com', port: '443', ws_path: '/stickrouter' });
setModalOpen(true); setModalOpen(true);
} }
function openEditModal(line) { function openEditModal(line) {
setEditingId(line.id); setEditingId(line.id);
setGroup(line.group);
setProtocol(line.protocol); setProtocol(line.protocol);
setDraft({ ...line }); setDraft({ ...line });
setModalOpen(true); setModalOpen(true);
} }
function duplicateLine(line) { function duplicateLine(line) {
setEditingId(null); // id线线 setEditingId(null); // id线线
setGroup(line.group);
setProtocol(line.protocol); setProtocol(line.protocol);
const { id, monitorStatus, lastChecked, ...rest } = line; const { id, monitorStatus, lastChecked, ...rest } = line;
setDraft({ ...rest, name: line.name ? `${line.name} 副本` : '' }); setDraft({ ...rest, name: line.name ? `${line.name} 副本` : '' });
@ -4648,20 +4508,14 @@ function JmsPage({ lines, onSave, onDelete, onCheck, onNotify }) {
} }
function closeModal() { setModalOpen(false); } function closeModal() { setModalOpen(false); }
function handleGroupChange(g) { function handleProtocolChange(p) {
setGroup(g); setProtocol(p);
if (g === 'selfbuilt') {
setProtocol('vless-cdn');
setDraft(d => ({ name: d.name, addr: d.addr || 'pb.impromx.com', port: d.port || '443', ws_path: d.ws_path || '/stickrouter' }));
} else {
setProtocol('ss');
setDraft(d => ({ name: d.name, addr: d.addr, port: d.port })); setDraft(d => ({ name: d.name, addr: d.addr, port: d.port }));
} }
}
function handleSave() { function handleSave() {
if (!draft.addr || !draft.port) { onNotify('请填写服务器地址和端口'); return; } if (!draft.addr || !draft.port) { onNotify('请填写服务器地址和端口'); return; }
onSave({ ...draft, group, protocol }, editingId); onSave({ ...draft, protocol }, editingId);
closeModal(); closeModal();
} }
@ -4670,7 +4524,7 @@ function JmsPage({ lines, onSave, onDelete, onCheck, onNotify }) {
<div className="page-head-row"> <div className="page-head-row">
<div> <div>
<h2 className="crm-display" style={{ fontSize: 22, fontWeight: 700, margin: '0 0 4px' }}>Just My Socks 节点管理</h2> <h2 className="crm-display" style={{ fontSize: 22, fontWeight: 700, margin: '0 0 4px' }}>Just My Socks 节点管理</h2>
<div className="disclaimer" style={{ marginBottom: 0 }}>管理自建VLESS-CDN线路和 justmysocks2.net 订阅线路 · DNS解析订阅链接二维码和连通性监控</div> <div className="disclaimer" style={{ marginBottom: 0 }}>管理 justmysocks2.net 订阅线路 · DNS解析订阅链接二维码</div>
</div> </div>
<button className="btn btn-primary" onClick={openAddModal}><Plus size={14} />新增线路</button> <button className="btn btn-primary" onClick={openAddModal}><Plus size={14} />新增线路</button>
</div> </div>
@ -4678,7 +4532,6 @@ function JmsPage({ lines, onSave, onDelete, onCheck, onNotify }) {
<div className="jms-stats-row"> <div className="jms-stats-row">
<div className="stat-chip"><div className="stat-num">{total}</div><div className="stat-lbl">线路总数</div></div> <div className="stat-chip"><div className="stat-num">{total}</div><div className="stat-lbl">线路总数</div></div>
<div className="stat-chip"><div className="stat-num">{ssCount}</div><div className="stat-lbl">SS</div></div> <div className="stat-chip"><div className="stat-num">{ssCount}</div><div className="stat-lbl">SS</div></div>
<div className="stat-chip"><div className="stat-num">{vmessCount}</div><div className="stat-lbl">VMess</div></div>
<div className="stat-chip"><div className="stat-num">{vlessCount}</div><div className="stat-lbl">VLESS</div></div> <div className="stat-chip"><div className="stat-num">{vlessCount}</div><div className="stat-lbl">VLESS</div></div>
</div> </div>
@ -4690,12 +4543,12 @@ function JmsPage({ lines, onSave, onDelete, onCheck, onNotify }) {
line={line} line={line}
ip={ipMap[line.id]} ip={ipMap[line.id]}
onRefreshIp={refreshIp} onRefreshIp={refreshIp}
onCheck={onCheck}
onCopy={copyLink} onCopy={copyLink}
onQr={openQr} onQr={openQr}
onDuplicate={duplicateLine} onDuplicate={duplicateLine}
onEdit={openEditModal} onEdit={openEditModal}
onDelete={onDelete} onDelete={onDelete}
onNotify={onNotify}
/> />
))} ))}
</div> </div>
@ -4708,29 +4561,20 @@ function JmsPage({ lines, onSave, onDelete, onCheck, onNotify }) {
<button className="btn btn-ghost btn-sm" onClick={closeModal}><X size={14} /></button> <button className="btn btn-ghost btn-sm" onClick={closeModal}><X size={14} /></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<div className="field">
<span className="field-label">线路分组</span>
<div className="jms-group-select">
<div className={`jms-group-opt ${group === 'selfbuilt' ? 'jms-group-selected jms-group-selfbuilt' : ''}`} onClick={() => handleGroupChange('selfbuilt')}>自建 VLESS-CDN</div>
<div className={`jms-group-opt ${group === 'jms' ? 'jms-group-selected jms-group-jms' : ''}`} onClick={() => handleGroupChange('jms')}>Just My Socks</div>
</div>
</div>
{group === 'jms' && (
<div className="field"> <div className="field">
<span className="field-label">协议</span> <span className="field-label">协议</span>
<div className="jms-proto-select"> <div className="jms-proto-select">
{JMS_PROTOCOLS.map(key => ( {JMS_PROTOCOLS.map(key => (
<div key={key} className={`jms-proto-opt jms-proto-opt-${key} ${protocol === key ? 'jms-proto-selected' : ''}`} onClick={() => setProtocol(key)}>{JMS_PROTO_META[key].label}</div> <div key={key} className={`jms-proto-opt jms-proto-opt-${key} ${protocol === key ? 'jms-proto-selected' : ''}`} onClick={() => handleProtocolChange(key)}>{JMS_PROTO_META[key].label}</div>
))} ))}
</div> </div>
</div> </div>
)}
<div className="field"><span className="field-label">名称备注</span><input className="input" value={draft.name || ''} onChange={e => setDraft(d => ({ ...d, name: e.target.value }))} placeholder="如SG-01 新加坡" /></div> <div className="field"><span className="field-label">名称备注</span><input className="input" value={draft.name || ''} onChange={e => setDraft(d => ({ ...d, name: e.target.value }))} placeholder="如SG-01 新加坡" /></div>
<div className="grid-2"> <div className="grid-2">
<div className="field"><span className="field-label">服务器地址域名</span><input className="input" value={draft.addr || ''} onChange={e => setDraft(d => ({ ...d, addr: e.target.value }))} placeholder="如sg1.jms-relay.net" /></div> <div className="field"><span className="field-label">服务器地址域名</span><input className="input" value={draft.addr || ''} onChange={e => setDraft(d => ({ ...d, addr: e.target.value }))} placeholder="如sg1.jms-relay.net" /></div>
<div className="field"><span className="field-label">端口</span><input className="input" value={draft.port || ''} onChange={e => setDraft(d => ({ ...d, port: e.target.value }))} placeholder="8388" /></div> <div className="field"><span className="field-label">端口</span><input className="input" value={draft.port || ''} onChange={e => setDraft(d => ({ ...d, port: e.target.value }))} placeholder="8388" /></div>
</div> </div>
<JmsLineFormFields group={group} protocol={protocol} draft={draft} setDraft={setDraft} /> <JmsLineFormFields protocol={protocol} draft={draft} setDraft={setDraft} />
</div> </div>
<div className="modal-foot"><button className="btn" onClick={closeModal}>取消</button><button className="btn btn-primary" onClick={handleSave}><Save size={13} />保存</button></div> <div className="modal-foot"><button className="btn" onClick={closeModal}>取消</button><button className="btn btn-primary" onClick={handleSave}><Save size={13} />保存</button></div>
</div> </div>