Forráskód Böngészése

feat: per-panel save buttons on settings page

KenZ1117 2 hete
szülő
commit
c90ad43966
3 módosított fájl, 212 hozzáadás és 176 törlés
  1. 55 39
      public/app.js
  2. 148 130
      public/index.html
  3. 9 7
      public/style.css

+ 55 - 39
public/app.js

@@ -37,12 +37,6 @@
   let logTimer = null;
   let lastLogSignature = '';
 
-  function showSettingsStatus(message, type = 'info') {
-    const status = document.getElementById('settingsStatus');
-    status.textContent = message;
-    status.className = `badge ${type === 'success' ? 'badge-ok' : type === 'error' ? 'badge-fail' : 'badge-info'}`;
-  }
-
   async function loadModels(selectedModel) {
     const select = document.getElementById('settingsModel');
     const response = await fetch('/api/models');
@@ -59,7 +53,6 @@
   }
 
   async function loadSettings() {
-    showSettingsStatus('加载中…');
     const response = await fetch('/api/settings');
     if (!response.ok) throw new Error('设置加载失败');
     const settings = await response.json();
@@ -91,53 +84,73 @@
     aitableSecretInput.value = '';
     aitableSecretInput.placeholder = settings.aitableSecretSet ? '已设置,留空保持不变(输入 - 清除)' : '未设置';
     await loadModels(settings.model);
-    showSettingsStatus('已同步');
   }
 
-  async function saveSettings() {
-    const button = document.getElementById('btnSaveSettings');
+  function setPanelStatus(badgeId, message, ok) {
+    const badge = document.getElementById(badgeId);
+    if (!badge) return;
+    badge.textContent = message;
+    badge.className = 'badge ' + (ok === true ? 'badge-ok' : ok === false ? 'badge-fail' : 'badge-info');
+  }
+
+  async function saveSettingsPart(fields, buttonId, badgeId, successText = '已保存') {
+    const button = document.getElementById(buttonId);
     button.disabled = true;
-    showSettingsStatus('保存中…');
+    setPanelStatus(badgeId, '保存中…');
     try {
       const response = await fetch('/api/settings', {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          apiKey: document.getElementById('settingsApiKey').value.trim(),
-          model: document.getElementById('settingsModel').value,
-          rootDir: document.getElementById('settingsRootDir').value.trim(),
-          pageSize: Number(document.getElementById('pageSizeInput').value),
-          delayMs: Number(document.getElementById('delayInput').value),
-          chatModel: document.getElementById('settingsChatModel').value.trim(),
-          ...(document.getElementById('settingsChatApiKey').value.trim() && !document.getElementById('settingsChatApiKey').value.includes('••••')
-            ? { chatApiKey: document.getElementById('settingsChatApiKey').value.trim() }
-            : {}),
-          ...(document.getElementById('settingsCookie').value.trim()
-            ? { cookie: document.getElementById('settingsCookie').value.trim() }
-            : {}),
-          ...(document.getElementById('settingsAccessToken').value.trim()
-            ? { accessToken: document.getElementById('settingsAccessToken').value.trim() }
-            : {}),
-          aitableEnabled: document.getElementById('settingsAitableEnabled').checked,
-          aitableAppKey: document.getElementById('settingsAitableAppKey').value.trim(),
-          aitableBaseId: document.getElementById('settingsAitableBaseId').value.trim(),
-          aitableSheetName: document.getElementById('settingsAitableSheetName').value.trim(),
-          ...(document.getElementById('settingsAitableAppSecret').value.trim()
-            ? { aitableAppSecret: document.getElementById('settingsAitableAppSecret').value.trim() }
-            : {}),
-        }),
+        body: JSON.stringify(fields()),
       });
       const data = await response.json();
       if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
       await loadSettings();
-      showSettingsStatus('已保存', 'success');
+      setPanelStatus(badgeId, successText, true);
     } catch (error) {
-      showSettingsStatus(error.message, 'error');
+      setPanelStatus(badgeId, error.message, false);
     } finally {
       button.disabled = false;
     }
   }
 
+  const val = (id) => document.getElementById(id).value.trim();
+  const hasNewSecret = (id) => { const v = val(id); return v && !v.includes('••••'); };
+
+  function saveApiSettings() {
+    return saveSettingsPart(() => ({
+      apiKey: val('settingsApiKey'),
+      model: document.getElementById('settingsModel').value,
+      chatModel: document.getElementById('settingsChatModel').value,
+      ...(hasNewSecret('settingsChatApiKey') ? { chatApiKey: val('settingsChatApiKey') } : {}),
+    }), 'btnSaveApi', 'apiSaveStatus');
+  }
+
+  function saveCrawlSettings() {
+    return saveSettingsPart(() => ({
+      rootDir: val('settingsRootDir'),
+      pageSize: Number(document.getElementById('pageSizeInput').value),
+      delayMs: Number(document.getElementById('delayInput').value),
+      ...(val('settingsCookie') ? { cookie: val('settingsCookie') } : {}),
+    }), 'btnSaveCrawl', 'crawlSaveStatus');
+  }
+
+  function saveSecuritySettings() {
+    return saveSettingsPart(() => ({
+      ...(val('settingsAccessToken') ? { accessToken: val('settingsAccessToken') } : {}),
+    }), 'btnSaveSecurity', 'securitySaveStatus');
+  }
+
+  function saveAitableSettings() {
+    return saveSettingsPart(() => ({
+      aitableEnabled: document.getElementById('settingsAitableEnabled').checked,
+      aitableAppKey: val('settingsAitableAppKey'),
+      aitableBaseId: val('settingsAitableBaseId'),
+      aitableSheetName: val('settingsAitableSheetName'),
+      ...(val('settingsAitableAppSecret') ? { aitableAppSecret: val('settingsAitableAppSecret') } : {}),
+    }), 'btnSaveAitable', 'aitableSaveStatus');
+  }
+
   async function testImageApi() {
     const status = document.getElementById('imageTestStatus');
     const button = document.getElementById('btnTestImage');
@@ -246,7 +259,10 @@
     if (new URLSearchParams(location.search).get('tab') === 'chat') {
       document.querySelector('#settingsApiTabs button[data-tab="chat"]')?.click();
     }
-    document.getElementById('btnSaveSettings').addEventListener('click', saveSettings);
+    document.getElementById('btnSaveApi').addEventListener('click', saveApiSettings);
+    document.getElementById('btnSaveCrawl').addEventListener('click', saveCrawlSettings);
+    document.getElementById('btnSaveSecurity').addEventListener('click', saveSecuritySettings);
+    document.getElementById('btnSaveAitable').addEventListener('click', saveAitableSettings);
     document.getElementById('btnTestImage').addEventListener('click', testImageApi);
     document.getElementById('btnTestChat').addEventListener('click', testChatModel);
     document.getElementById('btnTestAitable').addEventListener('click', () => aitableAction('test'));
@@ -259,7 +275,7 @@
       await loadSettings();
       settingsLoaded = true;
     } catch (error) {
-      showSettingsStatus(error.message, 'error');
+      setPanelStatus('apiSaveStatus', error.message, false);
     }
   }
 

+ 148 - 130
public/index.html

@@ -443,156 +443,174 @@
             <h1>系统设置</h1>
             <div class="sub">API 密钥与采集配置,保存后立即生效</div>
           </div>
-          <div class="toolbar">
-            <span id="settingsStatus" class="badge"></span>
-            <button class="btn btn-primary btn-small" id="btnSaveSettings">保存设置</button>
-          </div>
+
         </div>
         <div class="settings-workspace">
-          <div class="settings-forms">
-          <div class="panel panel-narrow">
-            <div class="stage-marker">
-              <span class="num">1</span><span>API 设置</span>
-              <div class="seg-control settings-api-tabs" id="settingsApiTabs" role="tablist">
-                <button type="button" class="active" data-tab="image">图像模型</button>
-                <button type="button" data-tab="chat">对话模型</button>
-              </div>
-            </div>
-            <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="7.5" cy="15.5" r="4.5"/><path d="M11 12L21 2"/><path d="M16 7l3 3"/></svg></span>
-            <div class="panel-body">
-              <div id="settingsTabImage" class="settings-tab-body" role="tabpanel">
-              <div class="field">
-                <div class="field-label-row">
-                  <label>API Key<span class="req">*</span></label>
-                  <span class="label-links">
-                    <a class="settings-link settings-link-compact" href="https://o-hubs.com/api/console/keys" target="_blank" rel="noopener noreferrer">
-                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="7.5" cy="15.5" r="4.5"/><path d="M11 12L21 2"/><path d="M16 7l3 3"/></svg>
-                      <span>获取 API Key</span>
-                    </a>
-                    <a class="settings-link settings-link-compact" href="https://o-hubs.com/home" target="_blank" rel="noopener noreferrer">
-                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="3"/><path d="M3 10h18"/><path d="M7 15h3"/></svg>
-                      <span>充值</span>
-                    </a>
-                  </span>
+          <div class="settings-col">
+            <div class="panel panel-narrow">
+              <div class="stage-marker">
+                <span class="num">1</span><span>API 设置</span>
+                <div class="seg-control settings-api-tabs" id="settingsApiTabs" role="tablist">
+                  <button type="button" class="active" data-tab="image">图像模型</button>
+                  <button type="button" data-tab="chat">对话模型</button>
                 </div>
-                <input type="text" id="settingsApiKey" placeholder="sk-...">
-              </div>
-              <div class="field">
-                <label>API 地址</label>
-                <input type="text" id="settingsApiBase" value="https://api.lk888.ai" disabled>
-              </div>
-              <div class="field">
-                <label>默认模型</label>
-                <select id="settingsModel"></select>
-              </div>
-              <div class="settings-links">
-                <button type="button" class="settings-link settings-link-btn" id="btnTestImage">
-                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v5h-5"/></svg>
-                  <span>测试 API 与模型</span>
-                </button>
-                <span id="imageTestStatus" class="badge"></span>
-              </div>
-              </div>
-              <div id="settingsTabChat" class="settings-tab-body" role="tabpanel" hidden>
-              <div class="field">
-                <label>API Key</label>
-                <input type="text" id="settingsChatApiKey" placeholder="留空复用上方 API Key">
-              </div>
-              <div class="field">
-                <label>API 地址</label>
-                <input type="text" id="settingsChatApiBase" disabled placeholder="默认与生图 API 地址一致">
-              </div>
-              <div class="field">
-                <label>对话模型</label>
-                <select id="settingsChatModel">
-                  <option value="deepseek-v4.1-flash">deepseek-v4.1-flash</option>
-                  <option value="glm-5.3-flash">glm-5.3-flash</option>
-                </select>
-              </div>
-              <div class="settings-links">
-                <button type="button" class="settings-link settings-link-btn" id="btnTestChat">
-                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v5h-5"/></svg>
-                  <span>测试对话模型</span>
-                </button>
-                <span id="chatTestStatus" class="badge"></span>
               </div>
+              <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="7.5" cy="15.5" r="4.5"/><path d="M11 12L21 2"/><path d="M16 7l3 3"/></svg></span>
+              <div class="panel-body">
+                <div id="settingsTabImage" class="settings-tab-body" role="tabpanel">
+                  <div class="field">
+                    <div class="field-label-row">
+                      <label>API Key<span class="req">*</span></label>
+                      <span class="label-links">
+                        <a class="settings-link settings-link-compact" href="https://o-hubs.com/api/console/keys" target="_blank" rel="noopener noreferrer">
+                          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="7.5" cy="15.5" r="4.5"/><path d="M11 12L21 2"/><path d="M16 7l3 3"/></svg>
+                          <span>获取 API Key</span>
+                        </a>
+                        <a class="settings-link settings-link-compact" href="https://o-hubs.com/home" target="_blank" rel="noopener noreferrer">
+                          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="3"/><path d="M3 10h18"/><path d="M7 15h3"/></svg>
+                          <span>充值</span>
+                        </a>
+                      </span>
+                    </div>
+                    <input type="text" id="settingsApiKey" placeholder="sk-...">
+                  </div>
+                  <div class="field">
+                    <label>API 地址</label>
+                    <input type="text" id="settingsApiBase" value="https://api.lk888.ai" disabled>
+                  </div>
+                  <div class="field">
+                    <label>默认模型</label>
+                    <select id="settingsModel"></select>
+                  </div>
+                  <div class="settings-links">
+                    <button type="button" class="settings-link settings-link-btn" id="btnTestImage">
+                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v5h-5"/></svg>
+                      <span>测试 API 与模型</span>
+                    </button>
+                    <span id="imageTestStatus" class="badge"></span>
+                  </div>
+                </div>
+                <div id="settingsTabChat" class="settings-tab-body" role="tabpanel" hidden>
+                  <div class="field">
+                    <label>API Key</label>
+                    <input type="text" id="settingsChatApiKey" placeholder="留空复用上方 API Key">
+                  </div>
+                  <div class="field">
+                    <label>API 地址</label>
+                    <input type="text" id="settingsChatApiBase" disabled placeholder="默认与生图 API 地址一致">
+                  </div>
+                  <div class="field">
+                    <label>对话模型</label>
+                    <select id="settingsChatModel">
+                      <option value="deepseek-v4.1-flash">deepseek-v4.1-flash</option>
+                      <option value="glm-5.3-flash">glm-5.3-flash</option>
+                    </select>
+                  </div>
+                  <div class="settings-links">
+                    <button type="button" class="settings-link settings-link-btn" id="btnTestChat">
+                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v5h-5"/></svg>
+                      <span>测试对话模型</span>
+                    </button>
+                    <span id="chatTestStatus" class="badge"></span>
+                  </div>
+                </div>
+                <div class="panel-save-row">
+                  <button class="btn btn-primary btn-small" id="btnSaveApi">保存设置</button>
+                  <span id="apiSaveStatus" class="badge"></span>
+                </div>
               </div>
             </div>
-          </div>
-          <div class="panel panel-narrow">
-            <div class="stage-marker"><span class="num">2</span><span>采集配置</span></div>
-            <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M3 11h18"/></svg></span>
-            <div class="panel-body">
-              <div class="field">
-                <label>输出根目录</label>
-                <input type="text" id="settingsRootDir" placeholder="/path/to/workspace">
-              </div>
-              <div class="field-row">
+
+            <div class="panel panel-narrow">
+              <div class="stage-marker"><span class="num">2</span><span>采集配置</span></div>
+              <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M3 11h18"/></svg></span>
+              <div class="panel-body">
                 <div class="field">
-                  <label>每页条数</label>
-                  <input type="number" id="pageSizeInput" min="1" value="50">
+                  <label>输出根目录</label>
+                  <input type="text" id="settingsRootDir" placeholder="/path/to/workspace">
+                </div>
+                <div class="field-row">
+                  <div class="field">
+                    <label>每页条数</label>
+                    <input type="number" id="pageSizeInput" min="1" value="50">
+                  </div>
+                  <div class="field">
+                    <label>间隔 (ms)</label>
+                    <input type="number" id="delayInput" min="0" value="300">
+                    <div class="help">建议 ≥ 200</div>
+                  </div>
                 </div>
                 <div class="field">
-                  <label>间隔 (ms)</label>
-                  <input type="number" id="delayInput" min="0" value="300">
-                  <div class="help">建议 ≥ 200</div>
+                  <label>Cookie(淘宝闪购)</label>
+                  <textarea id="settingsCookie" rows="3" placeholder="登录 melody.shop.ele.me 后从 DevTools 复制"></textarea>
+                  <div class="help">Cookie 保存后不再回显;会话过期时粘贴新值即可更新</div>
+                </div>
+                <div class="panel-save-row">
+                  <button class="btn btn-primary btn-small" id="btnSaveCrawl">保存设置</button>
+                  <span id="crawlSaveStatus" class="badge"></span>
                 </div>
-              </div>
-              <div class="field">
-                <label>Cookie(淘宝闪购)</label>
-	                <textarea id="settingsCookie" rows="3" placeholder="登录 melody.shop.ele.me 后从 DevTools 复制"></textarea>
-	                <div class="help">Cookie 保存后不再回显;会话过期时粘贴新值即可更新</div>
-              </div>
-	            </div>
-	          </div>
-<div class="panel panel-narrow">
-            <div class="stage-marker"><span class="num">3</span><span>安全设置</span></div>
-            <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l7 3v5c0 4.4-3 8.4-7 10-4-1.6-7-5.6-7-10V6Z"/><path d="M9.5 12l1.8 1.8L15 10"/></svg></span>
-            <div class="panel-body">
-              <div class="field">
-                <label>局域网访问口令(accessToken)</label>
-                <input type="text" id="settingsAccessToken" placeholder="未设置" autocomplete="off">
-                <div class="help">设置后,同一局域网的其他电脑访问本服务需要输入此口令(本机 127.0.0.1 不受影响)。留空保持不变;输入 - 清除口令。临时开关也可用环境变量 ACCESS_TOKEN。</div>
               </div>
             </div>
           </div>
-          <div class="panel panel-narrow">
-            <div class="stage-marker"><span class="num">4</span><span>钉钉 AI 表格</span></div>
-            <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="3"/><path d="M3 10h18M9 10v10"/></svg></span>
-            <div class="panel-body">
-              <div class="field">
-                <label style="display:flex;align-items:center;gap:6px;">
-                  <input type="checkbox" id="settingsAitableEnabled">
-                  <span>启用(同步完成后自动推送增量到云端)</span>
-                </label>
-              </div>
-              <div class="field">
-                <label>AppKey / AppSecret(钉钉企业内部应用)</label>
-                <input type="text" id="settingsAitableAppKey" placeholder="钉钉开放平台应用的 AppKey" autocomplete="off">
-                <input type="text" id="settingsAitableAppSecret" placeholder="AppSecret 未设置" autocomplete="off" style="margin-top:6px;">
-                <div class="help">Secret 保存后不回显;输入 - 清除。应用需开通「AI 表格」API 权限。</div>
-              </div>
-              <div class="field">
-                <label>Base ID / 数据表名称</label>
-                <input type="text" id="settingsAitableBaseId" placeholder="粘贴 AI 表格分享链接或直接填 Base ID" autocomplete="off">
-                <input type="text" id="settingsAitableSheetName" placeholder="经营数据" autocomplete="off" style="margin-top:6px;">
-                <div class="help">Base ID 可直接粘贴分享链接(形如 alidocs.dingtalk.com/i/nodes/xxx),保存时自动提取 ID;网页版打开表格时地址栏里也能看到。</div>
+
+          <div class="settings-col">
+            <div class="panel panel-narrow">
+              <div class="stage-marker"><span class="num">3</span><span>安全设置</span></div>
+              <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l7 3v5c0 4.4-3 8.4-7 10-4-1.6-7-5.6-7-10V6Z"/><path d="M9.5 12l1.8 1.8L15 10"/></svg></span>
+              <div class="panel-body">
+                <div class="field">
+                  <label>局域网访问口令(accessToken)</label>
+                  <input type="text" id="settingsAccessToken" placeholder="未设置" autocomplete="off">
+                  <div class="help">设置后,同一局域网的其他电脑访问本服务需要输入此口令(本机 127.0.0.1 不受影响)。留空保持不变;输入 - 清除口令。临时开关也可用环境变量 ACCESS_TOKEN。</div>
+                </div>
+                <div class="panel-save-row">
+                  <button class="btn btn-primary btn-small" id="btnSaveSecurity">保存设置</button>
+                  <span id="securitySaveStatus" class="badge"></span>
+                </div>
               </div>
-              <div class="settings-links">
-                <button type="button" class="settings-link settings-link-btn" id="btnTestAitable"><span>测试连通</span></button>
-                <button type="button" class="settings-link settings-link-btn" id="btnAitablePush"><span>推送云端</span></button>
-                <button type="button" class="settings-link settings-link-btn" id="btnAitablePull"><span>从云端拉取</span></button>
-                <span id="aitableStatus" class="badge"></span>
+            </div>
+
+            <div class="panel panel-narrow">
+              <div class="stage-marker"><span class="num">4</span><span>钉钉 AI 表格</span></div>
+              <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="3"/><path d="M3 10h18M9 10v10"/></svg></span>
+              <div class="panel-body">
+                <div class="field">
+                  <label style="display:flex;align-items:center;gap:6px;">
+                    <input type="checkbox" id="settingsAitableEnabled">
+                    <span>启用(同步完成后自动推送增量到云端)</span>
+                  </label>
+                </div>
+                <div class="field">
+                  <label>AppKey / AppSecret(钉钉企业内部应用)</label>
+                  <input type="text" id="settingsAitableAppKey" placeholder="钉钉开放平台应用的 AppKey" autocomplete="off">
+                  <input type="text" id="settingsAitableAppSecret" placeholder="AppSecret 未设置" autocomplete="off" style="margin-top:6px;">
+                  <div class="help">Secret 保存后不回显;输入 - 清除。应用需开通「AI 表格」API 权限。</div>
+                </div>
+                <div class="field">
+                  <label>Base ID / 数据表名称</label>
+                  <input type="text" id="settingsAitableBaseId" placeholder="粘贴 AI 表格分享链接或直接填 Base ID" autocomplete="off">
+                  <input type="text" id="settingsAitableSheetName" placeholder="经营数据" autocomplete="off" style="margin-top:6px;">
+                  <div class="help">Base ID 可直接粘贴分享链接(形如 alidocs.dingtalk.com/i/nodes/xxx),保存时自动提取 ID;网页版打开表格时地址栏里也能看到。</div>
+                </div>
+                <div class="settings-links">
+                  <button type="button" class="settings-link settings-link-btn" id="btnTestAitable"><span>测试连通</span></button>
+                  <button type="button" class="settings-link settings-link-btn" id="btnAitablePush"><span>推送云端</span></button>
+                  <button type="button" class="settings-link settings-link-btn" id="btnAitablePull"><span>从云端拉取</span></button>
+                  <span id="aitableStatus" class="badge"></span>
+                </div>
+                <div class="help">架构:取数机「同步 → 自动推送」,其他电脑「从云端拉取」到本地后再看报表/图表。云表为在线主档案,一人取数、多人共享。</div>
+                <div class="panel-save-row">
+                  <button class="btn btn-primary btn-small" id="btnSaveAitable">保存设置</button>
+                  <span id="aitableSaveStatus" class="badge"></span>
+                </div>
               </div>
-              <div class="help">架构:取数机「同步 → 自动推送」,其他电脑「从云端拉取」到本地后再看报表/图表。云表为在线主档案,一人取数、多人共享。</div>
             </div>
           </div>
-          	          </div>
-	                  </div>
+        </div>
       </main>
     </div>
 
-    <!-- ==================== 系统日志 ==================== -->
+    <!-- ==================== 系统日志 ==================== -->    <!-- ==================== 系统日志 ==================== -->
     <div id="viewLogs" class="view">
       <main class="content">
         <div class="page-header">

+ 9 - 7
public/style.css

@@ -163,13 +163,9 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .field-row .field input { width: 100%; min-width: 0; }
 .field-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
 .field-label-row label { flex: 1 1 auto; margin: 0; }
-.settings-workspace { max-width: 1080px; }
-.settings-forms { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; min-width: 0; }
+.settings-workspace { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; max-width: 1240px; }
+.settings-col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
 /* 显式定位:1 API设置 左上,2 采集配置 右列纵跨两行,3 安全设置 紧贴 1 下方(避免行对齐把 3 推远) */
-.settings-forms > .panel:nth-child(1) { grid-column: 1; grid-row: 1; }
-.settings-forms > .panel:nth-child(2) { grid-column: 2; grid-row: 1 / span 2; }
-.settings-forms > .panel:nth-child(3) { grid-column: 1; grid-row: 2; }
-.settings-forms > .panel:nth-child(4) { grid-column: 1; grid-row: 3; }
 .settings-forms .field textarea { min-height: 78px; resize: vertical; line-height: 1.5; }
 .settings-forms .panel,
 .panel-designed,
@@ -188,7 +184,7 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .settings-log .panel-title,
 .settings-log .log-box { position: relative; z-index: 1; }
 .settings-actions { display: flex; align-items: center; gap: 8px; }
-.settings-links { display: grid; grid-template-columns: 1fr; gap: 8px; }
+.settings-links { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
 .settings-link {
   display: inline-flex; align-items: center; justify-content: center; gap: 7px;
   min-height: 32px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--radius-sm);
@@ -980,3 +976,9 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 /* 图标按钮(视觉生成 · 保存弹层) */
 .btn.icon-btn { width: 34px; min-width: 34px; padding: 0; justify-content: center; }
 .btn.icon-btn svg { width: 16px; height: 16px; display: block; }
+
+/* ── 系统设置 · 两列布局 + 面板独立保存 ─────────────────── */
+.settings-col > .panel { padding: 18px; }
+.panel-save-row { display: flex; align-items: center; gap: 10px; padding-top: 4px; border-top: 1px dashed var(--border-light); margin-top: 2px; }
+.panel-save-row .badge:empty { display: none; }
+@media (max-width: 1080px) { .settings-workspace { grid-template-columns: 1fr; } }