Update App.jsx via upload script - 2026-07-31 17:57:05
This commit is contained in:
parent
d214109197
commit
afd0792df7
598
App.jsx
598
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 }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{view === 'jms' && (
|
||||
<JmsPage
|
||||
lines={jmsLines}
|
||||
onSave={saveJmsLine}
|
||||
onDelete={deleteJmsLine}
|
||||
onCheck={checkJmsLineNow}
|
||||
subUrl={jmsSubUrl}
|
||||
onSubUrlChange={setJmsSubUrl}
|
||||
traffic={jmsTraffic}
|
||||
trafficLoading={jmsTrafficLoading}
|
||||
onRefreshTraffic={refreshJmsTraffic}
|
||||
onNotify={notify}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'groups' && (
|
||||
<>
|
||||
<div className="disclaimer">按产品大类分组浏览;每个类目右上角管理品牌,类目内第二行管理类型;新建产品时先选类目,再从对应的品牌 / 类型里选。</div>
|
||||
|
|
@ -3448,6 +3662,36 @@ function MainApp({ username, onLogout }) {
|
|||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -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 (
|
||||
<div className="jms-card">
|
||||
<div className="jms-card-top">
|
||||
<div className="jms-card-title">
|
||||
<span className={`jms-dot ${dotClass}`} />
|
||||
<span className="jms-card-name">{line.name || line.addr}</span>
|
||||
</div>
|
||||
<span className={`jms-proto-chip jms-proto-${line.protocol}`}>{meta.label}</span>
|
||||
</div>
|
||||
<div className="jms-monitor-row">
|
||||
<span className={`jms-status-text ${status === 'abnormal' ? 'jms-status-abnormal' : ''}`}>● {statusLabel} · {jmsTimeAgo(line.lastChecked)}</span>
|
||||
<button className="jms-check-btn" disabled={status === 'checking'} onClick={() => onCheck(line.id)}>
|
||||
{status === 'checking' ? '检测中' : '立即检测'}
|
||||
</button>
|
||||
</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">
|
||||
<span className="jms-terminal-key">解析IP</span>
|
||||
<span className={`jms-terminal-val jms-ip ${!ip || ip === 'resolving' ? 'jms-ip-pending' : ''}`}>{ipDisplay}</span>
|
||||
<button className="jms-refresh-btn" onClick={() => onRefreshIp(line.id, line.addr)} title="重新解析">
|
||||
<RefreshCw size={13} className={ip === 'resolving' ? 'jms-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="jms-terminal-row"><span className="jms-terminal-key">端口</span><span className="jms-terminal-val">{line.port}</span></div>
|
||||
</div>
|
||||
<div className="jms-card-actions">
|
||||
<button className="btn btn-sm" onClick={() => onCopy(line)}><Copy size={13} />复制链接</button>
|
||||
<button className="btn btn-sm" onClick={() => onQr(line)}><QrCode size={13} />二维码</button>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => onEdit(line)}><Edit2 size={13} /></button>
|
||||
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => onDelete(line.id)}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JmsLineFormFields({ group, protocol, draft, setDraft }) {
|
||||
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') {
|
||||
return (
|
||||
<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"><span className="field-label">密码</span><input className="input" value={draft.password || ''} onChange={e => set({ password: e.target.value })} placeholder="连接密码" /></div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
if (protocol === 'vless-reality') {
|
||||
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">Public Key</span><input className="input" value={draft.publicKey || ''} onChange={e => set({ publicKey: e.target.value })} /></div>
|
||||
<div className="field"><span className="field-label">Short ID</span><input className="input" value={draft.shortId || ''} onChange={e => set({ shortId: e.target.value })} /></div>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="field"><span className="field-label">SNI(伪装域名)</span><input className="input" value={draft.sni || ''} onChange={e => set({ sni: e.target.value })} placeholder="如:www.microsoft.com" /></div>
|
||||
<div className="field"><span className="field-label">Fingerprint</span><input className="input" value={draft.fingerprint || 'chrome'} onChange={e => set({ fingerprint: e.target.value })} /></div>
|
||||
</div>
|
||||
<div className="disclaimer">* 流控xtls-rprx-vision · 传输层安全reality(固定,不需要手动填)</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<div className="page-head-row">
|
||||
<div>
|
||||
<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>
|
||||
<button className="btn btn-primary" onClick={openAddModal}><Plus size={14} />新增线路</button>
|
||||
</div>
|
||||
|
||||
<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">{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="jms-traffic-widget">
|
||||
<div className="jms-traffic-top">
|
||||
<span className="jms-traffic-title">JMS 流量</span>
|
||||
<span className="jms-traffic-badge">{traffic ? '已同步' : '未同步'}</span>
|
||||
<button className="jms-icon-btn" title="刷新用量" onClick={onRefreshTraffic} disabled={trafficLoading}>
|
||||
<RefreshCw size={12} className={trafficLoading ? 'jms-spin' : ''} />
|
||||
</button>
|
||||
<button className="jms-icon-btn" title="订阅地址设置" onClick={() => setSubSettingsOpen(v => !v)}><Settings2 size={12} /></button>
|
||||
</div>
|
||||
<div className="jms-traffic-bar"><div className="jms-traffic-bar-fill" style={{ width: traffic ? `${Math.min(100, Math.round(((traffic.upload + traffic.download) / (traffic.total || 1)) * 100))}%` : '0%' }} /></div>
|
||||
<div className="jms-traffic-meta">
|
||||
{traffic
|
||||
? `${((traffic.upload + traffic.download) / 1e9).toFixed(1)}/${(traffic.total / 1e9).toFixed(0)}GB · 剩余${((traffic.total - traffic.upload - traffic.download) / 1e9).toFixed(1)}GB`
|
||||
: '还没有流量数据,点设置填订阅地址'}
|
||||
</div>
|
||||
{subSettingsOpen && (
|
||||
<div className="jms-traffic-settings">
|
||||
{traffic && traffic.expire && <div className="field-label" style={{ marginBottom: 6 }}>下次重置:{new Date(traffic.expire * 1000).toLocaleDateString()}</div>}
|
||||
<span className="field-label">标准订阅地址</span>
|
||||
<input className="input" value={subUrl} onChange={e => onSubUrlChange(e.target.value)} placeholder="用于同步真实流量" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="jms-grid">
|
||||
{lines.length === 0 && <div className="empty-state" style={{ gridColumn: '1/-1' }}><Wifi size={28} />还没有线路,点右上角"新增线路"添加</div>}
|
||||
{lines.map(line => (
|
||||
<JmsLineCard
|
||||
key={line.id}
|
||||
line={line}
|
||||
ip={ipMap[line.id]}
|
||||
onRefreshIp={refreshIp}
|
||||
onCheck={onCheck}
|
||||
onCopy={copyLink}
|
||||
onQr={openQr}
|
||||
onEdit={openEditModal}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{modalOpen && (
|
||||
<div className="modal-overlay" onClick={closeModal}>
|
||||
<div className="modal-box" style={{ maxWidth: 460 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div className="panel-title"><Wifi size={15} />{editingId ? '编辑线路' : '新增线路'}</div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={closeModal}><X size={14} /></button>
|
||||
</div>
|
||||
<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">
|
||||
<span className="field-label">协议</span>
|
||||
<div className="jms-proto-select">
|
||||
{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>
|
||||
</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="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>
|
||||
<JmsLineFormFields group={group} protocol={protocol} draft={draft} setDraft={setDraft} />
|
||||
</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>
|
||||
)}
|
||||
|
||||
{qrLine && (
|
||||
<div className="modal-overlay" onClick={() => setQrLine(null)}>
|
||||
<div className="modal-box" style={{ maxWidth: 320, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div className="panel-title">{qrLine.name || qrLine.addr} 订阅二维码</div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setQrLine(null)}><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ alignItems: 'center' }}>
|
||||
{qrDataUrl ? <img src={qrDataUrl} alt="订阅二维码" style={{ width: 220, height: 220, borderRadius: 8 }} /> : <div style={{ padding: 40 }}>生成中…</div>}
|
||||
<div className="jms-qr-link">{jmsBuildLink(qrLine)}</div>
|
||||
<button className="btn btn-primary" style={{ width: '100%' }} onClick={() => copyLink(qrLine)}><Copy size={13} />复制订阅链接</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- product form / detail ----------------------------- */
|
||||
|
||||
function ProductForm({ draft, setDraft, onSave, onCancel, categories, onManage, onNotify }) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue