fix(detail-client): refresh saved project state and record full acceptance

This commit is contained in:
WuYongJie 2026-10-22 23:05:38 +08:00
parent e528cbda10
commit 8b982ff3b8
2 changed files with 176 additions and 0 deletions

View File

@ -76,6 +76,42 @@ fn finish_open_busy(busy: &mut Option<String>, automatic: bool) {
}
}
/// A local save acknowledges one canvas revision, not the latest cloud version or
/// edits made while it was saving. Never let that receipt roll either state back.
fn saved_project_state(
context: &Value,
dirty: bool,
loaded: Option<u64>,
active: Option<(u64, u64)>,
receipt: (u64, u64, &Value),
edited: bool,
) -> Option<(Value, bool)> {
let (id, revision, meta) = receipt;
let (_, active_revision) = active.filter(|(active_id, active_revision)| *active_id == id && loaded == Some(id) && *active_revision >= revision)?;
let mut next = if context.is_object() { context.clone() } else { json!({}) };
let current_link = &context["cloudLink"];
let received_link = &meta["cloudLink"];
let older_cloud = current_link["id"].is_string()
&& (current_link["id"] != received_link["id"]
|| current_link["source"] != received_link["source"]
|| current_link["revision"].as_u64() > received_link["revision"].as_u64());
let older_sync = context["syncedRevision"].as_u64() > meta["syncedRevision"].as_u64();
let newer_edit = edited || (active_revision != revision && context["syncedRevision"].as_u64() != Some(active_revision));
let mut dirty = dirty || newer_edit;
if active_revision == revision && !older_cloud && !older_sync {
for key in ["cloudLink", "syncedRevision", "status"] {
if !meta[key].is_null() {
next[key] = meta[key].clone();
}
}
dirty |= meta["syncDirty"].as_bool().unwrap_or(true);
}
if dirty {
next["status"] = json!("draft");
}
Some((next, dirty))
}
#[cfg(test)]
mod startup_resume_tests {
use super::*;
@ -940,6 +976,17 @@ impl Workspace {
Event::Saved(id, rev, meta) => {
self.saving = None;
self.saved = Some((id, rev, meta["time"].as_str().unwrap_or("").into()));
if let Some((context, dirty)) = saved_project_state(
&self.project_context,
self.sync_dirty,
self.loaded_draft,
app.session.active().map(|active| (active.doc.id.0, active.revision)),
(id, rev, &meta),
self.draft_changed || self.creation_dirty,
) {
self.project_context = context;
self.sync_dirty = dirty;
}
if let Some(local) = meta["localId"].as_str() {
self.open_projects.insert(id, self.local_identity(local));
}
@ -1380,6 +1427,65 @@ pub fn action(app: &mut PhotocraftApp, method: &str, input: Value) -> Result<Val
mod tests {
use super::*;
#[test]
fn saved_receipt_refreshes_only_the_loaded_active_project_state() {
let context = json!({"cloudLink":{"id":"cloud","source":"desktop","revision":3},"syncedRevision":8,"status":"completed","document":"preserved"});
let saved = json!({"cloudLink":{"id":"cloud","source":"desktop","revision":3},"syncedRevision":8,"status":"draft","syncDirty":true});
let (next, dirty) = saved_project_state(&context, false, Some(4), Some((4, 9)), (4, 9, &saved), false).unwrap();
assert!(dirty);
assert_eq!(next["status"], "draft");
assert_eq!(next["syncedRevision"], 8);
assert_eq!(next["cloudLink"], saved["cloudLink"]);
assert_eq!(next["document"], "preserved");
for (loaded, active, receipt) in [
(Some(4), Some((5, 9)), (4, 9, &saved)),
(Some(5), Some((4, 9)), (4, 9, &saved)),
(None, Some((4, 9)), (4, 9, &saved)),
(Some(4), None, (4, 9, &saved)),
(Some(4), Some((4, 9)), (5, 9, &saved)),
(Some(4), Some((4, 9)), (4, 10, &saved)),
] {
assert!(saved_project_state(&context, false, loaded, active, receipt, false).is_none());
}
}
#[test]
fn local_save_never_clears_new_canvas_or_product_edits_or_existing_dirty_state() {
let context = json!({"cloudLink":{"id":"cloud","source":"desktop","revision":3},"syncedRevision":8,"status":"completed"});
let saved = json!({"cloudLink":context["cloudLink"],"syncedRevision":8,"status":"completed","syncDirty":false});
for (active_revision, edited, dirty) in [(10, false, false), (9, true, false), (9, false, true)] {
let (next, dirty) = saved_project_state(&context, dirty, Some(4), Some((4, active_revision)), (4, 9, &saved), edited).unwrap();
assert!(dirty);
assert_eq!(next["status"], "draft");
assert_eq!(next["syncedRevision"], 8);
}
}
#[test]
fn older_local_save_cannot_rollback_newer_cloud_or_already_synced_state() {
let context = json!({"cloudLink":{"id":"cloud","source":"desktop","revision":5},"syncedRevision":9,"status":"completed"});
for saved in [
json!({"cloudLink":{"id":"cloud","source":"desktop","revision":4},"syncedRevision":9,"status":"draft","syncDirty":true}),
json!({"cloudLink":context["cloudLink"],"syncedRevision":8,"status":"draft","syncDirty":true}),
json!({"cloudLink":{"id":"old-cloud","source":"desktop","revision":99},"syncedRevision":9,"status":"draft","syncDirty":true}),
] {
let (next, dirty) = saved_project_state(&context, false, Some(4), Some((4, 9)), (4, 9, &saved), false).unwrap();
assert_eq!(next, context);
assert!(!dirty);
}
let old = json!({"cloudLink":context["cloudLink"],"syncedRevision":8,"status":"draft","syncDirty":true});
let (next, dirty) = saved_project_state(&context, false, Some(4), Some((4, 9)), (4, 8, &old), false).unwrap();
assert_eq!(next, context);
assert!(!dirty, "a superseded local ACK is not a new edit after a newer cloud sync");
}
#[test]
fn matching_clean_save_retains_completed_state_and_newer_cloud_metadata() {
let context = json!({"cloudLink":{"id":"cloud","source":"desktop","revision":3},"syncedRevision":8,"status":"draft"});
let saved = json!({"cloudLink":{"id":"cloud","source":"desktop","revision":4},"syncedRevision":9,"status":"completed","syncDirty":false});
let (next, dirty) = saved_project_state(&context, false, Some(4), Some((4, 9)), (4, 9, &saved), false).unwrap();
assert_eq!(next["cloudLink"], saved["cloudLink"]);
assert_eq!(next["syncedRevision"], 9);
assert_eq!(next["status"], "completed");
assert!(!dirty);
}
#[test]
fn input_hint_galley_uses_the_native_readable_token_without_global_style_changes() {
fn hint(shape: &egui::Shape, color: egui::Color32) -> bool {
match shape {

View File

@ -0,0 +1,70 @@
# 最终版客户端与 Web 全流程复验(2026-10-22)
## 本轮范围
在既有真实产品三屏验收基础上,补最终原生程序的资料准备、恢复、中文编辑、撤销、导出、草稿/完结、云同步、独立配置恢复和双端删除复验。测试只使用本人账户和本项目接口;98k 只允许既有对外适配,本轮不派发任何新的分析、文案、生图或恢复请求。
真实资料分析、两批卖点、方案确认和三张生成图来自此前已成功的顾家 `GBGJ-CYJ0056` 项目,详见[完整流程原始记录](client-web-closeout-acceptance-2026-10-22.md)。恢复查看这些真实结果,不等同于最终程序重新调用了一遍模型。旧未知请求不会因为验收而重发、改写或转成失败。
## 本轮修复
功能源码已提交并推送为 `e528cbda1059e814ffbe4e8054614d7de88108d1`,仅包含本轮 2 个原生生命周期文件、2 个 Web 只读文件及对应 2 个 Web 测试文件;其余知识库、旧 Web 创作流程、Prisma 等未提交工作保留。
发现并修复“取回成功但尚未应用”的生命周期漏检:原图片任务可能已经 `complete`,但目标屏冲突、待应用记录仍在;旧判断可能允许提前完结或删除。
- 本机完结、同步完结、删除及远端删除后的本机回收共用待处理保护。
- 草稿仍可正常备份,摘要保留原任务 ID,已知待应用使用 `pending=true, unknown=false`;真正未知仍是 `unknown=true`。
- 原任务已经自动或明确手动应用后放行;普通历史 `complete` 不被一律阻止。损坏记录拒绝放行,不改写原项目。
- 若云端草稿仍留着上一版未决摘要,解决后先正常同步草稿清除旧摘要,再同步完结,沿用现有服务端保护;不更改接口或计费规则。
5 项定向测试通过,涵盖真实文件/回收区、原任务身份、损坏记录、完结/同步入口及草稿摘要;Clippy `-D warnings` 通过。同步入口测试只抽取生产预检查并使用隔离账户传输桩,不冒充真实云上传;真实传输由下面的界面流程复验覆盖。独立只读审查未发现递归锁或遗漏入口。
真实查看又发现:仅准备好资料、尚未分析的草稿没有显示基本资料。现有只读折叠区补产品名称、品牌、编号类型、颜色、长宽高/单位、资料来源及参考图片数量,空值省略。安全投影仅返回数量,不返回原图片对象、本机路径或模型设置。保留原有鉴权、默认折叠区和 44px 命中区,没有新增编辑或生成入口。
Web 两组相关测试共 18/18 通过;另外用提交的精确 Git 归档在干净目录执行同两组测试,18/18、exit 0。工作目录 TypeScript 检查通过。最初默认 Jest 扫描到 `output` 内第三方 Rust 快照导致退出 1,随后用 CLI 排除该目录通过;干净归档不需要排除。没有删除第三方快照或改全局测试配置。
独立配置恢复时又发现一处实际问题:改字后的本机 `context.json` 已正确成为草稿/未同步,但 `Event::Saved` 没有将结果回填当前界面,仍显示完结/已同步。补当前且已加载项目的保存回执回填,保留保存期间新修改,旧回执不得回退较新的云关联/同步版本,本机保存不得清除云端 dirty。仅改原生状态逻辑,布局和文案不变。逐字提取的生产 helper 4/4、Clippy 通过,独立审阅通过;完整客户端复验以最终回执为准,不把 helper 测试当作 GUI 已通过。
## 实际界面与联动
本轮专用目录为 `output/native-final-userflow-20261022-qa/`,不覆盖旧验收目录。原云成品 `df8c6689-a053-4918-ad2b-0166f4622d3e` 只读保留;所有写入和删除均限定到本轮新建 QA ID。
### 已完成的线上查看
发布前公开健康接口实际返回 HTTP 200、`healthy`,旧版本为 `c7309f17da9ba656f96a4e12810b524590a16241`。三张真实预览均加载成功,原生预览宽度为 720px;产品资料和两批卖点可以只读展开。390px 视口无横向溢出,没有编辑输入框,资料展开命中区实际为 44px。临时视口随后恢复。
Impeccable 按 Operate 模式检查,保留现有深色导航与浅色内容区。补丁机械检测零结果,独立只读审阅通过;这不代表完成全面读屏、性能或对比度认证。本轮不引入动效。
Web 已从 3,238 个逐 Git blob 核对的干净归档构建并部署至 `e528cbda1059e814ffbe4e8054614d7de88108d1`,镜像配置 ID `sha256:794b3ab592722be857572d60d968139f5dc222c2e3053e58e8c039d9a2936ee2`。只构建 runner,不执行 migrator/DDL/修复 SQL;发布前无在途上传、转码或模型队列。发布后核对公网版本/数据库/Redis健康、素材及私有目录挂载、后台进程身份和协调定时器恢复,全部通过。
实际检查未分析的三屏 QA 项目:基本资料正常显示,三个预览实际加载;1440px 下双列、390px 下单列,最终稳定布局无横向溢出、折叠入口 44px、编辑控件 0。初次截图早于视口布局与图片完成,不作为通过证据;最终使用 `web-metadata-acceptance-settled.json` 和对应两张 `*-settled.jpg`。原有深浅导航样式保持。
### 最终客户端回执
最终程序为 `output/native-final-userflow-20261022-qa/portable-userflow-fixed/photocraft.exe`,85,364,012 字节,SHA256 为 `c404c4aa7d3ec2a16bfdd3f2038d156b0f00421cce9f4a46e0ae8128ecf3bfd6`。离线锁定依赖构建,Windows 资源保留;复用同一构建目标增量编译,没有再造第二份缓存。1,451 个编译输入前后相同,最终源码匹配记录为 `native-source-fixed-provenance.json`。初版 `bfa546…` 的结果和失败证据保留,不再作为默认启动程序。
已实际完成:默认上传入口、真实产品图片导入、品牌和分长宽高输入;素材库真实产品联动及三张参考图;恢复真实的 9 条事实、两批卖点、4 条勾选和已确认三屏方案;三屏中文修改、撤销/重做、保存和四种导出。独立解析 PNG/JPG 均为 1440×6519,PSD 为 3 个分屏组、6 个可编辑中文文字层,字体 Noto Sans SC。草稿与完结同步到同一云 ID、不同角标和 2 物理像素蓝色云项目描边已实际检查。
修复后的程序在新 `profile-c` 中从云端恢复三屏:六个中文文字层仍可编辑,没有伪造历史撤销;实际把标题改为“照见好日常”,界面自动成为草稿/未同步,同一云 ID 重新同步草稿和完结成功。重新导出 `final-fixed-three-screen.pcraft/png/jpg/psd`,PNG/JPG 均为 1440×6519,PSD 三组、六个可编辑 Noto Sans SC 中文层,独立解析通过。原完成品和首屏成功结果没有重生成。
双端删除仅处理本轮两个副本:原生侧 `9290e43c-c389-48a1-8d55-1e3d2cd072cd`、Web 侧 `65968926-18fa-43a4-9bca-cc2332d7e36d`。两端均实际检查提示、取消保留、确认删除;Web 刷新后新旧名称均不存在;旧客户端配置刷新后两个本机副本均进入可恢复回收区。原成品仍为 revision 5/完结。实际回执为 `restore-delete-fixed-report.json`、`reconcile-fixed-report.json`、`web-native-deletion-propagated.json`。
早期输入路径/异步保存时序脚本失败,以及初版暴露的 Saved 状态问题,均保留原始失败回执;窗口均正常退出,原草稿未丢失。不冒称一次不中断通过,也没有重发模型请求。
## 原请求与范围限制
- `6986651c-dff8-4af9-8870-2eefed8df21d` 继续保留原未知状态,文件指纹前后核对,不调用恢复接口。
- 原第二屏 `42e84e04-4738-48c0-b3c0-31ee097b7a35` 的公网图片 404 仍是已知外部限制;本轮不重新生成、不猜测接口,也不把已有缓存恢复说成公网下载成功。
- 独立配置恢复是在同一台 Windows 上进行;不冒称第二台实体电脑测试。
- 分层 PSD 以独立解析器核查中文文字层和字体;没有使用 Photoshop 打开验收。
- 本机预览包继续复用本机 Agent/许可运行资产,不将目录联接包当作可离线分发的安装包。
## 收尾
每个测试窗口都在独立配置中逐个启动;启动后全部步骤位于 `try/finally`,运行名冲突在启动前拒绝。退出前保存全部测试文档,正常退出并等待自有 PID 消失,没有强制结束。修复版两次真实流程 PID 12456/15556 均正常 exit 0,最终客户端进程为 0。此前观察到的用户 PID 51704 在本轮中途已自行消失,原因未知,本轮未向其发送关闭/结束命令;不将此归因为本次清理。临时浏览器标签已关闭,用户原标签保留。
桌面“由壹点详情页工作台(本地)”快捷方式已指向修复版,原 `PHOTOCRAFT_CONFIG_DIR` 用户配置路径保持不变。校验脚本语法、快捷方式实际入口和程序哈希,没有额外启动普通用户窗口。
已精确清理本轮构建目标、Git 构建副本、隔离配置/QA 本机副本、过渡 pcraft 和六个控制令牌,共 4,451,638,914 逻辑字节(约 4.15 GiB),17 项均确认消失。执行前后 D 盘可用空间增加 4,140,806,144 字节(约 3.86 GiB);盘符变化与逻辑文件大小分开记录,见 `cleanup-applied.json`。本轮 QA 在完成回收区验收后才清理,不清一般用户回收区。保留最终程序、两版导出物、必要截图、源码清单、失败/成功回执。
生产数据库、素材文件、原成品、旧未知任务和用户本机草稿未清理。此前被自动审批拒绝的旧测试缓存没有换工具重试。进度平台精确模块/子功能名称匹配失败后停止,不擅自建目录;不阻断 Git/发布。