Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d558cfe42d | |||
| 3e6fbaed9a | |||
| 3fa19600f2 | |||
| 1a03bfebf6 | |||
| 777f928830 | |||
| e15e3b455f | |||
| a08b224590 | |||
| 964290c641 | |||
| 3f8b438fdc | |||
| 584b0f95cb | |||
| 933ddca561 | |||
| 6271026e2d | |||
| 29b0b944bd | |||
| df72dd4bed | |||
| af8e1df139 | |||
| c50960f4c4 | |||
| 9deae852ec | |||
| 39736fb54f | |||
| 98c5768ebf | |||
| 9d53c44241 | |||
| dd6bd0cb7b | |||
| 48b78c1b33 | |||
| d8d4501665 | |||
| 5b75792d6a | |||
| 84222dd44e | |||
| 897683e63f | |||
| 8242885c79 | |||
| b4ba318849 | |||
| 6f29c60d63 | |||
| 6f0bae7724 | |||
| efd67911c9 | |||
| f029992083 | |||
| fe813802a5 | |||
| 7f3cf3b64b | |||
| e6b2539dfe | |||
| 620c2c5015 | |||
| e7345508df | |||
| fc37bfbceb | |||
| 20a065e540 | |||
| fb058adb17 | |||
| 4886dffda2 | |||
| d456b97eaf | |||
| 21aa894871 | |||
| 2d884f1bc5 | |||
| 5898d38083 | |||
| a2876eaf65 | |||
| 754964c57e |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@genarrative/ai-game-creator-shell",
|
||||
"private": true,
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.12",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start-tauri-dev.mjs",
|
||||
@@ -9,7 +9,6 @@
|
||||
"dev-stack": "node scripts/start-dev-stack.mjs",
|
||||
"build": "node scripts/build-release.mjs",
|
||||
"release:upload": "node scripts/release-upload.mjs",
|
||||
"bump-version": "node scripts/bump-version.mjs",
|
||||
"skill-pack:check": "node scripts/check-skill-pack.mjs",
|
||||
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
|
||||
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
|
||||
|
||||
@@ -5,7 +5,6 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = path.resolve(appRoot, '../..');
|
||||
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
|
||||
const releaseTarget =
|
||||
process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget;
|
||||
@@ -50,23 +49,16 @@ function parseVersion(value, label) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function bumpVersion(current, target = 'patch') {
|
||||
const [major, minor, patch] =
|
||||
parseVersion(current, '当前版本').split('.').map(Number);
|
||||
if (target === 'major') return `${major + 1}.0.0`;
|
||||
if (target === 'minor') return `${major}.${minor + 1}.0`;
|
||||
if (target === 'patch' || target == null) {
|
||||
if (patch === Number.MAX_SAFE_INTEGER) {
|
||||
throw new Error(`版本号 patch 已达到上限:${current}`);
|
||||
}
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
export function nextPatchVersion(localVersion, remoteVersion) {
|
||||
const local = parseVersion(localVersion, '本地版本');
|
||||
const remote =
|
||||
remoteVersion == null ? null : parseVersion(remoteVersion, 'OSS版本');
|
||||
const base = remote && compareVersions(remote, local) > 0 ? remote : local;
|
||||
const [major, minor, patch] = base.split('.').map(Number);
|
||||
if (patch === Number.MAX_SAFE_INTEGER) {
|
||||
throw new Error(`版本号 patch 已达到上限:${base}`);
|
||||
}
|
||||
if (typeof target === 'string' && /^\d+\.\d+\.\d+$/u.test(target)) {
|
||||
return parseVersion(target, '指定版本');
|
||||
}
|
||||
throw new Error(
|
||||
`无法识别的版本目标:${String(target)}(支持 patch / minor / major / 明确的三段版本号)`,
|
||||
);
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
|
||||
async function readRemoteVersion() {
|
||||
@@ -96,52 +88,14 @@ function replaceVersionLine(source, version, pattern, label) {
|
||||
return source.replace(pattern, `$1${version}$3`);
|
||||
}
|
||||
|
||||
const versionFileSources = [
|
||||
{
|
||||
file: packageJsonPath,
|
||||
pattern: /("version"\s*:\s*")([^"]+)(")/u,
|
||||
label: 'package.json',
|
||||
},
|
||||
{
|
||||
file: rootPackageLockPath,
|
||||
pattern: /("apps\/ai-game-creator-shell"\s*:\s*\{\s*\n\s*"name"\s*:\s*"@genarrative\/ai-game-creator-shell"\s*,\s*\n\s*"version"\s*:\s*")([^"]+)(")/u,
|
||||
label: 'package-lock.json',
|
||||
},
|
||||
{
|
||||
file: tauriConfigPath,
|
||||
pattern: /("version"\s*:\s*")([^"]+)(")/u,
|
||||
label: 'tauri.conf.json',
|
||||
},
|
||||
{
|
||||
file: cargoManifestPath,
|
||||
pattern: /(^\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/mu,
|
||||
label: 'Cargo.toml',
|
||||
},
|
||||
{
|
||||
file: cargoLockPath,
|
||||
pattern: /(^name\s*=\s*"genarrative-ai-game-creator-shell"\s*\nversion\s*=\s*")([^"]+)(")/mu,
|
||||
label: 'Cargo.lock',
|
||||
},
|
||||
];
|
||||
export async function prepareReleaseVersion() {
|
||||
const localVersion = parseVersion(readPackageJson().version, '本地版本');
|
||||
const remoteVersion = await readRemoteVersion();
|
||||
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
|
||||
const nextVersion = requestedVersion
|
||||
? parseVersion(requestedVersion, '指定版本')
|
||||
: nextPatchVersion(localVersion, remoteVersion);
|
||||
|
||||
export function readLocalVersion() {
|
||||
return parseVersion(readPackageJson().version, '本地版本');
|
||||
}
|
||||
|
||||
export function validateVersionConsistency(expectedVersion) {
|
||||
for (const source of versionFileSources) {
|
||||
const match = fs.readFileSync(source.file, 'utf8').match(source.pattern);
|
||||
const actual = match ? match[2].trim() : '<未找到>';
|
||||
if (actual !== expectedVersion) {
|
||||
throw new Error(
|
||||
`AGC 版本号不一致:${source.label} 应为 ${expectedVersion},实际 ${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function writeVersionFiles(version) {
|
||||
const nextVersion = parseVersion(version, '目标版本');
|
||||
const packageSource = fs.readFileSync(packageJsonPath, 'utf8');
|
||||
fs.writeFileSync(
|
||||
packageJsonPath,
|
||||
@@ -197,47 +151,14 @@ export function writeVersionFiles(version) {
|
||||
),
|
||||
);
|
||||
|
||||
console.log(
|
||||
requestedVersion
|
||||
? `[ai-game-creator-shell] 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})`
|
||||
: `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
|
||||
);
|
||||
return nextVersion;
|
||||
}
|
||||
|
||||
export function assertVersionCommitted() {
|
||||
const repoPaths = versionFileSources.map((source) =>
|
||||
path.relative(repoRoot, source.file).replaceAll('\\', '/'),
|
||||
);
|
||||
const changed =
|
||||
spawnSync(
|
||||
'git',
|
||||
['diff', '--quiet', 'HEAD', '--', ...repoPaths],
|
||||
{ cwd: repoRoot },
|
||||
).status === 1;
|
||||
if (changed) {
|
||||
throw new Error(
|
||||
'版本文件相对 HEAD 存在未提交改动,请先提交后再发布:运行 npm --prefix apps/ai-game-creator-shell run bump-version -- --commit',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareReleaseVersion() {
|
||||
const localVersion = readLocalVersion();
|
||||
validateVersionConsistency(localVersion);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 使用仓库版本 ${localVersion}(不再自动递增或自由指定版本)`,
|
||||
);
|
||||
return localVersion;
|
||||
}
|
||||
|
||||
export async function assertVersionNotBelowOss(version) {
|
||||
const remoteVersion = await readRemoteVersion();
|
||||
if (remoteVersion && compareVersions(version, remoteVersion) < 0) {
|
||||
throw new Error(
|
||||
`仓库版本 ${version} 低于线上 OSS 版本 ${remoteVersion}。请先运行 npm --prefix apps/ai-game-creator-shell run bump-version 提升版本后再发布。`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 线上 OSS 版本 ${remoteVersion ?? '不存在'},发布版本 ${version}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function runTauriBuild(args = []) {
|
||||
const noBundle = args.includes('--no-bundle');
|
||||
const hasTarget = args.includes('--target');
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
bumpVersion,
|
||||
compareVersions,
|
||||
createUpdateManifest,
|
||||
nextPatchVersion,
|
||||
selectReleaseArtifact,
|
||||
} from './build-release.mjs';
|
||||
|
||||
test('selects an explicit release artifact when configured', () => {
|
||||
const artifactPath = fileURLToPath(
|
||||
new URL('../package.json', import.meta.url),
|
||||
);
|
||||
const artifactPath = new URL('../package.json', import.meta.url).pathname;
|
||||
const previous = process.env.AGC_UPDATE_ARTIFACT;
|
||||
process.env.AGC_UPDATE_ARTIFACT = artifactPath;
|
||||
try {
|
||||
@@ -33,7 +30,7 @@ test('does not select unsupported files', () => {
|
||||
|
||||
test('manifest contains version, download URL and integrity fields', () => {
|
||||
const manifest = createUpdateManifest(
|
||||
fileURLToPath(new URL('../package.json', import.meta.url)),
|
||||
new URL('../package.json', import.meta.url).pathname,
|
||||
);
|
||||
assert.match(manifest.version, /^\d+\.\d+\.\d+$/u);
|
||||
assert.match(
|
||||
@@ -49,7 +46,7 @@ test('manifest preserves multiline release notes', () => {
|
||||
process.env.AGC_UPDATE_RELEASE_NOTES = '第一行\n第二行\r\n第三行';
|
||||
try {
|
||||
const manifest = createUpdateManifest(
|
||||
fileURLToPath(new URL('../package.json', import.meta.url)),
|
||||
new URL('../package.json', import.meta.url).pathname,
|
||||
);
|
||||
assert.equal(manifest.releaseNotes, '第一行\n第二行\r\n第三行');
|
||||
} finally {
|
||||
@@ -58,14 +55,11 @@ test('manifest preserves multiline release notes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('bump version follows the requested target', () => {
|
||||
test('next release version follows the higher local or OSS version', () => {
|
||||
assert.equal(compareVersions('0.1.15', '0.1.12'), 1);
|
||||
assert.equal(compareVersions('0.1.12', '0.1.12'), 0);
|
||||
assert.equal(bumpVersion('0.1.19', 'patch'), '0.1.20');
|
||||
assert.equal(bumpVersion('0.1.19'), '0.1.20');
|
||||
assert.equal(bumpVersion('0.1.19', 'minor'), '0.2.0');
|
||||
assert.equal(bumpVersion('0.1.19', 'major'), '1.0.0');
|
||||
assert.equal(bumpVersion('0.1.12', '0.1.25'), '0.1.25');
|
||||
assert.equal(nextPatchVersion('0.1.12', '0.1.15'), '0.1.16');
|
||||
assert.equal(nextPatchVersion('0.1.18', '0.1.15'), '0.1.19');
|
||||
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
|
||||
});
|
||||
|
||||
test('release upload forces overwrite for versioned artifact and latest pointer', () => {
|
||||
@@ -78,12 +72,3 @@ test('release upload forces overwrite for versioned artifact and latest pointer'
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test('release upload verifies the version is committed and not below OSS', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./release-upload.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /assertVersionCommitted\(\)/u);
|
||||
assert.match(source, /assertVersionNotBelowOss\(/u);
|
||||
});
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = path.resolve(appRoot, '../..');
|
||||
|
||||
const {
|
||||
bumpVersion,
|
||||
readLocalVersion,
|
||||
writeVersionFiles,
|
||||
} = await import('./build-release.mjs');
|
||||
|
||||
const versionFiles = [
|
||||
'apps/ai-game-creator-shell/package.json',
|
||||
'package-lock.json',
|
||||
'apps/ai-game-creator-shell/src-tauri/tauri.conf.json',
|
||||
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||||
'apps/ai-game-creator-shell/src-tauri/Cargo.lock',
|
||||
];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const explicitIndex = args.indexOf('--version');
|
||||
const explicit = explicitIndex >= 0 ? args[explicitIndex + 1] : null;
|
||||
const target =
|
||||
explicit ??
|
||||
args.find((arg) => arg === 'patch' || arg === 'minor' || arg === 'major') ??
|
||||
'patch';
|
||||
return { target, commit: args.includes('--commit') };
|
||||
}
|
||||
|
||||
function runGit(args) {
|
||||
const result = spawnSync('git', args, { cwd: repoRoot, stdio: 'inherit' });
|
||||
if (result.error) throw result.error;
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
function hasUncommittedVersionChanges() {
|
||||
return (
|
||||
spawnSync('git', ['diff', '--quiet', '--', ...versionFiles], {
|
||||
cwd: repoRoot,
|
||||
}).status === 1
|
||||
);
|
||||
}
|
||||
|
||||
function otherStagedFiles() {
|
||||
const result = spawnSync(
|
||||
'git',
|
||||
['diff', '--cached', '--name-only'],
|
||||
{ cwd: repoRoot, encoding: 'utf8' },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error('无法读取已暂存文件列表');
|
||||
}
|
||||
const versionSet = new Set(versionFiles);
|
||||
return result.stdout
|
||||
.split('\n')
|
||||
.map((file) => file.trim())
|
||||
.filter(Boolean)
|
||||
.filter((file) => !versionSet.has(file));
|
||||
}
|
||||
|
||||
const { target, commit } = parseArgs(process.argv);
|
||||
|
||||
// 提交前先确认没有其他已暂存改动,避免把无关改动一并提交;若存在则在改动任何文件前中止。
|
||||
if (commit) {
|
||||
const others = otherStagedFiles();
|
||||
if (others.length > 0) {
|
||||
console.error(
|
||||
`[bump-version] 检测到其他已暂存改动,为避免误提交,请先单独处理后再运行 --commit:\n ${others.join('\n ')}`,
|
||||
);
|
||||
console.error(
|
||||
'提示:用 git restore --staged <file> 取消暂存,或先提交这些改动。',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const current = readLocalVersion();
|
||||
|
||||
// 若版本文件已带着一次未提交的提升(例如先默认跑过一次),再 --commit 时不再重复递增,直接提交现有改动。
|
||||
const next =
|
||||
commit && hasUncommittedVersionChanges()
|
||||
? current
|
||||
: bumpVersion(current, target);
|
||||
|
||||
writeVersionFiles(next);
|
||||
|
||||
if (!commit) {
|
||||
console.log(
|
||||
`[bump-version] 版本 ${current} -> ${next}(已写入版本文件,未创建提交;如需提交加 --commit)`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const addStatus = runGit(['add', ...versionFiles]);
|
||||
if (addStatus !== 0) process.exit(addStatus);
|
||||
|
||||
const hasDiff =
|
||||
spawnSync('git', ['diff', '--cached', '--quiet'], {
|
||||
cwd: repoRoot,
|
||||
}).status === 1;
|
||||
|
||||
if (!hasDiff) {
|
||||
console.log(`[bump-version] 版本未变化(已是 ${current}),未创建提交`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const commitStatus = runGit([
|
||||
'commit',
|
||||
'-m',
|
||||
`提升 AGC 版本至 ${next}`,
|
||||
'-m',
|
||||
`- AGC 客户端版本提升至 ${next}`,
|
||||
'-m',
|
||||
'- 同步更新 package.json、根 package-lock.json、tauri.conf.json、Cargo.toml、Cargo.lock 中的 AGC 包条目',
|
||||
]);
|
||||
if (commitStatus !== 0) process.exit(commitStatus);
|
||||
|
||||
console.log(`[bump-version] 已提交版本 ${next}(本地提交,未推送)`);
|
||||
@@ -9,13 +9,8 @@ if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) {
|
||||
}
|
||||
process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`;
|
||||
|
||||
const {
|
||||
assertVersionCommitted,
|
||||
assertVersionNotBelowOss,
|
||||
generateUpdateManifest,
|
||||
prepareReleaseVersion,
|
||||
runTauriBuild,
|
||||
} = await import('./build-release.mjs');
|
||||
const { generateUpdateManifest, prepareReleaseVersion, runTauriBuild } =
|
||||
await import('./build-release.mjs');
|
||||
|
||||
function runOssutil(args) {
|
||||
const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil';
|
||||
@@ -41,9 +36,7 @@ function runOssutil(args) {
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
const releaseVersion = await prepareReleaseVersion();
|
||||
assertVersionCommitted();
|
||||
await assertVersionNotBelowOss(releaseVersion);
|
||||
await prepareReleaseVersion();
|
||||
runTauriBuild([]);
|
||||
const { artifact, manifestPath, manifest } = generateUpdateManifest();
|
||||
const artifactKey = `agc/${manifest.version}/${path.basename(artifact)}`;
|
||||
|
||||
+1
-1
@@ -1703,7 +1703,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.19"
|
||||
version = "0.1.12"
|
||||
dependencies = [
|
||||
"agent-runtime-core",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.19"
|
||||
version = "0.1.12"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -266,6 +266,7 @@ fn game_creator_codex_app_server_connection_error(
|
||||
) -> platform_llm::LlmError {
|
||||
match game_creator_codex_app_server_error_http_status(info, field) {
|
||||
Some(401 | 403) => game_creator_codex_app_server_error_kind("unauthorized"),
|
||||
Some(413) => game_creator_codex_app_server_error_kind("request-too-large"),
|
||||
Some(status_code) => platform_llm::LlmError::Upstream {
|
||||
status_code,
|
||||
message: "Codex app-server 连接上游失败".to_string(),
|
||||
@@ -303,6 +304,30 @@ fn game_creator_codex_app_server_error_detail_indicates_auth_failure(
|
||||
|| detail.contains("http 403")
|
||||
}
|
||||
|
||||
fn game_creator_codex_app_server_error_detail_indicates_request_too_large(
|
||||
error: &serde_json::Value,
|
||||
) -> bool {
|
||||
let Some(error) = error.as_object() else {
|
||||
return false;
|
||||
};
|
||||
let detail = ["message", "additionalDetails", "code"]
|
||||
.into_iter()
|
||||
.filter_map(|field| error.get(field).and_then(serde_json::Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.to_ascii_lowercase();
|
||||
if detail.is_empty() {
|
||||
return false;
|
||||
}
|
||||
detail.contains("413 payload too large")
|
||||
|| detail.contains("http 413")
|
||||
|| detail.contains("status 413")
|
||||
|| detail.contains("payload_too_large")
|
||||
|| detail.contains("payload too large")
|
||||
|| detail.contains("request too large")
|
||||
|| detail.contains("provider request too large")
|
||||
}
|
||||
|
||||
fn game_creator_codex_app_server_error_detail_indicates_insufficient_mud_points(
|
||||
error: &serde_json::Value,
|
||||
) -> bool {
|
||||
@@ -333,6 +358,9 @@ fn game_creator_codex_app_server_failed_turn_error(
|
||||
message: "泥点余额不足".to_string(),
|
||||
};
|
||||
}
|
||||
if game_creator_codex_app_server_error_detail_indicates_request_too_large(error) {
|
||||
return game_creator_codex_app_server_error_kind("request-too-large");
|
||||
}
|
||||
if game_creator_codex_app_server_error_detail_indicates_auth_failure(error) {
|
||||
return game_creator_codex_app_server_error_kind("unauthorized");
|
||||
}
|
||||
@@ -4787,6 +4815,12 @@ mod tests {
|
||||
"codex-app-server-error:unauthorized".to_string(),
|
||||
),
|
||||
),
|
||||
(
|
||||
serde_json::json!({"httpConnectionFailed":{"httpStatusCode":413}}),
|
||||
platform_llm::LlmError::InvalidRequest(
|
||||
"codex-app-server-error:request-too-large".to_string(),
|
||||
),
|
||||
),
|
||||
(
|
||||
serde_json::json!({"httpConnectionFailed":{"httpStatusCode":429}}),
|
||||
platform_llm::LlmError::Upstream {
|
||||
@@ -4847,6 +4881,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_app_server_failed_turn_maps_request_too_large_details() {
|
||||
for detail in [
|
||||
"HTTP 413 Payload Too Large",
|
||||
"status 413",
|
||||
"PAYLOAD_TOO_LARGE",
|
||||
"provider request too large",
|
||||
] {
|
||||
let error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": detail,
|
||||
"additionalDetails": "private upstream diagnostics",
|
||||
"codexErrorInfo": "other"
|
||||
}
|
||||
}));
|
||||
assert_eq!(
|
||||
error,
|
||||
platform_llm::LlmError::InvalidRequest(
|
||||
"codex-app-server-error:request-too-large".to_string(),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_app_server_failed_turn_maps_insufficient_mud_points_to_stable_upstream_error() {
|
||||
for detail in [
|
||||
|
||||
@@ -35,7 +35,9 @@ const SYSTEM_PROMPT: &str = r#"
|
||||
* 对每个 Component,直接完整返回其全部参数.
|
||||
* 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。
|
||||
* 纯结构节点可以返回空数组并标为 NoProblem。
|
||||
* 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致
|
||||
* 面向用户的 reason 使用中文。
|
||||
|
||||
"#;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Genarrative AI Game Creator",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.12",
|
||||
"identifier": "world.genarrative.ai-game-creator",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm --prefix ../.. run agc:serve",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ackClientErrorEventsWithRetry,
|
||||
type ClientErrorEvent,
|
||||
type DiagnosticLogFile,
|
||||
getPendingClientErrorEvents,
|
||||
getStableErrorReportSubmissionId,
|
||||
readApplicationDiagnosticLogs,
|
||||
submitErrorReportBatch,
|
||||
@@ -12,10 +13,12 @@ import { ThemedModal } from '../modal/ThemedModal';
|
||||
|
||||
type ErrorReportDialogProps = {
|
||||
open: boolean;
|
||||
events: ClientErrorEvent[];
|
||||
onClose: () => void;
|
||||
fallbackEvents?: ClientErrorEvent[];
|
||||
};
|
||||
|
||||
const emptyFallbackEvents: ClientErrorEvent[] = [];
|
||||
|
||||
function diagnosticLogLabel(
|
||||
logsReady: boolean,
|
||||
logsError: boolean,
|
||||
@@ -28,9 +31,12 @@ function diagnosticLogLabel(
|
||||
|
||||
export function ErrorReportDialog({
|
||||
open,
|
||||
events,
|
||||
onClose,
|
||||
fallbackEvents = emptyFallbackEvents,
|
||||
}: ErrorReportDialogProps) {
|
||||
const [events, setEvents] = useState<ClientErrorEvent[]>([]);
|
||||
const [eventsReady, setEventsReady] = useState(false);
|
||||
const [eventsError, setEventsError] = useState(false);
|
||||
const [selectedFingerprints, setSelectedFingerprints] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
@@ -45,29 +51,59 @@ export function ErrorReportDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedFingerprints(
|
||||
new Set(events.slice(0, 1).map((event) => event.fingerprint)),
|
||||
);
|
||||
let disposed = false;
|
||||
setEvents([]);
|
||||
setEventsReady(false);
|
||||
setEventsError(false);
|
||||
setSelectedFingerprints(new Set());
|
||||
setStatus('');
|
||||
setLogsReady(false);
|
||||
setLogsError(false);
|
||||
void getPendingClientErrorEvents()
|
||||
.then((nextEvents) => {
|
||||
if (disposed) return;
|
||||
setEvents(nextEvents);
|
||||
setSelectedFingerprints(
|
||||
new Set(nextEvents.map((event) => event.fingerprint)),
|
||||
);
|
||||
setEventsReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (disposed) return;
|
||||
if (fallbackEvents.length) {
|
||||
setEvents(fallbackEvents);
|
||||
setSelectedFingerprints(
|
||||
new Set(fallbackEvents.map((event) => event.fingerprint)),
|
||||
);
|
||||
setEventsReady(true);
|
||||
setStatus('最新错误读取失败,已使用打开通知时的快照');
|
||||
return;
|
||||
}
|
||||
setEventsError(true);
|
||||
setEventsReady(true);
|
||||
});
|
||||
void readApplicationDiagnosticLogs()
|
||||
.then((nextLogs) => {
|
||||
if (disposed) return;
|
||||
setLogs(nextLogs);
|
||||
setLogsReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (disposed) return;
|
||||
setLogs([]);
|
||||
setLogsError(true);
|
||||
setLogsReady(true);
|
||||
});
|
||||
}, [events, open]);
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [fallbackEvents, open]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
const selectedEvents = events.filter((event) =>
|
||||
selectedFingerprints.has(event.fingerprint),
|
||||
);
|
||||
if (!selectedEvents.length || busy || !logsReady) return;
|
||||
if (!selectedEvents.length || busy || !eventsReady || !logsReady) return;
|
||||
if (includeLogs && logsError) return;
|
||||
setBusy(true);
|
||||
setStatus('正在提交…');
|
||||
@@ -92,6 +128,7 @@ export function ErrorReportDialog({
|
||||
}, [
|
||||
busy,
|
||||
description,
|
||||
eventsReady,
|
||||
events,
|
||||
includeLogs,
|
||||
logs,
|
||||
@@ -124,7 +161,11 @@ export function ErrorReportDialog({
|
||||
<div className="my-5 grid gap-4">
|
||||
<section>
|
||||
<strong>错误事件({events.length})</strong>
|
||||
{events.length ? (
|
||||
{!eventsReady && <p>正在读取当前错误…</p>}
|
||||
{eventsReady && eventsError && (
|
||||
<p role="alert">错误事件暂不可用,请关闭后重试。</p>
|
||||
)}
|
||||
{eventsReady && !eventsError && events.length > 0 && (
|
||||
<ul className="m-0 mt-2 grid list-none gap-2 p-0">
|
||||
{events.map((event) => (
|
||||
<li
|
||||
@@ -154,7 +195,8 @@ export function ErrorReportDialog({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
)}
|
||||
{eventsReady && !eventsError && !events.length && (
|
||||
<p>当前没有待报告的错误。</p>
|
||||
)}
|
||||
</section>
|
||||
@@ -191,11 +233,12 @@ export function ErrorReportDialog({
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="cursor-pointer rounded-[9px] border-0 bg-[var(--platform-button-primary-fill)] px-3.5 py-[9px] text-[var(--platform-button-primary-text)]"
|
||||
className="cursor-pointer rounded-[9px] border border-(--platform-button-primary-border) bg-(image:--platform-button-primary-fill) px-3.5 py-[9px] text-(--platform-button-primary-text)"
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={
|
||||
busy ||
|
||||
!eventsReady ||
|
||||
!selectedFingerprints.size ||
|
||||
!logsReady ||
|
||||
(includeLogs && logsError)
|
||||
|
||||
@@ -12,7 +12,10 @@ export function ErrorReportNotice() {
|
||||
const [notificationEvents, setNotificationEvents] = useState<
|
||||
ClientErrorEvent[]
|
||||
>([]);
|
||||
const [reportEvents, setReportEvents] = useState<ClientErrorEvent[]>([]);
|
||||
const [reportFallbackEvents, setReportFallbackEvents] = useState<
|
||||
ClientErrorEvent[]
|
||||
>([]);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
|
||||
const requestId = useRef(0);
|
||||
const retryTimer = useRef<number | null>(null);
|
||||
@@ -65,31 +68,37 @@ export function ErrorReportNotice() {
|
||||
}, [loadEvents]);
|
||||
|
||||
const openReport = () => {
|
||||
setReportEvents(notificationEvents);
|
||||
setReportFallbackEvents(notificationEvents);
|
||||
setReportOpen(true);
|
||||
setNotificationEvents([]);
|
||||
};
|
||||
|
||||
const closeReport = () => {
|
||||
setReportOpen(false);
|
||||
setReportFallbackEvents([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{notificationEvents.length ? (
|
||||
<aside
|
||||
className="fixed top-[calc(var(--window-chrome-height)+14px)] right-4 z-40 flex max-w-[min(420px,calc(100vw-2rem))] items-center gap-2.5 rounded-xl border border-[var(--platform-surface-border)] bg-[var(--platform-subpanel-fill)] px-3.5 py-3 text-[var(--platform-text-strong)] shadow-[0_12px_30px_rgb(36_20_12_/_18%)] max-[560px]:top-[calc(var(--window-chrome-height)+8px)] max-[560px]:right-2 max-[560px]:left-2 max-[560px]:max-w-none"
|
||||
className="fixed top-[calc(var(--window-chrome-height)+12px)] right-4 z-40 flex max-w-[min(520px,calc(100vw-2rem))] items-center gap-4 rounded-xl border border-[#efc9ae] bg-[#fffaf5] px-3.5 py-3 shadow-[0_8px_24px_rgb(100_49_26_/_16%)]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="flex-[1_1_auto]">
|
||||
发现 {notificationEvents.length} 个问题
|
||||
</span>
|
||||
<div className="grid min-w-0 gap-[3px]">
|
||||
<strong className="text-[13px] text-[#4a220f]">发现问题</strong>
|
||||
</div>
|
||||
<button
|
||||
className="min-h-9 cursor-pointer rounded-lg border-0 bg-[var(--platform-button-primary-fill)] px-2.5 text-[var(--platform-button-primary-text)] last:w-9 last:bg-transparent last:px-0 last:text-xl last:text-[var(--platform-text-soft)]"
|
||||
className="flex-[0_0_auto] cursor-pointer rounded-lg border-0 bg-[#c7653d] px-3 py-[7px] text-[12px] font-bold text-white disabled:cursor-wait disabled:opacity-[0.65]"
|
||||
type="button"
|
||||
onClick={openReport}
|
||||
>
|
||||
查看并报告
|
||||
</button>
|
||||
<button
|
||||
className="min-h-9 cursor-pointer rounded-lg border-0 bg-[var(--platform-button-primary-fill)] px-2.5 text-[var(--platform-button-primary-text)] last:w-9 last:bg-transparent last:px-0 last:text-xl last:text-[var(--platform-text-soft)]"
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 flex-[0_0_auto] cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 text-[#8d6a58] hover:bg-[rgb(199_101_61_/_12%)] hover:text-[#4a220f] disabled:cursor-wait disabled:opacity-50"
|
||||
onClick={() => {
|
||||
const dismissed = notificationEvents;
|
||||
setNotificationEvents([]);
|
||||
@@ -102,9 +111,9 @@ export function ErrorReportNotice() {
|
||||
</aside>
|
||||
) : null}
|
||||
<ErrorReportDialog
|
||||
open={reportEvents.length > 0}
|
||||
events={reportEvents}
|
||||
onClose={() => setReportEvents([])}
|
||||
open={reportOpen}
|
||||
fallbackEvents={reportFallbackEvents}
|
||||
onClose={closeReport}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1951,6 +1951,7 @@ export function projectRuntimeVisibleError(
|
||||
'session-budget-exceeded': '本次会话预算已耗尽,请缩小任务范围或新建任务',
|
||||
'usage-limit-exceeded': '智能创作用量已达上限,请检查账户额度后重试',
|
||||
unauthorized: '智能服务鉴权失败,请重新登录后重试',
|
||||
'request-too-large': '模型请求体过大,请减少参考图或上下文后重试',
|
||||
'bad-request': '智能创作请求无效,请稍后重试',
|
||||
'cyber-policy': '智能创作安全策略拒绝了本次请求,请调整任务内容',
|
||||
'sandbox-error': '智能创作隔离环境启动失败,请重试或检查本机环境',
|
||||
@@ -1972,6 +1973,7 @@ export function projectRuntimeVisibleError(
|
||||
'session-budget-exceeded': '本次会话预算已耗尽,请缩小任务范围或新建任务',
|
||||
'usage-limit-exceeded': '用量已达上限,请检查账户额度后重试',
|
||||
unauthorized: '鉴权失败,请重新登录后重试',
|
||||
'request-too-large': '模型请求体过大,请减少参考图或上下文后重试',
|
||||
'bad-request': '请求无效,请稍后重试',
|
||||
'cyber-policy': '安全策略拒绝了本次请求,请调整任务内容',
|
||||
'sandbox-error': '工作区隔离启动失败,请检查项目目录后重试',
|
||||
|
||||
@@ -14,3 +14,20 @@ export function collectUiNodeIds(root: UiNode): Set<NodeId> {
|
||||
visitUiNodes(root, (node) => ids.add(node.id));
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function findUiNode(root: UiNode, nodeId: NodeId): UiNode | null {
|
||||
if (root.id === nodeId) return root;
|
||||
for (const child of root.children) {
|
||||
const found = findUiNode(child, nodeId);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function countUiNodeDescendants(root: UiNode): number {
|
||||
let count = 0;
|
||||
for (const child of root.children) {
|
||||
count += 1 + countUiNodeDescendants(child);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ThemedModal } from '../../../components/modal/ThemedModal';
|
||||
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
|
||||
|
||||
export type UiNodeDeleteRequest = {
|
||||
nodeId: NodeId;
|
||||
nodeLabel: string;
|
||||
descendantCount: number;
|
||||
};
|
||||
|
||||
type UiNodeDeleteConfirmModalProps = {
|
||||
request: UiNodeDeleteRequest | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: (nodeId: NodeId) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function UiNodeDeleteConfirmModal({
|
||||
request,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
disabled = false,
|
||||
}: UiNodeDeleteConfirmModalProps) {
|
||||
return (
|
||||
<ThemedModal
|
||||
open={request !== null}
|
||||
onClose={onCancel}
|
||||
ariaLabel="确认删除节点"
|
||||
panelClassName="w-[420px] rounded-2xl p-5"
|
||||
>
|
||||
<h2 className="m-0 text-base font-semibold">确认删除节点?</h2>
|
||||
<p className="text-sm leading-6 text-(--platform-text-soft)">
|
||||
将删除「{request?.nodeLabel ?? ''}」及其 {request?.descendantCount ?? 0}{' '}
|
||||
个后代节点,是否继续?
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (request) onConfirm(request.nodeId);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ChevronDown, Eye, EyeOff } from 'lucide-react';
|
||||
import { ChevronDown, Eye, EyeOff, Puzzle, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -9,16 +12,25 @@ import {
|
||||
adjustMoveIndex,
|
||||
type MoveHandler,
|
||||
type NodeRendererProps,
|
||||
type RowRendererProps,
|
||||
Tree,
|
||||
type TreeApi,
|
||||
} from 'react-arborist';
|
||||
|
||||
import {
|
||||
countUiNodeDescendants,
|
||||
findUiNode,
|
||||
} from '../../../features/ui-editor/treeUtils';
|
||||
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
|
||||
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
|
||||
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
|
||||
import type { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest';
|
||||
import type { UiEditorNodeFocusRequest } from '../model';
|
||||
import { UiNodeContextMenu } from './UiNodeContextMenu';
|
||||
import {
|
||||
UiNodeDeleteConfirmModal,
|
||||
type UiNodeDeleteRequest,
|
||||
} from './UiNodeDeleteConfirmModal';
|
||||
|
||||
type UiTreePanelProps = {
|
||||
root: UiNode | null;
|
||||
@@ -36,6 +48,38 @@ type UiTreePanelProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const TreeListOuter = forwardRef<
|
||||
HTMLDivElement,
|
||||
HTMLAttributes<HTMLDivElement>
|
||||
>(function TreeListOuter({ style, ...props }, ref) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={ref}
|
||||
style={{ ...style, overflowX: 'visible', overflowY: 'auto' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function TreeRowContainer<T>({
|
||||
node,
|
||||
innerRef,
|
||||
attrs,
|
||||
children,
|
||||
}: RowRendererProps<T>) {
|
||||
return (
|
||||
<div
|
||||
{...attrs}
|
||||
ref={innerRef}
|
||||
style={{ ...attrs.style, minWidth: 0 }}
|
||||
onFocus={(event) => event.stopPropagation()}
|
||||
onClick={node.handleClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeRow({
|
||||
node,
|
||||
style,
|
||||
@@ -44,11 +88,17 @@ function TreeRow({
|
||||
isNodeVisible,
|
||||
onToggleNodeVisibility,
|
||||
onOpenContextMenu,
|
||||
onRequestDelete,
|
||||
canDelete,
|
||||
deleteDisabled,
|
||||
}: NodeRendererProps<UiNode> & {
|
||||
onSelectNode: (id: NodeId) => void;
|
||||
isNodeVisible: (nodeId: NodeId) => boolean;
|
||||
onToggleNodeVisibility: (id: NodeId) => void;
|
||||
onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void;
|
||||
onRequestDelete: (id: NodeId) => void;
|
||||
canDelete: boolean;
|
||||
deleteDisabled: boolean;
|
||||
}) {
|
||||
const data = node.data;
|
||||
const nodeLabel = data.metadata.name || '未命名节点';
|
||||
@@ -56,8 +106,8 @@ function TreeRow({
|
||||
return (
|
||||
<div
|
||||
ref={dragHandle}
|
||||
style={style}
|
||||
className={`flex min-w-0 items-center gap-1 rounded-lg px-2 text-xs text-(--platform-text-strong) ${
|
||||
style={{ ...style, width: '100%', height: '100%', minWidth: 0 }}
|
||||
className={`relative flex min-w-0 items-center gap-1 rounded-lg px-2 pr-[68px] text-xs text-(--platform-text-strong) ${
|
||||
node.isSelected ? 'bg-orange-100 text-orange-900' : 'hover:bg-black/5'
|
||||
} ${isVisible ? '' : 'opacity-60'}`}
|
||||
onClick={() => {
|
||||
@@ -88,23 +138,53 @@ function TreeRow({
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 truncate">{nodeLabel}</span>
|
||||
<span className="text-[10px] text-(--platform-text-soft)">
|
||||
{data.components.length}
|
||||
<span className="shrink-0 whitespace-nowrap" title={nodeLabel}>
|
||||
{nodeLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}”`}
|
||||
title={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}”`}
|
||||
className="grid size-6 shrink-0 place-items-center rounded hover:bg-black/5"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleNodeVisibility(data.id);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-0.5 rounded-md bg-black/5 px-1 py-0.5 text-[10px] leading-none text-(--platform-text-soft)"
|
||||
title={`${data.components.length} 个组件`}
|
||||
aria-label={`${data.components.length} 个组件`}
|
||||
>
|
||||
{isVisible ? <Eye size={13} /> : <EyeOff size={13} />}
|
||||
</button>
|
||||
<Puzzle
|
||||
size={11}
|
||||
className="text-orange-600"
|
||||
fill="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{data.components.length}</span>
|
||||
</span>
|
||||
<span className="absolute inset-y-0 right-2 flex w-[52px] items-center justify-end gap-1 bg-inherit">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}”`}
|
||||
title={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}”`}
|
||||
className="grid size-6 shrink-0 place-items-center rounded hover:bg-black/5"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleNodeVisibility(data.id);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{isVisible ? <Eye size={13} /> : <EyeOff size={13} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除节点“${nodeLabel}”`}
|
||||
title={canDelete ? `删除节点“${nodeLabel}”` : '页面根节点不可删除'}
|
||||
disabled={!canDelete || deleteDisabled}
|
||||
aria-disabled={!canDelete || deleteDisabled}
|
||||
className="grid size-6 shrink-0 place-items-center rounded text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRequestDelete(data.id);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -127,11 +207,15 @@ export function UiTreePanel({
|
||||
const treeApiRef = useRef<TreeApi<UiNode> | undefined>(undefined);
|
||||
const treeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [treeHeight, setTreeHeight] = useState(0);
|
||||
const [treeWidth, setTreeWidth] = useState(0);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
nodeId: NodeId;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<
|
||||
(UiNodeDeleteRequest & { treeId: UIDesignImageId }) | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusRequest) return;
|
||||
@@ -150,6 +234,41 @@ export function UiTreePanel({
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
const treeRows = useMemo(() => {
|
||||
if (!root) return [];
|
||||
const rows: Array<{ node: UiNode; depth: number }> = [];
|
||||
const visit = (node: UiNode, depth: number) => {
|
||||
rows.push({ node, depth });
|
||||
node.children.forEach((child) => visit(child, depth + 1));
|
||||
};
|
||||
visit(root, 0);
|
||||
return rows;
|
||||
}, [root]);
|
||||
|
||||
const treeContentWidth = useMemo(
|
||||
() =>
|
||||
treeRows.reduce((width, { node, depth }) => {
|
||||
const labelWidth = Math.max(
|
||||
56,
|
||||
(node.metadata.name || '未命名节点').length * 8,
|
||||
);
|
||||
return Math.max(width, 16 + depth * 16 + 24 + labelWidth + 42 + 52);
|
||||
}, 0),
|
||||
[treeRows],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = treeContainerRef.current;
|
||||
if (!container) return;
|
||||
const updateWidth = () => {
|
||||
setTreeWidth(Math.max(treeContentWidth, container.clientWidth));
|
||||
};
|
||||
updateWidth();
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
resizeObserver.observe(container);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [treeContentWidth]);
|
||||
|
||||
const handleMove: MoveHandler<UiNode> = ({
|
||||
dragIds,
|
||||
parentId,
|
||||
@@ -184,21 +303,55 @@ export function UiTreePanel({
|
||||
const pageRootIds = new Set(root?.children.map((child) => child.id) ?? []);
|
||||
const contextTreeId = contextMenu ? treeIdForNode(contextMenu.nodeId) : null;
|
||||
|
||||
const requestDelete = (nodeId: NodeId) => {
|
||||
if (!root || isLocked || nodeId === root.id || pageRootIds.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
const treeId = treeIdForNode(nodeId);
|
||||
const node = findUiNode(root, nodeId);
|
||||
if (!treeId || !node) return;
|
||||
const descendantCount = countUiNodeDescendants(node);
|
||||
if (descendantCount === 0) {
|
||||
onDeleteNode(treeId, nodeId);
|
||||
return;
|
||||
}
|
||||
setPendingDelete({
|
||||
treeId,
|
||||
nodeId,
|
||||
nodeLabel: node.metadata.name || '未命名节点',
|
||||
descendantCount,
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDelete = (nodeId: NodeId) => {
|
||||
if (!pendingDelete || isLocked || pendingDelete.nodeId !== nodeId) {
|
||||
return;
|
||||
}
|
||||
const { treeId } = pendingDelete;
|
||||
setPendingDelete(null);
|
||||
onDeleteNode(treeId, nodeId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex h-full min-h-0 flex-col ${className ?? ''}`}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<div ref={treeContainerRef} className="min-h-0 flex-1">
|
||||
<div
|
||||
ref={treeContainerRef}
|
||||
className="relative min-h-0 flex-1 overflow-x-auto overflow-y-hidden"
|
||||
>
|
||||
{root && treeHeight > 0 ? (
|
||||
<Tree<UiNode>
|
||||
ref={treeApiRef}
|
||||
data={[root]}
|
||||
width="100%"
|
||||
width={treeWidth > 0 ? treeWidth : '100%'}
|
||||
outerElementType={TreeListOuter}
|
||||
height={treeHeight}
|
||||
rowHeight={34}
|
||||
indent={16}
|
||||
openByDefault
|
||||
renderRow={TreeRowContainer}
|
||||
selection={selectedNodeId ?? undefined}
|
||||
onSelect={(nodes) => {
|
||||
const selected = nodes[0]?.data;
|
||||
@@ -224,6 +377,12 @@ export function UiTreePanel({
|
||||
}}
|
||||
isNodeVisible={isNodePreviewVisible}
|
||||
onToggleNodeVisibility={onToggleNodeVisibility}
|
||||
onRequestDelete={requestDelete}
|
||||
canDelete={
|
||||
props.node.data.id !== root.id &&
|
||||
!pageRootIds.has(props.node.data.id)
|
||||
}
|
||||
deleteDisabled={isLocked}
|
||||
onOpenContextMenu={(event, nodeId) =>
|
||||
nodeId === root.id
|
||||
? undefined
|
||||
@@ -252,9 +411,15 @@ export function UiTreePanel({
|
||||
onClose={() => setContextMenu(null)}
|
||||
onInsertChild={(nodeId) => onInsertNode(contextTreeId, nodeId)}
|
||||
onInsertSibling={(nodeId) => onInsertNodeAfter(contextTreeId, nodeId)}
|
||||
onDelete={(nodeId) => onDeleteNode(contextTreeId, nodeId)}
|
||||
onDelete={requestDelete}
|
||||
/>
|
||||
) : null}
|
||||
<UiNodeDeleteConfirmModal
|
||||
request={pendingDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={confirmDelete}
|
||||
disabled={isLocked}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ThemedModal } from '../../../components/modal/ThemedModal';
|
||||
import type { WorkflowCompletionNotice } from './workflowCompletionNotice';
|
||||
import { workflowStepLabel } from './workflowCompletionNotice';
|
||||
|
||||
export function WorkflowCompletionModal({
|
||||
notice,
|
||||
onClose,
|
||||
}: {
|
||||
notice: WorkflowCompletionNotice | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!notice) return null;
|
||||
const stepLabel = workflowStepLabel(notice.step);
|
||||
const outcomeLabel = notice.outcome === 'success' ? '完成' : '失败';
|
||||
return (
|
||||
<ThemedModal
|
||||
open
|
||||
onClose={onClose}
|
||||
ariaLabel={`${stepLabel}${outcomeLabel}`}
|
||||
panelClassName="w-[min(440px,calc(100vw-2rem))] rounded-2xl p-5"
|
||||
>
|
||||
<h2 className="m-0 text-base font-semibold">
|
||||
{stepLabel}
|
||||
{outcomeLabel}
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-(--platform-text-soft)">
|
||||
{notice.message}
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
+116
-35
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
CANVAS_ZOOM_IN_FACTOR,
|
||||
CANVAS_ZOOM_OUT_FACTOR,
|
||||
type CanvasViewport,
|
||||
createPanDragState,
|
||||
type DragState,
|
||||
@@ -28,8 +30,14 @@ import type { Node } from '../../../../features/ui-editor/types/Node';
|
||||
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
|
||||
import type { UiEditorCanvasProjection } from '../../useUiEditorPage';
|
||||
import { UiNodeContextMenu } from '../UiNodeContextMenu';
|
||||
import {
|
||||
handlePreviewZoomKeyDown,
|
||||
isPreviewZoomInteractiveTarget,
|
||||
previewZoomUsesMetaModifier,
|
||||
} from './previewZoomKeyboard';
|
||||
import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
|
||||
import { useNodeTransformInteraction } from './useNodeTransformInteraction';
|
||||
import { ZoomPercentageInput } from './ZoomPercentageInput';
|
||||
|
||||
export function PreviewWorkspace({
|
||||
canvas,
|
||||
@@ -41,6 +49,8 @@ export function PreviewWorkspace({
|
||||
const viewportRef = useRef<CanvasViewport>({ x: 0, y: 0, scale: 0.5 });
|
||||
const handledFocusRequestIdRef = useRef<number | null>(null);
|
||||
const panRef = useRef<Extract<DragState, { kind: 'pan' }> | null>(null);
|
||||
const previewFocusedRef = useRef(false);
|
||||
const previewHoveredRef = useRef(false);
|
||||
const [viewport, setViewportState] = useState<CanvasViewport>(
|
||||
viewportRef.current,
|
||||
);
|
||||
@@ -150,6 +160,34 @@ export function PreviewWorkspace({
|
||||
);
|
||||
}, [logicalSize, setViewport]);
|
||||
|
||||
const scaleViewportFromCenter = useCallback(
|
||||
(nextScale: number) => {
|
||||
const element = viewportElementRef.current;
|
||||
const width = element?.clientWidth || canvasSize.width;
|
||||
const height = element?.clientHeight || canvasSize.height;
|
||||
setViewport(
|
||||
scaleViewportFromScreenPoint({
|
||||
viewport: viewportRef.current,
|
||||
nextScale,
|
||||
screenPoint: { x: width / 2, y: height / 2 },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[canvasSize.height, canvasSize.width, setViewport],
|
||||
);
|
||||
|
||||
const resetToActualSize = useCallback(() => {
|
||||
scaleViewportFromCenter(1);
|
||||
}, [scaleViewportFromCenter]);
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_IN_FACTOR);
|
||||
}, [scaleViewportFromCenter]);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_OUT_FACTOR);
|
||||
}, [scaleViewportFromCenter]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = viewportElementRef.current;
|
||||
if (!element) return;
|
||||
@@ -224,6 +262,9 @@ export function PreviewWorkspace({
|
||||
),
|
||||
);
|
||||
};
|
||||
const usesMetaModifier = previewZoomUsesMetaModifier(
|
||||
window.navigator.platform,
|
||||
);
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.code === 'Space' &&
|
||||
@@ -232,23 +273,21 @@ export function PreviewWorkspace({
|
||||
) {
|
||||
setSpaceHeld(true);
|
||||
}
|
||||
if (!event.ctrlKey && !event.metaKey) return;
|
||||
if (event.key === '0') {
|
||||
event.preventDefault();
|
||||
fitToCanvas();
|
||||
} else if (event.key === '1' && logicalSize) {
|
||||
event.preventDefault();
|
||||
const element = viewportElementRef.current;
|
||||
const width = element?.clientWidth ?? 900;
|
||||
const height = element?.clientHeight ?? 640;
|
||||
setViewport(
|
||||
scaleViewportFromScreenPoint({
|
||||
viewport: viewportRef.current,
|
||||
nextScale: 1,
|
||||
screenPoint: { x: width / 2, y: height / 2 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
handlePreviewZoomKeyDown(
|
||||
event,
|
||||
{
|
||||
hasZoomableViewport: logicalSize !== null,
|
||||
isFocused: previewFocusedRef.current,
|
||||
isHovered: previewHoveredRef.current,
|
||||
usesMetaModifier,
|
||||
},
|
||||
{
|
||||
fit: fitToCanvas,
|
||||
resetToActualSize,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
},
|
||||
);
|
||||
};
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.code === 'Space') setSpaceHeld(false);
|
||||
@@ -262,9 +301,12 @@ export function PreviewWorkspace({
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', onWindowBlur);
|
||||
};
|
||||
}, [fitToCanvas, logicalSize, setViewport]);
|
||||
}, [fitToCanvas, logicalSize, resetToActualSize, zoomIn, zoomOut]);
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button === 0 && !isPreviewZoomInteractiveTarget(event.target)) {
|
||||
event.currentTarget.focus({ preventScroll: true });
|
||||
}
|
||||
if (event.button === 1 || (event.button === 0 && spaceHeld)) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
@@ -371,7 +413,27 @@ export function PreviewWorkspace({
|
||||
</div>
|
||||
<SharedCanvasViewport
|
||||
ref={viewportElementRef}
|
||||
className="size-full"
|
||||
className="size-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-orange-400"
|
||||
role="region"
|
||||
aria-label="UI 预览画布"
|
||||
tabIndex={0}
|
||||
onPointerEnter={() => {
|
||||
previewHoveredRef.current = true;
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
previewHoveredRef.current = false;
|
||||
}}
|
||||
onFocus={() => {
|
||||
previewFocusedRef.current = true;
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (
|
||||
!(event.relatedTarget instanceof Node) ||
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
previewFocusedRef.current = false;
|
||||
}
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
@@ -448,21 +510,20 @@ export function PreviewWorkspace({
|
||||
<ZoomControls
|
||||
viewport={viewport}
|
||||
onFit={fitToCanvas}
|
||||
onScaleFromCenter={(nextScale) => {
|
||||
const element = viewportElementRef.current;
|
||||
const width = element?.clientWidth ?? canvasSize.width;
|
||||
const height = element?.clientHeight ?? canvasSize.height;
|
||||
setViewport(
|
||||
scaleViewportFromScreenPoint({
|
||||
viewport: viewportRef.current,
|
||||
nextScale,
|
||||
screenPoint: { x: width / 2, y: height / 2 },
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onScaleFromCenter={scaleViewportFromCenter}
|
||||
>
|
||||
{(actions) => (
|
||||
<div className="absolute bottom-3 right-3 z-10 flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) bg-white/90 p-1 shadow-lg">
|
||||
<div
|
||||
className="absolute bottom-3 right-3 z-10 flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) bg-white/90 p-1 shadow-lg"
|
||||
onMouseDown={(event) => {
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest('button')
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-8 place-items-center rounded hover:bg-black/5"
|
||||
@@ -471,13 +532,33 @@ export function PreviewWorkspace({
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={25}
|
||||
max={200}
|
||||
step={5}
|
||||
value={Math.round(
|
||||
Number.parseInt(actions.displayPercent, 10),
|
||||
)}
|
||||
aria-label="画布缩放"
|
||||
className="h-1 w-28 accent-orange-500"
|
||||
onChange={(event) =>
|
||||
actions.zoomToDisplayScale(Number(event.target.value) / 100)
|
||||
}
|
||||
/>
|
||||
<ZoomPercentageInput
|
||||
displayPercent={actions.displayPercent}
|
||||
onCommit={(percent) =>
|
||||
actions.zoomToDisplayScale(percent / 100)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-14 rounded px-2 py-1 text-[10px] tabular-nums hover:bg-black/5"
|
||||
aria-label={`适配画布,当前 ${actions.displayPercent}`}
|
||||
className="rounded px-2 py-1 text-[10px] hover:bg-black/5"
|
||||
aria-label="适配画布"
|
||||
onClick={actions.fit}
|
||||
>
|
||||
{actions.displayPercent}
|
||||
适配
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -90,7 +90,7 @@ function RenderNode({
|
||||
selectedNodeId,
|
||||
resources,
|
||||
onSelectNode,
|
||||
consumeNodeClick = () => false,
|
||||
consumeNodeClick,
|
||||
onNodeContextMenu,
|
||||
onNodePointerDown,
|
||||
onNodePointerMove,
|
||||
@@ -107,6 +107,8 @@ function RenderNode({
|
||||
isRoot?: boolean;
|
||||
parentContainer?: UiNode['layout']['container'];
|
||||
}) {
|
||||
const hasClickGestureConsumer = consumeNodeClick !== undefined;
|
||||
const consumeClick = consumeNodeClick ?? (() => false);
|
||||
const activePreviewTransforms = previewTransforms ?? EMPTY_PREVIEW_TRANSFORMS;
|
||||
if (hiddenNodeIds.has(node.id)) return null;
|
||||
const previewTransform = activePreviewTransforms.get(node.id);
|
||||
@@ -158,11 +160,12 @@ function RenderNode({
|
||||
hasDirectPointerGesture
|
||||
? (event) => {
|
||||
event.stopPropagation();
|
||||
consumeNodeClick();
|
||||
if (consumeClick()) return;
|
||||
if (hasClickGestureConsumer) onSelectNode(node.id);
|
||||
}
|
||||
: (event) => {
|
||||
event.stopPropagation();
|
||||
if (consumeNodeClick()) return;
|
||||
if (consumeClick()) return;
|
||||
onSelectNode(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const MIN_ZOOM_PERCENT = 25;
|
||||
const MAX_ZOOM_PERCENT = 200;
|
||||
|
||||
function displayPercentToValue(displayPercent: string) {
|
||||
return displayPercent.replace('%', '');
|
||||
}
|
||||
|
||||
export function ZoomPercentageInput({
|
||||
displayPercent,
|
||||
onCommit,
|
||||
}: {
|
||||
displayPercent: string;
|
||||
onCommit: (percent: number) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() =>
|
||||
displayPercentToValue(displayPercent),
|
||||
);
|
||||
const draftRef = useRef(draft);
|
||||
const isEditingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditingRef.current) {
|
||||
setDraft(displayPercentToValue(displayPercent));
|
||||
draftRef.current = displayPercentToValue(displayPercent);
|
||||
}
|
||||
}, [displayPercent]);
|
||||
|
||||
const commit = () => {
|
||||
const currentPercent = Number.parseFloat(
|
||||
displayPercentToValue(displayPercent),
|
||||
);
|
||||
const parsed = Number.parseFloat(draftRef.current);
|
||||
const nextPercent = Number.isFinite(parsed)
|
||||
? Math.min(MAX_ZOOM_PERCENT, Math.max(MIN_ZOOM_PERCENT, parsed))
|
||||
: currentPercent;
|
||||
setDraft(String(nextPercent));
|
||||
draftRef.current = String(nextPercent);
|
||||
isEditingRef.current = false;
|
||||
if (nextPercent !== currentPercent) {
|
||||
onCommit(nextPercent);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="flex min-w-14 items-center justify-center rounded px-1 py-1 hover:bg-black/5">
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_ZOOM_PERCENT}
|
||||
max={MAX_ZOOM_PERCENT}
|
||||
step={5}
|
||||
inputMode="numeric"
|
||||
value={draft}
|
||||
aria-label={`画布缩放,当前 ${displayPercent}`}
|
||||
className="w-10 bg-transparent text-center text-[10px] tabular-nums outline-none"
|
||||
onFocus={() => {
|
||||
isEditingRef.current = true;
|
||||
const nextDraft = displayPercentToValue(displayPercent);
|
||||
setDraft(nextDraft);
|
||||
draftRef.current = nextDraft;
|
||||
}}
|
||||
onChange={(event) => {
|
||||
draftRef.current = event.target.value;
|
||||
setDraft(event.target.value);
|
||||
}}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span aria-hidden="true" className="text-[10px] tabular-nums">
|
||||
%
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
export type PreviewZoomShortcut =
|
||||
| 'fit'
|
||||
| 'actual-size'
|
||||
| 'zoom-in'
|
||||
| 'zoom-out';
|
||||
|
||||
export type PreviewZoomKeyboardContext = {
|
||||
hasZoomableViewport: boolean;
|
||||
isFocused: boolean;
|
||||
isHovered: boolean;
|
||||
usesMetaModifier: boolean;
|
||||
};
|
||||
|
||||
export type PreviewZoomKeyboardActions = {
|
||||
fit: () => void;
|
||||
resetToActualSize: () => void;
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
};
|
||||
|
||||
const INTERACTIVE_TARGET_SELECTOR = [
|
||||
'button',
|
||||
'a[href]',
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
'summary',
|
||||
'[contenteditable]:not([contenteditable="false"])',
|
||||
'[role="button"]',
|
||||
'[role="link"]',
|
||||
'[role="textbox"]',
|
||||
'[role="combobox"]',
|
||||
'[role="slider"]',
|
||||
'[role="spinbutton"]',
|
||||
'[role="checkbox"]',
|
||||
'[role="radio"]',
|
||||
'[role="switch"]',
|
||||
'[role="tab"]',
|
||||
'[role="menuitem"]',
|
||||
'[role="option"]',
|
||||
'[role="dialog"]',
|
||||
'[aria-modal="true"]',
|
||||
].join(', ');
|
||||
|
||||
export function previewZoomUsesMetaModifier(platform: string) {
|
||||
return /Mac|iPhone|iPad|iPod/i.test(platform);
|
||||
}
|
||||
|
||||
export function isPreviewZoomInteractiveTarget(target: EventTarget | null) {
|
||||
const element = target instanceof Element ? target : null;
|
||||
return Boolean(element?.closest(INTERACTIVE_TARGET_SELECTOR));
|
||||
}
|
||||
|
||||
function hasPlatformModifier(event: KeyboardEvent, usesMetaModifier: boolean) {
|
||||
return usesMetaModifier
|
||||
? event.metaKey && !event.ctrlKey
|
||||
: event.ctrlKey && !event.metaKey;
|
||||
}
|
||||
|
||||
export function resolvePreviewZoomShortcut(
|
||||
event: KeyboardEvent,
|
||||
): PreviewZoomShortcut | null {
|
||||
if (event.altKey) return null;
|
||||
if (event.key === '0') return 'fit';
|
||||
if (event.key === '1') return 'actual-size';
|
||||
if (event.code === 'NumpadAdd' || event.key === '+' || event.key === '=') {
|
||||
return 'zoom-in';
|
||||
}
|
||||
if (event.code === 'NumpadSubtract' || event.key === '-') {
|
||||
return 'zoom-out';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function handlePreviewZoomKeyDown(
|
||||
event: KeyboardEvent,
|
||||
context: PreviewZoomKeyboardContext,
|
||||
actions: PreviewZoomKeyboardActions,
|
||||
) {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
!context.hasZoomableViewport ||
|
||||
(!context.isHovered && !context.isFocused) ||
|
||||
!hasPlatformModifier(event, context.usesMetaModifier) ||
|
||||
isPreviewZoomInteractiveTarget(event.target)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const shortcut = resolvePreviewZoomShortcut(event);
|
||||
if (!shortcut) return false;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (shortcut === 'fit') actions.fit();
|
||||
else if (shortcut === 'actual-size') actions.resetToActualSize();
|
||||
else if (shortcut === 'zoom-in') actions.zoomIn();
|
||||
else actions.zoomOut();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { UiEditorStepId } from '../model';
|
||||
|
||||
export type WorkflowCompletionNotice = {
|
||||
step: UiEditorStepId;
|
||||
outcome: 'success' | 'failure';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function appendWorkflowCheckPrompt(message: string): string {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return '请检查。';
|
||||
return `${trimmed.replace(/[。!?!?]+$/u, '')},请检查。`;
|
||||
}
|
||||
|
||||
export function workflowStepLabel(step: UiEditorStepId): string {
|
||||
switch (step) {
|
||||
case 'reference-analysis':
|
||||
return '分析参考图';
|
||||
case 'structure-recognition':
|
||||
return '识别界面结构';
|
||||
case 'visual-binding':
|
||||
return '绑定视觉素材';
|
||||
}
|
||||
const exhaustiveCheck: never = step;
|
||||
return exhaustiveCheck;
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import { PreviewWorkspace } from './components/preview/PreviewWorkspace';
|
||||
import { RecognitionOverview } from './components/RecognitionOverview';
|
||||
import { ToolNavigation } from './components/ToolNavigation';
|
||||
import { WorkflowActionCard } from './components/WorkflowActionCard';
|
||||
import { WorkflowCompletionModal } from './components/WorkflowCompletionModal';
|
||||
import { UI_EDITOR_STEPS, type UiEditorStepId } from './model';
|
||||
import { handleUiEditorKeyDown } from './uiEditorKeyboardShortcuts';
|
||||
import {
|
||||
type UiEditorWorkflowProjection,
|
||||
useUiEditorSession,
|
||||
@@ -79,41 +81,26 @@ export default function UiEditorPage({
|
||||
session.save.isLocked;
|
||||
const historyUndo = session.history.undo;
|
||||
const historyRedo = session.history.redo;
|
||||
const selectedNodeId = session.input.selectedNodeId;
|
||||
const activeImageId = session.input.activeImageId;
|
||||
const deleteNode = session.input.deleteNode;
|
||||
|
||||
useEffect(() => {
|
||||
if (session.save.isDirty) setGenerateSuccess(null);
|
||||
}, [session.save.isDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
const isEditableTarget = (target: EventTarget | null) => {
|
||||
const element = target instanceof HTMLElement ? target : null;
|
||||
return Boolean(
|
||||
element?.isContentEditable ||
|
||||
element?.closest('input, textarea, select, [contenteditable="true"]'),
|
||||
);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.repeat ||
|
||||
event.defaultPrevented ||
|
||||
isEditableTarget(event.target) ||
|
||||
(!event.ctrlKey && !event.metaKey)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey;
|
||||
const isRedo =
|
||||
(event.key.toLowerCase() === 'z' && event.shiftKey) ||
|
||||
(event.ctrlKey && event.key.toLowerCase() === 'y');
|
||||
if (isUndo && historyUndo()) {
|
||||
event.preventDefault();
|
||||
} else if (isRedo && historyRedo()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) =>
|
||||
handleUiEditorKeyDown(event, {
|
||||
selectedNodeId,
|
||||
activeImageId,
|
||||
deleteNode,
|
||||
historyUndo,
|
||||
historyRedo,
|
||||
});
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [historyRedo, historyUndo]);
|
||||
}, [activeImageId, deleteNode, historyRedo, historyUndo, selectedNodeId]);
|
||||
|
||||
async function save(afterReturn = false) {
|
||||
if (await session.save.save()) {
|
||||
@@ -298,6 +285,10 @@ export default function UiEditorPage({
|
||||
</div>
|
||||
</div>
|
||||
<EditorDialogs dialogs={session.dialogs} />
|
||||
<WorkflowCompletionModal
|
||||
notice={session.workflow.completionNotice}
|
||||
onClose={session.workflow.dismissCompletionNotice}
|
||||
/>
|
||||
<ThemedModal
|
||||
open={returnConfirmOpen}
|
||||
onClose={() => setReturnConfirmOpen(false)}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { NodeId } from '../../features/ui-editor/types/NodeId';
|
||||
import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId';
|
||||
|
||||
type DeleteResult = { ok: boolean } | undefined;
|
||||
|
||||
export type UiEditorKeyboardActions = {
|
||||
selectedNodeId: NodeId | null;
|
||||
activeImageId: UIDesignImageId | null;
|
||||
deleteNode: (nodeId: NodeId, treeId: UIDesignImageId) => DeleteResult;
|
||||
historyUndo: () => boolean;
|
||||
historyRedo: () => boolean;
|
||||
};
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
const element = target instanceof HTMLElement ? target : null;
|
||||
return Boolean(
|
||||
element?.isContentEditable ||
|
||||
element?.closest('input, textarea, select, [contenteditable="true"]'),
|
||||
);
|
||||
}
|
||||
|
||||
function isInteractiveTarget(target: EventTarget | null) {
|
||||
const element = target instanceof Element ? target : null;
|
||||
return Boolean(
|
||||
(target instanceof HTMLElement && target.isContentEditable) ||
|
||||
element?.closest(
|
||||
'button, a, input, textarea, select, [contenteditable="true"], [role="button"], [role="dialog"], [aria-modal="true"]',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function isModalTarget(target: EventTarget | null) {
|
||||
const element = target instanceof Element ? target : null;
|
||||
return Boolean(element?.closest('[role="dialog"], [aria-modal="true"]'));
|
||||
}
|
||||
|
||||
export function handleUiEditorKeyDown(
|
||||
event: KeyboardEvent,
|
||||
actions: UiEditorKeyboardActions,
|
||||
) {
|
||||
if (isModalTarget(event.target)) return;
|
||||
|
||||
if (
|
||||
(event.key === 'Delete' || event.key === 'Backspace') &&
|
||||
!event.repeat &&
|
||||
!event.defaultPrevented &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
!isInteractiveTarget(event.target)
|
||||
) {
|
||||
if (actions.selectedNodeId && actions.activeImageId) {
|
||||
const result = actions.deleteNode(
|
||||
actions.selectedNodeId,
|
||||
actions.activeImageId,
|
||||
);
|
||||
if (result?.ok) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
event.repeat ||
|
||||
event.defaultPrevented ||
|
||||
isEditableTarget(event.target) ||
|
||||
(!event.ctrlKey && !event.metaKey)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey;
|
||||
const isRedo =
|
||||
(event.key.toLowerCase() === 'z' && event.shiftKey) ||
|
||||
(event.ctrlKey && event.key.toLowerCase() === 'y');
|
||||
if (isUndo && actions.historyUndo()) {
|
||||
event.preventDefault();
|
||||
} else if (isRedo && actions.historyRedo()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
} from '../../features/ui-editor/importAdapter';
|
||||
import { applyMergeResult } from '../../features/ui-editor/merge';
|
||||
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
|
||||
import type { StageStatusField } from '../../features/ui-editor/stageStatusOverview';
|
||||
import {
|
||||
getStageStatusOverview,
|
||||
type StageStatusField,
|
||||
} from '../../features/ui-editor/stageStatusOverview';
|
||||
import { collectUiNodeIds } from '../../features/ui-editor/treeUtils';
|
||||
import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO';
|
||||
import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode';
|
||||
@@ -54,6 +57,10 @@ import {
|
||||
prerequisiteIssuesForStep,
|
||||
type UiEditorPrerequisiteIssue,
|
||||
} from './components/WorkflowChecks';
|
||||
import {
|
||||
appendWorkflowCheckPrompt,
|
||||
type WorkflowCompletionNotice,
|
||||
} from './components/workflowCompletionNotice';
|
||||
import {
|
||||
type PendingResourceRemoval,
|
||||
removalHasDownstreamReferences,
|
||||
@@ -175,6 +182,8 @@ export function useUiEditorSession(
|
||||
initialFurthestStepIndex = 0,
|
||||
) {
|
||||
const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE);
|
||||
const editorDeleteNode = editor.deleteNode;
|
||||
const editorUiTrees = editor.state.ui_trees;
|
||||
const replaceEditorState = editor.replaceState;
|
||||
const [isLoading, setIsLoading] = useState(Boolean(resourceId));
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
@@ -246,6 +255,19 @@ export function useUiEditorSession(
|
||||
const [hasSuggested, setHasSuggested] = useState(false);
|
||||
const [hasRecognized, setHasRecognized] = useState(false);
|
||||
const [hasBound, setHasBound] = useState(false);
|
||||
const [completionNotice, setCompletionNotice] =
|
||||
useState<WorkflowCompletionNotice | null>(null);
|
||||
|
||||
function reportWorkflowCompletion(
|
||||
step: UiEditorStepId,
|
||||
outcome: WorkflowCompletionNotice['outcome'],
|
||||
rawMessage: string,
|
||||
setStatus: (message: string) => void,
|
||||
) {
|
||||
const message = appendWorkflowCheckPrompt(rawMessage);
|
||||
setStatus(message);
|
||||
setCompletionNotice({ step, outcome, message });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setActiveStep(initialStep);
|
||||
@@ -437,6 +459,9 @@ export function useUiEditorSession(
|
||||
);
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
setSelectedNodeId((current) =>
|
||||
current && !validNodeIds.has(current) ? null : current,
|
||||
);
|
||||
}, [editor.state.ui_trees]);
|
||||
|
||||
const isNodePreviewVisible = useCallback(
|
||||
@@ -879,16 +904,26 @@ export function useUiEditorSession(
|
||||
return result;
|
||||
}
|
||||
|
||||
function deleteNode(nodeId: NodeId, treeId = activeImageId) {
|
||||
if (!treeId) return;
|
||||
const result = editor.deleteNode(treeId, nodeId);
|
||||
if (!result.ok) {
|
||||
setStatus('无法删除该节点。');
|
||||
const deleteNode = useCallback(
|
||||
(nodeId: NodeId, treeId = activeImageId) => {
|
||||
if (!treeId) return;
|
||||
const tree = editorUiTrees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
);
|
||||
const location = tree ? findUiNodeLocation(tree.root, nodeId) : null;
|
||||
const deletedNodeIds = location ? collectUiNodeIds(location.node) : null;
|
||||
const result = editorDeleteNode(treeId, nodeId);
|
||||
if (!result.ok) {
|
||||
setStatus('无法删除该节点。');
|
||||
return result;
|
||||
}
|
||||
if (selectedNodeId !== null && deletedNodeIds?.has(selectedNodeId)) {
|
||||
setSelectedNodeId(null);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (selectedNodeId === nodeId) setSelectedNodeId(null);
|
||||
return result;
|
||||
}
|
||||
},
|
||||
[activeImageId, editorDeleteNode, editorUiTrees, selectedNodeId],
|
||||
);
|
||||
|
||||
function selectSprite(id: SpriteAssetId) {
|
||||
setSelectedNodeId(null);
|
||||
@@ -939,6 +974,7 @@ export function useUiEditorSession(
|
||||
|
||||
async function suggestUiDesignSemantics() {
|
||||
if (isSuggesting || isWorkflowBusy) return;
|
||||
setCompletionNotice(null);
|
||||
setSuggestionStatus(null);
|
||||
setIsSuggesting(true);
|
||||
try {
|
||||
@@ -949,11 +985,19 @@ export function useUiEditorSession(
|
||||
);
|
||||
editor.replaceState(applyUiDesignSuggestions(snapshot, suggestions));
|
||||
setHasSuggested(true);
|
||||
setSuggestionStatus(`已应用 ${suggestions.length} 条参考图语义建议。`);
|
||||
reportWorkflowCompletion(
|
||||
'reference-analysis',
|
||||
'success',
|
||||
`参考图分析完成:已应用 ${suggestions.length} 条参考图语义建议`,
|
||||
setSuggestionStatus,
|
||||
);
|
||||
});
|
||||
} catch (cause) {
|
||||
setSuggestionStatus(
|
||||
reportWorkflowCompletion(
|
||||
'reference-analysis',
|
||||
'failure',
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
setSuggestionStatus,
|
||||
);
|
||||
} finally {
|
||||
setIsSuggesting(false);
|
||||
@@ -962,6 +1006,7 @@ export function useUiEditorSession(
|
||||
|
||||
async function recognizeUi() {
|
||||
if (isRecognizing || isWorkflowBusy) return;
|
||||
setCompletionNotice(null);
|
||||
setRecognitionStatus(null);
|
||||
setIsRecognizing(true);
|
||||
try {
|
||||
@@ -970,14 +1015,27 @@ export function useUiEditorSession(
|
||||
projectPath,
|
||||
state: snapshot,
|
||||
});
|
||||
editor.replaceState(applyRecognitionResult(snapshot, result));
|
||||
const nextState = applyRecognitionResult(snapshot, result);
|
||||
editor.replaceState(nextState);
|
||||
setHasRecognized(true);
|
||||
setSelectedNodeId(null);
|
||||
setRecognitionStatus(`已替换 ${result.ui_trees.length} 棵界面树。`);
|
||||
const overview = getStageStatusOverview(
|
||||
nextState.ui_trees,
|
||||
'layout_status',
|
||||
);
|
||||
reportWorkflowCompletion(
|
||||
'structure-recognition',
|
||||
'success',
|
||||
`界面结构识别完成:已替换 ${result.ui_trees.length} 棵界面树,待检查 ${overview.needsAttention} 项(必须修复 ${overview.blocked} 项)`,
|
||||
setRecognitionStatus,
|
||||
);
|
||||
});
|
||||
} catch (cause) {
|
||||
setRecognitionStatus(
|
||||
reportWorkflowCompletion(
|
||||
'structure-recognition',
|
||||
'failure',
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
setRecognitionStatus,
|
||||
);
|
||||
} finally {
|
||||
setIsRecognizing(false);
|
||||
@@ -1005,6 +1063,7 @@ export function useUiEditorSession(
|
||||
|
||||
async function bindComponents() {
|
||||
if (isBinding || isWorkflowBusy) return;
|
||||
setCompletionNotice(null);
|
||||
setBindingStatus(null);
|
||||
setIsBinding(true);
|
||||
try {
|
||||
@@ -1032,13 +1091,21 @@ export function useUiEditorSession(
|
||||
history: index < batches.length - 1 ? 'skip' : 'record',
|
||||
});
|
||||
}
|
||||
setBindingStatus(
|
||||
`组件绑定完成(${batches.length}/${batches.length})。`,
|
||||
reportWorkflowCompletion(
|
||||
'visual-binding',
|
||||
'success',
|
||||
`视觉素材绑定完成:已处理 ${batches.length}/${batches.length} 个批次`,
|
||||
setBindingStatus,
|
||||
);
|
||||
setHasBound(true);
|
||||
});
|
||||
} catch (cause) {
|
||||
setBindingStatus(cause instanceof Error ? cause.message : String(cause));
|
||||
reportWorkflowCompletion(
|
||||
'visual-binding',
|
||||
'failure',
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
setBindingStatus,
|
||||
);
|
||||
} finally {
|
||||
setIsBinding(false);
|
||||
}
|
||||
@@ -1303,6 +1370,7 @@ export function useUiEditorSession(
|
||||
isBinding,
|
||||
hasBound,
|
||||
bindingStatus,
|
||||
completionNotice,
|
||||
bindComponents,
|
||||
requestStepChange,
|
||||
continueToNextStep: () => {
|
||||
@@ -1310,6 +1378,7 @@ export function useUiEditorSession(
|
||||
},
|
||||
confirmStepChange,
|
||||
cancelStepChange: () => setPendingWorkflowStepChange(null),
|
||||
dismissCompletionNotice: () => setCompletionNotice(null),
|
||||
},
|
||||
dialogs: {
|
||||
projectPath,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user