fix(detail-client): protect pending results and show saved product brief
This commit is contained in:
parent
f927edcfb2
commit
e528cbda10
@ -18,7 +18,53 @@ fn local_path(base: &Path, id: &str) -> R<PathBuf> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Native generation has a second barrier after a successful GET: its result may
|
||||
/// still be staged rather than applied to the user's canonical canvas.
|
||||
pub(super) fn creation_barriers(dir: &Path) -> R<Vec<Value>> {
|
||||
if !dir.join("creation.json").exists() { return Ok(vec![]); }
|
||||
let creation = super::creation::load(dir)?;
|
||||
let generation = &creation["generation"];
|
||||
if generation.is_null() { return Ok(vec![]); }
|
||||
if !generation.is_object() { return Err("生成恢复记录无效,请保留原项目核查".into()); }
|
||||
let mut barriers = Vec::new();
|
||||
let pending = &generation["pending"];
|
||||
if !pending.is_null() {
|
||||
if !pending.is_object() { return Err("原图片待处理记录无效,请保留原项目核查".into()); }
|
||||
let id = match pending.get("taskId").filter(|id| !id.is_null()) {
|
||||
Some(id) => id.as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("原图片任务身份无效,请保留原项目核查")?,
|
||||
// Legacy/pre-dispatch journals have no task ID yet. This marker does
|
||||
// not invent an upstream identity and remains stable in cloud drafts.
|
||||
None => "creation-pending",
|
||||
};
|
||||
barriers.push(json!({"id":id,"role":"assistant","text":"页面生成待处理,原任务与结果已保留","pending":true,"unknown":false}));
|
||||
}
|
||||
let recovery = &generation["recovery"];
|
||||
if !recovery.is_null() {
|
||||
if !recovery.is_object() { return Err("原图片恢复记录无效,请保留原项目核查".into()); }
|
||||
let id = recovery["taskId"].as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("原图片恢复身份无效,请保留原项目核查")?;
|
||||
match recovery["applied"].as_bool() {
|
||||
Some(true) => {}, // Older successful native recoveries may retain phase=complete.
|
||||
Some(false) => {
|
||||
let task = read_json(&dir.join("image-jobs").join(id).join("task.json"))?;
|
||||
if task["id"] != id { return Err("原图片恢复任务不一致,请保留原项目核查".into()); }
|
||||
let phase = task["phase"].as_str().filter(|phase| matches!(*phase, "prepared" | "dispatched" | "running" | "unknown" | "complete" | "applied" | "failed" | "rejected")).ok_or("原图片恢复状态无效,请保留原项目核查")?;
|
||||
// The existing explicit image-applied acknowledgement also closes
|
||||
// a conflict staged in creation.json; never mistake another task's ACK.
|
||||
if phase != "applied" {
|
||||
let unknown = matches!(phase, "dispatched" | "running" | "unknown");
|
||||
barriers.push(json!({"id":id,"role":"assistant","text":if unknown {"原请求待核查,未重发"} else {"原图片已保留,尚待应用到画布"},"pending":true,"unknown":unknown}));
|
||||
}
|
||||
},
|
||||
None => return Err("原图片应用状态无效,请保留原项目核查".into()),
|
||||
}
|
||||
}
|
||||
Ok(barriers)
|
||||
}
|
||||
|
||||
pub(super) fn idle(dir: &Path) -> R<()> {
|
||||
if !creation_barriers(dir)?.is_empty() {
|
||||
return Err("项目有待处理的生成任务或待应用结果,请先处理原任务;不能完结或删除".into());
|
||||
}
|
||||
if let (Some(account), Some(id)) = (dir.parent().and_then(Path::parent), dir.file_name())
|
||||
&& !super::pi::unresolved(&account.join("pi-validation").join(id).join("requests"))?.is_empty() {
|
||||
return Err("项目有Pi请求待核查,不能删除或绕过原请求".into());
|
||||
@ -242,6 +288,70 @@ impl Backend {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn creation_barrier_fixture() -> (PathBuf, PathBuf, Value, Value) {
|
||||
let account = std::env::temp_dir().join(format!("creation-lifecycle-{}", Uuid::new_v4()));
|
||||
let dir = account.join("projects").join(Uuid::new_v4().to_string());
|
||||
let task = json!({"id":Uuid::new_v4().to_string(),"phase":"complete"});
|
||||
write_json(&dir.join("context.json"), &json!({"revision":7,"status":"draft"})).unwrap();
|
||||
write_json(&dir.join("image-task.json"), &task).unwrap();
|
||||
write_json(&dir.join("image-jobs").join(task["id"].as_str().unwrap()).join("task.json"), &task).unwrap();
|
||||
fs::write(dir.join("canvas.pcraft"), b"unchanged-canonical-canvas").unwrap();
|
||||
let c = json!({"version":1,"revision":3,"generation":{"pending":null,"recovery":null}});
|
||||
(account, dir, c, task)
|
||||
}
|
||||
#[test]
|
||||
fn generation_pending_and_unapplied_conflicts_block_the_shared_lifecycle_guard() {
|
||||
let (account, dir, original, task) = creation_barrier_fixture();
|
||||
for conflict in [false, true] {
|
||||
let mut c = original.clone();
|
||||
if conflict { c["generation"]["recovery"] = json!({"taskId":task["id"],"applied":false,"state":"conflict"}); }
|
||||
else { c["generation"]["pending"] = json!({"taskId":task["id"],"sequence":1}); }
|
||||
write_json(&dir.join("creation.json"), &c).unwrap();
|
||||
assert!(idle(&dir).unwrap_err().contains("待应用"));
|
||||
assert!(trash_local(&account, dir.file_name().unwrap().to_str().unwrap()).unwrap_err().contains("待应用"));
|
||||
assert_eq!(fs::read(dir.join("canvas.pcraft")).unwrap(), b"unchanged-canonical-canvas");
|
||||
assert!(!account.join("trash").exists());
|
||||
}
|
||||
fs::remove_dir_all(account).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn applied_and_ordinary_historical_complete_results_do_not_block_lifecycle() {
|
||||
let (account, dir, mut c, mut task) = creation_barrier_fixture();
|
||||
write_json(&dir.join("creation.json"), &c).unwrap();
|
||||
assert!(idle(&dir).is_ok(), "an ordinary historical complete task is not an unapplied recovery");
|
||||
c["generation"]["recovery"] = json!({"taskId":task["id"],"applied":true});
|
||||
write_json(&dir.join("creation.json"), &c).unwrap();
|
||||
assert!(idle(&dir).is_ok(), "old applied creation receipts may still have archive phase complete");
|
||||
c["generation"]["recovery"]["applied"] = json!(false);
|
||||
task["phase"] = json!("applied");
|
||||
write_json(&dir.join("creation.json"), &c).unwrap();
|
||||
write_json(&dir.join("image-jobs").join(task["id"].as_str().unwrap()).join("task.json"), &task).unwrap();
|
||||
assert!(idle(&dir).is_ok(), "the original task's explicit manual ACK releases a staged conflict");
|
||||
trash_local(&account, dir.file_name().unwrap().to_str().unwrap()).unwrap();
|
||||
assert!(!dir.exists());
|
||||
assert_eq!(fs::read(account.join("trash").join(dir.file_name().unwrap()).join("canvas.pcraft")).unwrap(), b"unchanged-canonical-canvas");
|
||||
fs::remove_dir_all(account).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn corrupt_creation_barriers_default_deny_without_touching_the_project() {
|
||||
let (account, dir, original, task) = creation_barrier_fixture();
|
||||
for malformed in [json!({"version":2}), json!({"version":1,"generation":[]}), json!({"version":1,"generation":{"pending":[]}}), json!({"version":1,"generation":{"pending":{"taskId":"../other"}}}), json!({"version":1,"generation":{"recovery":[]}}), json!({"version":1,"generation":{"recovery":{"taskId":task["id"]}}}), json!({"version":1,"generation":{"recovery":{"taskId":"../other","applied":false}}})] {
|
||||
write_json(&dir.join("creation.json"), &malformed).unwrap();
|
||||
assert!(idle(&dir).is_err());
|
||||
}
|
||||
let mut c = original;
|
||||
c["generation"]["recovery"] = json!({"taskId":task["id"],"applied":false});
|
||||
write_json(&dir.join("creation.json"), &c).unwrap();
|
||||
let file = dir.join("image-jobs").join(task["id"].as_str().unwrap()).join("task.json");
|
||||
write_json(&file, &json!({"id":Uuid::new_v4().to_string(),"phase":"applied"})).unwrap();
|
||||
assert!(idle(&dir).is_err(), "another task's ACK cannot clear this barrier");
|
||||
write_json(&file, &json!({"id":task["id"],"phase":"invalid"})).unwrap();
|
||||
assert!(idle(&dir).is_err());
|
||||
fs::remove_file(file).unwrap();
|
||||
assert!(idle(&dir).is_err());
|
||||
assert_eq!(fs::read(dir.join("canvas.pcraft")).unwrap(), b"unchanged-canonical-canvas");
|
||||
fs::remove_dir_all(account).unwrap();
|
||||
}
|
||||
fn fixture() -> Backend {
|
||||
let mut b = Backend::new(std::env::temp_dir().join(format!("jiansun-projects-{}", Uuid::new_v4()))).unwrap();
|
||||
b.auth = json!({"account":{"id":"local-fixture"}});
|
||||
|
||||
@ -233,6 +233,14 @@ fn recovery_summary(dir: &Path, requests: &Path) -> R<Vec<Value>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for barrier in super::projects::creation_barriers(dir)? {
|
||||
if let Some(existing) = entries.iter_mut().find(|entry| entry["id"] == barrier["id"]) {
|
||||
existing["pending"] = json!(true);
|
||||
if barrier["unknown"] == true { existing["unknown"] = json!(true); }
|
||||
} else {
|
||||
entries.push(barrier);
|
||||
}
|
||||
}
|
||||
if requests.exists() {
|
||||
for task in super::pi::unresolved(requests)? {
|
||||
entries.push(json!({"id":task["id"],"role":"assistant","text":"原请求待核查,未重发","unknown":true,"pending":false}));
|
||||
@ -654,6 +662,44 @@ impl Backend {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn draft_recovery_summary_preserves_unapplied_identity_without_claiming_unknown() {
|
||||
let dir = std::env::temp_dir().join(format!("draft-unapplied-summary-{}", Uuid::new_v4()));
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let task = json!({"id":id,"phase":"complete"});
|
||||
write_json(&dir.join("image-task.json"), &task).unwrap();
|
||||
let archive = dir.join("image-jobs").join(&id).join("task.json");
|
||||
write_json(&archive, &task).unwrap();
|
||||
let requests = dir.join("requests");
|
||||
let mut c = json!({"version":1,"generation":{"pending":null,"recovery":{"taskId":id,"applied":false,"state":"conflict"}}});
|
||||
write_json(&dir.join("creation.json"), &c).unwrap();
|
||||
let summary = recovery_summary(&dir, &requests).unwrap();
|
||||
assert_eq!(summary.len(), 1);
|
||||
assert_eq!(summary[0]["id"], id);
|
||||
assert_eq!(summary[0]["pending"], true);
|
||||
assert_eq!(summary[0]["unknown"], false);
|
||||
assert!(summary[0]["text"].as_str().unwrap().contains("尚待应用"));
|
||||
let mut unknown = task.clone(); unknown["phase"] = json!("unknown");
|
||||
write_json(&archive, &unknown).unwrap(); write_json(&dir.join("image-task.json"), &unknown).unwrap();
|
||||
let summary = recovery_summary(&dir, &requests).unwrap();
|
||||
assert_eq!(summary.len(), 1);
|
||||
assert_eq!(summary[0]["id"], id);
|
||||
assert_eq!(summary[0]["unknown"], true, "a genuine unknown task must never become a known result barrier");
|
||||
assert_eq!(summary[0]["pending"], true);
|
||||
write_json(&archive, &json!({"id":id,"phase":"applied"})).unwrap();
|
||||
write_json(&dir.join("image-task.json"), &json!({"id":id,"phase":"applied"})).unwrap();
|
||||
assert!(recovery_summary(&dir, &requests).unwrap().is_empty());
|
||||
c["generation"]["recovery"] = Value::Null;
|
||||
c["generation"]["pending"] = json!({"sequence":1,"previousTaskId":Uuid::new_v4().to_string()});
|
||||
write_json(&dir.join("creation.json"), &c).unwrap();
|
||||
let summary = recovery_summary(&dir, &requests).unwrap();
|
||||
assert_eq!(summary[0]["id"], "creation-pending");
|
||||
assert_eq!(summary[0]["pending"], true); assert_eq!(summary[0]["unknown"], false);
|
||||
c["generation"]["pending"]["taskId"] = json!("../invalid");
|
||||
write_json(&dir.join("creation.json"), &c).unwrap();
|
||||
assert!(recovery_summary(&dir, &requests).is_err());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
fn fixture(origin: &str) -> Backend {
|
||||
let mut b = Backend::new(std::env::temp_dir().join(format!("native-sync-{}", Uuid::new_v4()))).unwrap();
|
||||
b.origin = origin.into();
|
||||
|
||||
@ -57,3 +57,43 @@ test('native project cards and read-only pages use the uploaded first-screen pre
|
||||
expect(screen.queryByText('只读:true')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /生成|编辑|保存/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test.each([['sku', 'SKU'], ['spu', 'SPU'], ['barcode', '条码']])('native %s drafts show saved product details before any AI analysis', async (identifierType, label) => {
|
||||
const brief = { name: '顾家长桌', brandName: '顾家', identifierType, identifier: 'GBGJ-CYJ0056', colorName: '胡桃木色', source: 'library', referenceImageCount: 3, dimensions: { length: '1200', width: '600', height: '750', unit: 'mm' }, note: '' }
|
||||
;(global.fetch as jest.Mock).mockImplementation((url: string) => url === '/api/detail-projects' ? reply({ items: [item], nextCursor: null }) : reply({ source: 'desktop', project: { id: 'one', document: { version: 3, screens: [], brief, facts: { items: [] }, sellingPoints: { batches: [] } } }, assets: [] }))
|
||||
render(<DetailProjectBrowser />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看 顾家电脑桌' }))
|
||||
const summary = await screen.findByText('产品资料与卖点')
|
||||
expect(summary).toHaveClass('min-h-11')
|
||||
expect(summary.parentElement).not.toHaveAttribute('open')
|
||||
fireEvent.click(summary)
|
||||
const info = within(summary.parentElement!)
|
||||
for (const text of ['顾家长桌', '顾家', label, 'GBGJ-CYJ0056', '胡桃木色', '长 1200 × 宽 600 × 高 750 毫米', '素材库', '3 张']) expect(info.getByText(text)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /新建|生成|编辑|保存/ })).not.toBeInTheDocument()
|
||||
expect((global.fetch as jest.Mock).mock.calls.every(([, init]) => !init?.method)).toBe(true)
|
||||
})
|
||||
|
||||
test('complete native projects retain fact and selling-point text alongside partial saved dimensions', async () => {
|
||||
;(global.fetch as jest.Mock).mockImplementation((url: string) => url === '/api/detail-projects' ? reply({ items: [item], nextCursor: null }) : reply({ source: 'desktop', project: { id: 'one', document: { version: 3, screens: [], brief: { name: '电脑桌', source: 'upload', dimensions: { length: '120', width: '', height: '', unit: 'cm' } }, facts: { items: [{ text: '四条支腿' }] }, sellingPoints: { batches: [{ items: ['四腿支撑结构'] }] } } }, assets: [] }))
|
||||
render(<DetailProjectBrowser />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看 顾家电脑桌' }))
|
||||
expect(await screen.findByText('长 120 厘米')).toBeInTheDocument()
|
||||
for (const text of ['上传产品资料', '四条支腿', '四腿支撑结构']) expect(screen.getByText(text)).toBeInTheDocument()
|
||||
for (const label of ['品牌', '颜色', 'SKU', 'SPU', '条码', '参考图片']) expect(screen.queryByText(label)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('legacy Web product fields and free-text dimensions remain readable without a native brief', async () => {
|
||||
const document = { screens: [], inputSource: 'new', brand: { name: '朴美' }, product: { name: '新款边桌', identifierType: 'barcode', identifier: '6901234567890', facts: '圆形台面', specifications: { color: '橡木色', dimensions: '宽120cm,深度待定' }, assetIds: ['product-one', 'product-two'], referenceAssetIds: ['scene'] }, analysis: { sellingPoints: ['简洁圆形台面'] } }
|
||||
;(global.fetch as jest.Mock).mockImplementation((url: string) => url === '/api/detail-projects' ? reply({ items: [item], nextCursor: null }) : reply({ source: 'web', project: { id: 'one', document }, assets: [] }))
|
||||
render(<DetailProjectBrowser />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看 顾家电脑桌' }))
|
||||
for (const text of ['新款边桌', '朴美', '条码', '6901234567890', '橡木色', '宽120cm,深度待定', '上传产品资料', '3 张', '圆形台面', '简洁圆形台面']) expect(await screen.findByText(text)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('empty product details omit the disclosure and never invent source, dimensions or image counts', async () => {
|
||||
;(global.fetch as jest.Mock).mockImplementation((url: string) => url === '/api/detail-projects' ? reply({ items: [item], nextCursor: null }) : reply({ source: 'desktop', project: { id: 'one', document: { version: 3, screens: [], brief: { name: ' ', source: 'unknown', referenceImageCount: -1, dimensions: { length: '', width: '', height: '', unit: 'mm' } } } }, assets: [] }))
|
||||
render(<DetailProjectBrowser />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看 顾家电脑桌' }))
|
||||
expect(await screen.findByText('草稿尚未生成页面,已保存资料可在客户端继续处理。')).toBeInTheDocument()
|
||||
expect(screen.queryByText('产品资料与卖点')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -17,6 +17,29 @@ const control = 'inline-flex min-h-11 min-w-11 items-center justify-center gap-2
|
||||
const noop = () => {}
|
||||
const previewUrl = (project: Pick<Item, 'id' | 'source'>, source: string) => endpoint(project) + '/assets/' + encodeURIComponent(source)
|
||||
|
||||
function productDetails(document: Record<string, any>) {
|
||||
const brief = document.brief || {}, product = document.product || {}, specifications = product.specifications || {}
|
||||
const text = (value: unknown) => typeof value === 'string' ? value.trim() : ''
|
||||
const size = brief.dimensions || specifications.size
|
||||
const unit = ({ mm: '毫米', cm: '厘米', m: '米' } as Record<string, string>)[size?.unit] || ''
|
||||
const dimensions = size && ['length', 'width', 'height'].map((key, index) => {
|
||||
const value = text(size[key])
|
||||
return value ? `${['长', '宽', '高'][index]} ${value}` : ''
|
||||
}).filter(Boolean).join(' × ')
|
||||
const identifierType = brief.identifierType || product.identifierType
|
||||
const source = brief.source || document.inputSource
|
||||
const count = Number.isInteger(brief.referenceImageCount) ? brief.referenceImageCount : Array.isArray(product.assetIds) ? product.assetIds.length + (Array.isArray(product.referenceAssetIds) ? product.referenceAssetIds.length : 0) : undefined
|
||||
return [
|
||||
['产品名称', text(brief.name) || text(product.name)],
|
||||
['品牌', text(brief.brandName) || text(document.brand?.name)],
|
||||
[({ sku: 'SKU', spu: 'SPU', barcode: '条码' } as Record<string, string>)[identifierType] || '产品编号', text(brief.identifier) || text(product.identifier)],
|
||||
['颜色', text(brief.colorName) || text(specifications.color)],
|
||||
['尺寸', dimensions ? `${dimensions}${unit ? ` ${unit}` : ''}` : text(specifications.dimensions)],
|
||||
['资料来源', ({ library: '素材库', upload: '上传产品资料', new: '上传产品资料' } as Record<string, string>)[source]],
|
||||
['参考图片', typeof count === 'number' && count > 0 ? `${count} 张` : ''],
|
||||
].filter((entry): entry is [string, string] => !!entry[1])
|
||||
}
|
||||
|
||||
function NativePreview({ detail, screen }: { detail: Detail; screen: Record<string, any> }) {
|
||||
const [failed, setFailed] = useState(false), [loading, setLoading] = useState(true)
|
||||
const source = screen.previewSource
|
||||
@ -105,6 +128,7 @@ export default function DetailProjectBrowser() {
|
||||
const shown = items.filter(p => (p.name || '').toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()))
|
||||
const screens = detail?.project.document.screens || []
|
||||
const screen = screens.find((s: any) => s.id === screenId) || screens[0]
|
||||
const productInfo = useMemo(() => detail ? productDetails(detail.project.document) : [], [detail])
|
||||
const notes = useMemo(() => {
|
||||
const d = detail?.project.document
|
||||
if (!d) return []
|
||||
@ -136,7 +160,10 @@ export default function DetailProjectBrowser() {
|
||||
<h2 className="mb-3 break-words text-lg font-semibold">{detail?.project.document.name || selected.name}</h2>
|
||||
{opening && <p role="status" className="py-10 text-[#526176]">正在打开项目…</p>}
|
||||
{detail && <>
|
||||
{!!notes.length && <details className="mb-5 border-b border-[#d9e1ec] pb-4"><summary className="min-h-11 cursor-pointer rounded py-3 text-sm font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0052cc]">产品资料与卖点</summary><div className="mt-3 space-y-2 whitespace-pre-wrap text-sm text-[#475569]">{notes.map((note: string, i: number) => <p key={i}>{note}</p>)}</div></details>}
|
||||
{!!(productInfo.length || notes.length) && <details className="mb-5 border-b border-[#d9e1ec] pb-4"><summary className="min-h-11 cursor-pointer rounded py-3 text-sm font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0052cc]">产品资料与卖点</summary>
|
||||
{!!productInfo.length && <dl className="mt-3 grid grid-cols-1 gap-x-8 gap-y-3 text-sm sm:grid-cols-2">{productInfo.map(([label, value]) => <div key={label} className="min-w-0"><dt className="text-[#526176]">{label}</dt><dd className="mt-1 break-words whitespace-pre-wrap text-[#25364b]">{value}</dd></div>)}</dl>}
|
||||
{!!notes.length && <div className="mt-4 space-y-2 break-words whitespace-pre-wrap text-sm text-[#475569]">{notes.map((note: string, i: number) => <p key={i}>{note}</p>)}</div>}
|
||||
</details>}
|
||||
{!!screens.length && <div className="mb-4 flex gap-2 overflow-x-auto pb-2" aria-label="选择页面">{screens.map((s: any, i: number) => <button key={s.id} className={control + (s.id === screen?.id ? ' border-[#0052cc] bg-[#eaf2ff] text-[#0042a5]' : '')} aria-pressed={s.id === screen?.id} onClick={() => setScreenId(s.id)}>{i + 1}</button>)}</div>}
|
||||
{screen ? <div className="min-w-0 overflow-hidden rounded-lg border border-[#d9e1ec] bg-white p-2 sm:p-6" aria-label="只读页面预览">{detail.project.document.version === 3 ? <NativePreview key={identity(selected) + '/' + screen.id} detail={detail} screen={screen} /> : <ReadOnlyScreen key={identity(selected) + '/' + screen.id} detail={detail} screen={screen} />}</div> : <p className="py-12 text-center text-sm text-[#526176]">草稿尚未生成页面,已保存资料可在客户端继续处理。</p>}
|
||||
</>}
|
||||
|
||||
@ -35,6 +35,26 @@ test('native projects expose only rendered previews and creation summaries, neve
|
||||
expect(value.assets).toEqual([{ source: 'local:preview', mime: 'image/png' }])
|
||||
for (const privateValue of ['private-session','private-archive','session-backup.json','storageKey']) expect(text).not.toContain(privateValue)
|
||||
})
|
||||
|
||||
test('native preparation-only drafts expose safe product metadata without leaking reference image objects', async () => {
|
||||
const brief = { name: '电脑桌', brandName: '顾家', identifierType: 'sku', identifier: 'GBGJ-CYJ0056', colorName: '胡桃木色', dimensions: { length: '1200', width: '600', height: '750', unit: 'mm' }, source: 'library', note: '', images: [{ source: 'local:private-product-path', purpose: 'product' }, { source: 'local:private-product-two', purpose: 'product' }, { source: 'local:private-scene', purpose: 'scene' }], privateField: 'internal-reference-detail' }
|
||||
;(getDesktopProject as jest.Mock).mockResolvedValue({ source: 'desktop', project: { id: 'p', revision: 1, status: 'draft', document: { version: 3, name: '新项目', screens: [], creation: { brief } } }, assets: [] })
|
||||
const response = await projectViewerRoute(new Request('https://sc.jiansun.vip/api/detail-projects/desktop/p'), ['desktop', 'p'])
|
||||
const value = await response.json()
|
||||
expect(value.project.document.brief).toEqual({ name: brief.name, brandName: brief.brandName, identifierType: brief.identifierType, identifier: brief.identifier, colorName: brief.colorName, dimensions: brief.dimensions, source: 'library', referenceImageCount: 3, note: '' })
|
||||
const text = JSON.stringify(value)
|
||||
for (const privateValue of ['local:private', 'purpose', 'images', 'privateField', 'internal-reference-detail']) expect(text).not.toContain(privateValue)
|
||||
})
|
||||
|
||||
test('older desktop and Web projects retain readable metadata without inventing unsupported source fields', async () => {
|
||||
;(getDesktopProject as jest.Mock).mockResolvedValue({ source: 'desktop', project: { id: 'p', revision: 1, document: { version: 2, screens: [], brief: { name: '旧桌面工程', source: 'upload', identifierType: 'barcode', identifier: '6901234567890', images: [{ source: 'local:private' }] } } }, assets: [] })
|
||||
const desktop = await projectViewerRoute(new Request('https://sc.jiansun.vip/api/detail-projects/desktop/p'), ['desktop', 'p'])
|
||||
expect((await desktop.json()).project.document.brief).toEqual({ name: '旧桌面工程', source: 'upload', identifierType: 'barcode', identifier: '6901234567890', referenceImageCount: 1 })
|
||||
const product = { name: '旧网页工程', identifierType: 'spu', identifier: 'SPU-1', assetIds: ['image-one'], specifications: { color: '橡木色', dimensions: '1200×600' } }
|
||||
;(getDesktopProject as jest.Mock).mockResolvedValue({ source: 'web', project: { id: 'p', document: { screens: [], inputSource: 'library', product } }, assets: [] })
|
||||
const web = await projectViewerRoute(new Request('https://sc.jiansun.vip/api/detail-projects/web/p'), ['web', 'p'])
|
||||
expect((await web.json()).project.document).toEqual({ screens: [], inputSource: 'library', product })
|
||||
})
|
||||
test('browser deletion requires an allowed origin and sends the exact confirmation revision once', async () => {
|
||||
const url = 'https://sc.jiansun.vip/api/detail-projects/desktop/p'
|
||||
;(deleteDesktopProject as jest.Mock).mockResolvedValue({ deleted: true })
|
||||
|
||||
@ -6,6 +6,13 @@ import { desktopAssert, DesktopClientError } from './contract.mjs'
|
||||
import { deleteDesktopProject, desktopUser, getDesktopAsset, getDesktopProject, listDesktopProjects } from './service'
|
||||
|
||||
const headers = { 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff' }
|
||||
const viewerBrief = (brief: any) => brief ? {
|
||||
name: brief.name, brandName: brief.brandName, identifier: brief.identifier, colorName: brief.colorName,
|
||||
dimensions: brief.dimensions, note: brief.note,
|
||||
...(['upload', 'library'].includes(brief.source) ? { source: brief.source } : {}),
|
||||
...(['sku', 'spu', 'barcode'].includes(brief.identifierType) ? { identifierType: brief.identifierType } : {}),
|
||||
...(Array.isArray(brief.images) ? { referenceImageCount: brief.images.length } : {}),
|
||||
} : undefined
|
||||
/** Cookie-authenticated viewing. Desktop write tokens are never issued to this page. */
|
||||
export async function projectViewerRoute(request: Request, parts: string[] = []) {
|
||||
try {
|
||||
@ -22,9 +29,10 @@ export async function projectViewerRoute(request: Request, parts: string[] = [])
|
||||
// Do not expose storage keys, model bindings, provider diagnostics or task internals.
|
||||
const d = value.project.document
|
||||
const document = d.version === 3 ? { version: 3, name: d.name, screens: d.screens.map((screen: any) => ({ id: screen.id, name: screen.name, width: screen.width, height: screen.height, previewSource: screen.previewSource })),
|
||||
brief: d.creation?.brief ? { name: d.creation.brief.name, brandName: d.creation.brief.brandName, identifier: d.creation.brief.identifier, colorName: d.creation.brief.colorName, dimensions: d.creation.brief.dimensions, note: d.creation.brief.note } : undefined,
|
||||
brief: viewerBrief(d.creation?.brief),
|
||||
facts: d.creation?.facts, sellingPoints: d.creation?.sellingPoints, plan: d.creation?.plan } :
|
||||
{ name: d.name, screens: d.screens || [], product: d.product, brief: d.brief, brand: d.brand ? { name: d.brand.name } : undefined,
|
||||
{ name: d.name, screens: d.screens || [], product: d.product, brief: viewerBrief(d.brief), brand: d.brand ? { name: d.brand.name } : undefined,
|
||||
...(['library', 'new'].includes(d.inputSource) ? { inputSource: d.inputSource } : {}),
|
||||
analysis: d.analysis, planning: d.planning, facts: d.facts, sellingPoints: d.sellingPoints, plan: d.plan }
|
||||
return NextResponse.json({ source: value.source, project: { id: value.project.id, revision: value.project.revision, status: 'status' in value.project ? value.project.status : 'draft', updatedAt: value.project.updatedAt, document }, assets: value.assets.filter((a: any) => ['image/png','image/jpeg','image/webp'].includes(a.mime)).map((a: any) => ({ source: a.source, mime: a.mime })) }, { headers })
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user