feat(config-tool): 添加 VS Code 扩展实现配置文件管理功能

- 根据 VS Code 当前界面语言在英文和简体中文之间切换主要工具界面文本
- 实现配置验证消息的本地化支持,包括数组、标量、枚举等类型的错误提示
- 添加完整的 VS Code 扩展框架,支持配置文件浏览、验证和表单预览
- 实现批量编辑功能,支持对同一配置域内的多个 YAML 文件执行字段更新
- 集成诊断功能,在编辑器中显示配置验证错误和警告
- 提供树形视图展示配置目录结构和文件列表
This commit is contained in:
GeWuYou 2026-04-02 20:44:34 +08:00
parent 3dbe7a979f
commit 6df348fb4e
10 changed files with 403 additions and 90 deletions

View File

@ -200,6 +200,7 @@ var hotReload = loader.EnableHotReload(
- 浏览 `config/` 目录 - 浏览 `config/` 目录
- 打开 raw YAML 文件 - 打开 raw YAML 文件
- 打开匹配的 schema 文件 - 打开匹配的 schema 文件
- 根据 VS Code 当前界面语言在英文和简体中文之间切换主要工具界面文本
- 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验 - 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验
- 对嵌套对象字段、对象数组、顶层标量字段和顶层标量数组提供轻量表单入口 - 对嵌套对象字段、对象数组、顶层标量字段和顶层标量数组提供轻量表单入口
- 对同一配置域内的多份 YAML 文件执行批量字段更新 - 对同一配置域内的多份 YAML 文件执行批量字段更新

View File

@ -7,6 +7,7 @@ VS Code extension for the GFramework AI-First config workflow.
- Browse config files from the workspace `config/` directory - Browse config files from the workspace `config/` directory
- Open raw YAML files - Open raw YAML files
- Open matching schema files from `schemas/` - Open matching schema files from `schemas/`
- Localize extension UI text in English and Simplified Chinese, including the form preview, prompts, and notifications
- Run lightweight schema validation for nested required fields, unknown nested fields, scalar types, scalar arrays, and - Run lightweight schema validation for nested required fields, unknown nested fields, scalar types, scalar arrays, and
arrays of objects arrays of objects
- Open a lightweight form preview for nested object fields, object arrays, top-level scalar fields, and scalar arrays - Open a lightweight form preview for nested object fields, object arrays, top-level scalar fields, and scalar arrays

View File

@ -1,8 +1,8 @@
{ {
"name": "gframework-config-tool", "name": "gframework-config-tool",
"displayName": "GFramework Config Tool", "displayName": "%extension.displayName%",
"description": "VS Code tooling for browsing, validating, and editing AI-First config files in GFramework projects.", "description": "%extension.description%",
"version": "0.0.1", "version": "0.0.2",
"publisher": "GeWuYou", "publisher": "GeWuYou",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@ -54,34 +54,34 @@
"explorer": [ "explorer": [
{ {
"id": "gframeworkConfigExplorer", "id": "gframeworkConfigExplorer",
"name": "GFramework Config" "name": "%view.gframeworkConfig.name%"
} }
] ]
}, },
"commands": [ "commands": [
{ {
"command": "gframeworkConfig.refresh", "command": "gframeworkConfig.refresh",
"title": "GFramework Config: Refresh" "title": "%command.refresh.title%"
}, },
{ {
"command": "gframeworkConfig.openRaw", "command": "gframeworkConfig.openRaw",
"title": "GFramework Config: Open Raw File" "title": "%command.openRaw.title%"
}, },
{ {
"command": "gframeworkConfig.openSchema", "command": "gframeworkConfig.openSchema",
"title": "GFramework Config: Open Schema" "title": "%command.openSchema.title%"
}, },
{ {
"command": "gframeworkConfig.openFormPreview", "command": "gframeworkConfig.openFormPreview",
"title": "GFramework Config: Open Form Preview" "title": "%command.openFormPreview.title%"
}, },
{ {
"command": "gframeworkConfig.batchEditDomain", "command": "gframeworkConfig.batchEditDomain",
"title": "GFramework Config: Batch Edit Domain" "title": "%command.batchEditDomain.title%"
}, },
{ {
"command": "gframeworkConfig.validateAll", "command": "gframeworkConfig.validateAll",
"title": "GFramework Config: Validate All" "title": "%command.validateAll.title%"
} }
], ],
"menus": { "menus": {
@ -121,17 +121,17 @@
] ]
}, },
"configuration": { "configuration": {
"title": "GFramework Config", "title": "%configuration.title%",
"properties": { "properties": {
"gframeworkConfig.configPath": { "gframeworkConfig.configPath": {
"type": "string", "type": "string",
"default": "config", "default": "config",
"description": "Relative path from the workspace root to the config directory." "description": "%configuration.configPath.description%"
}, },
"gframeworkConfig.schemasPath": { "gframeworkConfig.schemasPath": {
"type": "string", "type": "string",
"default": "schemas", "default": "schemas",
"description": "Relative path from the workspace root to the schema directory." "description": "%configuration.schemasPath.description%"
} }
} }
} }

View File

@ -0,0 +1,14 @@
{
"extension.displayName": "GFramework Config Tool",
"extension.description": "VS Code tooling for browsing, validating, and editing AI-First config files in GFramework projects.",
"view.gframeworkConfig.name": "GFramework Config",
"command.refresh.title": "GFramework Config: Refresh",
"command.openRaw.title": "GFramework Config: Open Raw File",
"command.openSchema.title": "GFramework Config: Open Schema",
"command.openFormPreview.title": "GFramework Config: Open Form Preview",
"command.batchEditDomain.title": "GFramework Config: Batch Edit Domain",
"command.validateAll.title": "GFramework Config: Validate All",
"configuration.title": "GFramework Config",
"configuration.configPath.description": "Relative path from the workspace root to the config directory.",
"configuration.schemasPath.description": "Relative path from the workspace root to the schema directory."
}

View File

@ -0,0 +1,14 @@
{
"extension.displayName": "GFramework 配置工具",
"extension.description": "为 GFramework 项目中的 AI-First 配置文件提供浏览、校验和编辑能力的 VS Code 扩展。",
"view.gframeworkConfig.name": "GFramework 配置",
"command.refresh.title": "GFramework 配置:刷新",
"command.openRaw.title": "GFramework 配置:打开原始文件",
"command.openSchema.title": "GFramework 配置:打开 Schema",
"command.openFormPreview.title": "GFramework 配置:打开表单预览",
"command.batchEditDomain.title": "GFramework 配置:批量编辑配置域",
"command.validateAll.title": "GFramework 配置:校验全部",
"configuration.title": "GFramework 配置",
"configuration.configPath.description": "从工作区根目录到配置目录的相对路径。",
"configuration.schemasPath.description": "从工作区根目录到 Schema 目录的相对路径。"
}

View File

@ -100,11 +100,12 @@ function parseTopLevelYaml(text) {
* *
* @param {{type: "object", required: string[], properties: Record<string, SchemaNode>}} schemaInfo Parsed schema. * @param {{type: "object", required: string[], properties: Record<string, SchemaNode>}} schemaInfo Parsed schema.
* @param {YamlNode} parsedYaml Parsed YAML tree. * @param {YamlNode} parsedYaml Parsed YAML tree.
* @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer.
* @returns {Array<{severity: "error" | "warning", message: string}>} Validation diagnostics. * @returns {Array<{severity: "error" | "warning", message: string}>} Validation diagnostics.
*/ */
function validateParsedConfig(schemaInfo, parsedYaml) { function validateParsedConfig(schemaInfo, parsedYaml, localizer) {
const diagnostics = []; const diagnostics = [];
validateNode(schemaInfo, parsedYaml, "", diagnostics); validateNode(schemaInfo, parsedYaml, "", diagnostics, localizer);
return diagnostics; return diagnostics;
} }
@ -353,10 +354,11 @@ function parseSchemaNode(rawNode, displayPath) {
* @param {YamlNode} yamlNode YAML node. * @param {YamlNode} yamlNode YAML node.
* @param {string} displayPath Current logical path. * @param {string} displayPath Current logical path.
* @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink. * @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink.
* @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer.
*/ */
function validateNode(schemaNode, yamlNode, displayPath, diagnostics) { function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) {
if (schemaNode.type === "object") { if (schemaNode.type === "object") {
validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics); validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer);
return; return;
} }
@ -364,13 +366,15 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics) {
if (!yamlNode || yamlNode.kind !== "array") { if (!yamlNode || yamlNode.kind !== "array") {
diagnostics.push({ diagnostics.push({
severity: "error", severity: "error",
message: `Property '${displayPath}' is expected to be an array.` message: localizeValidationMessage("expectedArray", localizer, {
displayPath
})
}); });
return; return;
} }
for (let index = 0; index < yamlNode.items.length; index += 1) { for (let index = 0; index < yamlNode.items.length; index += 1) {
validateNode(schemaNode.items, yamlNode.items[index], `${displayPath}[${index}]`, diagnostics); validateNode(schemaNode.items, yamlNode.items[index], `${displayPath}[${index}]`, diagnostics, localizer);
} }
return; return;
} }
@ -378,7 +382,11 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics) {
if (!yamlNode || yamlNode.kind !== "scalar") { if (!yamlNode || yamlNode.kind !== "scalar") {
diagnostics.push({ diagnostics.push({
severity: "error", severity: "error",
message: `Property '${displayPath}' is expected to be '${schemaNode.type}', but the current YAML shape is '${yamlNode ? yamlNode.kind : "missing"}'.` message: localizeValidationMessage("expectedScalarShape", localizer, {
displayPath,
schemaType: schemaNode.type,
yamlKind: yamlNode ? yamlNode.kind : "missing"
})
}); });
return; return;
} }
@ -386,7 +394,10 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics) {
if (!isScalarCompatible(schemaNode.type, yamlNode.value)) { if (!isScalarCompatible(schemaNode.type, yamlNode.value)) {
diagnostics.push({ diagnostics.push({
severity: "error", severity: "error",
message: `Property '${displayPath}' is expected to be '${schemaNode.type}', but the current scalar value is incompatible.` message: localizeValidationMessage("expectedScalarValue", localizer, {
displayPath,
schemaType: schemaNode.type
})
}); });
return; return;
} }
@ -396,7 +407,10 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics) {
!schemaNode.enumValues.includes(unquoteScalar(yamlNode.value))) { !schemaNode.enumValues.includes(unquoteScalar(yamlNode.value))) {
diagnostics.push({ diagnostics.push({
severity: "error", severity: "error",
message: `Property '${displayPath}' must be one of: ${schemaNode.enumValues.join(", ")}.` message: localizeValidationMessage("enumMismatch", localizer, {
displayPath,
values: schemaNode.enumValues.join(", ")
})
}); });
} }
} }
@ -408,13 +422,17 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics) {
* @param {YamlNode} yamlNode YAML node. * @param {YamlNode} yamlNode YAML node.
* @param {string} displayPath Current logical path. * @param {string} displayPath Current logical path.
* @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink. * @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink.
* @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer.
*/ */
function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics) { function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) {
if (!yamlNode || yamlNode.kind !== "object") { if (!yamlNode || yamlNode.kind !== "object") {
const subject = displayPath.length === 0 ? "Root object" : `Property '${displayPath}'`; const subject = displayPath.length === 0 ? "Root object" : `Property '${displayPath}'`;
diagnostics.push({ diagnostics.push({
severity: "error", severity: "error",
message: `${subject} is expected to be an object.` message: localizeValidationMessage("expectedObject", localizer, {
subject,
displayPath
})
}); });
return; return;
} }
@ -423,7 +441,9 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics) {
if (!yamlNode.map.has(requiredProperty)) { if (!yamlNode.map.has(requiredProperty)) {
diagnostics.push({ diagnostics.push({
severity: "error", severity: "error",
message: `Required property '${combinePath(displayPath, requiredProperty)}' is missing.` message: localizeValidationMessage("missingRequired", localizer, {
displayPath: combinePath(displayPath, requiredProperty)
})
}); });
} }
} }
@ -432,7 +452,9 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics) {
if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) { if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) {
diagnostics.push({ diagnostics.push({
severity: "error", severity: "error",
message: `Property '${combinePath(displayPath, entry.key)}' is not declared in the matching schema.` message: localizeValidationMessage("unknownProperty", localizer, {
displayPath: combinePath(displayPath, entry.key)
})
}); });
continue; continue;
} }
@ -441,7 +463,60 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics) {
schemaNode.properties[entry.key], schemaNode.properties[entry.key],
entry.node, entry.node,
combinePath(displayPath, entry.key), combinePath(displayPath, entry.key),
diagnostics); diagnostics,
localizer);
}
}
/**
* Format one validation message in either English or Simplified Chinese.
*
* @param {"expectedArray" | "expectedScalarShape" | "expectedScalarValue" | "enumMismatch" | "expectedObject" | "missingRequired" | "unknownProperty"} key Message key.
* @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer.
* @param {Record<string, string>} params Message parameters.
* @returns {string} Localized validation message.
*/
function localizeValidationMessage(key, localizer, params) {
if (localizer && localizer.isChinese) {
switch (key) {
case "expectedArray":
return `属性“${params.displayPath}”应为数组。`;
case "expectedScalarShape":
return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前 YAML 结构是“${params.yamlKind}”。`;
case "expectedScalarValue":
return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前标量值不兼容。`;
case "enumMismatch":
return `属性“${params.displayPath}”必须是以下值之一:${params.values}`;
case "expectedObject":
return params.displayPath && params.displayPath.length > 0
? `属性“${params.displayPath}”应为对象。`
: "根对象应为对象。";
case "missingRequired":
return `缺少必填属性“${params.displayPath}”。`;
case "unknownProperty":
return `属性“${params.displayPath}”未在匹配的 schema 中声明。`;
default:
return key;
}
}
switch (key) {
case "expectedArray":
return `Property '${params.displayPath}' is expected to be an array.`;
case "expectedScalarShape":
return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current YAML shape is '${params.yamlKind}'.`;
case "expectedScalarValue":
return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current scalar value is incompatible.`;
case "enumMismatch":
return `Property '${params.displayPath}' must be one of: ${params.values}.`;
case "expectedObject":
return `${params.subject} is expected to be an object.`;
case "missingRequired":
return `Required property '${params.displayPath}' is missing.`;
case "unknownProperty":
return `Property '${params.displayPath}' is not declared in the matching schema.`;
default:
return key;
} }
} }

View File

@ -10,6 +10,9 @@ const {
unquoteScalar, unquoteScalar,
validateParsedConfig validateParsedConfig
} = require("./configValidation"); } = require("./configValidation");
const {createLocalizer} = require("./localization");
const localizer = createLocalizer(vscode.env.language);
/** /**
* Activate the GFramework config extension. * Activate the GFramework config extension.
@ -132,11 +135,11 @@ class ConfigTreeDataProvider {
if (!configRoot || !fs.existsSync(configRoot.fsPath)) { if (!configRoot || !fs.existsSync(configRoot.fsPath)) {
return [ return [
new ConfigTreeItem( new ConfigTreeItem(
"No config directory", localizer.t("tree.noConfigDirectory.label"),
"info", "info",
vscode.TreeItemCollapsibleState.None, vscode.TreeItemCollapsibleState.None,
undefined, undefined,
"Set gframeworkConfig.configPath or create the directory.") localizer.t("tree.noConfigDirectory.description"))
]; ];
} }
@ -171,8 +174,8 @@ class ConfigTreeDataProvider {
const fileUri = vscode.Uri.joinPath(domainUri, entry.name); const fileUri = vscode.Uri.joinPath(domainUri, entry.name);
const schemaUri = getSchemaUriForConfigFile(fileUri, workspaceRoot); const schemaUri = getSchemaUriForConfigFile(fileUri, workspaceRoot);
const description = schemaUri && fs.existsSync(schemaUri.fsPath) const description = schemaUri && fs.existsSync(schemaUri.fsPath)
? "schema" ? localizer.t("tree.fileDescription.schema")
: "schema missing"; : localizer.t("tree.fileDescription.schemaMissing");
const item = new ConfigTreeItem( const item = new ConfigTreeItem(
entry.name, entry.name,
"file", "file",
@ -183,7 +186,7 @@ class ConfigTreeDataProvider {
item.contextValue = "gframeworkConfigFile"; item.contextValue = "gframeworkConfigFile";
item.command = { item.command = {
command: "gframeworkConfig.openRaw", command: "gframeworkConfig.openRaw",
title: "Open Raw", title: localizer.t("command.openRaw.title"),
arguments: [item] arguments: [item]
}; };
@ -243,7 +246,7 @@ async function openSchemaFile(item) {
const schemaUri = getSchemaUriForConfigFile(configUri, workspaceRoot); const schemaUri = getSchemaUriForConfigFile(configUri, workspaceRoot);
if (!schemaUri || !fs.existsSync(schemaUri.fsPath)) { if (!schemaUri || !fs.existsSync(schemaUri.fsPath)) {
void vscode.window.showWarningMessage("Matching schema file was not found."); void vscode.window.showWarningMessage(localizer.t("message.schemaNotFound"));
return; return;
} }
@ -273,7 +276,7 @@ async function openFormPreview(item, diagnostics) {
const panel = vscode.window.createWebviewPanel( const panel = vscode.window.createWebviewPanel(
"gframeworkConfigFormPreview", "gframeworkConfigFormPreview",
`Config Form: ${path.basename(configUri.fsPath)}`, localizer.t("webview.panelTitle", {fileName: path.basename(configUri.fsPath)}),
vscode.ViewColumn.Beside, vscode.ViewColumn.Beside,
{enableScripts: true}); {enableScripts: true});
@ -294,7 +297,7 @@ async function openFormPreview(item, diagnostics) {
const document = await vscode.workspace.openTextDocument(configUri); const document = await vscode.workspace.openTextDocument(configUri);
await document.save(); await document.save();
await validateConfigFile(configUri, diagnostics); await validateConfigFile(configUri, diagnostics);
void vscode.window.showInformationMessage("Config file saved from form preview."); void vscode.window.showInformationMessage(localizer.t("message.formSaved"));
} }
if (message.type === "openRaw") { if (message.type === "openRaw") {
@ -353,13 +356,13 @@ async function validateConfigFile(configUri, diagnostics) {
if (!schemaInfo.exists) { if (!schemaInfo.exists) {
fileDiagnostics.push(new vscode.Diagnostic( fileDiagnostics.push(new vscode.Diagnostic(
new vscode.Range(0, 0, 0, 1), new vscode.Range(0, 0, 0, 1),
`Matching schema file not found: ${schemaInfo.schemaPath}`, localizer.t("diagnostic.schemaMissing", {schemaPath: schemaInfo.schemaPath}),
vscode.DiagnosticSeverity.Warning)); vscode.DiagnosticSeverity.Warning));
diagnostics.set(configUri, fileDiagnostics); diagnostics.set(configUri, fileDiagnostics);
return; return;
} }
for (const diagnostic of validateParsedConfig(schemaInfo, parsedYaml)) { for (const diagnostic of validateParsedConfig(schemaInfo, parsedYaml, localizer)) {
fileDiagnostics.push(new vscode.Diagnostic( fileDiagnostics.push(new vscode.Diagnostic(
new vscode.Range(0, 0, 0, 1), new vscode.Range(0, 0, 0, 1),
diagnostic.message, diagnostic.message,
@ -403,14 +406,14 @@ async function openBatchEdit(item, diagnostics, provider) {
}); });
if (fileItems.length === 0) { if (fileItems.length === 0) {
void vscode.window.showWarningMessage("No YAML config files were found in the selected domain."); void vscode.window.showWarningMessage(localizer.t("message.noYamlFilesInDomain"));
return; return;
} }
const selectedFiles = await vscode.window.showQuickPick(fileItems, { const selectedFiles = await vscode.window.showQuickPick(fileItems, {
canPickMany: true, canPickMany: true,
title: `Batch Edit: ${path.basename(domainUri.fsPath)}`, title: localizer.t("quickPick.batchEdit.title", {domain: path.basename(domainUri.fsPath)}),
placeHolder: "Select the config files to update." placeHolder: localizer.t("quickPick.batchEdit.placeholder")
}); });
if (!selectedFiles || selectedFiles.length === 0) { if (!selectedFiles || selectedFiles.length === 0) {
return; return;
@ -418,14 +421,13 @@ async function openBatchEdit(item, diagnostics, provider) {
const schemaInfo = await loadSchemaInfoForConfig(selectedFiles[0].fileUri, workspaceRoot); const schemaInfo = await loadSchemaInfoForConfig(selectedFiles[0].fileUri, workspaceRoot);
if (!schemaInfo.exists) { if (!schemaInfo.exists) {
void vscode.window.showWarningMessage("Batch edit requires a matching schema file for the selected domain."); void vscode.window.showWarningMessage(localizer.t("message.batchEditNeedsSchema"));
return; return;
} }
const editableFields = getEditableSchemaFields(schemaInfo); const editableFields = getEditableSchemaFields(schemaInfo);
if (editableFields.length === 0) { if (editableFields.length === 0) {
void vscode.window.showWarningMessage( void vscode.window.showWarningMessage(localizer.t("message.batchEditNoEditableFields"));
"No top-level scalar or scalar-array fields were found in the matching schema.");
return; return;
} }
@ -433,19 +435,19 @@ async function openBatchEdit(item, diagnostics, provider) {
editableFields.map((field) => ({ editableFields.map((field) => ({
label: field.title || field.key, label: field.title || field.key,
description: field.inputKind === "array" description: field.inputKind === "array"
? `array<${field.itemType}>` ? localizer.t("detail.arrayType", {itemType: field.itemType})
: field.type, : field.type,
detail: [ detail: [
field.required ? "required" : "", field.required ? localizer.t("detail.required") : "",
field.description || "", field.description || "",
field.refTable ? `ref: ${field.refTable}` : "" field.refTable ? localizer.t("detail.refTable", {refTable: field.refTable}) : ""
].filter((part) => part.length > 0).join(" · ") || undefined, ].filter((part) => part.length > 0).join(" · ") || undefined,
field field
})), })),
{ {
canPickMany: true, canPickMany: true,
title: `Batch Edit Fields: ${path.basename(domainUri.fsPath)}`, title: localizer.t("quickPick.batchEditFields.title", {domain: path.basename(domainUri.fsPath)}),
placeHolder: "Select the fields to apply across the chosen files." placeHolder: localizer.t("quickPick.batchEditFields.placeholder")
}); });
if (!selectedFields || selectedFields.length === 0) { if (!selectedFields || selectedFields.length === 0) {
return; return;
@ -492,13 +494,15 @@ async function openBatchEdit(item, diagnostics, provider) {
} }
if (changedFileCount === 0) { if (changedFileCount === 0) {
void vscode.window.showInformationMessage("Batch edit did not change any selected config files."); void vscode.window.showInformationMessage(localizer.t("message.batchEditNoChanges"));
return; return;
} }
const applied = await vscode.workspace.applyEdit(edit); const applied = await vscode.workspace.applyEdit(edit);
if (!applied) { if (!applied) {
throw new Error("VS Code rejected the batch edit workspace update."); throw new Error(localizer.isChinese
? "VS Code 拒绝了这次批量编辑工作区更新。"
: "VS Code rejected the batch edit workspace update.");
} }
for (const document of touchedDocuments) { for (const document of touchedDocuments) {
@ -507,8 +511,10 @@ async function openBatchEdit(item, diagnostics, provider) {
} }
provider.refresh(); provider.refresh();
void vscode.window.showInformationMessage( void vscode.window.showInformationMessage(localizer.t("message.batchEditUpdated", {
`Batch updated ${changedFileCount} config file(s) in '${path.basename(domainUri.fsPath)}'.`); count: changedFileCount,
domain: path.basename(domainUri.fsPath)
}));
} }
/** /**
@ -570,6 +576,9 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) {
*/ */
function renderFormHtml(fileName, schemaInfo, parsedYaml) { function renderFormHtml(fileName, schemaInfo, parsedYaml) {
const formModel = buildFormModel(schemaInfo, parsedYaml); const formModel = buildFormModel(schemaInfo, parsedYaml);
const saveButtonLabel = escapeHtml(localizer.t("webview.button.save"));
const openRawButtonLabel = escapeHtml(localizer.t("webview.button.openRaw"));
const objectArrayItemLabel = localizer.t("webview.objectArray.item");
const renderedFields = formModel.fields const renderedFields = formModel.fields
.map((field) => renderFormField(field)) .map((field) => renderFormField(field))
.join("\n"); .join("\n");
@ -583,8 +592,8 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
.join("\n"); .join("\n");
const schemaStatus = schemaInfo.exists const schemaStatus = schemaInfo.exists
? `Schema: ${escapeHtml(schemaInfo.schemaPath)}` ? escapeHtml(localizer.t("webview.meta.schema", {schemaPath: schemaInfo.schemaPath}))
: `Schema missing: ${escapeHtml(schemaInfo.schemaPath)}`; : escapeHtml(localizer.t("webview.meta.schemaMissing", {schemaPath: schemaInfo.schemaPath}));
const editableContent = renderedFields; const editableContent = renderedFields;
const unsupportedSection = unsupportedFields.length > 0 const unsupportedSection = unsupportedFields.length > 0
@ -592,10 +601,10 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
: ""; : "";
const emptyState = editableContent.length > 0 const emptyState = editableContent.length > 0
? `${editableContent}${unsupportedSection}` ? `${editableContent}${unsupportedSection}`
: "<p>No editable schema-bound fields were detected. Use raw YAML for unsupported shapes.</p>"; : `<p>${escapeHtml(localizer.t("webview.emptyState"))}</p>`;
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="${escapeHtml(localizer.languageTag)}">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -739,16 +748,17 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
</head> </head>
<body> <body>
<div class="toolbar"> <div class="toolbar">
<button id="save">Save Form</button> <button id="save">${saveButtonLabel}</button>
<button id="openRaw">Open Raw YAML</button> <button id="openRaw">${openRawButtonLabel}</button>
</div> </div>
<div class="meta"> <div class="meta">
<div>File: ${escapeHtml(fileName)}</div> <div>${escapeHtml(localizer.t("webview.meta.file", {fileName}))}</div>
<div>${schemaStatus}</div> <div>${schemaStatus}</div>
</div> </div>
<div id="fields">${emptyState}</div> <div id="fields">${emptyState}</div>
<script> <script>
const vscode = acquireVsCodeApi(); const vscode = acquireVsCodeApi();
const objectArrayItemLabel = ${JSON.stringify(objectArrayItemLabel)};
function parseArrayEditorValue(value) { function parseArrayEditorValue(value) {
return String(value) return String(value)
.split(/\\r?\\n/u) .split(/\\r?\\n/u)
@ -781,7 +791,7 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
items.forEach((item, index) => { items.forEach((item, index) => {
const title = item.querySelector(".object-array-item-title"); const title = item.querySelector(".object-array-item-title");
if (title) { if (title) {
title.textContent = "Item " + (index + 1); title.textContent = objectArrayItemLabel + " " + (index + 1);
} }
}); });
} }
@ -858,7 +868,7 @@ function renderFormField(field) {
if (field.kind === "section") { if (field.kind === "section") {
return ` return `
<div class="section depth-${field.depth}"> <div class="section depth-${field.depth}">
<div class="section-title">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</div> <div class="section-title">${escapeHtml(field.label)} ${field.required ? `<span class="badge">${escapeHtml(localizer.t("webview.badge.required"))}</span>` : ""}</div>
<div class="meta-key">${escapeHtml(field.displayPath || field.path)}</div> <div class="meta-key">${escapeHtml(field.displayPath || field.path)}</div>
${field.description ? `<span class="hint">${escapeHtml(field.description)}</span>` : ""} ${field.description ? `<span class="hint">${escapeHtml(field.description)}</span>` : ""}
</div> </div>
@ -870,34 +880,34 @@ function renderFormField(field) {
.map((item) => renderObjectArrayItem(item)) .map((item) => renderObjectArrayItem(item))
.join("\n"); .join("\n");
const renderedTemplate = renderObjectArrayItem({ const renderedTemplate = renderObjectArrayItem({
title: "Item", title: localizer.t("webview.objectArray.item"),
fields: field.templateFields fields: field.templateFields
}); });
return ` return `
<div class="object-array depth-${field.depth}" data-object-array-editor data-object-array-path="${escapeHtml(field.path)}"> <div class="object-array depth-${field.depth}" data-object-array-editor data-object-array-path="${escapeHtml(field.path)}">
<div class="label">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</div> <div class="label">${escapeHtml(field.label)} ${field.required ? `<span class="badge">${escapeHtml(localizer.t("webview.badge.required"))}</span>` : ""}</div>
<div class="meta-key">${escapeHtml(field.displayPath || field.path)}</div> <div class="meta-key">${escapeHtml(field.displayPath || field.path)}</div>
<span class="hint">Each item uses the object schema below.</span> <span class="hint">${escapeHtml(localizer.t("webview.objectArray.hint"))}</span>
${renderFieldHint(field.schema, true)} ${renderFieldHint(field.schema, true)}
<div class="object-array-items" data-object-array-items>${renderedItems}</div> <div class="object-array-items" data-object-array-items>${renderedItems}</div>
<template data-object-array-template>${renderedTemplate}</template> <template data-object-array-template>${renderedTemplate}</template>
<button type="button" class="secondary-button" data-add-object-array-item>Add Item</button> <button type="button" class="secondary-button" data-add-object-array-item>${escapeHtml(localizer.t("webview.objectArray.add"))}</button>
</div> </div>
`; `;
} }
if (field.kind === "array") { if (field.kind === "array") {
const itemType = field.itemType const itemType = field.itemType
? `array<${escapeHtml(field.itemType)}>` ? `array<${field.itemType}>`
: "array"; : "array";
const dataAttribute = field.itemMode const dataAttribute = field.itemMode
? `data-item-array-path="${escapeHtml(field.path)}"` ? `data-item-array-path="${escapeHtml(field.path)}"`
: `data-array-path="${escapeHtml(field.path)}"`; : `data-array-path="${escapeHtml(field.path)}"`;
return ` return `
<label class="field depth-${field.depth}"> <label class="field depth-${field.depth}">
<span class="label">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</span> <span class="label">${escapeHtml(field.label)} ${field.required ? `<span class="badge">${escapeHtml(localizer.t("webview.badge.required"))}</span>` : ""}</span>
<span class="meta-key">${escapeHtml(field.displayPath || field.path)}</span> <span class="meta-key">${escapeHtml(field.displayPath || field.path)}</span>
<span class="hint">One item per line. Expected type: ${itemType}</span> <span class="hint">${escapeHtml(localizer.t("webview.array.hint", {itemType}))}</span>
${renderFieldHint(field.schema, true)} ${renderFieldHint(field.schema, true)}
<textarea ${dataAttribute} rows="5">${escapeHtml(field.value.join("\n"))}</textarea> <textarea ${dataAttribute} rows="5">${escapeHtml(field.value.join("\n"))}</textarea>
</label> </label>
@ -922,7 +932,7 @@ function renderFormField(field) {
return ` return `
<label class="field depth-${field.depth}"> <label class="field depth-${field.depth}">
<span class="label">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</span> <span class="label">${escapeHtml(field.label)} ${field.required ? `<span class="badge">${escapeHtml(localizer.t("webview.badge.required"))}</span>` : ""}</span>
<span class="meta-key">${escapeHtml(field.displayPath || field.path)}</span> <span class="meta-key">${escapeHtml(field.displayPath || field.path)}</span>
${renderFieldHint(field.schema, false)} ${renderFieldHint(field.schema, false)}
${inputControl} ${inputControl}
@ -941,7 +951,7 @@ function renderObjectArrayItem(item) {
<div class="object-array-item" data-object-array-item> <div class="object-array-item" data-object-array-item>
<div class="object-array-item-header"> <div class="object-array-item-header">
<span class="object-array-item-title">${escapeHtml(item.title)}</span> <span class="object-array-item-title">${escapeHtml(item.title)}</span>
<button type="button" class="secondary-button" data-remove-object-array-item>Remove</button> <button type="button" class="secondary-button" data-remove-object-array-item>${escapeHtml(localizer.t("webview.objectArray.remove"))}</button>
</div> </div>
${item.fields.map((field) => renderFormField(field)).join("\n")} ${item.fields.map((field) => renderFormField(field)).join("\n")}
</div> </div>
@ -1062,8 +1072,8 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
unsupported.push({ unsupported.push({
path: propertyPath, path: propertyPath,
message: propertySchema.type === "array" message: propertySchema.type === "array"
? "Unsupported array shapes are currently raw-YAML-only in the form preview." ? localizer.t("webview.unsupported.array")
: `${propertySchema.type} fields are currently raw-YAML-only.` : localizer.t("webview.unsupported.type", {type: propertySchema.type})
}); });
} }
} }
@ -1090,7 +1100,7 @@ function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, u
if (!itemNode || itemNode.kind !== "object") { if (!itemNode || itemNode.kind !== "object") {
unsupported.push({ unsupported.push({
path: itemPath, path: itemPath,
message: "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items." message: localizer.t("webview.unsupported.objectArrayMixed")
}); });
continue; continue;
} }
@ -1105,7 +1115,7 @@ function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, u
fields, fields,
unsupported); unsupported);
items.push({ items.push({
title: `Item ${index + 1}`, title: localizer.t("webview.objectArray.itemNumber", {index: index + 1}),
fields fields
}); });
} }
@ -1197,8 +1207,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa
unsupported.push({ unsupported.push({
path: itemDisplayPath, path: itemDisplayPath,
message: propertySchema.type === "array" message: propertySchema.type === "array"
? "Nested object-array fields are currently raw-YAML-only inside the object-array editor." ? localizer.t("webview.unsupported.nestedObjectArray")
: `${propertySchema.type} fields are currently raw-YAML-only.` : localizer.t("webview.unsupported.type", {type: propertySchema.type})
}); });
} }
} }
@ -1261,7 +1271,7 @@ function renderFieldHint(propertySchema, isArrayField) {
} }
if (propertySchema.defaultValue) { if (propertySchema.defaultValue) {
hints.push(`Default: ${escapeHtml(propertySchema.defaultValue)}`); hints.push(escapeHtml(localizer.t("webview.hint.default", {value: propertySchema.defaultValue})));
} }
const enumValues = isArrayField const enumValues = isArrayField
@ -1270,11 +1280,11 @@ function renderFieldHint(propertySchema, isArrayField) {
: [] : []
: propertySchema.enumValues; : propertySchema.enumValues;
if (Array.isArray(enumValues) && enumValues.length > 0) { if (Array.isArray(enumValues) && enumValues.length > 0) {
hints.push(`Allowed: ${escapeHtml(enumValues.join(", "))}`); hints.push(escapeHtml(localizer.t("webview.hint.allowed", {values: enumValues.join(", ")})));
} }
if (propertySchema.refTable) { if (propertySchema.refTable) {
hints.push(`Ref table: ${escapeHtml(propertySchema.refTable)}`); hints.push(escapeHtml(localizer.t("webview.hint.refTable", {refTable: propertySchema.refTable})));
} }
if (hints.length === 0) { if (hints.length === 0) {
@ -1294,16 +1304,21 @@ async function promptBatchFieldValue(field) {
if (field.inputKind === "array") { if (field.inputKind === "array") {
const hintParts = []; const hintParts = [];
if (field.itemEnumValues && field.itemEnumValues.length > 0) { if (field.itemEnumValues && field.itemEnumValues.length > 0) {
hintParts.push(`Allowed items: ${field.itemEnumValues.join(", ")}`); hintParts.push(localizer.t("input.batchArray.placeholder.allowedItems", {
values: field.itemEnumValues.join(", ")
}));
} }
if (field.defaultValue) { if (field.defaultValue) {
hintParts.push(`Default: ${field.defaultValue}`); hintParts.push(localizer.t("input.batchArray.placeholder.default", {value: field.defaultValue}));
} }
return vscode.window.showInputBox({ return vscode.window.showInputBox({
title: `Batch Edit Array: ${field.title || field.key}`, title: localizer.t("input.batchArray.title", {field: field.title || field.key}),
prompt: `Enter comma-separated items for '${field.key}' (expected array<${field.itemType}>). Leave empty to clear the array.`, prompt: localizer.t("input.batchArray.prompt", {
fieldKey: field.key,
itemType: field.itemType
}),
placeHolder: hintParts.join(" | "), placeHolder: hintParts.join(" | "),
ignoreFocusOut: true ignoreFocusOut: true
}); });
@ -1313,22 +1328,27 @@ async function promptBatchFieldValue(field) {
const picked = await vscode.window.showQuickPick( const picked = await vscode.window.showQuickPick(
field.enumValues.map((value) => ({ field.enumValues.map((value) => ({
label: value, label: value,
description: value === field.defaultValue ? "default" : undefined description: value === field.defaultValue
? localizer.t("detail.default")
: undefined
})), })),
{ {
title: `Batch Edit Field: ${field.title || field.key}`, title: localizer.t("quickPick.batchField.title", {field: field.title || field.key}),
placeHolder: `Select a value for '${field.key}'.` placeHolder: localizer.t("quickPick.batchField.placeholder", {fieldKey: field.key})
}); });
return picked ? picked.label : undefined; return picked ? picked.label : undefined;
} }
return vscode.window.showInputBox({ return vscode.window.showInputBox({
title: `Batch Edit Field: ${field.title || field.key}`, title: localizer.t("input.batchField.title", {field: field.title || field.key}),
prompt: `Enter the new value for '${field.key}' (expected ${field.type}).`, prompt: localizer.t("input.batchField.prompt", {
fieldKey: field.key,
type: field.type
}),
placeHolder: [ placeHolder: [
field.description || "", field.description || "",
field.defaultValue ? `Default: ${field.defaultValue}` : "", field.defaultValue ? localizer.t("input.batchArray.placeholder.default", {value: field.defaultValue}) : "",
field.refTable ? `Ref table: ${field.refTable}` : "" field.refTable ? localizer.t("input.batchField.placeholder.refTable", {refTable: field.refTable}) : ""
].filter((part) => part.length > 0).join(" | ") || undefined, ].filter((part) => part.length > 0).join(" | ") || undefined,
ignoreFocusOut: true ignoreFocusOut: true
}); });

View File

@ -0,0 +1,142 @@
/**
* Create a tiny in-process localizer for the extension runtime and webview.
* VS Code contribution points use package.nls files, while runtime strings are
* resolved here so the preview panel and prompts stay readable for both
* Simplified Chinese and English users.
*
* @param {string | undefined} language VS Code UI language.
* @returns {{languageTag: string, isChinese: boolean, t: (key: string, params?: Record<string, string | number>) => string}} Localizer.
*/
function createLocalizer(language) {
const normalizedLanguage = String(language || "en").toLowerCase();
const isChinese = normalizedLanguage.startsWith("zh");
const languageTag = isChinese ? "zh-CN" : "en";
const dictionary = isChinese ? zhCnMessages : enMessages;
return {
languageTag,
isChinese,
t(key, params) {
const template = dictionary[key] || enMessages[key] || key;
return template.replace(/\{([A-Za-z0-9_]+)\}/gu, (match, token) => {
if (!params || !Object.prototype.hasOwnProperty.call(params, token)) {
return match;
}
return String(params[token]);
});
}
};
}
const enMessages = {
"tree.noConfigDirectory.label": "No config directory",
"tree.noConfigDirectory.description": "Set gframeworkConfig.configPath or create the directory.",
"tree.fileDescription.schema": "schema",
"tree.fileDescription.schemaMissing": "schema missing",
"command.openRaw.title": "Open Raw",
"message.schemaNotFound": "Matching schema file was not found.",
"message.formSaved": "Config file saved from form preview.",
"message.noYamlFilesInDomain": "No YAML config files were found in the selected domain.",
"message.batchEditNeedsSchema": "Batch edit requires a matching schema file for the selected domain.",
"message.batchEditNoEditableFields": "No top-level scalar or scalar-array fields were found in the matching schema.",
"message.batchEditNoChanges": "Batch edit did not change any selected config files.",
"message.batchEditUpdated": "Batch updated {count} config file(s) in '{domain}'.",
"diagnostic.schemaMissing": "Matching schema file not found: {schemaPath}",
"quickPick.batchEdit.title": "Batch Edit: {domain}",
"quickPick.batchEdit.placeholder": "Select the config files to update.",
"quickPick.batchEditFields.title": "Batch Edit Fields: {domain}",
"quickPick.batchEditFields.placeholder": "Select the fields to apply across the chosen files.",
"detail.required": "required",
"detail.refTable": "ref: {refTable}",
"detail.arrayType": "array<{itemType}>",
"detail.default": "default",
"input.batchArray.title": "Batch Edit Array: {field}",
"input.batchArray.prompt": "Enter comma-separated items for '{fieldKey}' (expected array<{itemType}>). Leave empty to clear the array.",
"input.batchArray.placeholder.allowedItems": "Allowed items: {values}",
"input.batchArray.placeholder.default": "Default: {value}",
"quickPick.batchField.title": "Batch Edit Field: {field}",
"quickPick.batchField.placeholder": "Select a value for '{fieldKey}'.",
"input.batchField.title": "Batch Edit Field: {field}",
"input.batchField.prompt": "Enter the new value for '{fieldKey}' (expected {type}).",
"input.batchField.placeholder.refTable": "Ref table: {refTable}",
"webview.panelTitle": "Config Form: {fileName}",
"webview.meta.file": "File: {fileName}",
"webview.meta.schema": "Schema: {schemaPath}",
"webview.meta.schemaMissing": "Schema missing: {schemaPath}",
"webview.emptyState": "No editable schema-bound fields were detected. Use raw YAML for unsupported shapes.",
"webview.button.save": "Save Form",
"webview.button.openRaw": "Open Raw YAML",
"webview.badge.required": "required",
"webview.objectArray.item": "Item",
"webview.objectArray.itemNumber": "Item {index}",
"webview.objectArray.hint": "Each item uses the object schema below.",
"webview.objectArray.add": "Add Item",
"webview.objectArray.remove": "Remove",
"webview.array.hint": "One item per line. Expected type: {itemType}",
"webview.hint.default": "Default: {value}",
"webview.hint.allowed": "Allowed: {values}",
"webview.hint.refTable": "Ref table: {refTable}",
"webview.unsupported.array": "Unsupported array shapes are currently raw-YAML-only in the form preview.",
"webview.unsupported.type": "{type} fields are currently raw-YAML-only.",
"webview.unsupported.objectArrayMixed": "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items.",
"webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor."
};
const zhCnMessages = {
"tree.noConfigDirectory.label": "未找到配置目录",
"tree.noConfigDirectory.description": "请设置 gframeworkConfig.configPath或先创建该目录。",
"tree.fileDescription.schema": "已匹配 schema",
"tree.fileDescription.schemaMissing": "缺少 schema",
"command.openRaw.title": "打开原始文件",
"message.schemaNotFound": "未找到匹配的 schema 文件。",
"message.formSaved": "已从表单预览保存配置文件。",
"message.noYamlFilesInDomain": "所选配置域中没有找到 YAML 配置文件。",
"message.batchEditNeedsSchema": "批量编辑要求该配置域存在匹配的 schema 文件。",
"message.batchEditNoEditableFields": "匹配的 schema 中没有可批量编辑的顶层标量字段或标量数组字段。",
"message.batchEditNoChanges": "批量编辑未修改任何已选配置文件。",
"message.batchEditUpdated": "已在“{domain}”中批量更新 {count} 个配置文件。",
"diagnostic.schemaMissing": "未找到匹配的 schema 文件:{schemaPath}",
"quickPick.batchEdit.title": "批量编辑:{domain}",
"quickPick.batchEdit.placeholder": "选择要更新的配置文件。",
"quickPick.batchEditFields.title": "批量编辑字段:{domain}",
"quickPick.batchEditFields.placeholder": "选择要应用到已选文件的字段。",
"detail.required": "必填",
"detail.refTable": "引用表:{refTable}",
"detail.arrayType": "数组<{itemType}>",
"detail.default": "默认值",
"input.batchArray.title": "批量编辑数组:{field}",
"input.batchArray.prompt": "请输入“{fieldKey}”的逗号分隔项(期望类型:数组<{itemType}>)。留空表示清空数组。",
"input.batchArray.placeholder.allowedItems": "允许项:{values}",
"input.batchArray.placeholder.default": "默认值:{value}",
"quickPick.batchField.title": "批量编辑字段:{field}",
"quickPick.batchField.placeholder": "为“{fieldKey}”选择一个值。",
"input.batchField.title": "批量编辑字段:{field}",
"input.batchField.prompt": "请输入“{fieldKey}”的新值(期望类型:{type})。",
"input.batchField.placeholder.refTable": "引用表:{refTable}",
"webview.panelTitle": "配置表单:{fileName}",
"webview.meta.file": "文件:{fileName}",
"webview.meta.schema": "Schema{schemaPath}",
"webview.meta.schemaMissing": "缺少 Schema{schemaPath}",
"webview.emptyState": "当前没有可编辑的 schema 绑定字段。对于暂不支持的结构,请回退到原始 YAML 编辑。",
"webview.button.save": "保存表单",
"webview.button.openRaw": "打开原始 YAML",
"webview.badge.required": "必填",
"webview.objectArray.item": "对象项",
"webview.objectArray.itemNumber": "对象项 {index}",
"webview.objectArray.hint": "每一项都按下面的对象 schema 编辑。",
"webview.objectArray.add": "新增对象项",
"webview.objectArray.remove": "删除",
"webview.array.hint": "每行一个元素。期望类型:{itemType}",
"webview.hint.default": "默认值:{value}",
"webview.hint.allowed": "允许值:{values}",
"webview.hint.refTable": "引用表:{refTable}",
"webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。",
"webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。",
"webview.unsupported.objectArrayMixed": "对象数组中的每一项都必须是映射对象。如果当前文件混用了标量项和对象项,请改用原始 YAML。",
"webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。"
};
module.exports = {
createLocalizer
};

View File

@ -174,6 +174,27 @@ reward:
assert.match(diagnostics[0].message, /coin, gem/u); assert.match(diagnostics[0].message, /coin, gem/u);
}); });
test("validateParsedConfig should localize diagnostics when Chinese UI is requested", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string" }
}
}
`);
const yaml = parseTopLevelYaml(`
id: 1
`);
const diagnostics = validateParsedConfig(schema, yaml, {isChinese: true});
assert.equal(diagnostics.length, 2);
assert.match(diagnostics[0].message, /缺少必填属性/u);
assert.match(diagnostics[1].message, /未在匹配的 schema 中声明/u);
});
test("applyFormUpdates should update nested scalar and scalar-array paths", () => { test("applyFormUpdates should update nested scalar and scalar-array paths", () => {
const updated = applyFormUpdates( const updated = applyFormUpdates(
[ [

View File

@ -0,0 +1,25 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {createLocalizer} = require("../src/localization");
test("createLocalizer should default to English strings", () => {
const localizer = createLocalizer("en");
assert.equal(localizer.languageTag, "en");
assert.equal(localizer.isChinese, false);
assert.equal(localizer.t("webview.button.save"), "Save Form");
assert.equal(
localizer.t("message.batchEditUpdated", {count: 2, domain: "monster"}),
"Batch updated 2 config file(s) in 'monster'.");
});
test("createLocalizer should switch to Simplified Chinese for zh languages", () => {
const localizer = createLocalizer("zh-cn");
assert.equal(localizer.languageTag, "zh-CN");
assert.equal(localizer.isChinese, true);
assert.equal(localizer.t("webview.button.save"), "保存表单");
assert.equal(
localizer.t("message.batchEditUpdated", {count: 2, domain: "monster"}),
"已在“monster”中批量更新 2 个配置文件。");
});