Files
lserp_cs_6.0/docs/superpowers/plans/2026-07-30-browser-json-datatable-complex-values.md
T

479 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 浏览器 JSON 复杂字段兼容 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让旧 WinForms 浏览器桥接能够把任意对象或数组字段保留为紧凑 JSON 字符串,并继续安全转换为 `DataTable`
**Architecture:**`Lskj.Util.JsonUtil` 增加一个只供明确调用方选用的转换入口:先扫描整批 JSON 行,确定复杂列,再把这些列统一字符串化,最后复用 Newtonsoft.Json 原有 `DataTableConverter``Lskj.Control` 只在网页右键和返回行两个边界调用新入口,其他 JSON 转换和 WPF 宿主保持不变。
**Tech Stack:** .NET Framework 4.0、C# 7.3、Newtonsoft.Json 13.0.1、`System.Data.DataTable`、MSBuild、无第三方测试框架的控制台回归测试程序。
## Global Constraints
- 保留对象和数组字段,不删除 `$styles` 或其他未来字段。
- 复杂值必须转换成无缩进的紧凑 JSON 字符串。
- 从未出现对象或数组的标量列继续使用现有 Newtonsoft.Json 类型推断。
- 不修改现有 `JsonUtil.ToDataTable``JsonUtil.ToDataRow` 的语义。
- 不修改全局 Newtonsoft.Json 设置,不替换项目中的其他 `DeserializeObject<DataTable>` 调用。
- 不修改 WPF、数据库、菜单查找、参数替换或 DLL 加载逻辑。
- 只接入 `BsOpenModuleHandler.OpenRightModule``BsOpenModuleHandler.AddReturnRow`
- 现有两个目标源码文件只有 CRLF/LF 换行差异;执行前必须确认没有语义差异,再机械归一化,避免产生整文件噪声提交。
---
### Task 1: 建立回归测试并实现公共转换器
**Files:**
- Create: `插件库/Lskj.Util.Tests/Lskj.Util.Tests.csproj`
- Create: `插件库/Lskj.Util.Tests/Program.cs`
- Modify: `插件库/Lskj.Util/JsonUtil.cs:1-190`
**Interfaces:**
- Consumes: Newtonsoft.Json `JToken``JArray``JObject` 和现有 `DataTableConverter`
- Produces: `public static DataTable JsonUtil.ToDataTablePreservingComplexValues(string json)`;接受顶层对象或对象数组,返回保留所有字段的 `DataTable`
- [ ] **Step 1: 确认目标源码没有未提交的语义改动**
Run:
```bash
git diff --ignore-space-at-eol --exit-code -- \
'插件库/Lskj.Util/JsonUtil.cs' \
'插件库/Lskj.Control/BrowserSetting/BsOpenModuleHandler.cs'
```
Expected: exit code `0`,无输出。当前普通 `git diff --numstat` 显示的是 CRLF/LF 差异,不代表业务代码变化。
- [ ] **Step 2: 机械归一化两个目标源码的换行符**
只有 Step 1 成功后执行:
```bash
perl -pi -e 's/\r$//' \
'插件库/Lskj.Util/JsonUtil.cs' \
'插件库/Lskj.Control/BrowserSetting/BsOpenModuleHandler.cs'
git diff --check -- \
'插件库/Lskj.Util/JsonUtil.cs' \
'插件库/Lskj.Control/BrowserSetting/BsOpenModuleHandler.cs'
```
Expected: `git diff --check` 无输出;此时两个文件不再显示整文件换行差异。
- [ ] **Step 3: 创建不依赖测试框架的回归测试项目**
Create `插件库/Lskj.Util.Tests/Lskj.Util.Tests.csproj`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{8E501869-816F-4E3E-81EE-A3BF9C223D61}</ProjectGuid>
<OutputType>Exe</OutputType>
<RootNamespace>Lskj.Util.Tests</RootNamespace>
<AssemblyName>Lskj.Util.Tests</AssemblyName>
<TargetFrameworkVersion>v4.0</TargetFrameworkVersion>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<OutputPath>bin\Debug\</OutputPath>
<PlatformTarget>AnyCPU</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Data" />
<Reference Include="Newtonsoft.Json, Version=13.0.1.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<SpecificVersion>False</SpecificVersion>
<HintPath>..\..\引用DLL\Newtonsoft.Json.dll</HintPath>
<Private>True</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="Program.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lskj.Util\Lskj.Util.csproj">
<Project>{A51BF642-6543-4DE3-8948-83F558B72BD4}</Project>
<Name>Lskj.Util</Name>
</ProjectReference>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
```
Create `插件库/Lskj.Util.Tests/Program.cs`:
```csharp
using Lskj.Util;
using Newtonsoft.Json;
using System;
using System.Data;
namespace Lskj.Util.Tests
{
internal static class Program
{
private static int Main()
{
try
{
PreservesStylesObjectAsCompactJson();
PreservesArrayAsCompactJson();
NormalizesEveryValueInAMixedComplexColumn();
LeavesScalarColumnsEquivalentToExistingConverter();
AcceptsSingleObject();
RejectsInvalidTopLevelValues();
Console.WriteLine("PASS: JsonUtil complex-value conversion tests");
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
return 1;
}
}
private static void PreservesStylesObjectAsCompactJson()
{
const string json = "[{\"id\":1,\"$styles\":{\"rowstyle\":\"{color:'#FFFF00','background-color':'#FF0000'}\"}}]";
DataTable table = JsonUtil.ToDataTablePreservingComplexValues(json);
AssertEqual(1, table.Rows.Count, "styles row count");
AssertEqual("{\"rowstyle\":\"{color:'#FFFF00','background-color':'#FF0000'}\"}",
table.Rows[0]["$styles"].ToString(), "styles JSON");
}
private static void PreservesArrayAsCompactJson()
{
const string json = "[{\"id\":1,\"items\":[{\"code\":\"A\"},{\"code\":\"B\"}]}]";
DataTable table = JsonUtil.ToDataTablePreservingComplexValues(json);
AssertEqual("[{\"code\":\"A\"},{\"code\":\"B\"}]",
table.Rows[0]["items"].ToString(), "array JSON");
}
private static void NormalizesEveryValueInAMixedComplexColumn()
{
const string json = "["
+ "{\"id\":1,\"payload\":5},"
+ "{\"id\":2,\"payload\":{\"code\":\"A\"}},"
+ "{\"id\":3,\"payload\":[1,2]},"
+ "{\"id\":4,\"payload\":null},"
+ "{\"id\":5,\"payload\":\"ready\"}]";
DataTable table = JsonUtil.ToDataTablePreservingComplexValues(json);
AssertEqual(typeof(string), table.Columns["payload"].DataType, "mixed column type");
AssertEqual("5", table.Rows[0]["payload"].ToString(), "numeric scalar text");
AssertEqual("{\"code\":\"A\"}", table.Rows[1]["payload"].ToString(), "object text");
AssertEqual("[1,2]", table.Rows[2]["payload"].ToString(), "array text");
AssertTrue(table.Rows[3].IsNull("payload"), "null remains null");
AssertEqual("ready", table.Rows[4]["payload"].ToString(), "string remains string");
}
private static void LeavesScalarColumnsEquivalentToExistingConverter()
{
const string json = "[{\"id\":1,\"title\":\"A\",\"active\":true},{\"id\":2,\"title\":\"B\",\"active\":false}]";
DataTable expected = JsonConvert.DeserializeObject<DataTable>(json);
DataTable actual = JsonUtil.ToDataTablePreservingComplexValues(json);
AssertEqual(expected.Columns.Count, actual.Columns.Count, "scalar column count");
AssertEqual(expected.Rows.Count, actual.Rows.Count, "scalar row count");
foreach (DataColumn column in expected.Columns)
{
AssertEqual(column.DataType, actual.Columns[column.ColumnName].DataType,
"scalar column type " + column.ColumnName);
for (int rowIndex = 0; rowIndex < expected.Rows.Count; rowIndex++)
{
AssertEqual(expected.Rows[rowIndex][column.ColumnName],
actual.Rows[rowIndex][column.ColumnName],
"scalar value " + column.ColumnName + " row " + rowIndex);
}
}
}
private static void AcceptsSingleObject()
{
const string json = "{\"ProductId\":\"P001\",\"meta\":{\"source\":\"web\"}}";
DataTable table = JsonUtil.ToDataTablePreservingComplexValues(json);
AssertEqual(1, table.Rows.Count, "single object row count");
AssertEqual("P001", table.Rows[0]["ProductId"].ToString(), "single object scalar");
AssertEqual("{\"source\":\"web\"}", table.Rows[0]["meta"].ToString(), "single object nested JSON");
}
private static void RejectsInvalidTopLevelValues()
{
AssertThrows<ArgumentException>(delegate { JsonUtil.ToDataTablePreservingComplexValues(" "); }, "empty input");
AssertThrows<JsonReaderException>(delegate { JsonUtil.ToDataTablePreservingComplexValues("{"); }, "invalid JSON");
AssertThrows<JsonSerializationException>(delegate { JsonUtil.ToDataTablePreservingComplexValues("[1]"); }, "non-object row");
}
private static void AssertThrows<TException>(Action action, string name) where TException : Exception
{
try { action(); }
catch (TException) { return; }
throw new InvalidOperationException("Expected " + typeof(TException).Name + ": " + name);
}
private static void AssertTrue(bool value, string name)
{
if (!value) throw new InvalidOperationException("Assertion failed: " + name);
}
private static void AssertEqual<T>(T expected, T actual, string name)
{
if (!object.Equals(expected, actual))
{
throw new InvalidOperationException(name + ": expected <" + expected + ">, actual <" + actual + ">");
}
}
}
}
```
- [ ] **Step 4: 构建测试项目并确认它先失败**
Run in Windows PowerShell from `E:\lserp_project\lserp_cs_6.0`:
```powershell
$msbuild = Get-ChildItem "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\*\MSBuild\Current\Bin\MSBuild.exe" |
Select-Object -First 1 -ExpandProperty FullName
if (-not $msbuild) { $msbuild = "$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe" }
& $msbuild "插件库\Lskj.Util.Tests\Lskj.Util.Tests.csproj" /t:Rebuild /p:Configuration=Debug /p:Platform=AnyCPU
```
Expected: build fails with `CS0117` because `JsonUtil.ToDataTablePreservingComplexValues` does not exist yet. Dependency or SDK errors do not satisfy this step。
- [ ] **Step 5: 实现复杂字段归一化方法**
Add `using System.Globalization;` to `插件库/Lskj.Util/JsonUtil.cs`, then add this method to `JsonUtil` without changing existing methods:
```csharp
public static DataTable ToDataTablePreservingComplexValues(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
throw new ArgumentException("网页行 JSON 不能为空。", "json");
}
JToken root = JToken.Parse(json);
JArray rows;
if (root.Type == JTokenType.Array)
{
rows = (JArray)root;
}
else if (root.Type == JTokenType.Object)
{
rows = new JArray(root);
}
else
{
throw new JsonSerializationException("网页行 JSON 的顶层必须是对象或对象数组。");
}
HashSet<string> complexColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (JToken rowToken in rows)
{
if (rowToken.Type != JTokenType.Object)
{
throw new JsonSerializationException("网页行 JSON 数组中的每一项都必须是对象。");
}
foreach (JProperty property in ((JObject)rowToken).Properties())
{
if (property.Value.Type == JTokenType.Object || property.Value.Type == JTokenType.Array)
{
complexColumns.Add(property.Name);
}
}
}
foreach (JObject row in rows.Children<JObject>())
{
foreach (JProperty property in row.Properties().ToList())
{
if (!complexColumns.Contains(property.Name)
|| property.Value.Type == JTokenType.Null
|| property.Value.Type == JTokenType.Undefined)
{
continue;
}
string text;
if (property.Value.Type == JTokenType.Object || property.Value.Type == JTokenType.Array)
{
text = property.Value.ToString(Formatting.None);
}
else if (property.Value.Type == JTokenType.String)
{
text = property.Value.Value<string>();
}
else
{
JValue scalar = property.Value as JValue;
text = scalar == null || scalar.Value == null
? null
: Convert.ToString(scalar.Value, CultureInfo.InvariantCulture);
}
property.Value = text == null ? JValue.CreateNull() : new JValue(text);
}
}
return JsonConvert.DeserializeObject<DataTable>(rows.ToString(Formatting.None));
}
```
- [ ] **Step 6: 构建并运行转换级回归测试**
```powershell
& $msbuild "插件库\Lskj.Util.Tests\Lskj.Util.Tests.csproj" /t:Rebuild /p:Configuration=Debug /p:Platform=AnyCPU
if ($LASTEXITCODE -ne 0) { throw "Lskj.Util.Tests build failed" }
& "插件库\Lskj.Util.Tests\bin\Debug\Lskj.Util.Tests.exe"
if ($LASTEXITCODE -ne 0) { throw "Lskj.Util.Tests failed" }
```
Expected: build succeeds and executable prints `PASS: JsonUtil complex-value conversion tests`.
- [ ] **Step 7: 提交公共转换器和回归测试**
```bash
git add \
'插件库/Lskj.Util/JsonUtil.cs' \
'插件库/Lskj.Util.Tests/Lskj.Util.Tests.csproj' \
'插件库/Lskj.Util.Tests/Program.cs'
git diff --cached --check
git commit -m 'fix(util): preserve complex JSON values in data tables'
```
Expected: commit only contains the new test project and the focused `JsonUtil` method.
---
### Task 2: 在旧浏览器桥接的两个入口接入转换器
**Files:**
- Modify: `插件库/Lskj.Control/BrowserSetting/BsOpenModuleHandler.cs:101-116`
- Modify: `插件库/Lskj.Control/BrowserSetting/BsOpenModuleHandler.cs:322-334`
**Interfaces:**
- Consumes: `JsonUtil.ToDataTablePreservingComplexValues(string json)` from Task 1。
- Produces: `OpenRightModule``AddReturnRow` 对任意对象/数组字段的兼容行为;其他菜单及模块调用接口不变。
- [ ] **Step 1: 将右键入口切换到公共转换器**
Replace:
```csharp
DataTable dataTable = JsonConvert.DeserializeObject<DataTable>(arrayItem);
```
with:
```csharp
DataTable dataTable = JsonUtil.ToDataTablePreservingComplexValues(arrayItem);
```
Do not change the `BaseImpl.GetDataRowResult` query, `GridRightMenuModel`, `DynamicModel` or `menu.ExecRightMenu` calls.
- [ ] **Step 2: 将网页返回行入口切换到公共转换器并删除冗余包装**
Replace:
```csharp
JObject rowObject = JsonConvert.DeserializeObject<JObject>(rowdata);
JArray jArray = new JArray();
jArray.Add(rowObject);
DataTable dataTable = JsonConvert.DeserializeObject<DataTable>(JsonConvert.SerializeObject(jArray));
```
with:
```csharp
DataTable dataTable = JsonUtil.ToDataTablePreservingComplexValues(rowdata);
```
Remove the now-unused `using Newtonsoft.Json;` and `using Newtonsoft.Json.Linq;`. Keep product-prefix renaming and `StaticControl.ReturnRowLisy.Add` unchanged.
- [ ] **Step 3: 静态检查浏览器桥接范围**
```bash
rg -n 'DeserializeObject\s*<\s*DataTable\s*>|ToDataTablePreservingComplexValues' \
'插件库/Lskj.Control/BrowserSetting/BsOpenModuleHandler.cs'
git diff --check -- '插件库/Lskj.Control/BrowserSetting/BsOpenModuleHandler.cs'
```
Expected: only two `ToDataTablePreservingComplexValues` calls remain in this file;没有直接的 `DeserializeObject<DataTable>`,且 `git diff --check` 无输出。
- [ ] **Step 4: 重跑转换级测试并构建 Lskj.Control**
```powershell
& "插件库\Lskj.Util.Tests\bin\Debug\Lskj.Util.Tests.exe"
if ($LASTEXITCODE -ne 0) { throw "Lskj.Util.Tests failed" }
& $msbuild "插件库\Lskj.Control\Lskj.Control.csproj" /t:Rebuild /p:Configuration=Debug /p:Platform=AnyCPU
if ($LASTEXITCODE -ne 0) { throw "Lskj.Control build failed" }
```
Expected: tests print `PASS``Lskj.Control` build succeeds without new warnings or errors and refreshes `Debug\Lskj.Control.dll` plus referenced `Lskj.Util.dll`.
- [ ] **Step 5: 提交浏览器桥接改动**
```bash
git add '插件库/Lskj.Control/BrowserSetting/BsOpenModuleHandler.cs'
git diff --cached --check
git commit -m 'fix(browser): accept nested values in row callbacks'
```
Expected: commit only changes the two intended browser callback conversions and removes the two unused Newtonsoft `using` directives.
---
### Task 3: 最终构建与业务验收交接
**Files:**
- Verify: `Debug/Lskj.Util.dll`
- Verify: `Debug/Lskj.Control.dll`
- Verify: `Debug/Ls_NERP.exe`
**Interfaces:**
- Consumes: Task 1 的转换器和 Task 2 的浏览器接入。
- Produces: 可由用户分别从旧 WinForms 和 WPF 宿主进行验收的 Debug 运行目录。
- [ ] **Step 1: 执行最终自动验证**
```powershell
& $msbuild "插件库\Lskj.Util.Tests\Lskj.Util.Tests.csproj" /t:Rebuild /p:Configuration=Debug /p:Platform=AnyCPU
if ($LASTEXITCODE -ne 0) { throw "Lskj.Util.Tests build failed" }
& "插件库\Lskj.Util.Tests\bin\Debug\Lskj.Util.Tests.exe"
if ($LASTEXITCODE -ne 0) { throw "Lskj.Util.Tests failed" }
& $msbuild "插件库\Lskj.Control\Lskj.Control.csproj" /t:Rebuild /p:Configuration=Debug /p:Platform=AnyCPU
if ($LASTEXITCODE -ne 0) { throw "Lskj.Control build failed" }
Get-Item "Debug\Lskj.Util.dll", "Debug\Lskj.Control.dll" |
Select-Object FullName, Length, LastWriteTime
```
Expected: both builds succeed, test executable prints `PASS`, and both runtime DLL timestamps reflect the current build.
- [ ] **Step 2: 核对最终差异和提交历史**
```bash
git diff --check
git status --short
git log -4 --oneline --decorate
```
Expected: no uncommitted implementation changes;历史包含设计提交、公共转换器提交和浏览器桥接提交。构建输出和原有忽略文件不应进入 Git。
- [ ] **Step 3: 交给用户执行业务验收**
Provide this exact checklist:
```text
1. 从 WPF 宿主启动,进入同一待办任务页面并点击“待办任务”。
2. 确认不再出现 Path '[0].$styles' / StartObject 的 DataTable 反序列化错误。
3. 确认右键配置继续解析到 PUR_4004,并正常进入原有客户端模块加载流程。
4. 使用一个不包含对象/数组字段的旧网页右键功能,确认行为未变化。
5. 如可用,再从旧 WinForms 入口重复步骤 1-4,确认两个宿主行为一致。
6. 验证网页返回行功能,确认 ProductId 前缀和原有回填逻辑未变化。
```
No additional source commit is required for this manual acceptance step.