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' && (
+