zcode-pro 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- // “ZCode Pro 增强设置”弹窗:功能开关 + 样式调整 + 运行状态。
1
+ // “ZCode Pro 增强设置”弹窗:功能开关 + 外观 + 运行状态。
2
2
  // 顶部标签页切换(视觉参考侧栏“分组/项目”切换);配置写入 helper(~/.zcode/zcodepro.json)。
3
3
  import { h, t, rpc, getConfig, clearConfigCache, errText, HELPER_URL, isZhLocale, isNewProjectSidebar, DEFAULT_SHORTCUTS, DEFAULT_STATUS_ITEMS, DEFAULT_TURN_ITEMS, STATUS_SESSION_IDS, STATUS_GLOBAL_IDS } from '../core.js';
4
4
  import { openDialog, dialogFooter, btnPrimary, btnSecondary, btnSmall, settingRow, ensureStyle, showToast, numberField, unitField } from '../ui.js';
@@ -6,6 +6,7 @@ import { comboField, providerOptionsOf, modelOptionsOf, modelPlaceholderOf, isMa
6
6
  import { refreshAliases } from './alias.js';
7
7
  import { migrationCard } from './alias-migration.js';
8
8
  import { applyStyles, STYLE_DEFAULTS, showStyleHighlight, hideStyleHighlight } from './styles.js';
9
+ import { THEME_SKINS, CUSTOM_DEFAULTS } from './theme.js';
9
10
  import { copyToClipboard, hCopyIcon } from './copy-path.js';
10
11
 
11
12
  // 调宿主桥在系统浏览器打开外链;宿主没暴露该能力时静默忽略
@@ -205,17 +206,21 @@ export function openSettingsDialog() {
205
206
  }, '✕');
206
207
  titleEl.replaceChildren(titleLeft, closeX);
207
208
  }
208
- const health = await rpc('/health');
209
- const config = await getConfig(true);
210
- const agentsRes = await rpc('/agents');
211
- const visionRes = await rpc('/vision');
212
- const titleGenRes = await rpc('/title-gen');
213
- const titleGenProvidersRes = await rpc('/title-gen/providers');
209
+ // 八个只读请求相互独立:并行取(串行时 headroom 的版本探测一项就约
210
+ // 700ms,弹窗打开明显卡顿)
211
+ const [health, config, agentsRes, visionRes, titleGenRes, titleGenProvidersRes, rtkRes, headroomRes] = await Promise.all([
212
+ rpc('/health'),
213
+ getConfig(true),
214
+ rpc('/agents'),
215
+ rpc('/vision'),
216
+ rpc('/title-gen'),
217
+ rpc('/title-gen/providers'),
218
+ rpc('/rtk'),
219
+ rpc('/headroom'),
220
+ ]);
214
221
  // 供应商表(含各供应商可选模型):视觉代理的模型下拉也用这份
215
222
  const titleGenProviders = titleGenProvidersRes.ok && Array.isArray(titleGenProvidersRes.providers)
216
223
  ? titleGenProvidersRes.providers : [];
217
- const rtkRes = await rpc('/rtk');
218
- const headroomRes = await rpc('/headroom');
219
224
 
220
225
  const setFeature = async (key, value) => {
221
226
  const res = await rpc('/config', { method: 'POST', body: { features: { [key]: value } } });
@@ -438,7 +443,7 @@ export function openSettingsDialog() {
438
443
  if (await copyToClipboard(WECHAT_ID)) showToast(L.wechatIdCopied);
439
444
  }));
440
445
 
441
- // 标签页切换:功能(现有内容)/ 样式调整 / 全局提示词 / 视觉代理
446
+ // 标签页切换:功能(现有内容)/ 外观 / 全局提示词 / 视觉代理
442
447
  let activeTab = 'features';
443
448
  // 插件更新卡片:显示 duanluan-zcode-plugins 市场状态,一键更新(helper /plugins/* 端点);
444
449
  // 状态异步加载,打开弹窗不等待
@@ -587,7 +592,7 @@ export function openSettingsDialog() {
587
592
  };
588
593
  renderTabs();
589
594
 
590
- // “样式调整”:一行两项、相关项同行;无描述文字,悬停名称显示 tip;
595
+ // “样式”:一行两项、相关项同行;无描述文字,悬停名称显示 tip;
591
596
  // 数字框滚轮/手输调节,改完即存即生效
592
597
  const savedStyles = config.styles || {};
593
598
  let saveTimer = null;
@@ -738,9 +743,12 @@ export function openSettingsDialog() {
738
743
  for (const c of fontCells) c.setOptions(list);
739
744
  })();
740
745
  renderToolbarSwitch();
741
- // “样式”标签页的二级结构:样式调整(原内容)/ 状态栏(统计项顺序与显隐)
746
+ // “外观”标签页的二级结构:样式(原内容)/ 状态栏(统计项顺序与显隐)/
747
+ // 主题(皮肤与自定义主题)/ 背景图(图库、文件夹轮换、网址)
742
748
  const paneStylesSub1 = h('div', { class: 'mt-4' });
743
749
  const paneStylesSub2 = h('div', { class: 'mt-4', style: 'display:none' });
750
+ const paneStylesSub3 = h('div', { class: 'mt-4', style: 'display:none' });
751
+ const paneStylesSub4 = h('div', { class: 'mt-4', style: 'display:none' });
744
752
  const ITEM_NAMES = {
745
753
  ctx: 'sbItemCtx', questions: 'sbItemQuestions',
746
754
  todayTokens: 'sbItemTodayTokens', totalTokens: 'sbItemTotalTokens', tasks: 'sbItemTasks',
@@ -858,11 +866,362 @@ export function openSettingsDialog() {
858
866
  sbGlobalList,
859
867
  h('div', { class: 'mt-3 mb-1 text-ui-sm font-medium' }, L.sbGroupTurn),
860
868
  turnList);
869
+ // —— “颜色”子页(paneStylesSub3):预设皮肤卡片 + 自定义主题编辑 ——
870
+ // —— “背景图”子页(paneStylesSub4):无背景 / 图库 / 文件夹轮换 / 网址 ——
871
+ let themeCfg = structuredClone(config.theme || {});
872
+ const numOrBg = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
873
+ themeCfg.skin = themeCfg.skin || 'default';
874
+ themeCfg.custom = {
875
+ light: (themeCfg.custom && themeCfg.custom.light) || {},
876
+ dark: (themeCfg.custom && themeCfg.custom.dark) || {},
877
+ };
878
+ themeCfg.bg = {
879
+ ...(themeCfg.bg || {}),
880
+ mode: themeCfg.bg?.mode || 'none',
881
+ maskOpacity: numOrBg(themeCfg.bg?.maskOpacity, 0.82),
882
+ blurPx: numOrBg(themeCfg.bg?.blurPx, 0),
883
+ panelOpacity: numOrBg(themeCfg.bg?.panelOpacity, 0.3),
884
+ };
885
+ // file/folder/url/fit/folderRandom/intervalMin/regions 由 helper 清洗保证形态,原样保留
886
+ themeCfg.bg.regions = (themeCfg.bg.regions && typeof themeCfg.bg.regions === 'object') ? themeCfg.bg.regions : {};
887
+ let bgFiles = [];
888
+ let bgLoading = false;
889
+ let folderInfo = { count: 0, dir: '' };
890
+ const themeInputCls = 'h-8 w-full rounded-lg border border-border bg-input px-2.5 text-ui-sm text-foreground outline-none transition-shadow placeholder:text-foreground-subtle focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40';
891
+ const numField = (val, min, max, commit, title) => {
892
+ const n = h('input', {
893
+ type: 'number', min: String(min), max: String(max), step: '1', title,
894
+ class: 'h-7 w-24 rounded-lg border border-border bg-input px-2 text-right text-ui-xs tabular-nums text-foreground outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40',
895
+ });
896
+ n.value = String(val);
897
+ n.addEventListener('change', () => {
898
+ const v = Math.max(min, Math.min(max, Math.round(Number(n.value) || 0)));
899
+ n.value = String(v);
900
+ commit(v);
901
+ });
902
+ return n;
903
+ };
904
+ const bgUrl = (name) => `${HELPER_URL}/bg/file/${encodeURIComponent(name)}`;
905
+ const saveTheme = async (patch) => {
906
+ themeCfg = {
907
+ skin: patch.skin !== undefined ? patch.skin : themeCfg.skin,
908
+ custom: patch.custom !== undefined ? patch.custom : themeCfg.custom,
909
+ bg: { ...themeCfg.bg, ...(patch.bg || {}) },
910
+ };
911
+ const res = await rpc('/config', { method: 'POST', body: { theme: themeCfg } });
912
+ clearConfigCache();
913
+ window.dispatchEvent(new CustomEvent('zcodepro:config-changed'));
914
+ if (!res.ok) showToast(L.failed + ': ' + errText(res), 'error');
915
+ renderThemeColors();
916
+ renderThemeBg();
917
+ };
918
+ const refreshBgFiles = async () => {
919
+ bgLoading = true;
920
+ renderThemeBg();
921
+ const res = await rpc('/bg');
922
+ bgLoading = false;
923
+ if (res.ok && Array.isArray(res.files)) bgFiles = res.files;
924
+ // 图库模式下当前文件可能已被删(同步/手动删除):自动回无背景并提示一次
925
+ if (themeCfg.bg.mode === 'library' && themeCfg.bg.file
926
+ && !bgFiles.some((f) => f.name === themeCfg.bg.file)) {
927
+ themeCfg = { ...themeCfg, bg: { ...themeCfg.bg, file: '' } };
928
+ await saveTheme({ bg: { file: '' } });
929
+ showToast(L.themeBgDeletedActive, 'success');
930
+ return;
931
+ }
932
+ renderThemeBg();
933
+ };
934
+ const refreshFolderInfo = async () => {
935
+ if (!themeCfg.bg.folder) { folderInfo = { count: 0, dir: '' }; renderThemeBg(); return; }
936
+ const res = await rpc('/bg/folder/list');
937
+ folderInfo = res.ok && !res.missing && Array.isArray(res.files)
938
+ ? { count: res.files.length, dir: res.dir || themeCfg.bg.folder }
939
+ : { count: 0, dir: themeCfg.bg.folder };
940
+ renderThemeBg();
941
+ };
942
+ // 上传:多选文件逐张转 base64 传 helper;第一张上传且当前无背景时自动选为当前
943
+ const bgInput = h('input', { type: 'file', accept: '.png,.jpg,.jpeg,.webp,.gif,.bmp,.avif', multiple: '', style: 'display:none' });
944
+ bgInput.addEventListener('change', () => {
945
+ const files = [...bgInput.files || []];
946
+ bgInput.value = '';
947
+ if (!files.length) return;
948
+ void (async () => {
949
+ let first = themeCfg.bg.file;
950
+ for (const f of files) {
951
+ const dataUrl = await new Promise((resolve) => {
952
+ const r = new FileReader();
953
+ r.onload = () => resolve(String(r.result || ''));
954
+ r.onerror = () => resolve('');
955
+ r.readAsDataURL(f);
956
+ });
957
+ if (!dataUrl) continue;
958
+ const res = await rpc('/bg', { method: 'POST', body: { name: f.name, data: dataUrl } });
959
+ if (!res.ok) { showToast(L.failed + ': ' + errText(res), 'error'); continue; }
960
+ if (Array.isArray(res.files)) bgFiles = res.files;
961
+ if (!first) first = f.name;
962
+ }
963
+ if (first && first !== themeCfg.bg.file) await saveTheme({ bg: { file: first } });
964
+ else renderThemeBg();
965
+ })();
966
+ });
967
+ // 自定义主题:改色即保存并切到自定义皮肤(实时生效);250ms 防抖收拖拽连发
968
+ let customSaveTimer = null;
969
+ const CUSTOM_FIELDS = [
970
+ ['bg', 'themeCustomBg'], ['card', 'themeCustomCard'], ['fg', 'themeCustomFg'],
971
+ ['muted', 'themeCustomMuted'], ['accent', 'themeCustomAccent'],
972
+ ];
973
+ const customVal = (mode, k) => (themeCfg.custom[mode] && /^#[0-9a-f]{6}$/i.test(themeCfg.custom[mode][k] || '')
974
+ ? themeCfg.custom[mode][k] : CUSTOM_DEFAULTS[mode][k]);
975
+ const customInput = (mode, k, label) => {
976
+ const inp = h('input', {
977
+ type: 'color', value: customVal(mode, k), title: label,
978
+ style: 'width:30px;height:22px;padding:0;border:1px solid var(--color-card-border);border-radius:5px;background:transparent;cursor:pointer',
979
+ });
980
+ inp.addEventListener('input', () => {
981
+ const next = {
982
+ light: { ...themeCfg.custom.light },
983
+ dark: { ...themeCfg.custom.dark },
984
+ };
985
+ next[mode][k] = inp.value.toLowerCase();
986
+ clearTimeout(customSaveTimer);
987
+ customSaveTimer = setTimeout(() => { void saveTheme({ skin: 'custom', custom: next }); }, 250);
988
+ });
989
+ return h('label', { class: 'flex items-center gap-1.5 text-ui-xs text-foreground-subtle' }, inp, label);
990
+ };
991
+ const customSwatch = (mode) => [
992
+ customVal(mode, 'bg'), customVal(mode, 'card'), customVal(mode, 'fg'),
993
+ customVal(mode, 'muted'), customVal(mode, 'accent'),
994
+ ];
995
+ const renderThemeColors = () => {
996
+ paneStylesSub3.replaceChildren(
997
+ h('div', { class: 'mb-1 text-ui-sm font-medium' }, L.themeSkinTitle),
998
+ h('p', { class: 'mb-2 text-ui-xs/relaxed text-foreground-subtle' }, L.themeSkinDesc),
999
+ // 4 列 × 3 行可视,超出滚动;色条 8 块(底/卡片/加深面/边框/强调×2/次级文字/正文)
1000
+ h('div', { class: 'grid grid-cols-4 gap-2', style: 'max-height:13.5rem;overflow-y:auto;' },
1001
+ ...THEME_SKINS.map((skin) => h('button', {
1002
+ type: 'button', class: 'zcodepro-skin-card',
1003
+ 'data-state': themeCfg.skin === skin.id ? 'active' : 'inactive',
1004
+ onClick: () => void saveTheme({ skin: skin.id }),
1005
+ },
1006
+ h('div', { class: 'zcodepro-skin-swatches' },
1007
+ ...skin.preview.map((c) => h('span', { style: `background:${c}` }))),
1008
+ h('span', { class: 'text-ui-xs text-foreground' }, isZhLocale() ? skin.zh : skin.en))),
1009
+ h('button', {
1010
+ type: 'button', class: 'zcodepro-skin-card',
1011
+ 'data-state': themeCfg.skin === 'custom' ? 'active' : 'inactive',
1012
+ onClick: () => void saveTheme({ skin: 'custom' }),
1013
+ },
1014
+ h('div', { class: 'zcodepro-skin-swatches' },
1015
+ ...customSwatch('light').map((c) => h('span', { style: `background:${c}` })),
1016
+ ...customSwatch('dark').map((c) => h('span', { style: `background:${c}` }))),
1017
+ h('span', { class: 'text-ui-xs text-foreground' }, L.themeSkinCustom))),
1018
+ h('div', { class: 'mt-3 mb-1 text-ui-sm font-medium' }, L.themeCustomTitle),
1019
+ h('p', { class: 'mb-2 text-ui-xs/relaxed text-foreground-subtle' }, L.themeCustomHint),
1020
+ h('div', { class: 'grid grid-cols-2 gap-3' },
1021
+ ...['light', 'dark'].map((mode) => h('div', { class: 'rounded-xl border border-border p-2' },
1022
+ h('div', { class: 'mb-1.5 text-ui-xs font-medium text-foreground-subtle' },
1023
+ mode === 'light' ? L.themeCustomLight : L.themeCustomDark),
1024
+ h('div', { class: 'flex flex-col gap-1.5' },
1025
+ ...CUSTOM_FIELDS.map(([k, key]) => customInput(mode, k, L[key])))))),
1026
+ );
1027
+ };
1028
+ // 分区背景行:跟随全局 / 图库(下拉选图)/ 文件夹 / 网址
1029
+ const setRegion = (region, value) => {
1030
+ void saveTheme({ bg: { regions: { ...themeCfg.bg.regions, [region]: value } } });
1031
+ };
1032
+ const regionRow = (region, label) => {
1033
+ const cur = themeCfg.bg.regions[region] || null;
1034
+ const mode = cur ? cur.mode : null;
1035
+ const rBtn = (id, text) => h('button', {
1036
+ type: 'button', class: 'zcodepro-tab',
1037
+ 'data-state': mode === id ? 'active' : 'inactive',
1038
+ onClick: () => {
1039
+ if (id === null) { setRegion(region, null); return; }
1040
+ const keep = themeCfg.bg.regions[region];
1041
+ setRegion(region, { mode: id, file: keep && keep.file || '', folder: keep && keep.folder || '', url: keep && keep.url || '' });
1042
+ },
1043
+ }, text);
1044
+ let valueCtrl = null;
1045
+ if (mode === 'library') {
1046
+ const sel = h('select', { class: 'h-7 max-w-40 rounded-lg border border-border bg-input px-1.5 text-ui-xs text-foreground outline-none' });
1047
+ sel.append(h('option', { value: '' }, '—'));
1048
+ for (const f of bgFiles) sel.append(h('option', { value: f.name }, f.name));
1049
+ sel.value = (cur && cur.file) || '';
1050
+ sel.addEventListener('change', () => setRegion(region, { ...cur, file: sel.value }));
1051
+ valueCtrl = sel;
1052
+ } else if (mode === 'folder') {
1053
+ const inp = h('input', { type: 'text', value: (cur && cur.folder) || '', class: themeInputCls.replace('h-8', 'h-7'), placeholder: '/path/to/wallpapers' });
1054
+ inp.addEventListener('change', () => { if (inp.value.trim()) setRegion(region, { ...cur, folder: inp.value.trim() }); });
1055
+ valueCtrl = inp;
1056
+ } else if (mode === 'url') {
1057
+ const inp = h('input', { type: 'url', value: (cur && cur.url) || '', class: themeInputCls.replace('h-8', 'h-7'), placeholder: 'https://...' });
1058
+ inp.addEventListener('change', () => { if (/^https?:\/\/\S+$/.test(inp.value.trim())) setRegion(region, { ...cur, url: inp.value.trim() }); });
1059
+ valueCtrl = inp;
1060
+ }
1061
+ return h('div', { class: 'flex flex-wrap items-center gap-2' },
1062
+ h('span', { class: 'w-16 shrink-0 text-ui-xs text-foreground' }, label),
1063
+ h('div', { class: 'flex flex-wrap gap-1' },
1064
+ rBtn(null, L.themeBgRegionFollow), rBtn('library', L.themeBgModeLibrary),
1065
+ rBtn('folder', L.themeBgModeFolder), rBtn('url', L.themeBgModeUrl)),
1066
+ valueCtrl || h('span', { class: 'text-ui-xs text-foreground-subtle' }, ''));
1067
+ };
1068
+ const renderThemeBg = () => {
1069
+ // 三个调节参数(滑杆行构造器:label + 范围 + 单位 + 提示 + 防抖保存)
1070
+ const sliderRow = (label, title, min, max, step, val, unit, commit, hint) => {
1071
+ // 标签列固定宽(容最长标签六字;内联样式——应用的 Tailwind 按需编译
1072
+ // 不包含 w-[72px] 这类任意值类):多行滑杆的轨道起点对齐同一竖线
1073
+ const lab = h('span', { class: 'shrink-0 whitespace-nowrap text-ui-xs font-medium text-foreground-subtle', title, style: 'width:72px' }, label);
1074
+ const valLabel = h('span', { class: 'w-12 text-right text-ui-xs tabular-nums text-foreground-subtle' }, val + unit);
1075
+ const range = h('input', {
1076
+ type: 'range', min: String(min), max: String(max), step: String(step), value: String(val),
1077
+ class: 'zcodepro-range', 'aria-label': label, style: 'flex:1',
1078
+ });
1079
+ let timer = null;
1080
+ range.addEventListener('input', () => {
1081
+ const v = Number(range.value);
1082
+ valLabel.textContent = v + unit;
1083
+ clearTimeout(timer);
1084
+ timer = setTimeout(() => commit(v), 300);
1085
+ });
1086
+ return h('div', { class: 'flex flex-col gap-0.5' },
1087
+ h('div', { class: 'flex items-center gap-3' }, lab, range, valLabel),
1088
+ hint ? h('span', { class: 'text-ui-xs/relaxed text-foreground-subtle' }, hint) : null);
1089
+ };
1090
+ // 数值兜底:null(旧配置带入)与 undefined 都要落默认,?? 只兜 undefined
1091
+ const numOr = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
1092
+ const maskRow = () => sliderRow(L.themeBgMask, L.themeBgMaskHint, 0, 99, 1, Math.round(numOr(themeCfg.bg.maskOpacity, 0.82) * 100), '%',
1093
+ (v) => { void saveTheme({ bg: { maskOpacity: v / 100 } }); }, L.themeBgMaskHint2);
1094
+ const blurRow = () => sliderRow(L.themeBgBlur, '', 0, 24, 1, numOr(themeCfg.bg.blurPx, 0), 'px',
1095
+ (v) => { void saveTheme({ bg: { blurPx: v } }); }, '');
1096
+ const panelRow = () => sliderRow(L.themeBgPanel, '', 0, 100, 1, Math.round(numOr(themeCfg.bg.panelOpacity, 0.3) * 100), '%',
1097
+ (v) => { void saveTheme({ bg: { panelOpacity: v / 100 } }); }, L.themeBgPanelHint);
1098
+ const modeBtn = (id, label) => h('button', {
1099
+ type: 'button', class: 'zcodepro-tab',
1100
+ 'data-state': themeCfg.bg.mode === id ? 'active' : 'inactive',
1101
+ onClick: () => void saveTheme({ bg: { mode: id } }),
1102
+ }, label);
1103
+ // 文件夹路径输入 + 浏览(系统对话框);确认时先校验目录再保存
1104
+ const folderInput = h('input', { type: 'text', value: themeCfg.bg.folder, class: themeInputCls, placeholder: '/path/to/wallpapers' });
1105
+ const applyFolder = async (dir) => {
1106
+ const v = (dir || '').trim();
1107
+ if (!v) return;
1108
+ const res = await rpc('/bg/folder', { method: 'POST', body: { dir: v } });
1109
+ if (!res.ok) {
1110
+ showToast(L.failed + ': ' + errText(res), 'error');
1111
+ renderThemeBg();
1112
+ return;
1113
+ }
1114
+ folderInfo = { count: (res.files || []).length, dir: res.dir || v };
1115
+ await saveTheme({ bg: { mode: 'folder', folder: v } });
1116
+ };
1117
+ folderInput.addEventListener('change', () => { void applyFolder(folderInput.value); });
1118
+ const browseBtn = btnSmall(L.themeBgFolderPick, () => {
1119
+ void (async () => {
1120
+ browseBtn.disabled = true;
1121
+ try {
1122
+ const res = await rpc('/pick-folder?start=' + encodeURIComponent(themeCfg.bg.folder || '') + '&title=' + encodeURIComponent(L.themeBgFolderPickTitle));
1123
+ if (res && res.ok && res.path) {
1124
+ folderInput.value = res.path;
1125
+ await applyFolder(res.path);
1126
+ }
1127
+ } finally { browseBtn.disabled = false; }
1128
+ })();
1129
+ });
1130
+ const randomCheck = h('input', { type: 'checkbox' });
1131
+ randomCheck.checked = themeCfg.bg.folderRandom !== false;
1132
+ randomCheck.addEventListener('change', () => { void saveTheme({ bg: { folderRandom: randomCheck.checked } }); });
1133
+ // 网址输入:change 提交;空值回无背景
1134
+ const urlInput = h('input', { type: 'url', value: themeCfg.bg.url, class: themeInputCls, placeholder: 'https://...' });
1135
+ urlInput.addEventListener('change', () => {
1136
+ const v = urlInput.value.trim();
1137
+ if (!v) { void saveTheme({ bg: { mode: 'none', url: '' } }); return; }
1138
+ if (!/^https?:\/\/\S+$/.test(v)) { showToast(L.themeBgUrlInvalid, 'error'); renderThemeBg(); return; }
1139
+ void saveTheme({ bg: { mode: 'url', url: v } });
1140
+ });
1141
+ const libCard = (f) => {
1142
+ const del = h('button', {
1143
+ type: 'button', class: 'zcodepro-bg-del', title: L.themeBgDelete,
1144
+ onClick: (ev) => {
1145
+ ev.stopPropagation();
1146
+ void (async () => {
1147
+ const res = await rpc('/bg/delete', { method: 'POST', body: { name: f.name } });
1148
+ if (!res.ok) { showToast(L.failed + ': ' + errText(res), 'error'); return; }
1149
+ if (Array.isArray(res.files)) bgFiles = res.files;
1150
+ await refreshBgFiles();
1151
+ })();
1152
+ },
1153
+ }, '✕');
1154
+ return h('button', {
1155
+ type: 'button', class: 'zcodepro-bg-card',
1156
+ 'data-state': themeCfg.bg.mode === 'library' && themeCfg.bg.file === f.name ? 'active' : 'inactive',
1157
+ title: f.name,
1158
+ onClick: () => void saveTheme({ bg: { mode: 'library', file: themeCfg.bg.file === f.name ? '' : f.name } }),
1159
+ }, h('img', { src: bgUrl(f.name), alt: '', loading: 'lazy' }), del);
1160
+ };
1161
+ const section = (titleText, body) => h('div', { class: 'mt-3' },
1162
+ h('div', { class: 'mb-1.5 text-ui-xs font-medium text-foreground-subtle' }, titleText), body);
1163
+ const modeArea = themeCfg.bg.mode === 'library' ? [
1164
+ section(L.themeBgLibrary + (bgFiles.length ? `(${bgFiles.length})` : ''), h('div', { class: 'flex flex-col gap-2' },
1165
+ h('div', { class: 'flex items-center justify-between gap-2' },
1166
+ h('span', { class: 'text-ui-xs/relaxed text-foreground-subtle' }, bgLoading ? '…' : (bgFiles.length ? '' : L.themeBgEmpty)),
1167
+ btnSmall(L.themeBgUpload, () => bgInput.click())),
1168
+ bgFiles.length ? h('div', { class: 'grid grid-cols-4 gap-2', style: 'max-height:13.5rem;overflow-y:auto;' },
1169
+ ...bgFiles.map(libCard)) : null)),
1170
+ ] : themeCfg.bg.mode === 'folder' ? [
1171
+ section(L.themeBgModeFolder, h('div', { class: 'flex flex-col gap-2' },
1172
+ h('div', { class: 'flex items-center gap-2' }, folderInput, browseBtn),
1173
+ h('div', { class: 'flex items-center gap-2' },
1174
+ h('label', { class: 'flex items-center gap-1.5 text-ui-xs text-foreground-subtle' }, randomCheck, L.themeBgFolderRandom),
1175
+ numField(themeCfg.bg.intervalMin, 0, 1440, (v) => { void saveTheme({ bg: { intervalMin: v } }); }, L.themeBgIntervalHint)
1176
+ , h('span', { class: 'text-ui-xs text-foreground-subtle' }, L.themeBgIntervalUnit)),
1177
+ h('p', { class: 'text-ui-xs/relaxed text-foreground-subtle' },
1178
+ themeCfg.bg.folder
1179
+ ? (folderInfo.count > 0 ? L.themeBgFolderCount.replaceAll('{n}', String(folderInfo.count)) : L.themeBgFolderMissing)
1180
+ : L.themeBgFolderHint))),
1181
+ ] : themeCfg.bg.mode === 'url' ? [
1182
+ section(L.themeBgModeUrl, h('div', { class: 'flex flex-col gap-2' },
1183
+ urlInput,
1184
+ h('div', { class: 'flex items-center gap-2' },
1185
+ numField(themeCfg.bg.intervalMin, 0, 1440, (v) => { void saveTheme({ bg: { intervalMin: v } }); }, L.themeBgUrlIntervalHint),
1186
+ h('span', { class: 'text-ui-xs text-foreground-subtle' }, L.themeBgIntervalUnit)),
1187
+ h('p', { class: 'text-ui-xs/relaxed text-foreground-subtle' }, L.themeBgUrlHint))),
1188
+ ] : [h('p', { class: 'mt-3 text-ui-xs/relaxed text-foreground-subtle' }, L.themeBgNoneHint)];
1189
+ const fitBtn = (id, label) => h('button', {
1190
+ type: 'button', class: 'zcodepro-tab',
1191
+ 'data-state': (themeCfg.bg.fit || 'cover') === id ? 'active' : 'inactive',
1192
+ onClick: () => void saveTheme({ bg: { fit: id } }),
1193
+ }, label);
1194
+ paneStylesSub4.replaceChildren(
1195
+ h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.themeBgDesc),
1196
+ h('div', { class: 'mt-2 flex flex-wrap gap-1' },
1197
+ modeBtn('none', L.themeBgModeNone), modeBtn('library', L.themeBgModeLibrary),
1198
+ modeBtn('folder', L.themeBgModeFolder), modeBtn('url', L.themeBgModeUrl)),
1199
+ ...modeArea,
1200
+ section(L.themeBgRegions, h('div', { class: 'flex flex-col gap-2' },
1201
+ regionRow('left', L.themeBgRegionLeft),
1202
+ regionRow('center', L.themeBgRegionCenter),
1203
+ regionRow('right', L.themeBgRegionRight))),
1204
+ h('div', { class: 'mt-3 flex flex-wrap items-center gap-2' },
1205
+ h('span', { class: 'text-ui-xs font-medium text-foreground-subtle' }, L.themeBgFit),
1206
+ fitBtn('cover', L.themeBgFitCover), fitBtn('contain', L.themeBgFitContain),
1207
+ fitBtn('fill', L.themeBgFitFill), fitBtn('tile', L.themeBgFitTile)),
1208
+ h('div', { class: 'mt-3 flex flex-col gap-2' },
1209
+ maskRow(), blurRow(), panelRow()),
1210
+ bgInput,
1211
+ );
1212
+ };
1213
+ renderThemeColors();
1214
+ renderThemeBg();
1215
+ void refreshBgFiles();
1216
+ void refreshFolderInfo();
1217
+
861
1218
  let stylesSubTab = 'styles';
862
1219
  const stylesSubTablist = h('div', { role: 'tablist', 'aria-orientation': 'horizontal', class: 'zcodepro-tablist' });
863
1220
  const renderStylesSubTabs = () => stylesSubTablist.replaceChildren(...[
864
1221
  ['styles', L.tabStylesPane],
865
1222
  ['status', L.tabStatusPane],
1223
+ ['colors', L.themeColorsPane],
1224
+ ['bgimg', L.themeBgPane],
866
1225
  ].map(([id, label]) => h('button', {
867
1226
  type: 'button', role: 'tab', class: 'zcodepro-tab',
868
1227
  'aria-selected': String(stylesSubTab === id),
@@ -871,11 +1230,13 @@ export function openSettingsDialog() {
871
1230
  stylesSubTab = id;
872
1231
  paneStylesSub1.style.display = id === 'styles' ? '' : 'none';
873
1232
  paneStylesSub2.style.display = id === 'status' ? '' : 'none';
1233
+ paneStylesSub3.style.display = id === 'colors' ? '' : 'none';
1234
+ paneStylesSub4.style.display = id === 'bgimg' ? '' : 'none';
874
1235
  renderStylesSubTabs();
875
1236
  },
876
1237
  }, label)));
877
1238
  renderStylesSubTabs();
878
- paneStyles.append(stylesSubTablist, paneStylesSub1, paneStylesSub2);
1239
+ paneStyles.append(stylesSubTablist, paneStylesSub1, paneStylesSub2, paneStylesSub3, paneStylesSub4);
879
1240
  paneStylesSub1.append(
880
1241
  toolbarSwitchWrap,
881
1242
  h('div', { class: 'mt-2 flex flex-col gap-0.5 rounded-xl border border-border p-1' },
@@ -1468,18 +1829,11 @@ export function openSettingsDialog() {
1468
1829
  h('p', { class: 'mt-2 text-ui-xs/relaxed text-foreground-subtle' }, L.syncHint),
1469
1830
  );
1470
1831
 
1471
- // “调试”:开发模式开关 + 重载按钮。重载 = 构建 → 换新启动器进程(新 helper
1472
- // 代码与新注入脚本)→ 各 ZCode 窗口检测到换代自动整页刷新重新注入;
1473
- // ZCode 本体与所有会话不受影响,不用重启 ZCode
1474
- const persistDevMode = async (v) => {
1475
- const res = await rpc('/config', { method: 'POST', body: { devMode: v } });
1476
- if (!res.ok) {
1477
- showToast(L.failed + ': ' + errText(res), 'error');
1478
- return false;
1479
- }
1480
- if (res.config) config.devMode = res.config.devMode;
1481
- return true;
1482
- };
1832
+ // “调试”:重载按钮。重载 = 构建 → 换新启动器进程(新 helper 代码与新注入
1833
+ // 脚本)→ 各 ZCode 窗口检测到换代自动整页刷新重新注入;ZCode 本体与所有
1834
+ // 会话不受影响,不用重启 ZCode。标签页本身只在开发模式生效时显示(环境
1835
+ // 变量 ZCODEPRO_DEV=1 或配置文件 devMode),不放开关——关掉开关标签页就
1836
+ // 消失,再也点不回来,没意义
1483
1837
  const devReloadBtn = btnSmall(L.devReload, () => { void runDevReload(); }, 'shrink-0', 'primary');
1484
1838
  const runDevReload = async () => {
1485
1839
  devReloadBtn.disabled = true;
@@ -1490,25 +1844,15 @@ export function openSettingsDialog() {
1490
1844
  // 就自己刷新,宁可多刷一次也不能停在旧代码 + 失效 token 上
1491
1845
  setTimeout(() => { try { location.reload(); } catch { /* ignore */ } }, 2000);
1492
1846
  };
1493
- const devModeWrap = h('div');
1494
- const renderDevPane = () => {
1495
- const dev = config.devMode === true;
1496
- devModeWrap.replaceChildren(
1497
- h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.devModeDesc),
1498
- h('div', { class: 'mt-3 divide-y divide-border rounded-xl border border-border' },
1499
- settingRow(L.devMode, L.devModeRowDesc, dev, async () => {
1500
- if (await persistDevMode(!dev)) renderDevPane();
1501
- }),
1502
- ...(dev ? [h('div', { class: 'flex items-center justify-between gap-2 px-2 py-1.5' },
1503
- h('div', { class: 'min-w-0' },
1504
- h('div', { class: 'text-ui-sm font-medium text-foreground' }, L.devReload),
1505
- h('div', { class: 'mt-0.5 text-ui-xs/relaxed text-foreground-subtle' }, L.devReloadDesc)),
1506
- devReloadBtn)] : []),
1507
- ),
1508
- );
1509
- };
1510
- renderDevPane();
1511
- paneDebug.append(devModeWrap);
1847
+ paneDebug.append(
1848
+ h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.devModeDesc),
1849
+ h('div', { class: 'mt-3 rounded-xl border border-border' },
1850
+ h('div', { class: 'flex items-center justify-between gap-2 px-2 py-1.5' },
1851
+ h('div', { class: 'min-w-0' },
1852
+ h('div', { class: 'text-ui-sm font-medium text-foreground' }, L.devReload),
1853
+ h('div', { class: 'mt-0.5 text-ui-xs/relaxed text-foreground-subtle' }, L.devReloadDesc)),
1854
+ devReloadBtn)),
1855
+ );
1512
1856
 
1513
1857
  // “视觉代理”:编辑 ~/.zcode/zcode-vision.json(zcode-vision 插件与 /vision-* 命令共用同一文件)。
1514
1858
  // 代理列表顺序即执行链;结构性改动(开关/模式/排序/删增)即时保存,文本输入防抖保存。
@@ -1534,6 +1878,8 @@ export function openSettingsDialog() {
1534
1878
  let visionCfg = visionRes.ok && visionRes.config && typeof visionRes.config === 'object'
1535
1879
  ? structuredClone(visionRes.config)
1536
1880
  : null;
1881
+ // 自动初始化只试一次(每次打开弹窗):写盘失败回退手动“添加代理”卡
1882
+ let visionAutoInitTried = false;
1537
1883
  let visionSaveTimer = null;
1538
1884
  const persistVision = async () => {
1539
1885
  visionCfg.chain = visionCfg.proxies.map((p) => (p.name || '').trim()).filter(Boolean);
@@ -1600,6 +1946,16 @@ export function openSettingsDialog() {
1600
1946
  return;
1601
1947
  }
1602
1948
  if (!visionCfg) {
1949
+ // 插件已装但配置文件未建(刚装完第一次打开的面板状态):自动写入默认链,
1950
+ // 不再要求手动点一次“添加代理”;先照常渲染手动卡兜底,写盘成功后整面重绘
1951
+ if (!visionAutoInitTried && visionRes.hook) {
1952
+ visionAutoInitTried = true;
1953
+ void (async () => {
1954
+ const res = await rpc('/vision', { method: 'POST', body: { config: VISION_DEFAULT_CFG } });
1955
+ if (res.ok) visionCfg = structuredClone(res.config);
1956
+ renderVision();
1957
+ })();
1958
+ }
1603
1959
  const initBtn = btnSmall(L.visionAddProxy, async () => {
1604
1960
  const res = await rpc('/vision', { method: 'POST', body: { config: VISION_DEFAULT_CFG } });
1605
1961
  if (res.ok) { visionCfg = structuredClone(res.config); renderVision(); }
@@ -10,6 +10,7 @@ let enabled = true;
10
10
  let items = DEFAULT_STATUS_ITEMS.map((x) => ({ ...x }));
11
11
  let barEl = null;
12
12
  let lastStats = null; // /stats/usage 响应缓存(失败保旧值)
13
+ let lastRenderedHtml = null; // renderBar 去重:内容没变不重写 DOM
13
14
 
14
15
  // —— 工具 ——
15
16
 
@@ -21,7 +22,7 @@ function readCtx() {
21
22
  if (el.closest('.zcodepro-statusbar')) continue;
22
23
  const text = el.getAttribute('title') || el.getAttribute('aria-label') || '';
23
24
  if (!/上下文|context/i.test(text)) continue;
24
- // 格式为「已用 497,101 / 总量 1,000,000」:斜杠后可能带“总量”等文字再接数字
25
+ // 格式为“已用 497,101 / 总量 1,000,000”:斜杠后可能带“总量”等文字再接数字
25
26
  const m = text.match(/([\d][\d,]*)\s*\/[^0-9]*([\d][\d,]*)/);
26
27
  if (!m) continue;
27
28
  const used = Number(m[1].replace(/,/g, ''));
@@ -187,12 +188,14 @@ function renderBar() {
187
188
  if (!enabled || !host || !host.parentElement) {
188
189
  if (barEl) barEl.remove();
189
190
  barEl = null;
191
+ lastRenderedHtml = null; // 元素没了,去重基线同步清空,重建后首渲染不被跳过
190
192
  return;
191
193
  }
192
194
  if (!barEl || !barEl.isConnected) {
193
195
  if (!barEl) {
194
196
  barEl = document.createElement('div');
195
197
  barEl.className = 'zcodepro-statusbar';
198
+ lastRenderedHtml = null; // 新元素:内容基线作废
196
199
  }
197
200
  host.parentElement.insertBefore(barEl, host.nextSibling);
198
201
  }
@@ -219,19 +222,29 @@ function renderBar() {
219
222
  // 组内项之间用 ·,跨组竖线两侧不再叠加 ·
220
223
  const SEP = '<span class="zcodepro-statusbar-sep">·</span>';
221
224
  const GROUPSEP = '<span class="zcodepro-statusbar-groupsep"></span>';
222
- barEl.innerHTML = parts.reduce((acc, p, i) => {
225
+ // 数值没变就不重写 DOM:每 2 秒 tick 全量重建会在流式渲染期持续触发布局
226
+ //(CPU 剖析中 renderBar 是我们唯一上榜的函数)
227
+ const html = parts.reduce((acc, p, i) => {
223
228
  if (i === 0) return p;
224
229
  if (p === GROUPSEP || parts[i - 1] === GROUPSEP) return acc + p;
225
230
  return acc + SEP + p;
226
231
  }, '');
227
232
  barEl.style.display = parts.length ? '' : 'none';
233
+ if (html === lastRenderedHtml) return;
234
+ lastRenderedHtml = html;
235
+ barEl.innerHTML = html;
228
236
  }
229
237
 
230
238
  async function tick() {
231
239
  if (!enabled || document.hidden) return;
232
240
  refreshOfficial();
233
- const res = await rpc('/stats/usage').catch(() => null);
234
- if (res && res.ok) lastStats = res;
241
+ // 全局项优先取官方快照(5 分钟一更):就绪后不再每 2 秒拉本地聚合——
242
+ // helper 侧聚合每次都要遍历全部 rollout 记录(稳态实测约 13ms/次),
243
+ // 官方不可用(刚启动/接口失败)时才回退本地轮询
244
+ if (!official) {
245
+ const res = await rpc('/stats/usage').catch(() => null);
246
+ if (res && res.ok) lastStats = res;
247
+ }
235
248
  renderBar();
236
249
  }
237
250