feat(settings): replicate the WorkBuddy general settings panel (TODO 10)
This commit is contained in:
1 parent
401926d7eb
commit
7b68d9eb25
21 files changed
+1150
-11
No files matched your search
@@ -303,11 +303,38 @@
|
|||||||
|
|
||||||
### 10. 通用
|
### 10. 通用
|
||||||
|
|
||||||
- [ ] 拆解原版通用设置页面的布局、控件和交互。
|
- [x] 拆解原版通用设置页面的布局、控件和交互。
|
||||||
- [ ] 将 YCode 当前已经支持的内容接入对应控件,记录暂未支持的差异。
|
- [x] 将 YCode 当前已经支持的内容接入对应控件,记录暂未支持的差异。
|
||||||
- [ ] 验收:控件反映真实配置;修改即时生效或明确提示生效时机,重启后持久化正确。
|
- [x] 验收:控件反映真实配置;修改即时生效或明确提示生效时机,重启后持久化正确。
|
||||||
|
|
||||||
状态:待 REA 拆解与能力盘点。
|
状态:已实现并通过真实窗口回归;未接入项按「保持原版外观 + 明确标注」处理,逐项记录在下方。
|
||||||
|
|
||||||
|
原版证据:
|
||||||
|
- REA `ev_a2dbeb23e30b9770743e857279a2e43da38fcc48d10612f75ace2938c5b29114`(WorkBuddy 5.8.3 设置→通用面板,900×680,自顶向底滚动采样 0/400/795/0,帧 frame-000~003):16 行文案、控件类型与取值、分组标题(常规/权限/存储/通知/隐私)、滚动边界。
|
||||||
|
- REA `ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e`(同页 DOM 边界、computed style、svg_nodes):面板 `[232,40,638,1415]`(5.8.2 复核)、组 `[40/218] [274/514] [804/265] [1085/234] [1335/120]`、行高 `[44,68,52 | 56,68,68,76,68,68,56 | 56,68,56 | 66]`、卡片 `#fff / radius 12 / padding 12px 16px`、标题 14px/500/22px、描述 14px/400/22px `rgba(0,0,0,.5)`、开关 34×20、下拉 120×32、滑块 224×40、存储卡 638×235、路径输入 530×32、更改按钮 68×32、打开目录 76×22、通知按钮 130×32。
|
||||||
|
- REA `ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94`:面板与控件样式来源(`contexts-BkjwYSZT.css` sha256 `50cbd673f7f35fc7f966bdddc6c9f5677fdfe944cb904888d214619fca259380`、`tokens-Df-i-zsO.css` sha256 `13f2bf82c96e5167a7d7098e36950c6c22c475b3337dbb8bb29ad2fe79cde696`、UI kit `src-Bh3YZ35H.css` sha256 `88dbdc1cd3f59d0e8b75824512a465a2be714bc7af481d790fe3a9484f85e01a`)。
|
||||||
|
- 观察范围:桌面端浅色主题(另加暗色抽查)、900×680 设置窗口、通用页默认态;未覆盖:各下拉展开态的选项列表(`general-settings-panel__link-open-select-popover` / `power-save-blocker-settings__popover` 均为 368 宽,规则已回收但本轮不接入)。
|
||||||
|
|
||||||
|
实现:
|
||||||
|
- `scripts/rea/recover-general-settings.mjs`:从上述 REA 回收样式表投影出面板规则(149 条)+ UI kit 控件规则 + 面板作用域 token,写入 `src/renderer/src/components/source-general-settings-recovered.css`;UI kit 规则用 `:where(.general-settings-panel)` 前缀并排在面板规则之前,以保持原版同权重、面板后加载的层叠(例如 `.wb-storage__open-folder{height:auto}` 必须压过 `.wb-button--medium{height:32px}`)。
|
||||||
|
- `src/renderer/src/components/GeneralSettingsPanel.tsx`(+ `source-general-settings.css`、`general-settings-icons.tsx`):按原版 DOM/类名/几何复刻 16 行;品牌替换遵循既有约定(「WorkBuddy 默认工作空间存储路径」→「YCode 默认工作空间存储路径」)。
|
||||||
|
- `src/main/general-settings.ts`、`src/main/config.ts`、`src/main/ipc.ts`、`src/main/index.ts`、`src/shared/{types,ipc}.ts`、`src/preload/index.ts`:新增 `window.ycode.general` API(get/update/openFolder/changeWorkspaceRoot/openSystemNotificationSettings)。
|
||||||
|
- 已接入(真实行为、重启持久化):
|
||||||
|
1. 运行时防止系统休眠 → `config.preventSleep`;任务运行期间主进程持有 `powerSaveBlocker`(`updateActiveRuns` 挂在 `chat:send` 运行生命周期上)。
|
||||||
|
2. 开机自动启动 → `config.autoLaunch` + 系统登录项(`app.setLoginItemSettings`),读取时以系统登录项真实状态为准。
|
||||||
|
3. 系统缓存目录 → 真实数据目录(`app.getPath('userData')`)、真实占用与磁盘容量(递归扫描 + `fs.statfs`)、「打开目录」调用系统文件管理器。
|
||||||
|
4. 默认工作空间存储路径 → `config.workspaceRoot`,只读填充态输入框 + 「更改」走系统目录选择器。
|
||||||
|
5. 桌面通知 → 「前往系统设置」打开系统通知设置页(Windows `ms-settings:notifications`)。
|
||||||
|
- 未接入(原版行为未实现,因此保留原版外观、控件类型与初始态,但标记 `aria-disabled` + `title="暂未接入"`,且不产生任何效果):语言、链接打开方式、字体大小、允许锁屏运行、网络代理、自动安装可信技能、技能自动更新、套件自动更新、通知提示音、定时任务产物通知、体验优化计划(含「了解更多」内联链接)。共 11 项。
|
||||||
|
|
||||||
|
验证:`scripts/verify-general-settings.cjs` 在真实隔离 Electron(真实 preload/IPC/渲染进程/文件系统 + 原生设置窗口,视口 900×680)中 43 项断言全部通过:导航「通用」可用且为当前页;面板列宽 638、顶层级顺序 group/group/storage/group/group;4 个分组标题;14 行标题与行高逐一匹配;分组标签 638×22 且左内边距 8px;卡片 `12px 16px`/radius 12/白底;标题 14/500/22、描述 14/400/22 三级色;开关 34×20;5 个下拉 120×32;滑块 224×40 且标签 小/默认/大;通知按钮 130×32 且带尾部图标;存储卡标题/说明/76×22 打开目录/530×32 输入框/68×32 更改/填充态样式;用量文案与真实数据目录;进度条 606×4 三段合计 604;分隔线 606×1;11 个占位项全部 `aria-disabled` + 「暂未接入」且点击无效(下拉不弹层、开关不切换);防休眠与开机自启动开关可切换、写入 `config.json` 并可还原;工作空间路径改写真实持久化(含 `workspace.get()` 与 `config.json`);重开设置窗口后偏好与路径仍正确渲染;暗色主题下面板卡片与标题重新着色;无渲染器报错。报告 `.tmp/general-settings-2elYNv/report.json`。
|
||||||
|
回归:`npm run typecheck`、`npm run build`、`npm run test:chat`、`npm run test:conversation`、`npm run test:conversation-ipc`、`node scripts/verify-settings-nav.cjs`、`node scripts/verify-composer-context-info.cjs`、`node scripts/verify-account-menu.cjs` 全部通过。`test:conversation-ipc` 的 electron mock 因新增 `powerSaveBlocker` 依赖而先失败,已在 `scripts/conversation-ipc-regression.ts` 补齐 mock(`powerSaveBlocker`、`app.isPackaged/isReady/getAppPath/getLoginItemSettings/setLoginItemSettings`、`shell.openExternal`)。
|
||||||
|
|
||||||
|
遗留:
|
||||||
|
1. 系统级写入的自动化隔离:验收时以 `YCODE_DISABLE_SYSTEM_INTEGRATION=1` 跳过登录项与系统设置页的真实写入,因此「写入注册表登录项」「打开 ms-settings:notifications」两条系统副作用只在人工路径覆盖,未纳入自动化断言。
|
||||||
|
2. 「更改」按钮会弹出系统目录选择器,无法自动点击;脚本改为断言按钮可用 + 通过 IPC 验证路径持久化,并在重开窗口后断言面板渲染新路径。
|
||||||
|
3. 数据目录路径较长时,缓存目录说明行会像原版一样换行(原版 `.wb-storage__path-text` 无 nowrap);脚本按实际行高换算期望的面板/卡片高度(QA 沙箱路径下为 1415+22=1437)。
|
||||||
|
4. 若后续要把语言/链接打开方式/网络代理/通知提示音等下拉接入,需要先按 REA 采集各下拉的展开态选项列表(当前仅有弹层容器 368 宽的样式规则)。
|
||||||
|
|
||||||
### 11. 外观与浅色/深色主题
|
### 11. 外观与浅色/深色主题
|
||||||
|
|
||||||
@@ -455,7 +482,7 @@
|
|||||||
## 推进顺序
|
## 推进顺序
|
||||||
|
|
||||||
1. 对话与侧栏:01 → 02 → 03 → 04 → 05 → 06 → 07 → 08 → 09 → 21(均已实现并留下验收记录,未完成验收点在各条目内标注)。
|
1. 对话与侧栏:01 → 02 → 03 → 04 → 05 → 06 → 07 → 08 → 09 → 21(均已实现并留下验收记录,未完成验收点在各条目内标注)。
|
||||||
2. 设置:16、17 的入口范围已落实并验证;接着依次推进 10 → 11 → 12 → 13 → 15 → 19。
|
2. 设置:16、17 的入口范围与 10(通用)均已落实并验证;接着依次推进 11 → 12 → 13 → 15 → 19。
|
||||||
3. 14、18、20 保留为待拆解,后续单独展开。
|
3. 14、18、20 保留为待拆解,后续单独展开。
|
||||||
|
|
||||||
## 每项交付记录模板
|
## 每项交付记录模板
|
||||||
|
|||||||
@@ -138,7 +138,12 @@ async function buildBundle(tempRoot: string, state: TestState, name: string): Pr
|
|||||||
const state = globalThis[${stateKey}];
|
const state = globalThis[${stateKey}];
|
||||||
export const app = {
|
export const app = {
|
||||||
getPath: (name) => name === 'userData' ? state.userData : name === 'home' ? state.home : state.userData,
|
getPath: (name) => name === 'userData' ? state.userData : name === 'home' ? state.home : state.userData,
|
||||||
getName: () => 'YCode', getVersion: () => '0.1.0'
|
getName: () => 'YCode', getVersion: () => '0.1.0',
|
||||||
|
isPackaged: false,
|
||||||
|
isReady: () => true,
|
||||||
|
getAppPath: () => state.userData,
|
||||||
|
getLoginItemSettings: () => ({ openAtLogin: false }),
|
||||||
|
setLoginItemSettings: () => {}
|
||||||
};
|
};
|
||||||
export const safeStorage = {
|
export const safeStorage = {
|
||||||
isEncryptionAvailable: () => false,
|
isEncryptionAvailable: () => false,
|
||||||
@@ -150,7 +155,12 @@ async function buildBundle(tempRoot: string, state: TestState, name: string): Pr
|
|||||||
showOpenDialog: async (...args) => ({ canceled: false, filePaths: state.dialogPaths.splice(0) }),
|
showOpenDialog: async (...args) => ({ canceled: false, filePaths: state.dialogPaths.splice(0) }),
|
||||||
showSaveDialog: async () => ({ canceled: true, filePath: undefined })
|
showSaveDialog: async () => ({ canceled: true, filePath: undefined })
|
||||||
};
|
};
|
||||||
export const shell = { openPath: async () => '' };
|
export const shell = { openPath: async () => '', openExternal: async () => {} };
|
||||||
|
export const powerSaveBlocker = {
|
||||||
|
start: () => { state.powerBlockers = (state.powerBlockers ?? 0) + 1; return state.powerBlockers; },
|
||||||
|
stop: () => true,
|
||||||
|
isStarted: () => (state.powerBlockers ?? 0) > 0
|
||||||
|
};
|
||||||
export class BrowserWindow {}
|
export class BrowserWindow {}
|
||||||
export class WebContentsView {}
|
export class WebContentsView {}
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"include_fonts": false,
|
||||||
|
"include_message_content": false,
|
||||||
|
"track_selectors": [
|
||||||
|
".settings-modal__panel",
|
||||||
|
".general-settings-panel"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{ "kind": "scroll", "step_id": "return-general-top", "selector": ".settings-modal__panel", "delta_y": -1000, "frames": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"include_fonts": false,
|
||||||
|
"include_message_content": false,
|
||||||
|
"track_selectors": [
|
||||||
|
".settings-modal__panel",
|
||||||
|
".general-settings-panel",
|
||||||
|
".general-settings-panel__group",
|
||||||
|
".general-settings-panel__group-card",
|
||||||
|
".general-settings-panel__section"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{ "kind": "wait", "step_id": "general-top", "duration_ms": 250, "frames": 1 },
|
||||||
|
{ "kind": "scroll", "step_id": "general-scroll-1", "selector": ".settings-modal__panel", "delta_y": 400, "frames": 1 },
|
||||||
|
{ "kind": "scroll", "step_id": "general-scroll-2-to-bottom", "selector": ".settings-modal__panel", "delta_y": 400, "frames": 1 },
|
||||||
|
{ "kind": "scroll", "step_id": "return-general-top", "selector": ".settings-modal__panel", "delta_y": -1000, "frames": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"include_fonts": false,
|
||||||
|
"include_message_content": false,
|
||||||
|
"track_selectors": [
|
||||||
|
".settings-modal__panel",
|
||||||
|
".general-settings-panel",
|
||||||
|
".general-settings-panel__group",
|
||||||
|
".general-settings-panel__section"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{ "kind": "click", "step_id": "select-general-tab", "selector": ".settings-navigation__item", "index": 0, "frames": 0 },
|
||||||
|
{ "kind": "wait", "step_id": "general-ready", "duration_ms": 500, "frames": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"include_fonts": false,
|
||||||
|
"include_message_content": false,
|
||||||
|
"track_selectors": [
|
||||||
|
".user-menu-popover",
|
||||||
|
".user-menu-items",
|
||||||
|
".settings-modal__panel",
|
||||||
|
".general-settings-panel"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{ "kind": "click", "step_id": "open-account-menu", "selector": ".user-menu-trigger--workbuddy", "index": 0, "frames": 0 },
|
||||||
|
{ "kind": "wait", "step_id": "account-menu-ready", "duration_ms": 500, "frames": 0 },
|
||||||
|
{ "kind": "click", "step_id": "open-settings", "selector": ".user-menu-items .user-menu-item", "index": 3, "frames": 0 },
|
||||||
|
{ "kind": "wait", "step_id": "settings-ready", "duration_ms": 800, "frames": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Recover only already-retained REA CSS; never access the installed application here.
|
||||||
|
//
|
||||||
|
// Source: REA evidence ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94
|
||||||
|
// (WorkBuddy 5.8.x settings window), projected through the retained contexts stylesheet
|
||||||
|
// `.tmp/rea-css-recovered/contexts-BkjwYSZT.css`. The general settings panel DOM classes were
|
||||||
|
// observed in REA evidence ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e
|
||||||
|
// (settings window layout: `general-settings-panel`, `font-size-slider`, `wb-storage`,
|
||||||
|
// `power-save-blocker-settings`, `proxy-section`).
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import postcss from 'postcss'
|
||||||
|
const sha = value => createHash('sha256').update(value).digest('hex')
|
||||||
|
const sourceEvidenceId = 'ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94'
|
||||||
|
const layoutEvidenceId = 'ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e'
|
||||||
|
const contexts = await fs.readFile('.tmp/rea-css-recovered/contexts-BkjwYSZT.css', 'utf8')
|
||||||
|
const contextsSha = sha(contexts)
|
||||||
|
if (contextsSha !== '50cbd673f7f35fc7f966bdddc6c9f5677fdfe944cb904888d214619fca259380') throw new Error('Contexts CSS changed')
|
||||||
|
const tokens = await fs.readFile('.tmp/rea-css-recovered/tokens-Df-i-zsO.css', 'utf8')
|
||||||
|
const tokensSha = sha(tokens)
|
||||||
|
if (tokensSha !== '13f2bf82c96e5167a7d7098e36950c6c22c475b3337dbb8bb29ad2fe79cde696') throw new Error('Tokens CSS changed')
|
||||||
|
// UI kit:设置窗口实际加载的控件样式表(按钮 / 输入框 / 开关 / 图标)。
|
||||||
|
const kit = await fs.readFile('.tmp/rea-css-recovered/src-Bh3YZ35H.css', 'utf8')
|
||||||
|
const kitSha = sha(kit)
|
||||||
|
if (kitSha !== '88dbdc1cd3f59d0e8b75824512a465a2be714bc7af481d790fe3a9484f85e01a') throw new Error('UI kit CSS changed')
|
||||||
|
// 选择器白名单:通用设置面板本体、字号滑块、存储卡片、电源与代理相关规则。
|
||||||
|
const panelled = /\.(general-settings-panel|font-size-slider|wb-storage|power-save-blocker-settings|proxy-section)(?![a-zA-Z0-9])/
|
||||||
|
function project(node) {
|
||||||
|
if (node.type === 'rule') {
|
||||||
|
const selectors = node.selectors.filter(selector => panelled.test(selector))
|
||||||
|
return selectors.length ? node.clone({ selector: selectors.join(',') }) : null
|
||||||
|
}
|
||||||
|
if (node.type === 'atrule' && node.nodes) {
|
||||||
|
const nodes = node.nodes.map(project).filter(Boolean)
|
||||||
|
return nodes.length ? node.clone({ nodes }) : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const parsed = postcss.parse(contexts)
|
||||||
|
const root = postcss.root({ nodes: parsed.nodes.map(project).filter(Boolean) })
|
||||||
|
// 原版的 .wb-button / .wb-input / .wb-switch / .wb-icon 来自全局 UI kit;YCode 里同样收敛到面板作用域。
|
||||||
|
// 用 :where() 前缀保持原版层叠:UI kit 规则与面板规则同权重,面板规则在后(原版面板 chunk 后加载),
|
||||||
|
// 例如 .wb-storage__open-folder{height:auto} 必须仍能覆盖 .wb-button--medium{height:32px}。
|
||||||
|
const kitScoped = /^\.wb-(button|input|switch|icon)(?![a-zA-Z])/
|
||||||
|
function projectKit(node) {
|
||||||
|
if (node.type === 'rule') {
|
||||||
|
const selectors = node.selectors.filter(selector => kitScoped.test(selector.trim()))
|
||||||
|
return selectors.length ? node.clone({ selector: selectors.map(selector => `:where(.general-settings-panel) ${selector}`).join(',') }) : null
|
||||||
|
}
|
||||||
|
if (node.type === 'atrule' && node.nodes) {
|
||||||
|
const nodes = node.nodes.map(projectKit).filter(Boolean)
|
||||||
|
return nodes.length ? node.clone({ nodes }) : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const kitRoot = postcss.root({ nodes: postcss.parse(kit).nodes.map(projectKit).filter(Boolean) })
|
||||||
|
// 原版把 `--wb-*` 设计变量放在全局 :root(tokens 样式表),YCode 的既有做法是按组件作用域
|
||||||
|
// 复制一份,避免全局污染。这里同样把 tokens 的 :root / 深色作用域合并成通用面板作用域。
|
||||||
|
const lightTokens = new Map()
|
||||||
|
const darkTokens = new Map()
|
||||||
|
for (const rule of postcss.parse(tokens).nodes) {
|
||||||
|
if (rule.type !== 'rule') continue
|
||||||
|
const selectors = rule.selectors ?? []
|
||||||
|
const dark = selectors.some(selector => selector.includes('[data-theme=dark]'))
|
||||||
|
const light = selectors.some(selector => selector.trim() === ':root')
|
||||||
|
if (!dark && !light) continue
|
||||||
|
for (const node of rule.nodes ?? []) {
|
||||||
|
if (node.type !== 'decl' || !node.prop.startsWith('--')) continue
|
||||||
|
if (dark) darkTokens.set(node.prop, node.value)
|
||||||
|
if (light) lightTokens.set(node.prop, node.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// UI kit 的控件 token 定义在它自己的样式表里。
|
||||||
|
for (const rule of postcss.parse(kit).nodes) {
|
||||||
|
if (rule.type !== 'rule') continue
|
||||||
|
const selectors = rule.selectors ?? []
|
||||||
|
const dark = selectors.some(selector => selector.includes('[data-theme=dark]'))
|
||||||
|
const light = selectors.some(selector => selector.trim() === ':root')
|
||||||
|
if (!dark && !light) continue
|
||||||
|
for (const node of rule.nodes ?? []) {
|
||||||
|
if (node.type !== 'decl' || !node.prop.startsWith('--')) continue
|
||||||
|
if (dark) darkTokens.set(node.prop, node.value)
|
||||||
|
if (light) lightTokens.set(node.prop, node.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lightTokens.size < 200 || darkTokens.size < 200) throw new Error('Tokens CSS lacks root scopes')
|
||||||
|
// 只输出面板规则真正引用到的变量(含 var() 传递闭包),避免把整套全局 token 复制进面板作用域。
|
||||||
|
const referenced = new Set()
|
||||||
|
const collect = (text) => { for (const match of text.matchAll(/var\(\s*(--[\w-]+)/g)) referenced.add(match[1]) }
|
||||||
|
collect(root.toString())
|
||||||
|
collect(kitRoot.toString())
|
||||||
|
for (let depth = 0; depth < 6; depth++) {
|
||||||
|
const before = referenced.size
|
||||||
|
for (const name of [...referenced]) {
|
||||||
|
const value = lightTokens.get(name) ?? darkTokens.get(name)
|
||||||
|
if (typeof value === 'string') collect(value)
|
||||||
|
}
|
||||||
|
if (referenced.size === before) break
|
||||||
|
}
|
||||||
|
const missing = [...referenced].filter(name => !lightTokens.has(name))
|
||||||
|
if (missing.length) console.log(`Tokens missing from light root scope: ${missing.join(', ')}`)
|
||||||
|
const pick = (map) => new Map([...referenced].filter(name => map.has(name)).map(name => [name, map.get(name)]))
|
||||||
|
const scope = (name, map) => `.${name}{${[...map].map(([property, value]) => `${property}:${value}`).join(';')}}`
|
||||||
|
const scopedTokens = `${scope('general-settings-panel', pick(lightTokens))}\n:root[data-theme=dark] ${scope('general-settings-panel', pick(darkTokens))}\n`
|
||||||
|
const css = `/* REA ${sourceEvidenceId} via contexts sha256=${contextsSha}; tokens sha256=${tokensSha}; ui-kit sha256=${kitSha}; general settings panel classes from REA ${layoutEvidenceId}. */\n${kitRoot.toString()}\n${root.toString()}\n${scopedTokens}`
|
||||||
|
await fs.writeFile('src/renderer/src/components/source-general-settings-recovered.css', css)
|
||||||
|
const rules = []
|
||||||
|
root.walkRules(rule => rules.push(rule.selector))
|
||||||
|
console.log(`Recovered ${rules.length} general-settings rules (${css.length} bytes)`)
|
||||||
|
console.log(rules.slice(0, 12).join('\n'))
|
||||||
|
if (rules.length < 60) throw new Error('Unexpectedly few general settings rules')
|
||||||
|
console.log(`Scoped ${pick(lightTokens).size} light / ${pick(darkTokens).size} dark referenced design tokens into .general-settings-panel`)
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
// TODO 10 验收:设置 → 通用面板的原版几何/排版 + YCode 真实能力接线 + 未接入项不伪装。
|
||||||
|
// 真实隔离 YCode(真实 preload/IPC/渲染进程/文件系统)+ 原生设置窗口,视口 900×680(与原版一致)。
|
||||||
|
//
|
||||||
|
// 原版依据(REA):
|
||||||
|
// - ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e(面板 DOM/几何/computed style/svg)
|
||||||
|
// - ev_a2dbeb23e30b9770743e857279a2e43da38fcc48d10612f75ace2938c5b29114(5.8.3 面板滚动采样 + 文案)
|
||||||
|
// 原版实测:面板 [232,40,638,1415];组 常规 218 / 权限 514 / 存储 265 / 通知 234 / 隐私 120;
|
||||||
|
// 行高 [44,68,52 | 56,68,68,76,68,68,56 | 56,68,56 | 66];卡片 bg #fff / radius 12 / padding 12px 16px;
|
||||||
|
// 标题 14px/500/22px;描述 14px/400/22px rgba(0,0,0,.5);开关 34×20;下拉 120×32;滑块 224×40。
|
||||||
|
const fs = require('node:fs/promises')
|
||||||
|
const path = require('node:path')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const EXPECTED_TITLES = ['语言', '链接打开方式', '字体大小', '允许锁屏运行', '运行时防止系统休眠', '开机自动启动',
|
||||||
|
'网络代理', '自动安装可信技能', '技能自动更新', '套件自动更新', '桌面通知', '通知提示音', '定时任务产物通知', '体验优化计划']
|
||||||
|
const EXPECTED_ROW_HEIGHTS = [44, 68, 52, 56, 68, 68, 76, 68, 68, 56, 56, 68, 56, 66]
|
||||||
|
const EXPECTED_GROUPS = ['常规', '权限', '通知', '隐私']
|
||||||
|
const EXPECTED_PLACEHOLDERS = 11
|
||||||
|
|
||||||
|
if (!process.versions.electron) {
|
||||||
|
;(async () => {
|
||||||
|
const directory = await fs.mkdtemp(path.resolve('.tmp/general-settings-'))
|
||||||
|
const data = path.join(directory, 'data')
|
||||||
|
const home = path.join(directory, 'home')
|
||||||
|
const nextWorkspace = path.join(directory, 'workspace-root')
|
||||||
|
await fs.mkdir(data, { recursive: true })
|
||||||
|
await fs.mkdir(home, { recursive: true })
|
||||||
|
await fs.mkdir(nextWorkspace, { recursive: true })
|
||||||
|
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
|
||||||
|
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||||
|
const child = require('node:child_process').spawn(require('electron'),
|
||||||
|
['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
let output = ''
|
||||||
|
child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('GENERAL_SETTINGS_')) process.stdout.write(bytes) })
|
||||||
|
child.stderr.on('data', bytes => { output += bytes })
|
||||||
|
const timer = setTimeout(() => child.kill(), 300000)
|
||||||
|
child.once('exit', async code => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
await fs.writeFile(path.join(directory, 'process.log'), output)
|
||||||
|
console.log(JSON.stringify({ directory, exitCode: code }))
|
||||||
|
if (code) console.error(output.slice(-4000))
|
||||||
|
process.exitCode = code ? 1 : 0
|
||||||
|
})
|
||||||
|
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||||
|
} else {
|
||||||
|
const { app, BrowserWindow } = require('electron')
|
||||||
|
const { pathToFileURL } = require('node:url')
|
||||||
|
const directory = process.env.YCODE_QA_DIRECTORY
|
||||||
|
const dataDir = process.env.YCODE_USER_DATA_DIR
|
||||||
|
const nextWorkspace = path.join(directory, 'workspace-root')
|
||||||
|
app.setPath('home', path.join(directory, 'home'))
|
||||||
|
const checks = [], snapshots = {}, rendererErrors = []
|
||||||
|
let main, settings, phase = 'boot'
|
||||||
|
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
async function read(source, win = main) {
|
||||||
|
let timer
|
||||||
|
try {
|
||||||
|
return await Promise.race([win.webContents.executeJavaScript(source), new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 15000)
|
||||||
|
})])
|
||||||
|
} finally { clearTimeout(timer) }
|
||||||
|
}
|
||||||
|
async function wait(source, label, win = main) {
|
||||||
|
for (let index = 0; index < 300; index++) { const value = await read(source, win); if (value) return value; await pause(40) }
|
||||||
|
throw Error('Timeout: ' + label)
|
||||||
|
}
|
||||||
|
async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) }
|
||||||
|
function check(label, actual, expected) {
|
||||||
|
const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected)
|
||||||
|
checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected })
|
||||||
|
}
|
||||||
|
function configFile() {
|
||||||
|
try { return JSON.parse(require('node:fs').readFileSync(path.join(dataDir, 'config.json'), 'utf8')) } catch { return {} }
|
||||||
|
}
|
||||||
|
async function openSettings(section) {
|
||||||
|
await read(`window.ycode.app.openSettings(${JSON.stringify(section)})`)
|
||||||
|
for (let index = 0; index < 200; index++) {
|
||||||
|
settings = BrowserWindow.getAllWindows().find(win => win !== main)
|
||||||
|
if (settings && !settings.webContents.isLoading()) break
|
||||||
|
await pause(25)
|
||||||
|
}
|
||||||
|
assert.ok(settings, 'settings window')
|
||||||
|
settings.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
|
||||||
|
await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings)
|
||||||
|
await pause(400)
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
const panelSource = `(()=>{
|
||||||
|
const rect = node => { const r = node.getBoundingClientRect(); return [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)] }
|
||||||
|
const panel = document.querySelector('.general-settings-panel')
|
||||||
|
const groups = [...document.querySelectorAll('.general-settings-panel__group')]
|
||||||
|
const sections = [...document.querySelectorAll('.general-settings-panel__group-card > .general-settings-panel__section')]
|
||||||
|
const storage = document.querySelector('.wb-storage')
|
||||||
|
const styles = node => { const s = getComputedStyle(node); return { fontSize: s.fontSize, fontWeight: s.fontWeight, lineHeight: s.lineHeight,
|
||||||
|
color: s.color, background: s.backgroundColor, borderRadius: s.borderRadius, padding: s.padding, gap: s.gap, paddingLeft: s.paddingLeft } }
|
||||||
|
const placeholderNodes = [...document.querySelectorAll('.general-settings-panel [data-settings-unavailable=true]')]
|
||||||
|
return {
|
||||||
|
panel: panel ? rect(panel) : null,
|
||||||
|
spacing3: panel ? getComputedStyle(panel).getPropertyValue('--wb-spacing-3').trim() : null,
|
||||||
|
labelPaddingLeft: panel ? getComputedStyle(panel.querySelector('.general-settings-panel__group-label')).paddingLeft : null,
|
||||||
|
order: panel ? [...panel.children].map(node => node.className.split(' ')[0]) : [],
|
||||||
|
groups: groups.map(node => ({ label: node.querySelector('.general-settings-panel__group-label')?.textContent?.trim(),
|
||||||
|
bounds: rect(node), labelBounds: rect(node.querySelector('.general-settings-panel__group-label')),
|
||||||
|
card: styles(node.querySelector('.general-settings-panel__group-card')) })),
|
||||||
|
groupCount: groups.length,
|
||||||
|
titles: sections.map(node => node.querySelector('.general-settings-panel__section-title')?.textContent?.trim()),
|
||||||
|
rowBounds: sections.map(rect),
|
||||||
|
rowHeight: sections.map(node => Math.round(node.getBoundingClientRect().height)),
|
||||||
|
titleStyle: styles(sections[0].querySelector('.general-settings-panel__section-title')),
|
||||||
|
descStyle: styles(sections[1].querySelector('.general-settings-panel__section-desc')),
|
||||||
|
switchWrap: rect(document.querySelector('.general-settings-panel__switch-wrap')),
|
||||||
|
switchTrack: rect(document.querySelector('.wb-switch__track')),
|
||||||
|
selects: [...document.querySelectorAll('.general-settings-panel .wb-select')].map(rect),
|
||||||
|
slider: rect(document.querySelector('.font-size-slider')),
|
||||||
|
sliderLabels: [...document.querySelectorAll('.font-size-slider__label')].map(node => node.textContent.trim()),
|
||||||
|
notificationButton: (() => { const button = [...document.querySelectorAll('.general-settings-panel .wb-button')].find(node => node.textContent.includes('前往系统设置'))
|
||||||
|
return button ? { bounds: rect(button), text: button.textContent.trim(), icons: button.querySelectorAll('svg').length } : null })(),
|
||||||
|
storage: storage ? { bounds: rect(storage), label: storage.querySelector('.wb-storage__group-label')?.textContent?.trim(),
|
||||||
|
card: rect(storage.querySelector('.wb-storage__card')), cardTitle: storage.querySelector('.wb-storage__card-title')?.textContent?.trim(),
|
||||||
|
cache: (() => { const node = storage.querySelector('.wb-storage__cache'); return node ? rect(node) : null })(),
|
||||||
|
titleRow: (() => { const node = storage.querySelector('.wb-storage__card-title-row'); return node ? rect(node) : null })(),
|
||||||
|
cachePathRow: (() => { const node = storage.querySelector('.wb-storage__path-row'); return node ? rect(node) : null })(),
|
||||||
|
overview: (() => { const node = storage.querySelector('.general-settings-panel__storage-overview'); return node ? rect(node) : null })(),
|
||||||
|
legend: (() => { const node = storage.querySelector('.general-settings-panel__storage-legend'); return node ? rect(node) : null })(),
|
||||||
|
workspace: (() => { const node = storage.querySelector('.general-settings-panel__workspace'); return node ? rect(node) : null })(),
|
||||||
|
workspaceHeader: (() => { const node = storage.querySelector('.general-settings-panel__workspace-header'); return node ? rect(node) : null })(),
|
||||||
|
workspaceDesc: (() => { const node = storage.querySelector('.general-settings-panel__workspace-path-desc'); return node ? rect(node) : null })(),
|
||||||
|
openFolder: rect(storage.querySelector('.wb-storage__open-folder')), pathText: storage.querySelector('.wb-storage__path-text')?.textContent?.trim(),
|
||||||
|
usageText: storage.querySelector('.general-settings-panel__storage-usage-text')?.textContent?.replace(/\\s+/g, ' ').trim(),
|
||||||
|
legend: [...storage.querySelectorAll('.general-settings-panel__storage-legend-label')].map(node => node.textContent.trim()),
|
||||||
|
segments: [...storage.querySelectorAll('.general-settings-panel__storage-progress-seg')].map(node => Math.round(node.getBoundingClientRect().width * 100) / 100),
|
||||||
|
divider: rect(storage.querySelector('.wb-storage__divider')),
|
||||||
|
workspaceTitle: storage.querySelector('.general-settings-panel__workspace-path-title')?.textContent?.trim(),
|
||||||
|
workspaceTitleStyle: styles(storage.querySelector('.general-settings-panel__workspace-path-title')),
|
||||||
|
workspaceDesc: storage.querySelector('.general-settings-panel__workspace-path-desc')?.textContent?.trim(),
|
||||||
|
input: (() => { const node = storage.querySelector('.general-settings-panel__path-input')
|
||||||
|
return node ? { bounds: rect(node), value: node.value, readOnly: node.readOnly, style: styles(node) } : null })(),
|
||||||
|
change: (() => { const button = [...storage.querySelectorAll('.wb-button')].find(node => node.textContent.trim() === '更改')
|
||||||
|
return button ? { bounds: rect(button), text: button.textContent.trim() } : null })() } : null,
|
||||||
|
placeholders: placeholderNodes.length,
|
||||||
|
placeholderHints: placeholderNodes.map(node => [node.getAttribute('aria-disabled'), node.getAttribute('title')]),
|
||||||
|
placeholderControlsDisabled: placeholderNodes.every(node => { const input = node.querySelector('input, button'); return !input || input.tabIndex === -1 }),
|
||||||
|
inlineLink: (() => { const link = document.querySelector('.general-settings-panel__inline-link')
|
||||||
|
return link ? { text: link.textContent.trim(), disabled: link.getAttribute('aria-disabled'), title: link.getAttribute('title'), bounds: rect(link) } : null })(),
|
||||||
|
switchStates: [...document.querySelectorAll('.general-settings-panel__switch-wrap')].map(node => node.querySelector('input')?.checked),
|
||||||
|
unavailableSwitches: [...document.querySelectorAll('.general-settings-panel__switch-wrap[data-settings-unavailable=true] input')].map(node => node.hasAttribute('readonly'))
|
||||||
|
}})()`
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
const sync = require('node:fs')
|
||||||
|
sync.mkdirSync(dataDir, { recursive: true })
|
||||||
|
app.setPath('userData', dataDir)
|
||||||
|
app.setPath('sessionData', dataDir)
|
||||||
|
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||||
|
await app.whenReady()
|
||||||
|
for (let index = 0; index < 200; index++) {
|
||||||
|
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
|
||||||
|
if (main && !main.webContents.isLoading()) break
|
||||||
|
await pause(40)
|
||||||
|
}
|
||||||
|
assert.ok(main, 'main window')
|
||||||
|
main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
|
||||||
|
await wait('!!window.ycode', 'main renderer')
|
||||||
|
await pause(1200)
|
||||||
|
|
||||||
|
await step('general-panel-structure')
|
||||||
|
await openSettings('settings')
|
||||||
|
const nav = await read(`(()=>{const item=[...document.querySelectorAll('.settings-navigation__item')].find(node=>node.dataset.settingsTab==='settings')
|
||||||
|
return { enabled: item?.getAttribute('aria-disabled') !== 'true', active: !!document.querySelector('.settings-navigation__item--active') &&
|
||||||
|
document.querySelector('.settings-navigation__item--active').dataset.settingsTab === 'settings' }})()`, settings)
|
||||||
|
check('nav 通用 entry is enabled and active', [nav.enabled, nav.active], [true, true])
|
||||||
|
const panel = await read(panelSource, settings)
|
||||||
|
snapshots.panel = panel
|
||||||
|
check('panel keeps the recovered 232,40 638-wide column', [panel.panel[0], panel.panel[2]], [232, 638])
|
||||||
|
// 原版 1415 高;数据目录路径比原版长时该行会像原版一样换行,因此按实际行高换算期望值。
|
||||||
|
const pathRowOverflow = panel.storage.cachePathRow[3] - 22
|
||||||
|
check('panel keeps the recovered content height (path row wrapping accounted)',
|
||||||
|
[panel.panel[3], pathRowOverflow], [1415 + pathRowOverflow, pathRowOverflow >= 0 ? pathRowOverflow : -1])
|
||||||
|
check('panel top-level order stays group/group/storage/group/group',
|
||||||
|
panel.order.map(name => name.startsWith('general-settings-panel__group') ? 'group' : name), ['group', 'group', 'wb-storage', 'group', 'group'])
|
||||||
|
check('panel keeps the four recovered group labels', panel.groups.map(group => group.label), EXPECTED_GROUPS)
|
||||||
|
check('panel keeps the 14 recovered row titles in order', panel.titles, EXPECTED_TITLES)
|
||||||
|
check('panel keeps the recovered row heights', panel.rowHeight, EXPECTED_ROW_HEIGHTS)
|
||||||
|
check('group label keeps the recovered 638x22 with 8px left padding',
|
||||||
|
[panel.groups[0].labelBounds[2], panel.groups[0].labelBounds[3], panel.labelPaddingLeft, panel.spacing3], [638, 22, '8px', '8px'])
|
||||||
|
check('group card keeps the recovered padding and radius',
|
||||||
|
[panel.groups[1].card.padding, panel.groups[1].card.borderRadius, panel.groups[1].card.background], ['12px 16px', '12px', 'rgb(255, 255, 255)'])
|
||||||
|
check('row title keeps 14px/500/22px', [panel.titleStyle.fontSize, panel.titleStyle.fontWeight, panel.titleStyle.lineHeight], ['14px', '500', '22px'])
|
||||||
|
check('row description keeps 14px/400/22px tertiary',
|
||||||
|
[panel.descStyle.fontSize, panel.descStyle.fontWeight, panel.descStyle.lineHeight, panel.descStyle.color], ['14px', '400', '22px', 'rgba(0, 0, 0, 0.5)'])
|
||||||
|
check('switch keeps the recovered 34x20 control', [panel.switchTrack[2], panel.switchTrack[3]], [34, 20])
|
||||||
|
check('panel selects keep the recovered 120x32 geometry', panel.selects.map(size => [size[2], size[3]]),
|
||||||
|
panel.selects.map(() => [120, 32]))
|
||||||
|
check('font size slider keeps the recovered 224x40 with 小/默认/大 labels',
|
||||||
|
[panel.slider[2], panel.slider[3], panel.sliderLabels], [224, 40, ['小', '默认', '大']])
|
||||||
|
check('桌面通知 keeps the recovered 130x32 button with a trailing icon',
|
||||||
|
[panel.notificationButton.bounds[2], panel.notificationButton.bounds[3], panel.notificationButton.text, panel.notificationButton.icons],
|
||||||
|
[130, 32, '前往系统设置', 1])
|
||||||
|
|
||||||
|
await step('general-panel-storage')
|
||||||
|
const storage = panel.storage
|
||||||
|
check('storage keeps the recovered 638-wide card and legend wording',
|
||||||
|
[storage.bounds[2], storage.bounds[3], storage.label, storage.legend], [638, 265 + pathRowOverflow, '存储', ['系统缓存', '磁盘已用', '磁盘可用']])
|
||||||
|
check('storage keeps the recovered titles and helper texts',
|
||||||
|
[storage.cardTitle, storage.workspaceTitle, storage.workspaceDesc, storage.change.text],
|
||||||
|
['系统缓存目录', 'YCode 默认工作空间存储路径', '新建任务、工作空间时将自动存放在该路径下;修改后不影响已有数据。', '更改'])
|
||||||
|
check('storage keeps the recovered 76x22 打开目录 button', [storage.openFolder[2], storage.openFolder[3]], [76, 22])
|
||||||
|
check('storage keeps the recovered input and 更改 button geometry',
|
||||||
|
[[storage.input.bounds[2], storage.input.bounds[3]], [storage.change.bounds[2], storage.change.bounds[3]]], [[530, 32], [68, 32]])
|
||||||
|
check('storage input is read-only and filled', [storage.input.readOnly, storage.input.style.background, storage.input.style.borderRadius],
|
||||||
|
[true, 'rgb(242, 242, 242)', '8px'])
|
||||||
|
check('storage shows the real isolated data directory and disk usage',
|
||||||
|
[storage.pathText.startsWith(dataDir), /^已用 \d+(\.\d+)? [KMGT]?B \/ 共 \d+(\.\d+)? [KMGT]B$/.test(storage.usageText)], [true, true])
|
||||||
|
// 606 宽的进度条内部有 2 个 1px 间隙(原版 gap:1px),三段合计 604。
|
||||||
|
check('storage progress keeps the recovered 606x4 bar with 3 segments filling 604px',
|
||||||
|
[Math.round(storage.segments.reduce((sum, value) => sum + value, 0)), storage.segments.length], [604, 3])
|
||||||
|
check('storage divider stays 606x1', [storage.divider[2], storage.divider[3]], [606, 1])
|
||||||
|
check('workspace path title keeps 14px/500/22px',
|
||||||
|
[storage.workspaceTitleStyle.fontSize, storage.workspaceTitleStyle.fontWeight, storage.workspaceTitleStyle.lineHeight], ['14px', '500', '22px'])
|
||||||
|
|
||||||
|
await step('general-panel-placeholders')
|
||||||
|
check('unwired rows stay explicit placeholders with 暂未接入 hints',
|
||||||
|
[panel.placeholders, panel.placeholderHints.every(([disabled, title]) => disabled === 'true' && title === '暂未接入'), panel.placeholderControlsDisabled],
|
||||||
|
[EXPECTED_PLACEHOLDERS, true, true])
|
||||||
|
check('unwired switches stay read-only', panel.unavailableSwitches.every(Boolean), true)
|
||||||
|
check('了解更多 stays a disabled inline link', [panel.inlineLink.text, panel.inlineLink.disabled, panel.inlineLink.title], ['了解更多', 'true', '暂未接入'])
|
||||||
|
const placeholderClick = await read(`(()=>{const select=document.querySelector('.general-settings-panel .wb-select[aria-disabled=true]')
|
||||||
|
select.click()
|
||||||
|
const switchInput=document.querySelector('.general-settings-panel__switch-wrap[data-settings-unavailable=true] input')
|
||||||
|
const before=switchInput.checked; switchInput.click()
|
||||||
|
return { popover: !!document.querySelector('.wb-select-popover'), switchBefore: before, switchAfter: switchInput.checked }})()`, settings)
|
||||||
|
check('placeholder select does not open a popover and placeholder switch does not toggle',
|
||||||
|
[placeholderClick.popover, placeholderClick.switchBefore === placeholderClick.switchAfter], [false, true])
|
||||||
|
|
||||||
|
await step('general-settings-wired-behaviour')
|
||||||
|
const initial = await read('window.ycode.general.get()', settings)
|
||||||
|
snapshots.initial = { ...initial, dataDir: initial.dataDir }
|
||||||
|
check('general view reports the isolated data directory and default preferences',
|
||||||
|
[initial.dataDir === dataDir, initial.preventSleep, initial.autoLaunch, initial.preventSleepActive], [true, true, false, false])
|
||||||
|
await read(`(()=>{const input=${'document.querySelectorAll(".general-settings-panel__switch-wrap:not([data-settings-unavailable]) input")[0]'}
|
||||||
|
input.click(); return true})()`, settings)
|
||||||
|
await pause(600)
|
||||||
|
const afterSleep = await read('window.ycode.general.get()', settings)
|
||||||
|
snapshots.afterSleep = afterSleep
|
||||||
|
check('toggling 运行时防止系统休眠 persists the preference',
|
||||||
|
[afterSleep.preventSleep, configFile().preventSleep], [false, false])
|
||||||
|
await read(`(()=>{${'document.querySelectorAll(".general-settings-panel__switch-wrap:not([data-settings-unavailable]) input")[0]'}.click(); return true})()`, settings)
|
||||||
|
await pause(600)
|
||||||
|
const restoredSleep = await read('window.ycode.general.get()', settings)
|
||||||
|
check('prevent-sleep preference can be restored to on', [restoredSleep.preventSleep, configFile().preventSleep], [true, true])
|
||||||
|
await read(`(()=>{${'document.querySelectorAll(".general-settings-panel__switch-wrap:not([data-settings-unavailable]) input")[1]'}.click(); return true})()`, settings)
|
||||||
|
await pause(600)
|
||||||
|
const afterLaunch = await read('window.ycode.general.get()', settings)
|
||||||
|
snapshots.afterLaunch = afterLaunch
|
||||||
|
check('toggling 开机自动启动 persists the preference (system integration isolated)',
|
||||||
|
[afterLaunch.autoLaunch, configFile().autoLaunch], [true, true])
|
||||||
|
await read(`(()=>{${'document.querySelectorAll(".general-settings-panel__switch-wrap:not([data-settings-unavailable]) input")[1]'}.click(); return true})()`, settings)
|
||||||
|
await pause(600)
|
||||||
|
const restoredLaunch = await read('window.ycode.general.get()', settings)
|
||||||
|
check('auto-launch preference can be restored to off', [restoredLaunch.autoLaunch, configFile().autoLaunch], [false, false])
|
||||||
|
const changed = await read(`window.ycode.general.changeWorkspaceRoot(${JSON.stringify(nextWorkspace)})`, settings)
|
||||||
|
await pause(400)
|
||||||
|
const rootListed = await read(`(async()=>(await window.ycode.workspace.get()).root)()`, settings)
|
||||||
|
check('更改工作空间路径 persists the real workspace root',
|
||||||
|
[changed.workspaceRoot === nextWorkspace, rootListed === nextWorkspace], [true, true])
|
||||||
|
// 说明:「更改」按钮会打开系统目录选择器,无法自动化点击;这里断言按钮可用,
|
||||||
|
// 并在下面的重开窗口步骤里断言面板确实渲染持久化后的路径。
|
||||||
|
const changeButton = await read(`(()=>{const button=[...document.querySelectorAll('.wb-storage .wb-button')].find(node => node.textContent.trim() === '更改')
|
||||||
|
return { disabled: button.disabled, label: button.textContent.trim() }})()`, settings)
|
||||||
|
check('更改 button stays enabled and labelled', changeButton, { disabled: false, label: '更改' })
|
||||||
|
check('workspace root persists to config.json', configFile().workspaceRoot, nextWorkspace)
|
||||||
|
check('general API exposes the wired actions',
|
||||||
|
await read(`(()=>{const api=window.ycode.general; return [typeof api.openFolder, typeof api.changeWorkspaceRoot, typeof api.openSystemNotificationSettings]})()`, settings),
|
||||||
|
['function', 'function', 'function'])
|
||||||
|
await read('window.ycode.general.openSystemNotificationSettings()', settings)
|
||||||
|
check('notification settings call resolves while system integration is isolated', true, true)
|
||||||
|
|
||||||
|
await step('general-panel-persistence')
|
||||||
|
settings.close()
|
||||||
|
await pause(600)
|
||||||
|
await openSettings('settings')
|
||||||
|
const reopened = await read(panelSource, settings)
|
||||||
|
const reopenedView = await read('window.ycode.general.get()', settings)
|
||||||
|
snapshots.reopened = reopenedView
|
||||||
|
check('reopening the settings window keeps the persisted preferences',
|
||||||
|
[reopenedView.preventSleep, reopenedView.autoLaunch, reopenedView.workspaceRoot === nextWorkspace], [true, false, true])
|
||||||
|
check('reopened panel renders the persisted workspace path', reopened.storage.input.value === nextWorkspace, true)
|
||||||
|
check('reopened panel still renders the recovered rows', reopened.rowHeight, EXPECTED_ROW_HEIGHTS)
|
||||||
|
|
||||||
|
await step('general-panel-dark-theme')
|
||||||
|
const dark = await read(`(()=>{document.documentElement.dataset.theme='dark'
|
||||||
|
const card=document.querySelector('.general-settings-panel__group-card')
|
||||||
|
const title=document.querySelector('.general-settings-panel__section-title')
|
||||||
|
return { card: getComputedStyle(card).backgroundColor, title: getComputedStyle(title).color }})()`, settings)
|
||||||
|
check('dark theme repaints the panel card and titles', [dark.card !== 'rgb(255, 255, 255)', dark.title !== 'rgb(0, 0, 0)'], [true, true])
|
||||||
|
await read(`(()=>{document.documentElement.dataset.theme='light'; return true})()`, settings)
|
||||||
|
|
||||||
|
await step('general-panel-errors')
|
||||||
|
check('no renderer error logs', rendererErrors, [])
|
||||||
|
|
||||||
|
const result = checks.every(item => item.pass) ? 'PASS' : 'FAIL'
|
||||||
|
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result, phase, checks, snapshots }, null, 2))
|
||||||
|
console.log('GENERAL_SETTINGS_' + result + ' ' + directory)
|
||||||
|
for (const failure of checks.filter(item => !item.pass)) console.log('GENERAL_SETTINGS_FAILURE ' + JSON.stringify(failure))
|
||||||
|
app.exit(result === 'PASS' ? 0 : 1)
|
||||||
|
})().catch(async error => {
|
||||||
|
console.error(error)
|
||||||
|
try { await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'ERROR', phase, checks, snapshots, error: String(error?.stack ?? error) }, null, 2)) } catch { /* ignore */ }
|
||||||
|
app.exit(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -124,10 +124,11 @@ if (!process.versions.electron) {
|
|||||||
[nav.nav[0] === 200 || nav.nav[0] === 190, nav.nav[2] === 0 ? navContentWidth === 200 : navContentWidth === 190], [true, true])
|
[nav.nav[0] === 200 || nav.nav[0] === 190, nav.nav[2] === 0 ? navContentWidth === 200 : navContentWidth === 190], [true, true])
|
||||||
// 原版 4 组;about 组按组件约定渲染为分隔线而不是标题。
|
// 原版 4 组;about 组按组件约定渲染为分隔线而不是标题。
|
||||||
check('nav keeps the four recovered groups', [nav.groups.length, nav.dividers], [3, 1])
|
check('nav keeps the four recovered groups', [nav.groups.length, nav.dividers], [3, 1])
|
||||||
const expectedDisabled = EXPECTED_TABS.filter(tab => tab !== 'models' && tab !== 'appearance')
|
// 通用(settings) 在 TODO 10 接入后同样可用。
|
||||||
|
const expectedDisabled = EXPECTED_TABS.filter(tab => !['settings', 'models', 'appearance'].includes(tab))
|
||||||
check('unwired entries stay disabled', nav.tabs.filter((tab, index) => nav.disabled[index] === 'true'), expectedDisabled)
|
check('unwired entries stay disabled', nav.tabs.filter((tab, index) => nav.disabled[index] === 'true'), expectedDisabled)
|
||||||
check('unwired entries keep the 暂未接入 hint', nav.titles.filter(title => (title ?? '').includes('暂未接入')).length, expectedDisabled.length)
|
check('unwired entries keep the 暂未接入 hint', nav.titles.filter(title => (title ?? '').includes('暂未接入')).length, expectedDisabled.length)
|
||||||
check('models and appearance stay enabled', nav.tabs.filter((tab, index) => !nav.disabled[index]), ['appearance', 'models'])
|
check('settings, models and appearance stay enabled', nav.tabs.filter((tab, index) => !nav.disabled[index]), ['settings', 'appearance', 'models'])
|
||||||
|
|
||||||
await step('settings-search-excludes-removed')
|
await step('settings-search-excludes-removed')
|
||||||
await read(`(()=>{const input=document.querySelector('.settings-navigation input[type="search"]');
|
await read(`(()=>{const input=document.querySelector('.settings-navigation input[type="search"]');
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ interface StoredConfig {
|
|||||||
apiKeyEnc?: string
|
apiKeyEnc?: string
|
||||||
apiKeyPlain?: string
|
apiKeyPlain?: string
|
||||||
workspaceRoot?: string
|
workspaceRoot?: string
|
||||||
|
/** 运行任务时阻止系统休眠(通用设置;默认开启,与原版初始态一致)。 */
|
||||||
|
preventSleep?: boolean
|
||||||
|
/** 开机自动启动(通用设置;默认关闭)。 */
|
||||||
|
autoLaunch?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: StoredConfig = {
|
const DEFAULTS: StoredConfig = {
|
||||||
@@ -92,6 +96,28 @@ export async function setWorkspaceRoot(root: string): Promise<void> {
|
|||||||
await persist({ ...stored, workspaceRoot: root })
|
await persist({ ...stored, workspaceRoot: root })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 通用设置:运行任务时阻止系统休眠,默认开启。 */
|
||||||
|
export async function getPreventSleep(): Promise<boolean> {
|
||||||
|
const stored = await load()
|
||||||
|
return stored.preventSleep !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPreventSleep(enabled: boolean): Promise<void> {
|
||||||
|
const stored = await load()
|
||||||
|
await persist({ ...stored, preventSleep: enabled })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用设置:开机自动启动,默认关闭(由系统登录项反映实际状态)。 */
|
||||||
|
export async function getAutoLaunch(): Promise<boolean> {
|
||||||
|
const stored = await load()
|
||||||
|
return stored.autoLaunch === true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAutoLaunch(enabled: boolean): Promise<void> {
|
||||||
|
const stored = await load()
|
||||||
|
await persist({ ...stored, autoLaunch: enabled })
|
||||||
|
}
|
||||||
|
|
||||||
/** 供渲染进程使用:脱敏视图 */
|
/** 供渲染进程使用:脱敏视图 */
|
||||||
export async function getConfigView(): Promise<GatewayConfigView> {
|
export async function getConfigView(): Promise<GatewayConfigView> {
|
||||||
const stored = await load()
|
const stored = await load()
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* 通用设置的真实运行链路(REA 对照原版「通用」页面):
|
||||||
|
*
|
||||||
|
* - 数据目录用量与所在磁盘容量:真实扫描 userData 目录并读取磁盘统计。
|
||||||
|
* - 开机自动启动:写入/读取系统登录项(可用 YCODE_DISABLE_SYSTEM_INTEGRATION=1 隔离验收)。
|
||||||
|
* - 运行时防止系统休眠:设置项开启且存在运行中的任务时,持有 powerSaveBlocker。
|
||||||
|
*
|
||||||
|
* 未接入的原版设置项(语言、链接打开方式、字体大小、锁屏运行、网络代理、
|
||||||
|
* 技能/套件自动更新、通知提示音、定时任务产物通知、体验优化计划)只做视觉占位,
|
||||||
|
* 不在此模块伪造行为。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { app, powerSaveBlocker } from 'electron'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
let preventSleepEnabled = true
|
||||||
|
let activeRuns = 0
|
||||||
|
let blockerId: number | null = null
|
||||||
|
|
||||||
|
function systemIntegrationDisabled(): boolean {
|
||||||
|
return process.env.YCODE_DISABLE_SYSTEM_INTEGRATION === '1' || !app.isReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 隔离验收环境:不触碰真实系统设置(登录项 / 系统设置页)。 */
|
||||||
|
export function systemIntegrationSuppressed(): boolean {
|
||||||
|
return process.env.YCODE_DISABLE_SYSTEM_INTEGRATION === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBlocker(): void {
|
||||||
|
const shouldBlock = preventSleepEnabled && activeRuns > 0
|
||||||
|
if (shouldBlock && blockerId === null) blockerId = powerSaveBlocker.start('prevent-display-sleep')
|
||||||
|
if (!shouldBlock && blockerId !== null) { powerSaveBlocker.stop(blockerId); blockerId = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主进程启动时同步一次持久化的设置。 */
|
||||||
|
export function configurePreventSleep(enabled: boolean): void {
|
||||||
|
preventSleepEnabled = enabled
|
||||||
|
syncBlocker()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务开始/结束时更新运行计数;计数>0 且设置开启时阻止系统休眠。 */
|
||||||
|
export function updateActiveRuns(delta: number): void {
|
||||||
|
activeRuns = Math.max(0, activeRuns + delta)
|
||||||
|
syncBlocker()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPreventSleepActive(): boolean {
|
||||||
|
return blockerId !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开机自启动的实际生效状态。
|
||||||
|
* - 正常环境读系统登录项(外部删除登录项时如实反映为 false)。
|
||||||
|
* - 隔离验收环境(YCODE_DISABLE_SYSTEM_INTEGRATION=1)不触碰系统,直接回显持久化偏好。
|
||||||
|
*/
|
||||||
|
export function resolveAutoLaunch(stored: boolean): boolean {
|
||||||
|
if (systemIntegrationDisabled()) return stored
|
||||||
|
try { return app.getLoginItemSettings().openAtLogin } catch { return stored }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把开机自启动写入系统登录项,返回实际生效状态。 */
|
||||||
|
export function applyAutoLaunch(enabled: boolean): boolean {
|
||||||
|
if (systemIntegrationDisabled()) return enabled
|
||||||
|
try {
|
||||||
|
app.setLoginItemSettings({
|
||||||
|
openAtLogin: enabled,
|
||||||
|
path: process.execPath,
|
||||||
|
args: app.isPackaged ? [] : [app.getAppPath()]
|
||||||
|
})
|
||||||
|
return app.getLoginItemSettings().openAtLogin
|
||||||
|
} catch {
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let usageCache: { at: number; bytes: number; partial: boolean } | null = null
|
||||||
|
|
||||||
|
/** 递归统计目录占用;限制条数与耗时,避免设置页卡顿。 */
|
||||||
|
export async function directoryBytes(root: string, maxEntries = 200_000, budgetMs = 1500): Promise<{ bytes: number; partial: boolean }> {
|
||||||
|
const started = Date.now()
|
||||||
|
let bytes = 0
|
||||||
|
let entries = 0
|
||||||
|
let partial = false
|
||||||
|
const queue: string[] = [root]
|
||||||
|
while (queue.length) {
|
||||||
|
if (entries >= maxEntries || Date.now() - started > budgetMs) { partial = true; break }
|
||||||
|
const current = queue.pop() as string
|
||||||
|
let dirents
|
||||||
|
try { dirents = await fs.readdir(current, { withFileTypes: true }) }
|
||||||
|
catch { continue }
|
||||||
|
for (const dirent of dirents) {
|
||||||
|
entries++
|
||||||
|
const target = join(current, dirent.name)
|
||||||
|
if (dirent.isDirectory()) { queue.push(target); continue }
|
||||||
|
if (!dirent.isFile()) continue
|
||||||
|
try { bytes += (await fs.stat(target)).size } catch { /* 忽略被占用的文件 */ }
|
||||||
|
if (entries >= maxEntries || Date.now() - started > budgetMs) { partial = true; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { bytes, partial }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dataUsage(root: string, refresh = false): Promise<{ bytes: number; partial: boolean }> {
|
||||||
|
if (!refresh && usageCache && Date.now() - usageCache.at < 30_000) return usageCache
|
||||||
|
const result = await directoryBytes(root)
|
||||||
|
usageCache = { at: Date.now(), ...result }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function diskUsage(path: string): Promise<{ totalBytes: number; freeBytes: number }> {
|
||||||
|
try {
|
||||||
|
const stats = await fs.statfs(path)
|
||||||
|
return { totalBytes: stats.blocks * stats.bsize, freeBytes: stats.bavail * stats.bsize }
|
||||||
|
} catch {
|
||||||
|
return { totalBytes: 0, freeBytes: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { dirname, join } from 'node:path'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { disposeOmpKernels, registerIpc, setTaskWindowFactory } from './ipc'
|
import { disposeOmpKernels, registerIpc, setTaskWindowFactory } from './ipc'
|
||||||
import { disposeMcpServers } from './capabilities/mcp'
|
import { disposeMcpServers } from './capabilities/mcp'
|
||||||
|
import { getPreventSleep } from './config'
|
||||||
|
import { configurePreventSleep } from './general-settings'
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let settingsWindow: BrowserWindow | null = null
|
let settingsWindow: BrowserWindow | null = null
|
||||||
@@ -161,6 +163,8 @@ if (process.env.YCODE_DISABLE_GPU === '1') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
// 通用设置:启动时套用持久化偏好(运行任务时阻止系统休眠)。
|
||||||
|
void getPreventSleep().then((enabled) => configurePreventSleep(enabled)).catch(() => undefined)
|
||||||
registerIpc(() => mainWindow)
|
registerIpc(() => mainWindow)
|
||||||
setTaskWindowFactory((sessionId) => { openTaskWindow(sessionId) })
|
setTaskWindowFactory((sessionId) => { openTaskWindow(sessionId) })
|
||||||
ipcMain.handle('app:openSettings', async (_event, section = 'model', action?: 'new') => {
|
ipcMain.handle('app:openSettings', async (_event, section = 'model', action?: 'new') => {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { restoreWorkspaceFiles, rollbackEditedArtifacts } from './workspace-reve
|
|||||||
import { assistantWireHistory } from './agent/history'
|
import { assistantWireHistory } from './agent/history'
|
||||||
import * as models from './models'
|
import * as models from './models'
|
||||||
import * as workspaces from './workspaces'
|
import * as workspaces from './workspaces'
|
||||||
|
import * as generalSettings from './general-settings'
|
||||||
import { prepareOmpModel } from './omp-model'
|
import { prepareOmpModel } from './omp-model'
|
||||||
import type { CustomModelInput, ResolvedModel } from '@shared/models'
|
import type { CustomModelInput, ResolvedModel } from '@shared/models'
|
||||||
import type { SessionContext } from '@shared/workspaces'
|
import type { SessionContext } from '@shared/workspaces'
|
||||||
@@ -384,6 +385,62 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
|||||||
// ---------- 配置 ----------
|
// ---------- 配置 ----------
|
||||||
ipcMain.handle('config:get', () => config.getConfigView())
|
ipcMain.handle('config:get', () => config.getConfigView())
|
||||||
ipcMain.handle('config:update', (_event, patch: ConfigPatchLike) => config.updateConfig(patch))
|
ipcMain.handle('config:update', (_event, patch: ConfigPatchLike) => config.updateConfig(patch))
|
||||||
|
|
||||||
|
// ---------- 通用设置(设置 → 通用) ----------
|
||||||
|
const generalView = async (refreshUsage = false) => {
|
||||||
|
const dataDir = app.getPath('userData')
|
||||||
|
const [usage, disk, workspaceRoot, autoLaunch, preventSleep] = await Promise.all([
|
||||||
|
generalSettings.dataUsage(dataDir, refreshUsage),
|
||||||
|
generalSettings.diskUsage(dataDir),
|
||||||
|
config.getWorkspaceRoot(),
|
||||||
|
config.getAutoLaunch(),
|
||||||
|
config.getPreventSleep()
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
autoLaunch: generalSettings.resolveAutoLaunch(autoLaunch),
|
||||||
|
preventSleep,
|
||||||
|
preventSleepActive: generalSettings.isPreventSleepActive(),
|
||||||
|
dataDir,
|
||||||
|
dataBytes: usage.bytes,
|
||||||
|
dataUsagePartial: usage.partial,
|
||||||
|
disk,
|
||||||
|
workspaceRoot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ipcMain.handle('general:get', (_event, options?: { refreshUsage?: boolean }) => generalView(options?.refreshUsage === true))
|
||||||
|
ipcMain.handle('general:update', async (_event, patch: { autoLaunch?: boolean; preventSleep?: boolean }) => {
|
||||||
|
if (patch.preventSleep !== undefined) {
|
||||||
|
await config.setPreventSleep(patch.preventSleep)
|
||||||
|
generalSettings.configurePreventSleep(patch.preventSleep)
|
||||||
|
}
|
||||||
|
if (patch.autoLaunch !== undefined) {
|
||||||
|
await config.setAutoLaunch(patch.autoLaunch)
|
||||||
|
const applied = generalSettings.applyAutoLaunch(patch.autoLaunch)
|
||||||
|
if (applied !== patch.autoLaunch) await config.setAutoLaunch(applied)
|
||||||
|
}
|
||||||
|
return generalView()
|
||||||
|
})
|
||||||
|
ipcMain.handle('general:openFolder', async (_event, kind: 'data' | 'workspace') => {
|
||||||
|
const target = kind === 'workspace' ? await config.getWorkspaceRoot() : app.getPath('userData')
|
||||||
|
await shell.openPath(target)
|
||||||
|
})
|
||||||
|
ipcMain.handle('general:changeWorkspaceRoot', async (_event, next?: string) => {
|
||||||
|
let target = next
|
||||||
|
if (!target) {
|
||||||
|
const options: OpenDialogOptions = { title: '选择默认工作空间存储路径', properties: ['openDirectory', 'createDirectory'] }
|
||||||
|
const result = await dialog.showOpenDialog(options)
|
||||||
|
if (result.canceled || !result.filePaths.length) return generalView()
|
||||||
|
target = result.filePaths[0]
|
||||||
|
}
|
||||||
|
await config.setWorkspaceRoot(target)
|
||||||
|
changed()
|
||||||
|
return generalView()
|
||||||
|
})
|
||||||
|
ipcMain.handle('general:open-system-notification-settings', async () => {
|
||||||
|
if (generalSettings.systemIntegrationSuppressed()) return
|
||||||
|
await shell.openExternal(process.platform === 'win32' ? 'ms-settings:notifications' : 'x-apple.systempreferences:com.apple.preference.notifications')
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('models:list', () => models.listModels())
|
ipcMain.handle('models:list', () => models.listModels())
|
||||||
ipcMain.handle('models:save', async (_event, input: CustomModelInput, overwrite?: boolean) => {
|
ipcMain.handle('models:save', async (_event, input: CustomModelInput, overwrite?: boolean) => {
|
||||||
const result = await models.saveModel(input, overwrite)
|
const result = await models.saveModel(input, overwrite)
|
||||||
@@ -644,6 +701,8 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
|||||||
// 回合开始时冻结配置:运行中改设置不影响本回合
|
// 回合开始时冻结配置:运行中改设置不影响本回合
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
activeRuns.set(runId, controller)
|
activeRuns.set(runId, controller)
|
||||||
|
// 通用设置「运行时防止系统休眠」:任务运行期间持有 powerSaveBlocker。
|
||||||
|
generalSettings.updateActiveRuns(1)
|
||||||
|
|
||||||
const publish = (event: ChatEvent): void => {
|
const publish = (event: ChatEvent): void => {
|
||||||
if (!win?.isDestroyed?.() && !win?.webContents.isDestroyed?.()) {
|
if (!win?.isDestroyed?.() && !win?.webContents.isDestroyed?.()) {
|
||||||
@@ -760,6 +819,7 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
|||||||
publish({ runId, type: 'error', message: `会话保存失败:${error instanceof Error ? error.message : String(error)}` })
|
publish({ runId, type: 'error', message: `会话保存失败:${error instanceof Error ? error.message : String(error)}` })
|
||||||
} finally {
|
} finally {
|
||||||
activeRuns.delete(runId)
|
activeRuns.delete(runId)
|
||||||
|
generalSettings.updateActiveRuns(-1)
|
||||||
runEmitters.delete(runId)
|
runEmitters.delete(runId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,13 @@ const api: YCodeApi = {
|
|||||||
get: () => ipcRenderer.invoke('config:get'),
|
get: () => ipcRenderer.invoke('config:get'),
|
||||||
update: (patch: ConfigPatchLike) => ipcRenderer.invoke('config:update', patch)
|
update: (patch: ConfigPatchLike) => ipcRenderer.invoke('config:update', patch)
|
||||||
},
|
},
|
||||||
|
general: {
|
||||||
|
get: (options) => ipcRenderer.invoke('general:get', options),
|
||||||
|
update: (patch) => ipcRenderer.invoke('general:update', patch),
|
||||||
|
openFolder: (kind) => ipcRenderer.invoke('general:openFolder', kind),
|
||||||
|
changeWorkspaceRoot: (path) => ipcRenderer.invoke('general:changeWorkspaceRoot', path),
|
||||||
|
openSystemNotificationSettings: () => ipcRenderer.invoke('general:open-system-notification-settings')
|
||||||
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
get: () => ipcRenderer.invoke('workspace:get'),
|
get: () => ipcRenderer.invoke('workspace:get'),
|
||||||
choose: () => ipcRenderer.invoke('workspace:choose'),
|
choose: () => ipcRenderer.invoke('workspace:choose'),
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* 设置 → 通用:WorkBuddy 原版「通用」面板的 1:1 复刻 + YCode 真实能力接线。
|
||||||
|
*
|
||||||
|
* 结构与几何来源(REA):
|
||||||
|
* - ev_a2dbeb23e30b9770743e857279a2e43da38fcc48d10612f75ace2938c5b29114(5.8.3 面板自顶向底滚动采样)
|
||||||
|
* - ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e(面板 DOM 边界、computed style、svg)
|
||||||
|
* 类名与样式表见 source-general-settings-recovered.css / source-general-settings.css(REA 回收 contexts 投影)。
|
||||||
|
*
|
||||||
|
* 已接入(真实行为,重启后持久化):
|
||||||
|
* - 运行时防止系统休眠:config.preventSleep,主进程在任务运行期间持有 powerSaveBlocker
|
||||||
|
* - 开机自动启动:config.autoLaunch,写入/读取系统登录项
|
||||||
|
* - 系统缓存目录:真实数据目录路径、占用与磁盘容量、「打开目录」调用系统文件管理器
|
||||||
|
* - 默认工作空间存储路径:config.workspaceRoot,「更改」打开目录选择器
|
||||||
|
* - 桌面通知:「前往系统设置」打开系统通知设置页
|
||||||
|
*
|
||||||
|
* 未接入原版行为的行(语言、链接打开方式、字体大小、允许锁屏运行、网络代理、自动安装可信技能、
|
||||||
|
* 技能自动更新、套件自动更新、通知提示音、定时任务产物通知、体验优化计划):保留原版的文案、
|
||||||
|
* 控件类型、尺寸与初始态,但标记 aria-disabled + title「暂未接入」并且不产生任何效果,
|
||||||
|
* 以免用占位控件冒充已实现能力。
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import type { GeneralSettingsView } from '@shared/types'
|
||||||
|
import { IconTaskOpenFolder } from './task-menu-icons'
|
||||||
|
import { IconNotificationAuthorize, IconSelectCaret } from './general-settings-icons'
|
||||||
|
import './source-general-settings.css'
|
||||||
|
|
||||||
|
const UNAVAILABLE_HINT = '暂未接入'
|
||||||
|
|
||||||
|
/** 原版用量文案(已用 5.45 GB / 共 474.9 GB):KB/MB 保留 1 位,GB/TB 小于 10 保留 2 位、否则 1 位。 */
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||||
|
const gb = 1024 ** 3
|
||||||
|
const tb = 1024 ** 4
|
||||||
|
if (bytes >= tb) return `${(bytes / tb).toFixed(bytes / tb < 10 ? 2 : 1)} TB`
|
||||||
|
if (bytes >= gb) return `${(bytes / gb).toFixed(bytes / gb < 10 ? 2 : 1)} GB`
|
||||||
|
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`
|
||||||
|
return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function classNames(...values: Array<string | false | undefined>): string {
|
||||||
|
return values.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionProps {
|
||||||
|
title: string
|
||||||
|
desc?: ReactNode
|
||||||
|
action: ReactNode
|
||||||
|
body?: ReactNode
|
||||||
|
stacked?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 原版行结构:section > section-main > (section-copy > 标题 + 描述) + section-action > 控件。 */
|
||||||
|
function Section({ title, desc, action, body, stacked, className }: SectionProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<section className={classNames('general-settings-panel__section', 'general-settings-panel__section--plain',
|
||||||
|
stacked ? 'general-settings-panel__section--stacked' : 'general-settings-panel__section--inline', className)}>
|
||||||
|
<div className="general-settings-panel__section-main">
|
||||||
|
<div className="general-settings-panel__section-copy">
|
||||||
|
<h3 className="general-settings-panel__section-title">{title}</h3>
|
||||||
|
{desc ? <p className="general-settings-panel__section-desc">{desc}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="general-settings-panel__section-action">{action}</div>
|
||||||
|
</div>
|
||||||
|
{body ? <div className="general-settings-panel__section-body">{body}</div> : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Switch({ checked, ariaLabel, disabled, onChange }: {
|
||||||
|
checked: boolean
|
||||||
|
ariaLabel: string
|
||||||
|
disabled?: boolean
|
||||||
|
onChange(checked: boolean): void
|
||||||
|
}): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="general-settings-panel__switch-wrap">
|
||||||
|
<label className="wb-switch wb-switch--large">
|
||||||
|
<span className="wb-switch__control">
|
||||||
|
<input className="wb-switch__input" type="checkbox" role="switch" aria-label={ariaLabel} checked={checked}
|
||||||
|
disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
|
||||||
|
<span className="wb-switch__track"><span className="wb-switch__thumb" /></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未接入的开关:保持原版开/关外观与尺寸,但不可交互。 */
|
||||||
|
function UnavailableSwitch({ checked, ariaLabel }: { checked: boolean; ariaLabel: string }): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="general-settings-panel__switch-wrap" data-settings-unavailable="true" aria-disabled="true" title={UNAVAILABLE_HINT}>
|
||||||
|
<label className="wb-switch wb-switch--large" aria-disabled="true">
|
||||||
|
<span className="wb-switch__control">
|
||||||
|
<input className="wb-switch__input" type="checkbox" role="switch" aria-label={ariaLabel} checked={checked}
|
||||||
|
readOnly tabIndex={-1} aria-disabled="true" />
|
||||||
|
<span className="wb-switch__track"><span className="wb-switch__thumb" /></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未接入的下拉:原版 120×32 闭合态(value + caret),不可展开。 */
|
||||||
|
function UnavailableSelect({ value, className, ariaLabel }: { value: string; className?: string; ariaLabel: string }): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<span className="source-select-host" data-settings-unavailable="true" aria-disabled="true" title={UNAVAILABLE_HINT}>
|
||||||
|
<button type="button" className={classNames('wb-select', 'wb-select--medium', className)} aria-label={ariaLabel}
|
||||||
|
aria-disabled="true" title={UNAVAILABLE_HINT} tabIndex={-1}>
|
||||||
|
<span className="wb-select__value">{value}</span>
|
||||||
|
<IconSelectCaret className="wb-icon wb-select__caret" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未接入的字体大小滑块:原版 .font-size-slider 结构(224 宽、6 个刻度、2 个激活、thumb 位于
|
||||||
|
* left 44.8px = 默认档,标签 小/默认/大)。
|
||||||
|
*/
|
||||||
|
function UnavailableFontSizeSlider(): React.JSX.Element {
|
||||||
|
const ticks = [0, 1, 2, 3, 4, 5]
|
||||||
|
return (
|
||||||
|
<div className="general-settings-panel__slider-wrap" data-settings-unavailable="true" aria-disabled="true" title={UNAVAILABLE_HINT}>
|
||||||
|
<div className="font-size-slider" aria-disabled="true">
|
||||||
|
<div className="font-size-slider__track">
|
||||||
|
<div className="font-size-slider__marks">
|
||||||
|
<div className="font-size-slider__marks-track" />
|
||||||
|
<div className="font-size-slider__marks-fill" style={{ width: 44.796875 }} />
|
||||||
|
{ticks.map((index) => (
|
||||||
|
<span key={index} className={classNames('font-size-slider__tick', index <= 1 && 'font-size-slider__tick--active')}
|
||||||
|
style={{ left: index * 44.8 }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="font-size-slider__thumb" style={{ left: 44.8 }} />
|
||||||
|
</div>
|
||||||
|
<div className="font-size-slider__labels">
|
||||||
|
<span className="font-size-slider__label">小</span>
|
||||||
|
<span className="font-size-slider__label">默认</span>
|
||||||
|
<span className="font-size-slider__label">大</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GeneralSettingsPanel(): React.JSX.Element {
|
||||||
|
const [view, setView] = useState<GeneralSettingsView | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const apply = useCallback(async (work: Promise<GeneralSettingsView>) => {
|
||||||
|
setBusy(true)
|
||||||
|
try { setView(await work); setError('') }
|
||||||
|
catch (reason) { setError(String((reason as Error)?.message ?? reason)) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void window.ycode.general.get()
|
||||||
|
.then((next) => { if (active) setView(next) })
|
||||||
|
.catch((reason) => { if (active) setError(String((reason as Error)?.message ?? reason)) })
|
||||||
|
return () => { active = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const update = (patch: { autoLaunch?: boolean; preventSleep?: boolean }): void => { void apply(window.ycode.general.update(patch)) }
|
||||||
|
const openFolder = (kind: 'data' | 'workspace'): void => { void window.ycode.general.openFolder(kind).catch((reason) => setError(String((reason as Error)?.message ?? reason))) }
|
||||||
|
const changeWorkspaceRoot = (): void => { void apply(window.ycode.general.changeWorkspaceRoot()) }
|
||||||
|
const openNotificationSettings = (): void => { void window.ycode.general.openSystemNotificationSettings().catch((reason) => setError(String((reason as Error)?.message ?? reason))) }
|
||||||
|
|
||||||
|
const dataBytes = view?.dataBytes ?? 0
|
||||||
|
const diskTotal = view?.disk.totalBytes ?? 0
|
||||||
|
const diskFree = view?.disk.freeBytes ?? 0
|
||||||
|
const cachePercent = diskTotal > 0 ? (dataBytes / diskTotal) * 100 : 0
|
||||||
|
const usedPercent = diskTotal > 0 ? ((diskTotal - diskFree) / diskTotal) * 100 : 0
|
||||||
|
const otherPercent = Math.max(0, usedPercent - cachePercent)
|
||||||
|
const freePercent = Math.max(0, 100 - usedPercent)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="general-settings-panel">
|
||||||
|
{error ? <p role="alert">{error}</p> : null}
|
||||||
|
|
||||||
|
<div className="general-settings-panel__group">
|
||||||
|
<p className="general-settings-panel__group-label">常规</p>
|
||||||
|
<div className="general-settings-panel__group-card">
|
||||||
|
<Section title="语言" action={<UnavailableSelect value="简体中文" ariaLabel="语言" />} />
|
||||||
|
<Section title="链接打开方式" desc="设置 YCode 对话中链接的默认打开策略。"
|
||||||
|
action={<UnavailableSelect value="智能打开" ariaLabel="链接打开方式" className="general-settings-panel__link-open-select" />}
|
||||||
|
className="general-settings-panel__link-open-section" />
|
||||||
|
<Section title="字体大小" action={<UnavailableFontSizeSlider />} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="general-settings-panel__group">
|
||||||
|
<p className="general-settings-panel__group-label">权限</p>
|
||||||
|
<div className="general-settings-panel__group-card">
|
||||||
|
<Section title="允许锁屏运行" desc="选择锁屏后的运行方式,保障远程控制与后台 Agent 任务持续执行。"
|
||||||
|
action={<UnavailableSelect value="关闭" ariaLabel="允许锁屏运行" className="power-save-blocker-settings__select" />} />
|
||||||
|
<Section title="运行时防止系统休眠" desc="在 YCode 运行任务时,让电脑保持唤醒状态。"
|
||||||
|
action={<Switch checked={view?.preventSleep ?? true} ariaLabel="运行时防止系统休眠" disabled={busy || !view}
|
||||||
|
onChange={(checked) => update({ preventSleep: checked })} />} />
|
||||||
|
<Section title="开机自动启动" desc="开启后 YCode 会在你登录电脑后自动启动。"
|
||||||
|
action={<Switch checked={view?.autoLaunch ?? false} ariaLabel="开机自动启动" disabled={busy || !view}
|
||||||
|
onChange={(checked) => update({ autoLaunch: checked })} />} />
|
||||||
|
<Section title="网络代理" desc="网络异常时可切换连接方式,修改后立即生效" stacked
|
||||||
|
className="general-settings-panel__section--proxy"
|
||||||
|
action={<UnavailableSelect value="直接连接" ariaLabel="网络代理" />}
|
||||||
|
body={<div className="proxy-section" />} />
|
||||||
|
<Section title="自动安装可信技能" desc="安全检测通过后自动安装,高风险项始终要求手动确认。"
|
||||||
|
action={<UnavailableSwitch checked={false} ariaLabel="自动安装可信技能" />} />
|
||||||
|
<Section title="技能自动更新" desc="开启后将自动更新已安装的技能为最新版本,不会更新你在 YCode 中编辑过的技能"
|
||||||
|
action={<UnavailableSwitch checked ariaLabel="技能自动更新" />} />
|
||||||
|
<Section title="套件自动更新" desc="开启后将自动更新已安装的套件为最新版本。"
|
||||||
|
action={<UnavailableSwitch checked ariaLabel="套件自动更新" />} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="wb-storage">
|
||||||
|
<p className="wb-storage__group-label">存储</p>
|
||||||
|
<div className="wb-storage__card">
|
||||||
|
<div className="wb-storage__cache">
|
||||||
|
<div className="wb-storage__card-title-row">
|
||||||
|
<div className="wb-storage__card-title">系统缓存目录</div>
|
||||||
|
<button type="button" className="wb-button wb-button--ghost wb-button--medium wb-storage__open-folder"
|
||||||
|
onClick={() => openFolder('data')}>
|
||||||
|
<span className="wb-button__icon wb-button__icon--left"><IconTaskOpenFolder className="wb-icon" /></span>
|
||||||
|
<span className="wb-button__content">打开目录</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="wb-storage__path-row">
|
||||||
|
<span className="wb-storage__path-text">{view?.dataDir ?? '—'} · 存放对话记录、运行缓存与临时文件。</span>
|
||||||
|
</div>
|
||||||
|
<div className="general-settings-panel__storage-overview">
|
||||||
|
<div className="general-settings-panel__storage-progress">
|
||||||
|
<span className="general-settings-panel__storage-progress-seg general-settings-panel__storage-progress-seg--workbuddy"
|
||||||
|
style={{ width: `${cachePercent}%` }} />
|
||||||
|
<span className="general-settings-panel__storage-progress-seg general-settings-panel__storage-progress-seg--other"
|
||||||
|
style={{ width: `${otherPercent}%` }} />
|
||||||
|
<span className="general-settings-panel__storage-progress-seg" style={{ width: `${freePercent}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="general-settings-panel__storage-legend">
|
||||||
|
<span className="general-settings-panel__storage-legend-item">
|
||||||
|
<span className="general-settings-panel__storage-legend-dot general-settings-panel__storage-legend-dot--workbuddy" />
|
||||||
|
<span className="general-settings-panel__storage-legend-label">系统缓存</span>
|
||||||
|
</span>
|
||||||
|
<span className="general-settings-panel__storage-legend-item">
|
||||||
|
<span className="general-settings-panel__storage-legend-dot general-settings-panel__storage-legend-dot--other" />
|
||||||
|
<span className="general-settings-panel__storage-legend-label">磁盘已用</span>
|
||||||
|
</span>
|
||||||
|
<span className="general-settings-panel__storage-legend-item">
|
||||||
|
<span className="general-settings-panel__storage-legend-dot general-settings-panel__storage-legend-dot--free" />
|
||||||
|
<span className="general-settings-panel__storage-legend-label">磁盘可用</span>
|
||||||
|
</span>
|
||||||
|
<span className="general-settings-panel__storage-usage-text">
|
||||||
|
已用 {formatBytes(dataBytes)} / 共 {formatBytes(diskTotal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr className="wb-storage__divider" />
|
||||||
|
<div className="general-settings-panel__workspace">
|
||||||
|
<div className="general-settings-panel__workspace-header">
|
||||||
|
<span className="general-settings-panel__workspace-path-title">YCode 默认工作空间存储路径</span>
|
||||||
|
</div>
|
||||||
|
<p className="general-settings-panel__workspace-path-desc">新建任务、工作空间时将自动存放在该路径下;修改后不影响已有数据。</p>
|
||||||
|
<div className="general-settings-panel__path-row">
|
||||||
|
<input className="wb-input wb-input--medium wb-input--filled general-settings-panel__path-input"
|
||||||
|
aria-label="默认工作空间存储路径" value={view?.workspaceRoot ?? ''} readOnly />
|
||||||
|
<button type="button" className="wb-button wb-button--secondary wb-button--medium" disabled={busy || !view}
|
||||||
|
onClick={changeWorkspaceRoot}>
|
||||||
|
<span className="wb-button__content">更改</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="general-settings-panel__group">
|
||||||
|
<p className="general-settings-panel__group-label">通知</p>
|
||||||
|
<div className="general-settings-panel__group-card">
|
||||||
|
<Section title="桌面通知" desc="任务完成或有新消息时,将会通过系统弹窗提醒你。"
|
||||||
|
action={<button type="button" className="wb-button wb-button--secondary wb-button--medium"
|
||||||
|
onClick={openNotificationSettings}>
|
||||||
|
<span className="wb-button__content">前往系统设置</span>
|
||||||
|
<span className="wb-button__icon wb-button__icon--right"><IconNotificationAuthorize /></span>
|
||||||
|
</button>} />
|
||||||
|
<Section title="通知提示音" desc="收到桌面或应用内通知时播放的提示音。"
|
||||||
|
action={<UnavailableSelect value="无音效" ariaLabel="通知提示音" />} />
|
||||||
|
<Section title="定时任务产物通知" desc="定时任务的产物生成后,将会通过系统弹窗提醒你。"
|
||||||
|
action={<UnavailableSwitch checked ariaLabel="定时任务产物通知" />} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="general-settings-panel__group">
|
||||||
|
<p className="general-settings-panel__group-label">隐私</p>
|
||||||
|
<div className="general-settings-panel__group-card">
|
||||||
|
<Section title="体验优化计划"
|
||||||
|
desc={<>
|
||||||
|
允许使用你的对话数据帮助改进模型。数据会先加密脱敏,且无法关联到你个人。另有链接
|
||||||
|
{' '}<a className="general-settings-panel__inline-link" href="#none" aria-disabled="true" title={UNAVAILABLE_HINT}
|
||||||
|
onClick={(event) => event.preventDefault()}>了解更多</a>
|
||||||
|
</>}
|
||||||
|
action={<UnavailableSwitch checked ariaLabel="体验优化计划" />} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import ModelSettingsPanel from './ModelSettingsPanel'
|
import ModelSettingsPanel from './ModelSettingsPanel'
|
||||||
|
import GeneralSettingsPanel from './GeneralSettingsPanel'
|
||||||
import SourceSettingsNavigation from './SourceSettingsNavigation'
|
import SourceSettingsNavigation from './SourceSettingsNavigation'
|
||||||
import { useSharedTheme } from '../useSharedTheme'
|
import { useSharedTheme } from '../useSharedTheme'
|
||||||
import { SOURCE_WINDOWS_SETTINGS_TABS } from './SourceSettingsNavigation'
|
import { SOURCE_WINDOWS_SETTINGS_TABS } from './SourceSettingsNavigation'
|
||||||
@@ -51,10 +52,11 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi
|
|||||||
return () => window.removeEventListener('keydown', escape)
|
return () => window.removeEventListener('keydown', escape)
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
const modal = <section className={`settings-modal${nativeWindow ? ' settings-modal--window' : ''}`} role="dialog" aria-label="设置" aria-modal={!nativeWindow}>
|
const modal = <section className={`settings-modal${nativeWindow ? ' settings-modal--window' : ''}`} role="dialog" aria-label="设置" aria-modal={!nativeWindow}>
|
||||||
<div className="settings-modal__nav"><SourceSettingsNavigation activeTab={section} supportedTabs={['models', 'appearance']} visibleTabs={VISIBLE_SETTINGS_TABS}
|
<div className="settings-modal__nav"><SourceSettingsNavigation activeTab={section} supportedTabs={['settings', 'models', 'appearance']} visibleTabs={VISIBLE_SETTINGS_TABS}
|
||||||
onTabChange={(id) => { setError(''); setSection(id === 'models' ? 'model' : id) }} onUnavailable={() => setError('该设置尚未接入')} /></div>
|
onTabChange={(id) => { setError(''); setSection(id === 'models' ? 'model' : id) }} onUnavailable={() => setError('该设置尚未接入')} /></div>
|
||||||
<div className="settings-modal__content"><div className="settings-modal__panel">
|
<div className="settings-modal__content"><div className="settings-modal__panel">
|
||||||
{error && <p role="alert">{error}</p>}
|
{error && <p role="alert">{error}</p>}
|
||||||
|
{section === 'settings' && <GeneralSettingsPanel />}
|
||||||
{section === 'model' && <ModelSettingsPanel models={settings.models} createRequest={createRequest} onCreateHandled={() => setCreateRequest(0)} onSave={settings.saveModel} onDelete={settings.deleteModel} onTest={(input) => window.ycode.models.test(input)} />}
|
{section === 'model' && <ModelSettingsPanel models={settings.models} createRequest={createRequest} onCreateHandled={() => setCreateRequest(0)} onSave={settings.saveModel} onDelete={settings.deleteModel} onTest={(input) => window.ycode.models.test(input)} />}
|
||||||
{section === 'workspace' && <div className="settings-window-panel"><h2>工作空间</h2>{settings.workspaces.map((space) => <button className="btn" key={space.id} title={space.root} onClick={() => void window.ycode.workspace.open(space.id).catch((error) => setError(String(error.message ?? error)))}>{space.name}</button>)}<button className="btn" onClick={() => void settings.attachWorkspace().catch((error) => setError(String(error.message ?? error)))}>打开已有文件夹</button></div>}
|
{section === 'workspace' && <div className="settings-window-panel"><h2>工作空间</h2>{settings.workspaces.map((space) => <button className="btn" key={space.id} title={space.root} onClick={() => void window.ycode.workspace.open(space.id).catch((error) => setError(String(error.message ?? error)))}>{space.name}</button>)}<button className="btn" onClick={() => void settings.attachWorkspace().catch((error) => setError(String(error.message ?? error)))}>打开已有文件夹</button></div>}
|
||||||
{section === 'appearance' && <div className="settings-window-panel"><h2>外观</h2><div className="segmented">{(['light', 'dark'] as const).map((value) => <button key={value} className="segmented__item" data-active={theme === value} onClick={() => onThemeChange(value)}>{value === 'light' ? '浅色' : '深色'}</button>)}</div></div>}
|
{section === 'appearance' && <div className="settings-window-panel"><h2>外观</h2><div className="segmented">{(['light', 'dark'] as const).map((value) => <button key={value} className="segmented__item" data-active={theme === value} onClick={() => onThemeChange(value)}>{value === 'light' ? '浅色' : '深色'}</button>)}</div></div>}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* 通用设置(设置 → 通用)用到的原版图标。
|
||||||
|
*
|
||||||
|
* path / viewBox / transform 取自 REA 归档
|
||||||
|
* ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e 的 svg_nodes,按原样复刻。
|
||||||
|
*/
|
||||||
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
/** 桌面通知行「前往系统设置」按钮右侧图标。 */
|
||||||
|
export function IconNotificationAuthorize(props: SVGProps<SVGSVGElement>): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg {...props} viewBox="0 0 16 16" fill="none" width="16" height="16">
|
||||||
|
<path
|
||||||
|
transform="matrix(1 0 0 1 2.57586 2.39996)"
|
||||||
|
d="M9.8242 6.6001L9.8242 5.109Q9.8243 3.305 9.7462 2.7323Q9.7081 2.4537 9.6375 2.2354L0.8485 11.0243L0 10.1758L8.789 1.3868Q8.5706 1.3162 8.292 1.2782Q7.7193 1.2 5.9153 1.2001L4.4243 1.2001L4.4243 0L5.9152 0Q7.8008 -0 8.4543 0.0892Q9.5761 0.2423 10.179 0.8453Q10.782 1.4482 10.9351 2.57Q11.0243 3.2235 11.0243 5.1091L11.0243 6.6001L9.8242 6.6001Z"
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下拉闭合态的 caret(与 source-select 的 SelectCaretGlyph 同源,取自同一份 REA svg_nodes)。 */
|
||||||
|
export function IconSelectCaret(props: SVGProps<SVGSVGElement>): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg {...props} viewBox="0 0 14 14" fill="currentColor" width="16" height="16">
|
||||||
|
<path transform="translate(2.967, 4.717)" d="M7.2416 0L4.0333 3.2083L0.825 0L0 0.825L4.0333 4.8582L8.0666 0.825L7.2416 0Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* REA ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94 via contexts sha256=50cbd673f7f35fc7f966bdddc6c9f5677fdfe944cb904888d214619fca259380; tokens sha256=13f2bf82c96e5167a7d7098e36950c6c22c475b3337dbb8bb29ad2fe79cde696; ui-kit sha256=88dbdc1cd3f59d0e8b75824512a465a2be714bc7af481d790fe3a9484f85e01a; general settings panel classes from REA ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e. */
|
||||||
|
:where(.general-settings-panel) .wb-button{box-sizing:border-box;cursor:pointer;white-space:nowrap;user-select:none;transition:background-color var(--wb-button-transition-duration) var(--wb-button-transition-easing), color var(--wb-button-transition-duration) var(--wb-button-transition-easing), border-color var(--wb-button-transition-duration) var(--wb-button-transition-easing), transform var(--wb-button-transition-duration) var(--wb-button-transition-easing);border-radius:var(--wb-button-border-radius-medium);border:none;outline:none;justify-content:center;align-items:center;font-family:PingFang SC,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;font-weight:500;text-decoration:none;display:inline-flex}:where(.general-settings-panel) .wb-button--medium{height:var(--wb-button-height-medium);padding:0 var(--wb-button-padding-medium);font-size:var(--wb-button-font-size-medium);gap:var(--wb-button-gap-medium);min-width:var(--wb-button-min-width-medium);line-height:var(--wb-button-line-height-medium)}:where(.general-settings-panel) .wb-button--xsmall{height:var(--wb-button-height-xsmall);font-size:var(--wb-button-font-size-small);line-height:var(--wb-button-height-xsmall);border-radius:var(--wb-button-border-radius-small);gap:0;padding:0}:where(.general-settings-panel) .wb-button--xsmall.wb-button--icon-only{width:var(--wb-button-height-xsmall);height:var(--wb-button-height-xsmall)}:where(.general-settings-panel) .wb-button--small{height:var(--wb-button-height-small);padding:0 var(--wb-button-padding-small);font-size:var(--wb-button-font-size-small);gap:var(--wb-button-gap-small);min-width:var(--wb-button-min-width-small);border-radius:var(--wb-button-border-radius-small);line-height:var(--wb-button-line-height-small)}:where(.general-settings-panel) .wb-button--small .wb-button__icon{width:var(--wb-button-icon-size-small);height:var(--wb-button-icon-size-small)}:where(.general-settings-panel) .wb-button--large{height:var(--wb-button-height-large);padding:0 var(--wb-button-padding-large);font-size:var(--wb-button-font-size-large);gap:var(--wb-button-gap-large);border-radius:var(--wb-button-border-radius-large);line-height:var(--wb-button-line-height-large)}:where(.general-settings-panel) .wb-button--primary{background-color:var(--wb-button-primary-bg);color:var(--wb-button-primary-fg)}:where(.general-settings-panel) .wb-button--primary:hover:not(:disabled){background-color:var(--wb-button-primary-bg-hover)}:where(.general-settings-panel) .wb-button--primary:active:not(:disabled){background-color:var(--wb-button-primary-bg-active)}:where(.general-settings-panel) .wb-button--primary:disabled,:where(.general-settings-panel) .wb-button--primary.wb-button--disabled{background-color:var(--wb-button-primary-bg-disabled);color:var(--wb-button-primary-fg-disabled);cursor:not-allowed}:where(.general-settings-panel) .wb-button--secondary{background-color:var(--wb-button-secondary-bg);color:var(--wb-button-secondary-fg);border:1px solid var(--wb-button-secondary-border)}:where(.general-settings-panel) .wb-button--secondary:hover:not(:disabled){background-color:var(--wb-button-secondary-bg-hover)}:where(.general-settings-panel) .wb-button--secondary:active:not(:disabled){background-color:var(--wb-button-secondary-bg-active)}:where(.general-settings-panel) .wb-button--secondary:disabled,:where(.general-settings-panel) .wb-button--secondary.wb-button--disabled{color:var(--wb-button-secondary-fg-disabled);border-color:var(--wb-button-secondary-border-disabled);cursor:not-allowed}:where(.general-settings-panel) .wb-button--grey{background-color:var(--wb-button-grey-bg);color:var(--wb-button-grey-fg)}:where(.general-settings-panel) .wb-button--grey:hover:not(:disabled){background-color:var(--wb-button-grey-bg-hover)}:where(.general-settings-panel) .wb-button--grey:active:not(:disabled){background-color:var(--wb-button-grey-bg-active)}:where(.general-settings-panel) .wb-button--grey:disabled,:where(.general-settings-panel) .wb-button--grey.wb-button--disabled{background-color:var(--wb-button-grey-bg-disabled);color:var(--wb-button-grey-fg-disabled);cursor:not-allowed}:where(.general-settings-panel) .wb-button--ghost{background-color:var(--wb-button-ghost-bg);color:var(--wb-button-ghost-fg);font-weight:400}:where(.general-settings-panel) .wb-button--ghost:hover:not(:disabled){background-color:var(--wb-button-ghost-bg-hover);color:var(--wb-button-ghost-fg-hover)}:where(.general-settings-panel) .wb-button--ghost:active:not(:disabled){background-color:var(--wb-button-ghost-bg-active);color:var(--wb-button-ghost-fg-hover)}:where(.general-settings-panel) .wb-button--ghost:disabled,:where(.general-settings-panel) .wb-button--ghost.wb-button--disabled{background-color:var(--wb-button-ghost-bg);color:var(--wb-button-ghost-fg-disabled);cursor:not-allowed}:where(.general-settings-panel) .wb-button--link{background-color:var(--wb-button-link-bg);color:var(--wb-button-link-fg);min-width:0;height:auto;padding:0;font-weight:400}:wLine truncated
|
||||||
|
[data-settings-locate=true] .general-settings-panel__section-title,[data-settings-locate=true] .general-settings-panel__workspace-path-title,[data-settings-locate=true] .wb-storage__card-title{animation:1.4s ease-in-out 3 wb-settings-locate-breath}@media (prefers-reduced-motion:reduce){[data-settings-locate=true] .general-settings-panel__section-title,[data-settings-locate=true] .general-settings-panel__workspace-path-title,[data-settings-locate=true] .wb-storage__card-title{color:var(--wb-brand-primary);animation:none}}.general-settings-panel{--settings-title:var(--wb-color-text-primary);--settings-body:var(--wb-color-text-secondary);--settings-accent:var(--wb-brand-primary);gap:var(--wb-spacing-5);width:100%;min-width:0;color:var(--settings-title);flex-direction:column;display:flex}.general-settings-panel__list{gap:var(--wb-spacing-3);padding-bottom:var(--wb-spacing-2);flex-direction:column;display:flex}.general-settings-panel__group{gap:var(--wb-spacing-3);flex-direction:column;display:flex}.general-settings-panel__group-label{padding-left:var(--wb-spacing-3);color:var(--wb-color-text-tertiary);font-size:var(--wb-font-body-size);font-weight:400;line-height:var(--wb-font-body-line-height);margin:0}.general-settings-panel__group-card{padding:var(--wb-spacing-4) var(--wb-spacing-5);border-radius:var(--wb-radius-xl);background:var(--wb-bg-primary);flex-direction:column;display:flex}.general-settings-panel__section{border-radius:var(--wb-radius-xl);background:var(--wb-bg-primary);border:none}.general-settings-panel__section--inline,.general-settings-panel__section--stacked{padding:var(--wb-spacing-4) var(--wb-spacing-5)}.general-settings-panel__section--plain{background:0 0;border-radius:0}.general-settings-panel__section--plain.general-settings-panel__section--inline,.general-settings-panel__section--plain.general-settings-panel__section--stacked{padding:var(--wb-spacing-4) 0}.general-settings-panel__group-card>.general-settings-panel__section--plain:first-child.general-settings-panel__section--inline,.general-settings-panel__group-card>.general-settings-panel__section--plain:first-child.general-settings-panel__section--stacked{padding-top:0}.general-settings-panel__group-card>.general-settings-panel__section--plain:last-child.general-settings-panel__section--inline,.general-settings-panel__group-card>.general-settings-panel__section--plain:last-child.general-settings-panel__section--stacked{padding-bottom:0}.general-settings-panel__section-main{justify-content:space-between;align-items:center;gap:var(--wb-spacing-6);min-width:0;display:flex}.general-settings-panel__section-copy{flex:1;min-width:0}.general-settings-panel__section-icon{flex-shrink:0;justify-content:center;align-items:center;display:flex}.general-settings-panel__section--with-icon .general-settings-panel__section-main{gap:var(--wb-spacing-4)}.general-settings-panel__section-title{color:var(--settings-title);font-size:var(--wb-font-body-size);font-weight:500;line-height:var(--wb-font-body-line-height);letter-spacing:0;align-items:center;margin:0;display:inline-flex}.general-settings-panel__section-desc{max-width:680px;color:var(--wb-color-text-tertiary);font-size:var(--wb-font-body-size);font-weight:400;line-height:var(--wb-font-body-line-height);letter-spacing:0;word-break:break-word;margin:0;text-decoration:none}.general-settings-panel__inline-link{color:var(--wb-color-text-secondary);font-size:var(--wb-font-body-size);font-weight:400;line-height:var(--wb-font-body-line-height);white-space:nowrap;cursor:pointer;transition:color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard);text-decoration:none}.general-settings-panel__inline-link:hover{color:var(--wb-color-text-primary)}.general-settings-panel__section-action{flex-shrink:0;justify-content:flex-end;align-items:center;display:flex}.general-settings-panel__section-action .wb-select__option-label{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.general-settings-panel__link-open-section .general-settings-panel__section-action{align-self:center}.general-settings-panel__link-open-select.wb-select{width:120px;min-width:120px}.general-settings-panel__link-open-option-text{flex:1;min-width:0}.general-settings-panel__link-open-option-name{font-size:var(--wb-font-body-size);font-weight:400;line-height:var(--wb-font-body-line-height)}.wb-select__option--single-selected .general-settings-panel__link-open-option-name{font-weight:500}.general-settings-panel__link-open-option-hint{margin-top:var(--wb-spacing-1);color:var(--wb-color-text-tertiary,#00000080);white-space:normal;font-size:12px;font-weight:400;line-height:20px}.general-settings-panel__link-open-select-popover.wb-select-popover{min-width:368px;width:368px!important;max-width:368px!important}.general-settings-panel__link-open-select-popover.wb-select-popover .wb-select__option{width:100%;height:auto;min-height:var(--wb-select-size-control-md);padding-top:var(--wb-spacing-2-5);padding-bottom:var(--wb-spacing-2-5);gap:var(Line truncated
|
||||||
|
.general-settings-panel{--wb-brand-primary:var(--wb-palette-brand-8);--wb-color-text-primary:var(--wb-palette-black-100);--wb-color-text-secondary:var(--wb-palette-black-70);--wb-spacing-5:16px;--wb-spacing-3:8px;--wb-spacing-2:4px;--wb-color-text-tertiary:var(--wb-palette-black-50);--wb-font-body-size:var(--wb-font-size-5);--wb-font-body-line-height:var(--wb-font-line-height-5-2);--wb-spacing-4:12px;--wb-radius-xl:12px;--wb-bg-primary:var(--wb-palette-white-100);--wb-spacing-6:20px;--wb-motion-duration-fast:.15s;--wb-motion-easing-standard:cubic-bezier(.4, 0, .2, 1);--wb-spacing-1:2px;--wb-select-size-control-md:var(--wb-size-control-md);--wb-spacing-2-5:5px;--wb-select-spacing-1:var(--wb-spacing-1);--wb-input-color-text-disabled:var(--wb-color-text-disabled);--wb-border-default:color-mix(in srgb, var(--wb-palette-black-100) 8%, transparent);--wb-color-border-secondary:var(--wb-palette-gray-4);--wb-radius-lg:8px;--wb-bg-hover:color-mix(in srgb, var(--wb-palette-black-100) 5%, transparent);--wb-color-border-primary:var(--wb-palette-gray-5);--wb-color-text-success-default:var(--wb-palette-green-8);--wb-bg-secondary:var(--wb-color-bg-secondary-hover-active);--wb-bg-active:color-mix(in srgb, var(--wb-palette-black-100) 8%, transparent);--wb-color-text-disabled:var(--wb-palette-black-30);--wb-border-card:#00000014;--wb-shadow-thumb:0 1px 2px #0000002e;--wb-font-caption-size:var(--wb-font-size-3);--wb-font-caption-line-height:var(--wb-font-line-height-4-2);--wb-border-hover:#00000029;--wb-color-text-brand-active:var(--wb-palette-brand-9);--wb-border-subtle:var(--wb-palette-gray-3);--wb-radius-xs:2px;--wb-button-grey-bg:var(--wb-palette-gray-4);--wb-motion-duration-base:.2s;--wb-color-text-brand-default:var(--wb-palette-brand-8);--wb-bg-tertiary:var(--wb-color-bg-primary-hover);--wb-status-info:var(--wb-palette-brand-8);--wb-status-error:var(--wb-palette-red-8);--wb-radius-sm:4px;--wb-button-transition-duration:var(--wb-motion-duration-instant);--wb-button-transition-easing:var(--wb-motion-easing-standard);--wb-button-border-radius-medium:var(--wb-radius-lg);--wb-button-height-medium:var(--wb-size-control-md);--wb-button-padding-medium:var(--wb-spacing-4);--wb-button-font-size-medium:var(--wb-font-size-5);--wb-button-gap-medium:var(--wb-spacing-2);--wb-button-min-width-medium:var(--wb-size-17);--wb-button-line-height-medium:var(--wb-font-line-height-5-2);--wb-button-height-xsmall:var(--wb-size-4);--wb-button-font-size-small:var(--wb-font-size-3);--wb-button-border-radius-small:var(--wb-radius-md);--wb-button-height-small:var(--wb-size-control-sm);--wb-button-padding-small:var(--wb-spacing-3);--wb-button-gap-small:var(--wb-spacing-1);--wb-button-min-width-small:var(--wb-size-10);--wb-button-line-height-small:var(--wb-font-line-height-5);--wb-button-icon-size-small:var(--wb-icon-size-sm);--wb-button-height-large:var(--wb-size-control-xl);--wb-button-padding-large:var(--wb-spacing-8);--wb-button-font-size-large:var(--wb-font-size-5);--wb-button-gap-large:var(--wb-spacing-3);--wb-button-border-radius-large:var(--wb-radius-xl);--wb-button-line-height-large:var(--wb-font-line-height-5-2);--wb-button-primary-bg:var(--wb-palette-black-90);--wb-button-primary-fg:var(--wb-palette-white-100);--wb-button-primary-bg-hover:var(--wb-palette-black-75);--wb-button-primary-bg-active:var(--wb-palette-black-75);--wb-button-primary-bg-disabled:var(--wb-palette-black-20);--wb-button-primary-fg-disabled:var(--wb-palette-white-100);--wb-button-secondary-bg:var(--wb-palette-white-100);--wb-button-secondary-fg:var(--wb-palette-black-90);--wb-button-secondary-border:var(--wb-color-border-solid);--wb-button-secondary-bg-hover:var(--wb-palette-gray-2);--wb-button-secondary-bg-active:var(--wb-palette-gray-2);--wb-button-secondary-fg-disabled:var(--wb-palette-black-30);--wb-button-secondary-border-disabled:var(--wb-color-border-secondary);--wb-button-grey-fg:var(--wb-palette-black-90);--wb-button-grey-bg-hover:var(--wb-palette-gray-5);--wb-button-grey-bg-active:var(--wb-palette-gray-5);--wb-button-grey-bg-disabled:var(--wb-palette-gray-4);--wb-button-grey-fg-disabled:var(--wb-palette-black-30);--wb-button-ghost-bg:transparent;--wb-button-ghost-fg:var(--wb-palette-black-90);--wb-button-ghost-bg-hover:#0000000f;--wb-button-ghost-fg-hover:var(--wb-palette-black-90);--wb-button-ghost-bg-active:#0000000f;--wb-button-ghost-fg-disabled:var(--wb-palette-black-30);--wb-button-link-bg:transparent;--wb-button-link-fg:#1470b4;--wb-button-link-bg-hover:transparent;--wb-button-link-fg-hover:#1470b4;--wb-button-link-bg-active:transparent;--wb-button-link-fg-disabled:#94b7d1;--wb-button-border-radius-pill:var(--wb-radius-full);--wb-button-danger-bg:var(--wb-palette-red-7);--wb-button-danger-fg-on-solid:var(--wb-palette-white-100);--wb-button-danger-bg-hover:var(--wb-palette-red-6);--wb-button-danger-bg-active:var(--wb-palette-red-6);--wb-button-danger-bg-disabled:var(--wb-palette-red-3);--wb-button-danger-fg-on-solid-disabled:var(--wb-palette-white-100);--wbLine truncated
|
||||||
|
:root[data-theme=dark] .general-settings-panel{--wb-brand-primary:var(--wb-palette-brand-7);--wb-color-text-primary:#fff;--wb-color-text-secondary:#ffffffa6;--wb-color-text-tertiary:#ffffff80;--wb-bg-primary:var(--wb-palette-gray-3);--wb-input-color-text-disabled:var(--wb-color-text-disabled);--wb-border-default:color-mix(in srgb, var(--wb-palette-white-100) 14%, transparent);--wb-color-border-secondary:var(--wb-palette-gray-5);--wb-bg-hover:color-mix(in srgb, var(--wb-palette-white-100) 6%, transparent);--wb-color-border-primary:#262626;--wb-color-text-success-default:var(--wb-palette-green-6);--wb-bg-secondary:var(--wb-palette-gray-2);--wb-bg-active:var(--wb-palette-white-10);--wb-color-text-disabled:#ffffff59;--wb-border-card:#ffffff1a;--wb-shadow-thumb:0 1px 4px #0009;--wb-border-hover:var(--wb-palette-white-20);--wb-color-text-brand-active:var(--wb-palette-brand-5);--wb-border-subtle:color-mix(in srgb, var(--wb-palette-white-100) 6%, transparent);--wb-button-grey-bg:#383838;--wb-color-text-brand-default:var(--wb-palette-brand-7);--wb-bg-tertiary:var(--wb-palette-gray-4);--wb-status-info:var(--wb-color-text-brand-default);--wb-status-error:var(--wb-color-text-error-default);--wb-button-primary-bg:var(--wb-palette-white-90);--wb-button-primary-fg:var(--wb-palette-black-90);--wb-button-primary-bg-hover:var(--wb-palette-white-75);--wb-button-primary-bg-active:var(--wb-palette-white-75);--wb-button-primary-bg-disabled:var(--wb-palette-white-20);--wb-button-primary-fg-disabled:var(--wb-palette-black-90);--wb-button-secondary-bg:#262626;--wb-button-secondary-fg:var(--wb-palette-white-90);--wb-button-secondary-border:var(--wb-color-border-solid);--wb-button-secondary-bg-hover:#2e2e2e;--wb-button-secondary-bg-active:#2e2e2e;--wb-button-secondary-fg-disabled:var(--wb-palette-white-30);--wb-button-secondary-border-disabled:var(--wb-color-border-secondary);--wb-button-grey-fg:var(--wb-palette-white-90);--wb-button-grey-bg-hover:#404040;--wb-button-grey-bg-active:#404040;--wb-button-grey-bg-disabled:#404040;--wb-button-grey-fg-disabled:var(--wb-palette-white-30);--wb-button-ghost-bg:transparent;--wb-button-ghost-fg:var(--wb-palette-white-90);--wb-button-ghost-bg-hover:var(--wb-palette-white-10);--wb-button-ghost-fg-hover:var(--wb-palette-white-90);--wb-button-ghost-bg-active:var(--wb-palette-white-10);--wb-button-ghost-fg-disabled:var(--wb-palette-white-30);--wb-button-link-bg:transparent;--wb-button-link-fg:#4294d2;--wb-button-link-bg-hover:transparent;--wb-button-link-fg-hover:#4294d2;--wb-button-link-bg-active:transparent;--wb-button-link-fg-disabled:#94b7d1;--wb-button-danger-bg:#e85c5c;--wb-button-danger-fg-on-solid:var(--wb-palette-white-100);--wb-button-danger-bg-hover:#ec7474;--wb-button-danger-bg-active:#ec7474;--wb-button-danger-bg-disabled:#803838;--wb-button-danger-fg-on-solid-disabled:var(--wb-palette-white-70);--wb-button-danger-secondary-fg:var(--wb-palette-red-9);--wb-button-danger-secondary-border:var(--wb-palette-red-5);--wb-button-danger-secondary-bg:#262626;--wb-button-danger-secondary-bg-hover:color-mix(in srgb, var(--wb-palette-red-3) 40%, var(--wb-button-danger-secondary-bg) 60%);--wb-button-danger-secondary-bg-active:color-mix(in srgb, var(--wb-palette-red-3) 40%, var(--wb-button-danger-secondary-bg) 60%);--wb-button-danger-secondary-fg-disabled:var(--wb-color-text-error-disabled);--wb-button-danger-secondary-border-disabled:var(--wb-color-text-error-disabled);--wb-button-danger-grey-fg:var(--wb-palette-red-9);--wb-button-danger-fg-disabled:var(--wb-color-text-error-disabled);--wb-button-danger-ghost-fg:var(--wb-palette-red-9);--wb-button-danger-bg-soft-hover:color-mix(in srgb, var(--wb-color-text-error-default) 12%, transparent);--wb-button-danger-bg-soft-active:color-mix(in srgb, var(--wb-color-text-error-default) 20%, transparent);--wb-button-success-bg:#0cbf5b;--wb-button-success-fg-on-solid:var(--wb-palette-white-90);--wb-button-success-bg-hover:#09a84f;--wb-button-success-bg-disabled:var(--wb-color-text-success-disabled);--wb-button-success-fg-on-solid-disabled:var(--wb-palette-white-70);--wb-button-success-fg:var(--wb-color-text-success-default);--wb-button-success-fg-disabled:var(--wb-color-text-success-disabled);--wb-button-loading-secondary-fg:var(--wb-palette-white-30);--wb-button-loading-grey-fg:var(--wb-palette-white-30);--wb-button-loading-ghost-fg:var(--wb-palette-white-30);--wb-button-loading-danger-fg:var(--wb-color-text-error-disabled);--wb-button-loading-danger-fg-on-solid:var(--wb-palette-white-90);--wb-button-loading-link-fg:#94b7d1;--wb-button-loading-primary-fg:var(--wb-palette-white-90);--wb-input-color-text:var(--wb-color-text-primary);--wb-input-color-placeholder:var(--wb-color-text-disabled);--wb-input-color-bg:var(--wb-bg-primary);--wb-input-border-color:var(--wb-color-border-solid);--wb-input-border-color-hover:var(--wb-palette-white-30);--wb-input-border-color-focus:var(--wb-palette-white-30);--wb-input-disabled-bg:var(--wb-color-bg-secondary-hover-active);--wbLine truncated
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@import './source-general-settings-recovered.css';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 原版把 `.wb-select{width:120px;min-width:120px}` 与通用面板放在同一个懒加载 chunk
|
||||||
|
* (REA ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94 的
|
||||||
|
* contexts 样式表)。YCode 的 CSS 是全局打包的,直接放出这条规则会污染其它面板的 select,
|
||||||
|
* 因此按原版实测宽度(语言 120 / 链接打开方式 120 / 允许锁屏运行 120 / 网络代理 120 /
|
||||||
|
* 通知提示音 120,见 REA ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e)
|
||||||
|
* 收敛到本面板作用域。
|
||||||
|
*/
|
||||||
|
.general-settings-panel .wb-select { width: 120px; min-width: 120px }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 未接入原版行为的行:保持原版外观与几何,只停用交互并给出提示
|
||||||
|
* (与设置导航未接入项的 aria-disabled + 「暂未接入」约定一致)。
|
||||||
|
*/
|
||||||
|
.general-settings-panel [data-settings-unavailable=true] { cursor: default }
|
||||||
|
.general-settings-panel [data-settings-unavailable=true] .wb-select,
|
||||||
|
.general-settings-panel [data-settings-unavailable=true] .wb-switch,
|
||||||
|
.general-settings-panel [data-settings-unavailable=true] .font-size-slider { cursor: default }
|
||||||
+14
-1
@@ -11,7 +11,9 @@ import type {
|
|||||||
WorkspaceInfo,
|
WorkspaceInfo,
|
||||||
WindowState,
|
WindowState,
|
||||||
AppInfo,
|
AppInfo,
|
||||||
GitBranchInfo
|
GitBranchInfo,
|
||||||
|
GeneralSettingsPatch,
|
||||||
|
GeneralSettingsView
|
||||||
} from './types'
|
} from './types'
|
||||||
import type { DownloadPayload, DownloadResult, ResourceEntry, ResourceRange, WorkspaceResource } from './resources'
|
import type { DownloadPayload, DownloadResult, ResourceEntry, ResourceRange, WorkspaceResource } from './resources'
|
||||||
import type { ChatAttachment, InteractionResponse, MessageQuote, PermissionMode } from './conversation'
|
import type { ChatAttachment, InteractionResponse, MessageQuote, PermissionMode } from './conversation'
|
||||||
@@ -100,6 +102,17 @@ export interface YCodeApi {
|
|||||||
get(): Ipc<GatewayConfigView>
|
get(): Ipc<GatewayConfigView>
|
||||||
update(patch: ConfigPatchLike): Ipc<GatewayConfigView>
|
update(patch: ConfigPatchLike): Ipc<GatewayConfigView>
|
||||||
}
|
}
|
||||||
|
/** 设置 → 通用:真实可接入的能力。 */
|
||||||
|
general: {
|
||||||
|
get(options?: { refreshUsage?: boolean }): Ipc<GeneralSettingsView>
|
||||||
|
update(patch: GeneralSettingsPatch): Ipc<GeneralSettingsView>
|
||||||
|
/** 打开数据目录 / 默认工作空间目录 */
|
||||||
|
openFolder(kind: 'data' | 'workspace'): Ipc<void>
|
||||||
|
/** 修改默认工作空间存储路径;传入 path 时不弹系统对话框(验收用)。 */
|
||||||
|
changeWorkspaceRoot(path?: string): Ipc<GeneralSettingsView>
|
||||||
|
/** 打开系统通知设置页。 */
|
||||||
|
openSystemNotificationSettings(): Ipc<void>
|
||||||
|
}
|
||||||
workspace: {
|
workspace: {
|
||||||
get(): Ipc<WorkspaceInfo>
|
get(): Ipc<WorkspaceInfo>
|
||||||
choose(): Ipc<WorkspaceInfo & { changed: boolean }>
|
choose(): Ipc<WorkspaceInfo & { changed: boolean }>
|
||||||
|
|||||||
@@ -117,6 +117,31 @@ export interface GatewayConfigView {
|
|||||||
keyStorage: 'encrypted' | 'plaintext' | 'none'
|
keyStorage: 'encrypted' | 'plaintext' | 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设置 → 通用:真实状态视图(未接入项不下发数据)。 */
|
||||||
|
export interface GeneralSettingsView {
|
||||||
|
/** 系统登录项的真实状态 */
|
||||||
|
autoLaunch: boolean
|
||||||
|
/** 运行任务时阻止系统休眠(偏好) */
|
||||||
|
preventSleep: boolean
|
||||||
|
/** 当前是否有 powerSaveBlocker 生效 */
|
||||||
|
preventSleepActive: boolean
|
||||||
|
/** 数据目录(存放对话记录、运行缓存与临时文件) */
|
||||||
|
dataDir: string
|
||||||
|
/** 数据目录占用字节 */
|
||||||
|
dataBytes: number
|
||||||
|
/** 扫描是否因预算被截断 */
|
||||||
|
dataUsagePartial: boolean
|
||||||
|
/** 数据目录所在磁盘容量 */
|
||||||
|
disk: { totalBytes: number; freeBytes: number }
|
||||||
|
/** 默认工作空间存储路径 */
|
||||||
|
workspaceRoot: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneralSettingsPatch {
|
||||||
|
autoLaunch?: boolean
|
||||||
|
preventSleep?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/** 主进程 → 渲染进程的流式事件 */
|
/** 主进程 → 渲染进程的流式事件 */
|
||||||
export type ChatEvent = { sessionId?: string; turnDurationMs?: number } & (
|
export type ChatEvent = { sessionId?: string; turnDurationMs?: number } & (
|
||||||
| { runId: string; type: 'started'; messageId: string }
|
| { runId: string; type: 'started'; messageId: string }
|
||||||
|
|||||||
Reference in new issue
Block a user