From afd0792df717f4744a866c79ad2a0be9a8eab303 Mon Sep 17 00:00:00 2001 From: mike Date: Fri, 31 Jul 2026 17:57:29 +0800 Subject: [PATCH] Update App.jsx via upload script - 2026-07-31 17:57:05 --- App.jsx | 598 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 595 insertions(+), 3 deletions(-) diff --git a/App.jsx b/App.jsx index 49c1e07..ea7cce3 100644 --- a/App.jsx +++ b/App.jsx @@ -5,13 +5,15 @@ import { Building2, Globe, Phone, Save, RefreshCw, LayoutDashboard, Users, Package, Layers, Zap, Tag, Inbox, Paperclip, Menu, LogOut, GripVertical, BookOpen, FileText, FolderTree, Lock, Crown, Sparkles, Printer, Image as ImageIcon, - ShieldCheck, Settings2, Boxes, Download + ShieldCheck, Settings2, Boxes, Download, Wifi, Radio, QrCode, Send } from 'lucide-react'; import { storageGet, storageSet, uploadFile, deleteFile, authCheck, authLogin, authLogout, authChangePassword, getAiConfig, saveAiConfig, deleteAiConfig, testAiConfig, generateAiDraft, parseCatalogFiles, translateTerms, + getTelegramConfig, saveTelegramConfig, deleteTelegramConfig, testTelegramConfig, + checkJmsLine, fetchJmsTraffic, } from './storage'; /* ----------------------------- constants ----------------------------- */ @@ -246,7 +248,7 @@ const COUNTRIES = [ const COUNTRY_BY_NAME = Object.fromEntries(COUNTRIES.map(c => [c.name, c])); -const STORAGE_KEYS = { customers: 'crm:customers', products: 'crm:products', settings: 'crm:settings', quotes: 'crm:quotes', sellerProfiles: 'crm:sellerProfiles', certifications: 'crm:certifications', productCategories: 'crm:productCategories', packingLists: 'crm:packingLists', catalogDocs: 'crm:catalogDocs' }; +const STORAGE_KEYS = { customers: 'crm:customers', products: 'crm:products', settings: 'crm:settings', quotes: 'crm:quotes', sellerProfiles: 'crm:sellerProfiles', certifications: 'crm:certifications', productCategories: 'crm:productCategories', packingLists: 'crm:packingLists', catalogDocs: 'crm:catalogDocs', jmsLines: 'crm:jmsLines', jmsSubUrl: 'crm:jmsSubUrl' }; const blankCustomer = () => ({ id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7), @@ -1263,7 +1265,17 @@ async function loadAll() { const cd = await storageGet(STORAGE_KEYS.catalogDocs); if (cd && cd.value) catalogDocs = JSON.parse(cd.value); } catch (e) {} - return { customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs }; + let jmsLines = []; + let jmsSubUrl = ''; + try { + const jl = await storageGet(STORAGE_KEYS.jmsLines); + if (jl && jl.value) jmsLines = JSON.parse(jl.value); + } catch (e) {} + try { + const ju = await storageGet(STORAGE_KEYS.jmsSubUrl); + if (ju && ju.value) jmsSubUrl = ju.value; + } catch (e) {} + return { customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs, jmsLines, jmsSubUrl }; } /* -------------------------------- styles -------------------------------- */ @@ -1394,6 +1406,78 @@ const Styles = () => ( .search-box { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border: 1px solid var(--line); border-radius: 8px; background: white; margin-bottom: 10px; } .search-box input { border: none; outline: none; font-size: 13px; flex: 1; } + /* ---------- Just My Socks ---------- */ + .page-head-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 20px; flex-wrap: wrap; } + .jms-stats-row { display: flex; gap: 12px; margin-bottom: 22px; flex-wrap: wrap; } + .stat-chip { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 18px; display: flex; flex-direction: column; gap: 2px; min-width: 100px; } + .stat-num { font-family: 'Space Grotesk', sans-serif; font-size: 22px; font-weight: 700; } + .stat-lbl { font-size: 11.5px; color: var(--ink-soft); } + + .jms-traffic-widget { position: relative; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 16px; min-width: 252px; display: flex; flex-direction: column; gap: 6px; justify-content: center; } + .jms-traffic-top { display: flex; align-items: center; gap: 6px; } + .jms-traffic-title { font-size: 11.5px; font-weight: 700; color: var(--ink-soft); } + .jms-traffic-badge { font-size: 9.5px; font-weight: 600; color: var(--copper); background: var(--copper-soft); padding: 2px 7px; border-radius: 100px; } + .jms-icon-btn { margin-left: 0; background: none; border: none; color: var(--ink-soft); cursor: pointer; padding: 2px; display: flex; border-radius: 4px; } + .jms-icon-btn:last-child { margin-left: auto; } + .jms-icon-btn:hover { background: var(--bg); color: var(--ink); } + .jms-traffic-bar { height: 6px; background: var(--line); border-radius: 100px; overflow: hidden; } + .jms-traffic-bar-fill { height: 100%; background: linear-gradient(90deg, var(--copper), #D08A44); border-radius: 100px; transition: width .4s; } + .jms-traffic-meta { font-family: 'IBM Plex Mono', monospace; font-size: 12px; font-weight: 600; color: var(--ink); } + .jms-traffic-settings { + position: absolute; top: calc(100% + 6px); right: 0; z-index: 20; background: var(--panel); + border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); width: 260px; + } + + .jms-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; } + .jms-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px 16px 14px; display: flex; flex-direction: column; gap: 12px; } + .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-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-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-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-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-val { color: #F0EDE6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .jms-terminal-val.jms-ip { color: #8FD19E; } + .jms-terminal-val.jms-ip-pending { color: rgba(255,255,255,0.5); font-style: italic; } + .jms-refresh-btn { margin-left: auto; background: none; border: none; color: rgba(255,255,255,0.5); cursor: pointer; padding: 2px; display: flex; border-radius: 4px; flex-shrink: 0; } + .jms-refresh-btn:hover { color: #fff; background: rgba(255,255,255,0.1); } + .jms-spin { animation: jms-spin .8s linear infinite; } + @keyframes jms-spin { to { transform: rotate(360deg); } } + + .jms-card-actions { display: flex; gap: 8px; flex-wrap: wrap; } + .jms-card-actions .btn { flex: 1; justify-content: center; } + + .jms-group-select { display: grid; grid-template-columns: 1fr 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-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-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; } + .modal-overlay { position: fixed; inset: 0; background: rgba(24,35,58,0.45); display: flex; align-items: center; justify-content: center; z-index: 50; padding: 20px; @@ -1695,6 +1779,7 @@ const NAV_GROUPS = [ { label: '客户', items: [ { id: 'monitor', label: '客户监控', icon: LayoutDashboard }, { id: 'manage', label: '客户管理', icon: Users }, + { id: 'jms', label: 'Just My Socks', icon: Wifi }, ] }, { label: '产品', items: [ { id: 'groups', label: '产品分组', icon: FolderTree }, @@ -1846,6 +1931,10 @@ function MainApp({ username, onLogout }) { const [aiKeyInput, setAiKeyInput] = useState(''); const [aiSaving, setAiSaving] = 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 [aiDraftModal, setAiDraftModal] = useState(null); // { channel, customerId, subject, body } @@ -1875,6 +1964,12 @@ function MainApp({ username, onLogout }) { const [editingCatalogId, setEditingCatalogId] = useState(null); const [catalogEditDraft, setCatalogEditDraft] = useState({ title: '', category: '', brand: '', type: '' }); const [catalogCategoryFilter, setCatalogCategoryFilter] = useState('all'); + const [jmsLines, setJmsLines] = useState([]); + const [jmsSubUrl, setJmsSubUrl] = useState(''); + const [jmsTraffic, setJmsTraffic] = useState(null); // { upload, download, total, expire } 或 null(还没拉取过) + const [jmsTrafficLoading, setJmsTrafficLoading] = useState(false); + const [telegramConfigHasToken, setTelegramConfigHasToken] = useState(false); + const [telegramConfigChatId, setTelegramConfigChatId] = useState(''); useEffect(() => { getAiConfig().then((res) => setAiConfigHasKey(!!res.hasKey)).catch(() => {}); @@ -1898,9 +1993,17 @@ function MainApp({ username, onLogout }) { setCertifications(data.certifications); setPackingLists(data.packingLists); setCatalogDocs((data.catalogDocs || []).filter(d => d.fileName)); + setJmsLines(data.jmsLines || []); + setJmsSubUrl(data.jmsSubUrl || ''); 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.products, JSON.stringify(products)).catch(() => {}); }, [products, loaded]); useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.settings, JSON.stringify(settings)).catch(() => {}); }, [settings, loaded]); @@ -1910,6 +2013,8 @@ function MainApp({ username, onLogout }) { useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.productCategories, JSON.stringify(productCategories)).catch(() => {}); }, [productCategories, loaded]); useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.packingLists, JSON.stringify(packingLists)).catch(() => {}); }, [packingLists, loaded]); useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.catalogDocs, JSON.stringify(catalogDocs)).catch(() => {}); }, [catalogDocs, loaded]); + useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.jmsLines, JSON.stringify(jmsLines)).catch(() => {}); }, [jmsLines, loaded]); + useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.jmsSubUrl, jmsSubUrl).catch(() => {}); }, [jmsSubUrl, loaded]); function notify(msg) { setToast(msg); setTimeout(() => setToast(''), 2200); } @@ -2130,6 +2235,24 @@ function MainApp({ username, onLogout }) { setAiConfigHasKey(false); 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) { if (!aiConfigHasKey) { notify('请先在设置中配置DeepSeek API Key'); return; } setAiGenerating(true); @@ -2268,6 +2391,82 @@ function MainApp({ username, onLogout }) { notify('已删除'); } + /* --------------------------------- Just My Socks --------------------------------- */ + + function saveJmsLine(line, editingId) { + if (editingId) { + setJmsLines(prev => prev.map(l => (l.id === editingId ? { ...line, id: editingId } : l))); + notify('已保存修改'); + } else { + setJmsLines(prev => [...prev, { ...line, id: 'jms_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7) }]); + notify('线路已新增'); + } + } + + function deleteJmsLine(id) { + if (!window.confirm('确定删除这条线路?')) return; + setJmsLines(prev => prev.filter(l => l.id !== id)); + 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 refreshJmsTraffic() { + if (!jmsSubUrl.trim()) { notify('请先在流量小组件的设置里填写标准订阅地址'); return; } + setJmsTrafficLoading(true); + try { + const res = await fetchJmsTraffic(jmsSubUrl.trim()); + if (res.ok) setJmsTraffic(res); + else notify(res.error || '获取流量信息失败'); + } catch (e) { + notify('获取流量信息失败:' + e.message); + } finally { + setJmsTrafficLoading(false); + } + } + + 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() { if (!selectedProduct) return; setProductExportPdfBusy(true); @@ -2975,6 +3174,21 @@ function MainApp({ username, onLogout }) { )} + {view === 'jms' && ( + + )} + {view === 'groups' && ( <>
按产品大类分组浏览;每个类目右上角管理品牌,类目内第二行管理类型;新建产品时先选类目,再从对应的品牌 / 类型里选。
@@ -3448,6 +3662,36 @@ function MainApp({ username, onLogout }) { + +
+
Telegram 报警(Just My Socks线路监控)
+
+
+ 状态:{telegramConfigHasToken ? 已配置(Chat ID: {telegramConfigChatId}) : 未配置} +
+
+
+ Bot Token + setTgTokenInput(e.target.value)} + placeholder={telegramConfigHasToken ? '已设置,重新填写可覆盖' : '找 @BotFather 申请'} + /> +
+
+ Chat ID + setTgChatIdInput(e.target.value)} placeholder="接收报警的对话ID" /> +
+
+
+ + + {telegramConfigHasToken && } +
+
+ Just My Socks页面的线路每10分钟自动巡检一次,只在状态发生变化(正常↔异常)时才发消息,不会每次检测都刷屏。Bot Token加密保存在服务器本地。 +
+
+
)} @@ -4020,6 +4264,354 @@ function CustomerDetail({ ); } +/* ----------------------------- Just My Socks ----------------------------- */ + +const JMS_PROTO_META = { + 'ss': { label: 'SS' }, + 'vmess': { label: 'VMess' }, + 'vless-reality': { label: 'VLESS Reality' }, + 'vless-cdn': { label: 'VLESS CDN' }, +}; +const JMS_PROTOCOLS = ['ss', 'vmess', 'vless-reality']; + +function jmsTimeAgo(ts) { + 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://),一键导入和二维码都用这个 +function jmsBuildLink(line) { + const { protocol, addr, port, name } = line; + const tag = encodeURIComponent(name || addr); + if (protocol === 'ss') { + const userinfo = btoa(`${line.method}:${line.password}`).replace(/=+$/, ''); + 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') { + const q = new URLSearchParams({ + encryption: 'none', security: 'reality', sni: line.sni, fp: line.fingerprint || 'chrome', + pbk: line.publicKey, sid: line.shortId, type: 'tcp', flow: line.flow || 'xtls-rprx-vision', + }); + return `vless://${line.uuid}@${addr}:${port}?${q.toString()}#${tag}`; + } + return ''; +} + +async function jmsResolveIp(domain) { + const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}&type=A`, { + headers: { Accept: 'application/dns-json' }, + }); + const data = await res.json(); + const answer = (data.Answer || []).find(a => a.type === 1); + return answer ? answer.data : null; +} + +function JmsLineCard({ line, ip, onRefreshIp, onCheck, onCopy, onQr, onEdit, onDelete }) { + const meta = JMS_PROTO_META[line.protocol]; + const status = line.monitorStatus || 'normal'; + const dotClass = status === 'abnormal' ? 'jms-dot-abnormal' : status === 'checking' ? 'jms-dot-checking' : 'jms-dot-normal'; + const statusLabel = status === 'abnormal' ? '异常,无法连接' : status === 'checking' ? '检测中…' : '正常'; + const isVlessCdn = line.protocol === 'vless-cdn'; + const checkNote = isVlessCdn + ? '检测方式:请求WS路径看是否返回520-524源站不可达错误,并做真实WebSocket握手(不是单纯测TCP)' + : '检测方式:TCP端口连通性测试'; + const ipDisplay = ip === 'resolving' ? '解析中…' : (ip || '点击右侧刷新解析'); + + return ( +
+
+
+ + {line.name || line.addr} +
+ {meta.label} +
+
+ ● {statusLabel} · {jmsTimeAgo(line.lastChecked)} + +
+
{checkNote}
+
+
域名{line.addr}
+
+ 解析IP + {ipDisplay} + +
+
端口{line.port}
+
+
+ + + + +
+
+ ); +} + +function JmsLineFormFields({ group, protocol, draft, setDraft }) { + const set = (patch) => setDraft(d => ({ ...d, ...patch })); + if (group === 'selfbuilt') { + return ( + <> +
UUID set({ uuid: e.target.value })} placeholder="用户UUID" />
+
+
WS路径 set({ ws_path: e.target.value })} placeholder="/stickrouter" />
+
SNI / Host set({ sni: e.target.value })} placeholder="留空则用服务器地址" />
+
+
* vless加密none · 勾选tls · 指纹chrome(固定,不需要手动填)
+ + ); + } + if (protocol === 'ss') { + return ( +
+
加密方式 set({ method: e.target.value })} />
+
密码 set({ password: e.target.value })} placeholder="连接密码" />
+
+ ); + } + if (protocol === 'vmess') { + return ( +
+
UUID set({ uuid: e.target.value })} placeholder="用户UUID" />
+
AlterId set({ alterId: e.target.value })} />
+
+ ); + } + if (protocol === 'vless-reality') { + return ( + <> +
UUID set({ uuid: e.target.value })} placeholder="用户UUID" />
+
+
Public Key set({ publicKey: e.target.value })} />
+
Short ID set({ shortId: e.target.value })} />
+
+
+
SNI(伪装域名) set({ sni: e.target.value })} placeholder="如:www.microsoft.com" />
+
Fingerprint set({ fingerprint: e.target.value })} />
+
+
* 流控xtls-rprx-vision · 传输层安全reality(固定,不需要手动填)
+ + ); + } + return null; +} + +function JmsPage({ lines, onSave, onDelete, onCheck, subUrl, onSubUrlChange, traffic, trafficLoading, onRefreshTraffic, onNotify }) { + const [ipMap, setIpMap] = useState({}); + const [modalOpen, setModalOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [group, setGroup] = useState('selfbuilt'); + const [protocol, setProtocol] = useState('vless-cdn'); + const [draft, setDraft] = useState({}); + const [qrLine, setQrLine] = useState(null); + const [qrDataUrl, setQrDataUrl] = useState(''); + const [subSettingsOpen, setSubSettingsOpen] = useState(false); + + const total = lines.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.startsWith('vless')).length; + + async function refreshIp(id, addr) { + setIpMap(m => ({ ...m, [id]: 'resolving' })); + try { + const ip = await jmsResolveIp(addr); + setIpMap(m => ({ ...m, [id]: ip || '解析失败' })); + } catch (e) { + setIpMap(m => ({ ...m, [id]: '解析失败(网络问题)' })); + } + } + + function copyLink(line) { + const link = jmsBuildLink(line); + navigator.clipboard.writeText(link).then(() => onNotify('订阅链接已复制')).catch(() => onNotify('复制失败,请手动选择')); + } + + async function openQr(line) { + setQrLine(line); + setQrDataUrl(''); + try { + const mod = await import('qrcode'); + const QRCode = mod.default || mod; + const link = jmsBuildLink(line); + const dataUrl = await QRCode.toDataURL(link, { width: 220, margin: 1, color: { dark: '#18233A', light: '#ffffff' } }); + setQrDataUrl(dataUrl); + } catch (e) { + onNotify('生成二维码失败:' + e.message); + } + } + + function openAddModal() { + setEditingId(null); + setGroup('selfbuilt'); + setProtocol('vless-cdn'); + setDraft({ name: '', addr: 'pb.impromx.com', port: '443', ws_path: '/stickrouter' }); + setModalOpen(true); + } + function openEditModal(line) { + setEditingId(line.id); + setGroup(line.group); + setProtocol(line.protocol); + setDraft({ ...line }); + setModalOpen(true); + } + function closeModal() { setModalOpen(false); } + + function handleGroupChange(g) { + setGroup(g); + 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 })); + } + } + + function handleSave() { + if (!draft.addr || !draft.port) { onNotify('请填写服务器地址和端口'); return; } + onSave({ ...draft, group, protocol }, editingId); + closeModal(); + } + + return ( + <> +
+
+

Just My Socks 节点管理

+
管理自建VLESS-CDN线路和 justmysocks2.net 订阅线路 · DNS解析、订阅链接、二维码和连通性监控
+
+ +
+ +
+
{total}
线路总数
+
{ssCount}
SS
+
{vmessCount}
VMess
+
{vlessCount}
VLESS
+ +
+
+ JMS 流量 + {traffic ? '已同步' : '未同步'} + + +
+
+
+ {traffic + ? `${((traffic.upload + traffic.download) / 1e9).toFixed(1)}/${(traffic.total / 1e9).toFixed(0)}GB · 剩余${((traffic.total - traffic.upload - traffic.download) / 1e9).toFixed(1)}GB` + : '还没有流量数据,点设置填订阅地址'} +
+ {subSettingsOpen && ( +
+ {traffic && traffic.expire &&
下次重置:{new Date(traffic.expire * 1000).toLocaleDateString()}
} + 标准订阅地址 + onSubUrlChange(e.target.value)} placeholder="用于同步真实流量" /> +
+ )} +
+
+ +
+ {lines.length === 0 &&
还没有线路,点右上角"新增线路"添加
} + {lines.map(line => ( + + ))} +
+ + {modalOpen && ( +
+
e.stopPropagation()}> +
+
{editingId ? '编辑线路' : '新增线路'}
+ +
+
+
+ 线路分组 +
+
handleGroupChange('selfbuilt')}>自建 VLESS-CDN
+
handleGroupChange('jms')}>Just My Socks
+
+
+ {group === 'jms' && ( +
+ 协议 +
+ {JMS_PROTOCOLS.map(key => ( +
setProtocol(key)}>{JMS_PROTO_META[key].label}
+ ))} +
+
+ )} +
名称备注 setDraft(d => ({ ...d, name: e.target.value }))} placeholder="如:SG-01 新加坡" />
+
+
服务器地址(域名) setDraft(d => ({ ...d, addr: e.target.value }))} placeholder="如:sg1.jms-relay.net" />
+
端口 setDraft(d => ({ ...d, port: e.target.value }))} placeholder="8388" />
+
+ +
+
+
+
+ )} + + {qrLine && ( +
setQrLine(null)}> +
e.stopPropagation()}> +
+
{qrLine.name || qrLine.addr} 订阅二维码
+ +
+
+ {qrDataUrl ? 订阅二维码 :
生成中…
} +
{jmsBuildLink(qrLine)}
+ +
+
+
+ )} + + ); +} + /* ----------------------------- product form / detail ----------------------------- */ function ProductForm({ draft, setDraft, onSave, onCancel, categories, onManage, onNotify }) {