Support explicit current-output A/B fill, protected edited drafts, scoped cloud update offers and atomic native restore with stale-open CAS. Preserve original unknown requests, improve Pi conversation continuity and native DPI/navigation reads, and publish the authenticated template metadata contract. Validated: native backend 172 passed/5 environment tests ignored, UI 110, Pi 53, template/rule API 24 and native cloud contract 6; strict Clippy, layering and wasm. Real S01 selected B, edited/undo-redo, PCraft reopen and layered PSD roundtrip pass with exact pixels. Full nine-output generation and Photoshop acceptance remain open.
156 lines
12 KiB
JavaScript
156 lines
12 KiB
JavaScript
// Shared data contract. No database, filesystem, credentials or model transport.
|
|
const fail = message => { const error = new Error(message); error.status = 400; error.code = 'invalid_creation'; throw error }
|
|
const string = (value, max, label) => typeof value === 'string' && value.length <= max ? value : fail(label + '格式无效')
|
|
const list = (value, max, label) => Array.isArray(value) && value.length <= max ? value : fail(label + '超出限制')
|
|
const object = value => value && typeof value === 'object' && !Array.isArray(value) ? value : fail('创作资料格式无效')
|
|
const localSource = value => /^local:[a-zA-Z0-9-]{1,100}$/.test(value || '') ? value : fail('产品图片必须属于本地项目')
|
|
const identifier = value => typeof value === 'string' && /^[\w.:-]{1,150}$/.test(value) ? value : fail('资料编号无效')
|
|
const date = value => value === null || typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : fail('资料时间无效')
|
|
const states = (value, allowed) => allowed.includes(value) ? value : fail('创作状态无效')
|
|
const stable=value=>JSON.stringify(value,(_,item)=>item&&typeof item==='object'&&!Array.isArray(item)?Object.fromEntries(Object.keys(item).sort().map(key=>[key,item[key]])):item)
|
|
/** Three-way canvas merge: only actual edits against the renderer's base can
|
|
* replace model-owned pixels. A stale placeholder is never a user deletion. */
|
|
export function mergeCreationCanvas(local,canonical,base){
|
|
if(!base||stable(local.screens)===stable(base.screens))return canonical.screens
|
|
const fields=(value,current,before,skip=[])=>{const result={...current};for(const [key,item]of Object.entries(value))if(!skip.includes(key)&&stable(item)!==stable(before?.[key]))result[key]=item;return result}
|
|
const screens=canonical.screens.flatMap(current=>{
|
|
const before=base.screens.find(item=>item.id===current.id),value=local.screens.find(item=>item.id===current.id)
|
|
if(!before)return [current]
|
|
if(!value)return []
|
|
const result=fields(value,current,before,['layers'])
|
|
result.layers=current.layers.flatMap(layer=>{
|
|
const old=before.layers.find(item=>item.id===layer.id),edited=value.layers.find(item=>item.id===layer.id)
|
|
if(!old)return [layer]
|
|
if(!edited)return []
|
|
return [fields(edited,layer,old)]
|
|
})
|
|
for(const layer of value.layers)if(!before.layers.some(item=>item.id===layer.id)&&!result.layers.some(item=>item.id===layer.id))result.layers.push(layer)
|
|
return [result]
|
|
})
|
|
for(const screen of local.screens)if(!base.screens.some(item=>item.id===screen.id)&&!screens.some(item=>item.id===screen.id))screens.push(screen)
|
|
return screens
|
|
}
|
|
export function emptyBrief() {
|
|
return { source: 'upload', name: '', brandId: '', brandName: '', categoryId: '', categoryName: '', identifierType: 'sku', identifier: '', variant: '',
|
|
colorMaterialId: '', colorName: '', dimensions: { length: '', width: '', height: '', unit: 'mm' }, images: [], note: '' }
|
|
}
|
|
export function emptyCreation() {
|
|
return { creationRevision: 0, brief: emptyBrief(), facts: { status: 'idle', items: [], missing: [] },
|
|
sellingPoints: { batches: [], selected: [], direction: '', stale: false },
|
|
plan: { status: 'idle', screens: [], invalidReason: '', confirmedAt: null, confirmedRevision: null }, designSnapshot: null }
|
|
}
|
|
export function validateBrief(value) {
|
|
const input = object(value), dimensions = object(input.dimensions)
|
|
const clean = { source: states(input.source, ['upload', 'library']), name: string(input.name, 120, '产品名称'),
|
|
brandId: string(input.brandId, 150, '品牌'), brandName: string(input.brandName, 120, '品牌名称'),
|
|
categoryId: string(input.categoryId ?? '', 150, '品类'), categoryName: string(input.categoryName ?? '', 120, '品类名称'),
|
|
identifierType: states(input.identifierType, ['sku', 'spu', 'barcode']), identifier: string(input.identifier, 200, '产品编号'),
|
|
variant: string(input.variant, 200, '产品款式'), colorMaterialId: string(input.colorMaterialId, 150, '材质编号'),
|
|
colorName: string(input.colorName, 200, '材质颜色'), dimensions: { unit: states(dimensions.unit, ['mm', 'cm', 'm']) },
|
|
note: string(input.note, 6000, '补充说明'), images: [] }
|
|
for (const axis of ['length', 'width', 'height']) {
|
|
const number = string(dimensions[axis], 12, '尺寸')
|
|
if (number && (!/^\d{1,6}(?:\.\d{1,3})?$/.test(number) || Number(number) <= 0)) fail('尺寸请填写大于 0 的数字,最多三位小数')
|
|
clean.dimensions[axis] = number
|
|
}
|
|
const seen = new Set()
|
|
clean.images = list(input.images, 8, '产品图与场景参考图合计').map(image => {
|
|
object(image)
|
|
const source = localSource(image.source), purpose = states(image.purpose, ['product', 'scene'])
|
|
if (seen.has(source)) fail('同一图片只能指定一种用途')
|
|
seen.add(source)
|
|
const result = { source, purpose, name: string(image.name, 200, '图片名称') }
|
|
if (image.origin) {
|
|
object(image.origin)
|
|
result.origin = { materialId: identifier(image.origin.materialId), kind: states(image.origin.kind, ['asset', 'sku']), id: identifier(image.origin.id),
|
|
productModel: string(image.origin.productModel, 200, '产品型号'), variant: string(image.origin.variant, 200, '产品款式') }
|
|
}
|
|
return result
|
|
})
|
|
return clean
|
|
}
|
|
function snapshot(value) {
|
|
if (value === null) return null
|
|
const encoded = JSON.stringify(value)
|
|
if (!encoded || encoded.length > 256000) fail('知识快照过大')
|
|
const check = (item, depth = 0) => {
|
|
if (depth > 16) fail('知识快照嵌套过深')
|
|
if (item && typeof item === 'object') for (const [key, child] of Object.entries(item)) {
|
|
if (/^(?:__proto__|constructor|prototype|api_?key|access_?token|authorization|password|secret)$/i.test(key)) fail('项目快照不能包含密钥或无效字段')
|
|
check(child, depth + 1)
|
|
}
|
|
}
|
|
check(value)
|
|
return JSON.parse(encoded)
|
|
}
|
|
export function validateCreation(value) {
|
|
if (!Number.isSafeInteger(value.creationRevision) || value.creationRevision < 0 || value.creationRevision > 2147483646) fail('创作版本无效')
|
|
const facts = object(value.facts), points = object(value.sellingPoints), plan = object(value.plan)
|
|
const batches = list(points.batches, 12, '卖点批次').map(batch => ({ id: identifier(batch.id), direction: string(batch.direction, 500, '表达方向'),
|
|
items: list(batch.items, 6, '每批卖点').map(item => string(item, 500, '卖点')),
|
|
...(batch.factsHash ? { factsHash: string(batch.factsHash, 64, '事实版本') } : {}),
|
|
...(batch.warnings ? { warnings: list(batch.warnings, 40, '卖点待确认项').map(item => string(item, 500, '待确认项')) } : {}) }))
|
|
if (new Set(batches.map(batch => batch.id)).size !== batches.length) fail('卖点批次编号重复')
|
|
const possible = new Set(batches.flatMap(batch => batch.items.map((_, index) => `${batch.id}:${index}`)))
|
|
const selected = list(points.selected, 20, '已选卖点').map(identifier)
|
|
if (new Set(selected).size !== selected.length || selected.some(id => !possible.has(id))) fail('选择了不存在或重复的卖点')
|
|
const screens = list(plan.screens, 12, '页面方案').map(screen => ({ id: identifier(screen.id), name: string(screen.name, 120, '页面名称'),
|
|
module: string(screen.module, 120, '页面模块'), image: Boolean(screen.image), title: string(screen.title || '', 120, '页面标题'),
|
|
body: string(screen.body || '', 1800, '页面正文'), imagePrompt: string(screen.imagePrompt || '', 8000, '图片要求') }))
|
|
if (new Set(screens.map(screen => screen.id)).size !== screens.length) fail('方案屏幕编号重复')
|
|
const confirmedRevision = plan.confirmedRevision
|
|
if (confirmedRevision !== null && (!Number.isSafeInteger(confirmedRevision) || confirmedRevision < 0)) fail('方案确认版本无效')
|
|
return { creationRevision: value.creationRevision, brief: validateBrief(value.brief),
|
|
facts: { status: states(facts.status, ['idle', 'ready', 'stale']), items: list(facts.items, 100, '产品事实').map(item => ({ text: string(item.text, 1000, '事实'), source: string(item.source, 500, '事实依据') })),
|
|
missing: list(facts.missing, 50, '待确认项').map(item => string(item, 500, '待确认项')) },
|
|
sellingPoints: { batches, selected, direction: string(points.direction, 500, '表达方向'), stale: Boolean(points.stale) },
|
|
plan: { status: states(plan.status, ['idle', 'needs-confirm', 'confirmed', 'stale']), screens,
|
|
invalidReason: string(plan.invalidReason, 500, '失效原因'), confirmedAt: date(plan.confirmedAt), confirmedRevision,
|
|
...(plan.warnings ? { warnings: list(plan.warnings, 40, '方案待确认项').map(item => string(item, 500, '待确认项')) } : {}) },
|
|
designSnapshot: snapshot(value.designSnapshot),
|
|
...(value.generation ? {generation:validateGeneration(value.generation)} : {}) }
|
|
}
|
|
function validateGeneration(value){
|
|
object(value)
|
|
if(!/^[a-f0-9]{64}$/.test(value.planHash||''))fail('生成方案版本无效')
|
|
const screens={}
|
|
for(const [id,item]of Object.entries(object(value.screens))){
|
|
identifier(id);object(item)
|
|
if(Object.keys(screens).length>=12||!/^[a-f0-9]{64}$/.test(item.planHash||''))fail('生成屏幕记录无效')
|
|
screens[id]={taskId:identifier(item.taskId),source:localSource(item.source),planHash:item.planHash}
|
|
}
|
|
return {planHash:value.planHash,anchorSource:value.anchorSource?localSource(value.anchorSource):null,screens}
|
|
}
|
|
export function upgradeCreation(document) {
|
|
if (document.version === 2) return { ...document, ...validateCreation(document) }
|
|
if (document.version !== 1) fail('项目版本暂不支持')
|
|
return { ...document, version: 2, ...emptyCreation() }
|
|
}
|
|
/** Preserve historical work, invalidate its authority; never dispatch anything here. */
|
|
export function prepareCreationEdit(next, previous) {
|
|
if (next.version !== 2) {
|
|
if (previous?.version === 2) fail('创作项目不能降级为旧画布格式')
|
|
return next
|
|
}
|
|
const result = upgradeCreation(next)
|
|
const before = previous?.version === 2 ? previous : null
|
|
// Variant is a binding from the original lookup, not a second editable SKU.
|
|
// A deliberate identifier edit must not silently keep a different hidden variant.
|
|
if (before && result.brief.identifier !== before.brief.identifier && result.brief.variant === before.brief.variant) result.brief.variant = ''
|
|
const briefChanged = before && stable(result.brief) !== stable(before.brief)
|
|
const selectedChanged = before && stable(result.sellingPoints.selected) !== stable(before.sellingPoints.selected)
|
|
const snapshotChanged = before && stable(result.designSnapshot) !== stable(before.designSnapshot)
|
|
const widthChanged = before && result.screens[0]?.width !== before.screens[0]?.width
|
|
const factsChanged = before && stable(result.facts) !== stable(before.facts)
|
|
if (briefChanged) {
|
|
if (result.facts.items.length) result.facts = { ...result.facts, status: 'stale' }
|
|
if (result.sellingPoints.batches.length) result.sellingPoints = { ...result.sellingPoints, stale: true }
|
|
if (result.brief.brandId !== before.brief.brandId && result.designSnapshot?.brand?.id !== result.brief.brandId) result.designSnapshot = null
|
|
}
|
|
if ((briefChanged || selectedChanged || widthChanged || snapshotChanged || factsChanged) && result.plan.screens.length) result.plan = { ...result.plan, status: 'stale',
|
|
invalidReason: briefChanged ? '产品资料已变更,请重新核对事实并确认页面方案' : selectedChanged ? '已选卖点已变更,请更新并确认页面方案' : snapshotChanged ? '品牌规范、规则、模板或模型配置已变更,请重新确认页面方案' : factsChanged ? '产品事实已更新,请重新挑选卖点并确认页面方案' : '画布尺寸已变更,请重新确认页面方案', confirmedAt: null, confirmedRevision: null }
|
|
result.creationRevision = before?.creationRevision || 0
|
|
if (!before || stable(result) !== stable(before)) result.creationRevision++
|
|
return result
|
|
}
|