diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index adcb151..ef493d5 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -21,4 +21,6 @@ jobs: if: failure() with: name: browser-test-results - path: examples/dam/character-material-library/workbench/test-results/ + path: | + examples/*/*/*/workbench/test-results/ + test-results/ diff --git a/.gitignore b/.gitignore index 2983cde..2725639 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ test-results/ *.tsbuildinfo **/.local/ **/poc/runs/ +**/supabase/.temp/ diff --git a/AGENTS.md b/AGENTS.md index 2880835..ab560cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,9 @@ - Read `docs/case-standard.md` before changing a case or adding one. - Keep public business documentation in Chinese; IDs and filenames use lowercase kebab-case. -- One case has one primary domain from `catalog/domains.json`; use tags for cross-domain discovery. +- Organize cases as `examples////`: choose one service model from `catalog/service-models.json` and one domain belonging to that model from `catalog/domains.json`. Use tags for cross-domain discovery; do not create empty case directories. +- Case manifests use version 3 and declare `serviceModel`, `blueprintId`, `variantId` and `caseKind`. Keep manifest, catalog path, links and workspace paths consistent when moving a case. Classify the target application, platform or infrastructure capability independently of deployment and data providers. +- Reuse a standard blueprint and scenario variant from `catalog/blueprints.json` and `catalog/variants.json`. Reference designs and runnable reference implementations are distinct from real project cases; do not inflate case counts or claim customer delivery without published, redacted evidence. - Update the manifest, catalog entry and evidence together when changing delivery status. - Preserve the distinction between mock, unavailable and connected capabilities. Never label a simulated model output as real generation. - Use synthetic fixtures. Do not include private conversations, client identities, signed media URLs, credentials or real-person source footage. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 561ab7d..a2a5f3d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,11 +1,15 @@ # 新增与维护案例 -1. 在 [领域字典](catalog/domains.json) 中选择一个主领域;已有领域优先,新领域要补定义与示例。 -2. 复制 `templates/case/` 到 `examples///`,替换所有 `REPLACE_*` 占位符。 -3. 填写需求、角色、流程、数据模型、集成与验收。将讨论中的原话事实、设计建议和待确认内容分别标明。 -4. 在 `case.json` 中声明真实交付状态、数据来源与能力模式,在 `catalog/cases.json` 增加同一 ID、路径、领域、状态和标签。 -5. 有原型时提供运行命令、数据重置方法与演示路径;提供外部服务时声明配置、权限、失败状态与接入证据。 -6. 执行 `npm ci && npm run check`。手工走验收流程;把命令、结果、日期和限制写进 `evidence/verification.md`。 -7. 提交变更。PR 使用英文说明业务问题、最终行为与验证证据;标题使用 Conventional Commits,例如 `feat(dam): add character material workbench prototype`。 +1. 阅读 [分类边界](docs/domains.md)、[资产模型](docs/case-assets.md)和[案例规范](docs/case-standard.md)。先选择服务模型、业务族与主领域,按目标能力分类;云厂商和数据库不决定分类。 +2. 查找 [标准主干](catalog/blueprints.json) 与 [场景变体](catalog/variants.json)。沿用已有 ID,新增时写清稳定角色、业务对象、流程、页面、扩展点,以及变体相对主干的差异。只有分类或设计时维护目录 JSON,不创建空案例。 +3. 有具体实现材料后,复制 `templates/case/` 到 `examples////`,替换全部 `REPLACE_*` 占位符。一个实现只保留一个目录。 +4. 填写需求、流程、数据模型、接入与验收;区分来源事实、设计建议和待确认内容。案例 README 说明继承的主干、采用的变体、实际覆盖和复用限制。 +5. 在版本 3 的 `case.json` 中声明 `blueprintId`、`variantId`、`caseKind`、来源、状态、证据和能力模式,在 `catalog/cases.json` 增加一致的索引。变体必须属于主干,主干必须属于案例领域。 +6. 参考实现使用 `reference-implementation`;真实项目使用 `project-case` 并提供可公开、脱敏的项目证据文件。纯研究、需求转述或技术测试不应包装成客户交付。项目来源与成熟度独立,`verified` 仍需对应验收证据和确认。 +7. 有原型时提供运行命令、重置方法和入口;工作台源码可放在 `workbench/`。通过 `npm run demo` 的统一首页 验证能找到主干、变体及实现。外部服务要声明配置、权限和失败处理。 +8. 执行 `npm ci && npm run check`;交互变化执行 `npm run test:e2e` 并实际浏览。把命令、结果、日期和限制写进相关验证记录。 +9. PR 使用英文说明业务问题、最终行为与验证证据;标题使用 Conventional Commits,例如 `feat(dam): add content production reference implementation`。 -贡献者可以采用不同前端栈;只有真正跨案例稳定复用的代码才提取为共享模块。模板不会自动安装 Agent、创建 SaaS 租户或绑定真实客户账号。 +只有真正跨案例稳定复用的代码才提取为共享模块;不同案例可以使用不同前端栈和数据提供方。标准主干先约定业务语义,不强制同一套数据库或账号。 + +公开提交只保留获准的抽象需求、合成数据和脱敏证据,不提交私人聊天、客户身份、凭据或带签名的素材链接。 diff --git a/README.md b/README.md index f89c970..f86474f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,17 @@ # ByteFolk Workbench Examples -面向 SaaS 与行业业务场景的工作台案例库。从业务需求出发,沉淀可以演示、验证和复用的工作台。 +工作台案例资产库:按 **SaaS / PaaS / IaaS → 业务领域 → 标准主干 → 场景变体 → 案例实现** 沉淀可复用方案。先找业务场景,再看标准流程与差异,最后复用已有实现和验证材料。 ## 从这里开始 -- [场景分类](docs/domains.md):CRM、ERP、MES、DAM 等 16 个业务领域。 -- [案例目录](catalog/cases.json):机器可读索引,目前包含两个案例。 -- [本色 Persona Studio · 企业数字人工作台](examples/dam/character-material-library/README.md):首个 DAM 案例,含六页工作台、业务规格、合成数据与制作审核闭环。 -- [MUSE 缪思 · 品牌数字人技术验证工作台](examples/dam/brand-digital-human-studio/README.md):四页工作台、图片上传、候选确认、可播放示意样片及可配置的阿里云万相、声音复刻/TTS/对口型与Higgsfield本机适配代码。 -- [案例规范](docs/case-standard.md):目录、元信息、交付状态与验收要求。 -- [新增案例](CONTRIBUTING.md):从模板开始,提交前执行检查。 +- [分类目录](docs/domains.md):SaaS 的 60 个领域,按 10 个业务族组织;保留 PaaS 8 类和 IaaS 5 类。 +- [案例资产模型](docs/case-assets.md):22 个标准主干、44 个场景变体,以及从项目差异回收共性的方式。 +- [案例索引](catalog/cases.json):目前三个参考实现,尚无按项目证据收录的实际项目案例。 +- [本色 Persona Studio](examples/saas/dam/character-material-library/README.md):DAM 内容资产主干 → 数字人内容制作变体 → 素材管理与模拟制作审核实现。 +- [MUSE 缪思](examples/saas/dam/brand-digital-human-studio/README.md):复用 DAM 数字人内容制作变体,演示品牌资产、候选确认、模拟生成与审核复用;保留待真实验证的模型适配。 +- [工厂运营工作台](examples/saas/mes/factory-operations/README.md):MES 生产运营主干 → 服装车间耗材变体 → 申领、审批配送、回收与销售拜访实现。 +- [案例规范](docs/case-standard.md)与[贡献指南](CONTRIBUTING.md):关联、来源、运行、证据与维护要求。 +- [目录验证记录](docs/evidence/catalog-assets.md):本次资产关系、浏览交互与回归检查。 ```bash npm ci @@ -17,52 +19,72 @@ npm run check npm run demo ``` -打开 。目录入口可按领域筛选;首个案例可管理人物与素材、解析需求、模拟视频任务、人工审核和归档溯源。`npm run demo` 先构建工作台再提供统一目录服务,目录入口全部使用合成数据,不连接外部 SaaS 或生成模型。MUSE 的真实接口验证需按其案例说明单独启动工作台及本机代理,默认关闭付费调用。 +打开统一首页 ,按业务族、领域、行业与关键词查找主干、变体和实现,查看它们的关系与材料。目录浏览无需登录或数据库。主干和变体是参考设计,数量与可运行实现、实际项目分别统计。 -需要 Node.js 22.12 或更新版本。Windows 若使用 `\\wsl.localhost\...` 工作目录,请在 WSL 中执行 npm 命令;Windows npm 的默认脚本 shell 不支持 UNC 工作目录。 +Persona Studio 与 MUSE 使用合成数据演示素材与模拟视频制作。MUSE 保留独立本机模型适配代码,公开目录只运行模拟流程,真实模型效果尚未验证。工厂运营工作台保留已实现的数据服务接入,运行该案例时按其 README 配置。账号系统不属于当前目录建设的重点。 + +需要 Node.js 22.12 或更新版本。Windows 若使用 `\\wsl.localhost\...` 工作目录,请在 WSL 中执行 npm 命令。 + +## 三种可复用资产 + +| 资产 | 保存的内容 | 当前收录 | +| -------- | -------------------------------------------- | -------------------------- | +| 标准主干 | 稳定角色、业务对象、流程、页面与扩展点 | 22 个参考设计 | +| 场景变体 | 行业和使用情境,以及相对主干需要改变的内容 | 44 个参考设计 | +| 案例实现 | 代码、运行入口、业务规格、接入情况与验证证据 | 3 个参考实现;0 个项目案例 | + +例如,新车间项目可以先定位 MES 主干,再找到适合的场景变体,复用已有工厂实现的适用部分。共性进入主干,行业差异留在变体,具体项目的交付与证据留在案例里。不会因为新增分类或参考设计就增加实际案例数量。 + +目录中的 `caseKind` 表示参考实现或项目案例;`status` 表示实现成熟度,两者独立。Persona Studio 与 MUSE 为 `prototype`,工厂运营为 `integrated`;现有材料支持原型和技术接入验证,不作为已交付客户项目展示。 ## 目录结构 ```text -catalog/ # 领域字典与案例索引 -docs/ # 分类规则、案例规范、架构与推进方式 -schemas/ # 本仓库的案例、分类和索引 JSON Schema -templates/case/ # 新案例模板(不算已交付案例) -examples/// - case.json # 标识、状态、入口、能力与接入声明 - README.md # 场景说明与运行入口 - requirements.md # 已知需求、MVP 建议、待确认问题 - workflows.md # 角色、步骤、状态与异常 - data-model.md # 业务实体、关系与约束 - integrations.md # 外部能力、接口与接入状态 +catalog/ + service-models.json # SaaS / PaaS / IaaS + domain-groups.json # 业务族 + domains.json # 领域及所属业务族 + blueprints.json # 标准主干 + variants.json # 场景变体及其主干 + cases.json # 实现索引及主干、变体关联 +docs/ # 分类、资产模型、规范与验证记录 +schemas/ # JSON Schema +templates/case/ # 新案例模板,不计入案例 +examples//// + case.json # 关联、来源、状态、运行与能力 + README.md # 适用场景、复用边界与运行说明 + requirements.md # 事实、需求与待确认事项 + workflows.md # 角色、流程、状态与异常 + data-model.md # 实体、关系与约束 + integrations.md # 数据提供方、配置与能力状态 acceptance.md # 验收条件与检查方法 - data/ # 合成演示数据及其 schema - demo/ # 简版或静态演示 - workbench/ # 完整工作台源码(npm workspace,可选) - evidence/ # 实际检查记录与限制 -scripts/ # 索引校验与本地演示服务 -tests/ # 业务边界与仓库检查的验证 + data/ # 合成数据及 schema + demo/ # 简版或静态演示(可选) + workbench/ # 工作台源码(可选 npm workspace) + evidence/ # 实际检查与可公开的项目证据 +scripts/ # 关系、路径、数据校验与目录服务 +tests/ # 仓库和业务规则验证 ``` -## 案例状态 +业务主干通过 ID 与变体、案例关联。案例代码继续保留一个稳定目录,跨领域用标签检索,不为每个变体复制工作台。 -`proposal → specified → prototype → integrated → verified`。状态表示已有证据覆盖的最高阶段,详细定义见[案例规范](docs/case-standard.md)。首个案例为 **prototype**:素材管理与任务编排可以演示,视频生成、真人形象数字人和产品试戴尚未接入。 +## 部署与数据独立选择 -没有案例的领域仅列在分类字典中;不会用空目录或占位页面表示已有产品。 +分类按工作台解决的问题确定,与云厂商、数据库或部署方式无关。SaaS 下的工作台模板可以在本地或企业环境运行;分类不代表已经提供托管 SaaS 服务。 -## 与 ByteFolk 其他仓库的关系 +[数据提供方契约](docs/data-provider-contract.md)保留身份、业务和资产三个独立端口。工厂实现支持 Supabase 组合和标准 HTTP 客户端;使用 MySQL 或企业资产平台的部署者可以实现对应后端。主干、变体和案例关联不绑定 Supabase,也不要求统一账号方案。 -| 仓库 | 在本案例库中的用途 | -| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| [design-system](https://github.com/bytefolk/design-system) | 正式工作台可复用的设计令牌、组件与应用框架;当前案例保留交接工作台的原有组件与设计 | -| [digital-employee](https://github.com/bytefolk/digital-employee) | 需要数字员工执行任务时采用的包契约与运行能力;每个案例单独声明接入 | -| [digital-employee-quickstart](https://github.com/bytefolk/digital-employee-quickstart) | 数字员工包和组织示例,保持其现有格式;本仓库存放面向业务用户的工作台场景 | -| [roleweave](https://github.com/bytefolk/roleweave) | 有岗位协作需求的案例可接入的桌面工作区;不是所有工作台的必需运行环境 | +工厂工作台的「数据接入」已实现整包快照导出、校验预览和事务替换,覆盖工厂档案、生产申领、流转历史与销售拜访。照片先存入资产平台,再生成可编辑的模拟草稿,人工核对后通过同一导入流程入账;页面与记录持续显示数据来源。交换能力通过可选 `LedgerExchange` 端口接入,具体范围、数据库迁移和本地验证见[工厂案例](examples/saas/mes/factory-operations/README.md)。 -`case.json` 与 `catalog/` 是本仓库的案例资产格式,不是这些产品的新 API。案例中的数字人指媒体生成的真人或虚拟形象;数字员工指处理任务的 Agent,两者各自声明能力与依赖。 +## 与 ByteFolk 其他仓库的关系 -## 名称与发布范围 +| 仓库 | 在本案例库中的用途 | +| -------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| [design-system](https://github.com/bytefolk/design-system) | 可复用设计令牌、组件与应用框架,案例记录实际采用情况 | +| [digital-employee](https://github.com/bytefolk/digital-employee) | 需要 Agent 执行任务时引用的包契约与运行能力 | +| [digital-employee-quickstart](https://github.com/bytefolk/digital-employee-quickstart) | 数字员工包和组织示例 | +| [roleweave](https://github.com/bytefolk/roleweave) | 有岗位协作需求时可接入的桌面工作区 | -仓库名使用 **workbench-examples**,SaaS 是描述和分类标签。范围也覆盖制造、内容制作和可自托管工作台。 +机器可读资产为后续检索、知识库与技能编写提供材料;本仓库不自动创建或发布技能。数字人指媒体中的真人或虚拟形象,数字员工指处理任务的 Agent,各自声明能力与依赖。 -代码和文档采用 Apache-2.0。公开内容只包含抽象需求和合成数据;案例要收录真实客户内容时,先完成其来源、发布范围和授权核对,不提交凭据或私人聊天记录。 +代码和文档采用 Apache-2.0。公开内容仅保留抽象需求、合成数据和可公开证据;不提交私人聊天、客户身份或凭据。 diff --git a/catalog/app.mjs b/catalog/app.mjs index e1c52fb..5720422 100644 --- a/catalog/app.mjs +++ b/catalog/app.mjs @@ -1,102 +1,659 @@ const escape = (value) => - String(value).replace( + String(value ?? "").replace( /[&<>"']/g, - (char) => + (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ - char + character ], ); const $ = (selector) => document.querySelector(selector); -try { - const responses = await Promise.all([ - fetch("/catalog/domains.json"), - fetch("/catalog/cases.json"), - ]); - if (responses.some((response) => !response.ok)) - throw new Error("目录数据加载失败"); - const [domains, catalog] = await Promise.all( - responses.map((response) => response.json()), +const serviceLabels = { saas: "SaaS", paas: "PaaS", iaas: "IaaS" }; +const statusLabels = { + proposal: "需求提议", + specified: "规格已整理", + prototype: "可交互原型", + integrated: "已接入服务", + verified: "已验收", +}; +const typeLabels = { + blueprints: "标准主干", + variants: "场景变体", + "reference-implementation": "参考实现", + "project-case": "项目案例", +}; +const typeDescriptions = { + blueprints: "可复用的业务规格:角色、对象、流程、页面与扩展点。", + variants: "在标准主干上,记录行业差异与场景适配方式。", + "reference-implementation": + "用于验证方案的具体实现;运行状态与接入条件见案例材料。", + "project-case": "以具体项目材料为依据沉淀的案例,验证范围以证据记录为准。", +}; +const icons = { + saas: '', + paas: '', + iaas: '', +}; +const icon = (name) => + ``; +let models = [], + groups = [], + domains = [], + blueprints = [], + variants = [], + entries = []; +let manifests = new Map(); +const defaults = { + serviceModel: "saas", + group: "all", + domain: "all", + type: "blueprints", + industry: "all", + search: "", + blueprint: "", + variant: "", +}; +let state = { ...defaults }, + searchTimer, + loading = false; +const findBlueprint = (id) => blueprints.find((item) => item.id === id); +const findDomain = (id) => domains.find((item) => item.id === id); +const textOf = (...values) => + values.flat(Infinity).filter(Boolean).join(" ").toLocaleLowerCase(); +const bpText = (bp) => { + const domain = findDomain(bp?.domain); + return textOf( + bp?.id, + bp?.title, + bp?.summary, + bp?.roles, + bp?.entities, + bp?.workflow, + bp?.pages, + bp?.extensionPoints, + domain?.id, + domain?.title, + domain?.summary, + groups.find((group) => group.id === domain?.groupId)?.title, ); - const manifests = new Map( - await Promise.all( - catalog.cases.map(async (entry) => { - const response = await fetch(`/${entry.path}/case.json`); - if (!response.ok) throw new Error(`案例信息加载失败:${entry.id}`); - return [entry.id, await response.json()]; - }), +}; +const variantText = (variant) => + textOf( + variant?.id, + variant?.title, + variant?.summary, + variant?.industries, + variant?.adaptations, + ); +const caseText = (entry) => + textOf(entry.id, entry.title, entry.summary, entry.tags); +const relatedVariants = (id) => + variants.filter((variant) => variant.blueprintId === id); +const relatedCases = (id, variantId = "") => + entries.filter( + (entry) => + entry.blueprintId === id && (!variantId || entry.variantId === variantId), + ); +const layerIndustries = () => + [ + ...new Set( + variants + .filter( + (variant) => + findBlueprint(variant.blueprintId)?.serviceModel === + state.serviceModel, + ) + .flatMap((variant) => variant.industries), ), + ].sort((a, b) => a.localeCompare(b, "zh-CN")); +const domainLabel = (domain) => + domain?.id.length < 6 ? domain.id.toUpperCase() : domain?.title; + +function readLocation() { + const params = new URLSearchParams(location.search); + const next = Object.fromEntries( + Object.entries(defaults).map(([key, fallback]) => [ + key, + params.get(key) ?? fallback, + ]), + ); + if (!models.some((item) => item.id === next.serviceModel)) + next.serviceModel = "saas"; + if ( + !groups.some( + (item) => + item.id === next.group && item.serviceModel === next.serviceModel, + ) + ) + next.group = "all"; + const domain = domains.find( + (item) => + item.id === next.domain && item.serviceModel === next.serviceModel, + ); + if (!domain) next.domain = "all"; + else next.group = domain.groupId; + if (!Object.hasOwn(typeLabels, next.type)) next.type = "blueprints"; + let bp = findBlueprint(next.blueprint); + const variant = variants.find((item) => item.id === next.variant); + if (!bp && variant) bp = findBlueprint(variant.blueprintId); + if (!bp || bp.serviceModel !== next.serviceModel) { + next.blueprint = ""; + next.variant = ""; + } else { + next.blueprint = bp.id; + if (variant?.blueprintId !== bp.id) next.variant = ""; + } + const availableIndustries = variants + .filter( + (item) => + findBlueprint(item.blueprintId)?.serviceModel === next.serviceModel, + ) + .flatMap((item) => item.industries); + if (!availableIndustries.includes(next.industry)) next.industry = "all"; + return next; +} +function persistState(mode = "push") { + const url = new URL(location.href); + for (const [key, value] of Object.entries(state)) { + if (key === "serviceModel" || value !== defaults[key]) + url.searchParams.set(key, value); + else url.searchParams.delete(key); + } + if (url.href !== location.href) + history[mode === "replace" ? "replaceState" : "pushState"](null, "", url); +} +function updateState(change) { + clearTimeout(searchTimer); + persistState(); + state = { ...state, ...change }; + persistState(); + render(); +} +function withinScope(bp) { + const domain = findDomain(bp.domain); + return ( + bp.serviceModel === state.serviceModel && + (state.group === "all" || domain?.groupId === state.group) && + (state.domain === "all" || bp.domain === state.domain) + ); +} +function filteredAssets() { + const query = state.search.trim().toLocaleLowerCase(); + const industryMatches = (variant) => + state.industry === "all" || variant.industries.includes(state.industry); + const scopedBlueprints = blueprints.filter(withinScope); + const ids = new Set(scopedBlueprints.map((bp) => bp.id)); + const scopedVariants = variants.filter((variant) => + ids.has(variant.blueprintId), + ); + const matchesBlueprint = (bp) => { + const children = relatedVariants(bp.id).filter(industryMatches); + const matchingCases = relatedCases(bp.id).filter( + (entry) => + state.industry === "all" || + children.some((variant) => variant.id === entry.variantId), + ); + return ( + (state.industry === "all" || children.length > 0) && + textOf( + bpText(bp), + children.map(variantText), + matchingCases.map(caseText), + ).includes(query) + ); + }; + const filteredVariants = scopedVariants.filter( + (variant) => + industryMatches(variant) && + textOf( + variantText(variant), + bpText(findBlueprint(variant.blueprintId)), + relatedCases(variant.blueprintId, variant.id).map(caseText), + ).includes(query), + ); + const cases = entries.filter( + (entry) => + ids.has(entry.blueprintId) && + (state.industry === "all" || + variants + .find((variant) => variant.id === entry.variantId) + ?.industries.includes(state.industry)) && + textOf( + caseText(entry), + bpText(findBlueprint(entry.blueprintId)), + variantText(variants.find((variant) => variant.id === entry.variantId)), + ).includes(query), ); - const labels = { - proposal: "需求提议", - specified: "规格已整理", - prototype: "可交互原型", - integrated: "已接入服务", - verified: "已验收", + return { + blueprints: scopedBlueprints.filter(matchesBlueprint), + variants: filteredVariants, + "reference-implementation": cases.filter( + (entry) => entry.caseKind === "reference-implementation", + ), + "project-case": cases.filter((entry) => entry.caseKind === "project-case"), }; - let selected = "all"; - const options = [{ id: "all", title: "全部场景" }, ...domains.domains]; - $("#domain-select").innerHTML = options +} +function renderModels() { + $("#service-models").innerHTML = models .map( - (domain) => - ``, + (model) => + ``, ) .join(""); - $("#domains").innerHTML = options + $("#service-models").setAttribute("aria-busy", "false"); + const layerBlueprints = blueprints.filter( + (bp) => bp.serviceModel === state.serviceModel, + ); + const layerIds = new Set(layerBlueprints.map((bp) => bp.id)); + const counts = [ + [ + "domains", + "业务分类", + domains.filter((domain) => domain.serviceModel === state.serviceModel) + .length, + ], + ["blueprints", "标准主干", layerBlueprints.length], + [ + "variants", + "场景变体", + variants.filter((variant) => layerIds.has(variant.blueprintId)).length, + ], + [ + "reference-implementation", + "参考实现", + entries.filter( + (entry) => + entry.serviceModel === state.serviceModel && + entry.caseKind === "reference-implementation", + ).length, + ], + [ + "project-case", + "项目案例", + entries.filter( + (entry) => + entry.serviceModel === state.serviceModel && + entry.caseKind === "project-case", + ).length, + ], + ]; + $("#inventory").innerHTML = counts .map( - (domain) => - ``, + ([id, title, count]) => + `
${count}${title}${id === "project-case" && !count ? "待沉淀" : ""}
`, ) .join(""); - const render = () => { - const query = $("#search").value.trim().toLowerCase(); - const matches = catalog.cases.filter( - (entry) => - (selected === "all" || entry.domain === selected) && - `${entry.title} ${entry.summary} ${entry.tags.join(" ")} ${entry.domain}` - .toLowerCase() - .includes(query), + $("#inventory").hidden = false; +} +function renderDomains() { + const layerGroups = groups.filter( + (group) => group.serviceModel === state.serviceModel, + ); + const layerDomains = domains.filter( + (domain) => domain.serviceModel === state.serviceModel, + ); + $("#group-select").innerHTML = + '' + + layerGroups + .map( + (group) => + ``, + ) + .join(""); + $("#group-select").value = state.group; + $("#category-layer").textContent = serviceLabels[state.serviceModel]; + $("#domain-total").textContent = `${layerDomains.length} 类`; + const query = $("#domain-search").value.trim().toLocaleLowerCase(); + const matches = layerDomains.filter( + (domain) => + (query || state.group === "all" || domain.groupId === state.group) && + textOf(domain.title, domain.id, domain.summary).includes(query), + ); + const allButton = ``; + const domainButton = (domain) => { + const count = blueprints.filter( + (bp) => bp.domain === domain.id && bp.serviceModel === state.serviceModel, + ).length; + return ``; + }; + $("#domains").innerHTML = query + ? matches.map(domainButton).join("") + : allButton + + (state.group === "all" + ? layerGroups + .map( + (group) => + ``, + ) + .join("") + : matches.map(domainButton).join("")); + $("#no-domains").hidden = matches.length > 0; +} +const pills = (items, className = "") => + `
${items.map((item) => `${escape(item)}`).join("")}
`; +function blueprintCard(bp) { + const children = relatedVariants(bp.id), + implementations = relatedCases(bp.id); + return `
${escape(serviceLabels[bp.serviceModel])} / ${escape(domainLabel(findDomain(bp.domain)))}标准主干

${escape(bp.title)}

${escape(bp.summary)}

典型角色${escape(bp.roles.slice(0, 3).join(" · "))}${bp.roles.length > 3 ? " 等" : ""}
${bp.workflow + .slice(0, 3) + .map((step, index) => `${index + 1}${escape(step)}`) + .join( + '', + )}${bp.workflow.length > 3 ? '' : ""}
`; +} +function variantCard(variant) { + const bp = findBlueprint(variant.blueprintId); + return `
${escape(domainLabel(findDomain(bp.domain)))}场景变体

${escape(variant.title)}

${escape(variant.summary)}

${pills(variant.industries, "industry-pills")}
源自主干 ${escape(bp.title)}
`; +} +function caseArtwork(entry) { + const factory = entry.id === "factory-operations", + studio = entry.id === "character-material-library"; + const drawing = factory + ? '' + : studio + ? '' + : ''; + return ``; +} +function materialLinks(entry, manifest) { + const materials = [ + { title: "案例说明", path: "README.md" }, + { title: "需求规格", path: "requirements.md" }, + { title: "业务流程", path: "workflows.md" }, + { title: "数据模型", path: "data-model.md" }, + { title: "验收范围", path: "acceptance.md" }, + ...(manifest?.evidence ?? []), + ]; + return ``; +} +function implementationCard(entry, compact = false) { + const manifest = manifests.get(entry.id), + bp = findBlueprint(entry.blueprintId), + variant = variants.find((item) => item.id === entry.variantId); + const playable = + manifest && + ["prototype", "integrated", "verified"].includes(manifest.status) && + manifest.runtime?.mode !== "none" && + manifest.runtime?.entry; + return `
${compact ? "" : caseArtwork(entry)}
${escape(serviceLabels[entry.serviceModel])} / ${escape(domainLabel(findDomain(entry.domain)))}${escape(statusLabels[entry.status] ?? entry.status)}
${escape(typeLabels[entry.caseKind])}

${escape(entry.title)}

${escape(entry.summary)}

标准主干 ${escape(bp?.title ?? entry.blueprintId)}场景变体 ${escape(variant?.title ?? entry.variantId)}
${!manifest ? '

运行信息暂不可用,可先查看案例说明。

' : ""}${materialLinks(entry, manifest)}
`; +} +function renderResults() { + const model = models.find((item) => item.id === state.serviceModel), + group = groups.find((item) => item.id === state.group), + domain = findDomain(state.domain); + const assets = filteredAssets(), + matches = assets[state.type]; + $("#breadcrumb").innerHTML = + `${group ? `/` : ""}${domain ? `/${escape(domainLabel(domain))}` : ""}`; + $("#results-title").textContent = + domain?.title ?? + group?.title ?? + `全部 ${serviceLabels[state.serviceModel]} 资产`; + $("#domain-description").textContent = + domain?.summary ?? group?.summary ?? model.summary; + $("#results-count").textContent = + `${matches.length} 个${typeLabels[state.type]}`; + $("#asset-tabs").innerHTML = Object.entries(typeLabels) + .map( + ([id, title]) => + ``, + ) + .join(""); + $("#asset-results").setAttribute("aria-labelledby", `tab-${state.type}`); + $("#type-description").textContent = typeDescriptions[state.type]; + $("#summary").textContent = `显示 ${matches.length} 项`; + $("#cases").className = + `case-grid ${state.type === "blueprints" ? "blueprint-grid" : state.type === "variants" ? "variant-grid" : ""}`; + $("#cases").innerHTML = matches + .map((item) => + state.type === "blueprints" + ? blueprintCard(item) + : state.type === "variants" + ? variantCard(item) + : implementationCard(item), + ) + .join(""); + const constrained = Boolean(state.search.trim() || state.industry !== "all"); + $("#empty").hidden = matches.length > 0; + $("#empty-title").textContent = constrained + ? "没有找到匹配资产" + : `${typeLabels[state.type]}待沉淀`; + $("#empty-description").textContent = constrained + ? `当前范围内没有符合${state.search.trim() ? `“${state.search.trim()}”` : "所选行业"}的${typeLabels[state.type]},可以清除检索条件或切换资产类型。` + : state.type === "project-case" + ? "当前尚未收录有项目材料支撑的项目案例。参考实现可在相应类型下查阅。" + : state.type === "blueprints" || state.type === "variants" + ? "分类已建立,标准规格与场景适配尚待整理。这里不会把分类本身计为可运行案例。" + : "当前范围尚未关联参考实现。可先查阅标准主干和变体,了解业务边界与适配方式。"; + $("#clear-filter").hidden = !constrained; +} +function standardSections(bp) { + return `

01 业务角色

${pills(bp.roles)}

02 业务对象

${pills(bp.entities)}

03 标准流程

    ${bp.workflow.map((step, index) => `
  1. ${String(index + 1).padStart(2, "0")}

    ${escape(step)}

  2. `).join("")}

04 页面组成

    ${bp.pages.map((page) => `
  • ${escape(page)}
  • `).join("")}

05 扩展点

    ${bp.extensionPoints.map((point) => `
  • ${escape(point)}
  • `).join("")}
`; +} +function renderDetail() { + const dialog = $("#asset-detail"), + bp = findBlueprint(state.blueprint), + variant = variants.find( + (item) => item.id === state.variant && item.blueprintId === bp?.id, ); - $("#summary").textContent = - `${domains.domains.length} 个业务领域 · ${catalog.cases.length} 个已收录案例 · 当前显示 ${matches.length} 个`; - $("#cases").innerHTML = matches - .map((entry) => { - const manifest = manifests.get(entry.id); - const playable = - ["prototype", "integrated", "verified"].includes(manifest.status) && - manifest.runtime.mode !== "none"; - const primary = playable - ? `体验工作台 →` - : `查看案例 →`; - return `
业务流程工作台
${escape(labels[entry.status])}

${escape(entry.title)}

${escape(entry.summary)}

${entry.tags.map((tag) => `${escape(tag)}`).join("")}
`; - }) + if (!bp) { + if (dialog.open) dialog.close(); + return; + } + const children = relatedVariants(bp.id), + implementations = relatedCases(bp.id, variant?.id); + const title = variant?.title ?? bp.title; + $("#detail-content").innerHTML = + `
${escape(serviceLabels[bp.serviceModel])} / ${escape(domainLabel(findDomain(bp.domain)))} / ${variant ? "场景变体" : "标准主干"}
${variant ? "场景适配规格" : "标准业务规格"}

${escape(title)}

${escape(variant?.summary ?? bp.summary)}

${variant ? `
继承标准主干

适用行业

${pills(variant.industries, "industry-pills")}

场景适配

    ${variant.adaptations.map((item) => `
  • ${escape(item)}
  • `).join("")}
查阅继承的标准主干 角色 · 对象 · 流程 · 页面 · 扩展点${standardSections(bp)}
` : standardSections(bp)}`; + if (!dialog.open) dialog.showModal(); +} +function render() { + const active = document.activeElement, + focused = active?.dataset; + $("#search").value = state.search; + renderModels(); + renderDomains(); + $("#industry-select").innerHTML = + '' + + layerIndustries() + .map( + (industry) => + ``, + ) .join(""); - $("#empty").hidden = matches.length > 0; - $("#empty h3").textContent = query - ? "没有找到匹配案例" - : "这个领域还没有案例"; - document.querySelectorAll("[data-domain]").forEach((button) => { - button.classList.toggle("active", button.dataset.domain === selected); - button.setAttribute( - "aria-pressed", - String(button.dataset.domain === selected), - ); + $("#industry-select").value = state.industry; + renderResults(); + renderDetail(); + if (!$("#asset-detail").open && focused) + for (const key of ["serviceModel", "domain", "group", "type"]) + if (focused[key]) { + const attribute = key.replace( + /[A-Z]/g, + (letter) => `-${letter.toLowerCase()}`, + ); + document + .querySelector(`[data-${attribute}="${CSS.escape(focused[key])}"]`) + ?.focus({ preventScroll: true }); + break; + } +} +async function loadCatalog() { + if (loading) return; + loading = true; + $("#catalog-error").hidden = true; + $("#service-models").setAttribute("aria-busy", "true"); + try { + const resources = [ + "service-models", + "domain-groups", + "domains", + "blueprints", + "variants", + "cases", + ]; + const responses = await Promise.all( + resources.map((name) => fetch(`/catalog/${name}.json`)), + ); + if (responses.some((response) => !response.ok)) + throw new Error("资产目录数据读取失败,请确认服务正在运行后重试。"); + const data = await Promise.all( + responses.map((response) => response.json()), + ); + [models, groups, domains, blueprints, variants, entries] = data.map( + (resource, index) => + resource[ + [ + "serviceModels", + "groups", + "domains", + "blueprints", + "variants", + "cases", + ][index] + ], + ); + if ( + ![models, groups, domains, blueprints, variants, entries].every( + Array.isArray, + ) || + !models.some((model) => model.id === "saas") + ) + throw new Error("资产目录格式无效,请检查分类与关系索引。"); + manifests = new Map( + await Promise.all( + entries.map(async (entry) => { + try { + const response = await fetch(`/${entry.path}/case.json`); + return [entry.id, response.ok ? await response.json() : null]; + } catch { + return [entry.id, null]; + } + }), + ), + ); + state = readLocation(); + persistState("replace"); + render(); + $("#catalog-content").hidden = false; + } catch (error) { + $("#catalog-error").hidden = false; + $("#error-message").textContent = + error instanceof Error + ? error.message + : "请通过 npm run demo 启动服务后重试。"; + $("#service-models").innerHTML = + '
服务分类尚未加载
'; + } finally { + loading = false; + $("#service-models").setAttribute("aria-busy", "false"); + } +} +document.addEventListener("click", (event) => { + const button = event.target.closest("button"); + if (!button) return; + const data = button.dataset; + if (data.serviceModel) { + $("#domain-search").value = ""; + updateState({ + serviceModel: data.serviceModel, + group: "all", + domain: "all", + industry: "all", + blueprint: "", + variant: "", }); - }; - $("#domains").addEventListener("click", (event) => { - const button = event.target.closest("[data-domain]"); - if (button) { - selected = button.dataset.domain; - $("#domain-select").value = selected; - render(); + } else if (data.group) { + $("#domain-search").value = ""; + updateState({ + group: data.group, + domain: "all", + blueprint: "", + variant: "", + }); + } else if (data.domain) { + const domain = findDomain(data.domain); + $("#domain-search").value = ""; + updateState({ + domain: data.domain, + ...(domain ? { group: domain.groupId } : {}), + blueprint: "", + variant: "", + }); + } else if ("resetScope" in data) { + $("#domain-search").value = ""; + updateState({ group: "all", domain: "all", blueprint: "", variant: "" }); + } else if (data.type) + updateState({ type: data.type, blueprint: "", variant: "" }); + else if (data.blueprint) { + updateState({ blueprint: data.blueprint, variant: "" }); + $("#asset-detail").scrollTop = 0; + } else if (data.variant) { + const variant = variants.find((item) => item.id === data.variant); + if (variant) { + updateState({ blueprint: variant.blueprintId, variant: variant.id }); + $("#asset-detail").scrollTop = 0; } + } else if ("closeDetail" in data) updateState({ blueprint: "", variant: "" }); +}); +$("#group-select").addEventListener("change", () => { + $("#domain-search").value = ""; + updateState({ + group: $("#group-select").value, + domain: "all", + blueprint: "", + variant: "", }); - $("#domain-select").addEventListener("change", () => { - selected = $("#domain-select").value; - render(); - }); - $("#search").addEventListener("input", render); +}); +$("#industry-select").addEventListener("change", () => + updateState({ + industry: $("#industry-select").value, + blueprint: "", + variant: "", + }), +); +$("#domain-search").addEventListener("input", renderDomains); +$("#search").addEventListener("input", () => { + clearTimeout(searchTimer); + state = { ...state, search: $("#search").value, blueprint: "", variant: "" }; + renderResults(); + renderDetail(); + searchTimer = setTimeout(() => persistState(), 300); +}); +$("#search").addEventListener("change", () => { + clearTimeout(searchTimer); + persistState(); +}); +$("#clear-filter").addEventListener("click", () => + updateState({ search: "", industry: "all" }), +); +$("#retry").addEventListener("click", loadCatalog); +$("#asset-detail").addEventListener("cancel", (event) => { + event.preventDefault(); + updateState({ blueprint: "", variant: "" }); +}); +$("#asset-detail").addEventListener("click", (event) => { + if (event.target === $("#asset-detail")) + updateState({ blueprint: "", variant: "" }); +}); +$("#asset-tabs").addEventListener("keydown", (event) => { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const types = Object.keys(typeLabels), + index = types.indexOf(state.type); + const next = + event.key === "Home" + ? 0 + : event.key === "End" + ? types.length - 1 + : (index + (event.key === "ArrowRight" ? 1 : -1) + types.length) % + types.length; + updateState({ type: types[next], blueprint: "", variant: "" }); + $(`#tab-${types[next]}`).focus(); +}); +window.addEventListener("popstate", () => { + clearTimeout(searchTimer); + if (!models.length) return; + state = readLocation(); + $("#domain-search").value = ""; render(); -} catch (error) { - $("#summary").textContent = - `无法加载案例目录:${error.message}。请通过 npm run demo 启动。`; -} +}); +void loadCatalog(); diff --git a/catalog/blueprints.json b/catalog/blueprints.json new file mode 100644 index 0000000..48966f5 --- /dev/null +++ b/catalog/blueprints.json @@ -0,0 +1,710 @@ +{ + "version": 1, + "blueprints": [ + { + "id": "crm-sales-pipeline", + "serviceModel": "saas", + "domain": "crm", + "title": "客户与商机推进", + "summary": "把客户关系、销售机会、沟通记录和下一步行动串成可追踪的销售过程。", + "roles": ["销售代表", "销售主管", "销售运营"], + "entities": [ + "客户与联系人", + "商机", + "沟通记录", + "跟进任务", + "商机阶段记录" + ], + "workflow": [ + "录入或认领客户并检查重复", + "创建商机并记录需求与预计成交日", + "记录拜访及下一步负责人和日期", + "按阶段条件推进并复核停滞机会", + "记录赢单或失单原因并交接履约" + ], + "pages": [ + "销售概览", + "客户详情", + "商机看板", + "拜访与跟进日历", + "团队漏斗" + ], + "extensionPoints": [ + "客户主数据匹配规则", + "商机阶段与必填字段", + "邮件或日历连接器", + "订单系统交接接口" + ] + }, + { + "id": "service-request-resolution", + "serviceModel": "saas", + "domain": "service", + "title": "客户请求与服务闭环", + "summary": "围绕受理、分派、协作处理和客户确认管理服务请求及超时风险。", + "roles": ["客服坐席", "服务主管", "二线专家", "客户申请人"], + "entities": [ + "服务请求", + "客户与服务权益", + "处理记录", + "服务时限", + "满意度反馈" + ], + "workflow": [ + "从表单或渠道受理并识别客户权益", + "按问题类型和优先级分派", + "补充证据并升级二线处理", + "提出解决方案并等待客户确认", + "完成或重新打开并统计超时原因" + ], + "pages": [ + "服务队列", + "请求详情与时间线", + "客户服务记录", + "升级与超时面板", + "知识检索" + ], + "extensionPoints": [ + "渠道接入及去重标识", + "服务时限和工作日历", + "知识库检索", + "现场派工或退换货接口" + ] + }, + { + "id": "marketing-campaign-delivery", + "serviceModel": "saas", + "domain": "marketing", + "title": "活动策划与内容交付", + "summary": "把营销目标、渠道计划、创意任务、审核和效果复盘连接起来。", + "roles": ["营销负责人", "内容策划", "设计制作", "品牌审核人"], + "entities": [ + "营销活动", + "渠道计划", + "创意简报", + "制作任务", + "审核记录", + "效果快照" + ], + "workflow": [ + "定义目标受众与预算", + "拆分渠道物料与负责人", + "制作并提交版本审核", + "确认排期与发布回执", + "收集效果数据并复盘归因" + ], + "pages": ["活动总览", "活动日历", "创意任务板", "物料审核", "效果复盘"], + "extensionPoints": [ + "品牌审核规则", + "DAM资产引用", + "渠道发布连接器", + "指标归因与回传口径" + ] + }, + { + "id": "commerce-store-operations", + "serviceModel": "saas", + "domain": "commerce", + "title": "店铺交易与售后运营", + "summary": "管理可售商品、上架、交易异常及退款协作,保留支付与履约系统边界。", + "roles": ["店铺运营", "商品专员", "售后专员", "店长"], + "entities": [ + "上架商品", + "渠道店铺", + "交易订单", + "售后申请", + "促销执行记录" + ], + "workflow": [ + "确认商品与渠道上架信息", + "检查价格和可售库存", + "跟进交易及履约异常", + "审核售后并发起退款或换货", + "核对结算回执并复盘门店表现" + ], + "pages": [ + "店铺概览", + "商品上架", + "交易列表", + "售后工作台", + "门店经营指标" + ], + "extensionPoints": [ + "支付与退款幂等接口", + "OMS及库存可售量接口", + "渠道上架适配", + "促销叠加与退款分摊规则" + ] + }, + { + "id": "finance-settlement-control", + "serviceModel": "saas", + "domain": "finance", + "title": "应收应付与结算协同", + "summary": "以业务单据和对账差异为线索,管理收付款计划、核销和账龄。", + "roles": ["应收专员", "应付专员", "财务复核人", "业务经办人"], + "entities": [ + "结算单据", + "往来单位", + "收付款计划", + "核销记录", + "对账差异" + ], + "workflow": [ + "收集订单或服务确认形成结算依据", + "校验金额与单据并安排收付计划", + "记录银行或支付回执", + "按规则匹配并人工复核差异", + "核销并追踪逾期和未结项目" + ], + "pages": ["收付概览", "应收账龄", "应付计划", "对账差异", "结算单据详情"], + "extensionPoints": [ + "ERP及银行回执接口", + "多币种与舍入规则", + "核销匹配策略", + "复核权限与金额阈值" + ] + }, + { + "id": "hrm-employee-lifecycle", + "serviceModel": "saas", + "domain": "hrm", + "title": "员工入转调离", + "summary": "以员工主档和生效日期驱动组织、岗位与入转调离协作。", + "roles": ["人事专员", "部门负责人", "员工", "入离职协作人"], + "entities": [ + "员工主档", + "组织与岗位", + "任职记录", + "人事变更申请", + "交接清单" + ], + "workflow": [ + "核对入职资料并建立员工主档", + "分派入职任务和权限申请", + "按生效日期处理转正或调动", + "审批离职并完成资产与工作交接", + "封存任职历史并确认访问撤销" + ], + "pages": [ + "员工目录", + "组织与岗位", + "人事变更", + "入离职任务板", + "员工自助" + ], + "extensionPoints": [ + "组织主数据同步", + "身份开通及撤销接口", + "地区资料与保留规则", + "敏感字段按角色脱敏" + ] + }, + { + "id": "lms-training-compliance", + "serviceModel": "saas", + "domain": "lms", + "title": "培训分配与考核", + "summary": "连接课程版本、学习对象、出勤或学习记录、考核与复训安排。", + "roles": ["培训管理员", "讲师", "学员", "部门主管"], + "entities": [ + "课程与版本", + "培训班", + "学习分配", + "学习记录", + "考核结果", + "资格有效期" + ], + "workflow": [ + "发布课程版本和通过要求", + "按岗位或名单分配学习任务", + "记录线上学习或线下签到", + "完成考试并处理补考", + "发放记录并安排到期复训" + ], + "pages": [ + "培训概览", + "课程目录", + "班次与名单", + "个人学习", + "考核与到期提醒" + ], + "extensionPoints": [ + "学习内容与视频存储", + "考试题库及评分接口", + "岗位资格规则", + "外部学习平台记录导入" + ] + }, + { + "id": "scm-supply-exception-control", + "serviceModel": "saas", + "domain": "scm", + "title": "供需协同与交付异常", + "summary": "用同一组供需承诺和异常记录协调供应、生产、库存与客户交付。", + "roles": ["供应计划员", "采购协调人", "生产协调人", "销售交付经理"], + "entities": ["需求承诺", "供应承诺", "缺口清单", "交付异常", "协调方案"], + "workflow": [ + "收集需求与供应计划快照", + "识别缺口及受影响订单", + "协商替代供货或产能方案", + "确认责任人与承诺日期", + "跟踪执行回执并关闭异常" + ], + "pages": ["供需概览", "缺口清单", "承诺日历", "跨部门异常单", "方案对比"], + "extensionPoints": [ + "ERP及供应商计划接口", + "需求优先级策略", + "物料替代规则", + "承诺变更通知渠道" + ] + }, + { + "id": "srm-supplier-qualification", + "serviceModel": "saas", + "domain": "srm", + "title": "供应商准入与绩效", + "summary": "维护供应商资质、准入决策、周期评价和整改,形成可复核的关系记录。", + "roles": ["供应商管理员", "采购评审人", "质量评审人", "供应商联系人"], + "entities": ["供应商", "资质材料", "准入评审", "绩效评分", "整改任务"], + "workflow": [ + "收集申请与资质材料", + "按品类分派资质和质量评审", + "记录准入结论及有效期", + "按交付和质量事实开展周期评分", + "跟踪整改并决定复评或暂停" + ], + "pages": ["供应商库", "准入评审", "资质到期", "绩效评分", "整改跟踪"], + "extensionPoints": [ + "供应商自助身份", + "资质验证接口", + "分品类评分模型", + "采购与质量绩效数据接口" + ] + }, + { + "id": "wms-warehouse-execution", + "serviceModel": "saas", + "domain": "wms", + "title": "仓内收发与盘点", + "summary": "以库位、批次和作业任务协调收货、上架、拣选、发运和盘点。", + "roles": ["收货员", "上架与拣选员", "仓库主管", "盘点复核人"], + "entities": ["仓库与库位", "入库单", "批次库存", "拣选任务", "盘点差异"], + "workflow": [ + "校验到货并记录收货差异", + "按策略分配库位上架", + "根据出库需求生成拣选任务", + "复核包装并确认发运交接", + "冻结盘点范围并复核差异调整" + ], + "pages": ["仓库概览", "收货与上架", "拣货任务", "发运复核", "盘点工作台"], + "extensionPoints": [ + "扫码设备与标签规则", + "批次效期及先进先出策略", + "库存账及OMS接口", + "自动化仓储设备适配" + ] + }, + { + "id": "tms-shipment-dispatch", + "serviceModel": "saas", + "domain": "tms", + "title": "运输调度与签收结算", + "summary": "管理运输订单、承运商选择、节点异常、签收凭证和运费核对。", + "roles": ["运输计划员", "调度员", "承运商联系人", "运费结算员"], + "entities": [ + "运输订单", + "承运商", + "运单", + "运输节点", + "签收凭证", + "运费账单" + ], + "workflow": [ + "汇总发运需求并计划装载", + "比选承运商并下达运单", + "跟踪提货及在途异常", + "收集签收和破损证明", + "核对运费并关闭争议" + ], + "pages": [ + "运输看板", + "待调度订单", + "运单时间线", + "异常与签收", + "运费对账" + ], + "extensionPoints": [ + "承运商或定位接口", + "装载及路线约束", + "电子回单存储", + "运价和附加费规则" + ] + }, + { + "id": "mes-production-operations", + "serviceModel": "saas", + "domain": "mes", + "title": "生产现场执行与追溯", + "summary": "以工位、物料和执行记录连接现场上报、主管处置与交接闭环;可按场景选择报工或领用子流程。", + "roles": ["现场操作员", "车间主管", "物料配送员", "生产管理员"], + "entities": [ + "工位与班组", + "工单或合同引用", + "物料", + "现场上报", + "处理与交接记录", + "追溯附件" + ], + "workflow": [ + "记录工位、物料和任务上下文", + "提交执行结果或现场物料异常", + "主管确认并分派处置", + "执行配送或作业交接并记录证据", + "满足场景关闭条件后完结并保留时间线" + ], + "pages": [ + "现场概览", + "执行与上报", + "主管待办", + "物料与附件", + "追溯时间线" + ], + "extensionPoints": [ + "ERP工单及物料主数据", + "扫码与现场设备接口", + "场景状态机与关闭前置条件", + "身份、业务数据及资产存储端口" + ] + }, + { + "id": "plm-engineering-change", + "serviceModel": "saas", + "domain": "plm", + "title": "产品基线与工程变更", + "summary": "围绕产品结构、设计资料和变更审批维护可追溯的发布基线。", + "roles": ["产品工程师", "设计工程师", "制造评审人", "变更批准人"], + "entities": [ + "产品", + "物料清单", + "设计版本", + "变更申请", + "影响评估", + "发布基线" + ], + "workflow": [ + "建立产品结构与设计资料", + "提出变更并圈定受影响版本", + "评估采购、库存和制造影响", + "会签批准并确定生效范围", + "发布基线并回读下游同步结果" + ], + "pages": ["产品结构", "版本对比", "变更队列", "影响评估", "发布与同步"], + "extensionPoints": [ + "CAD及文档系统引用", + "BOM与物料编码规则", + "电子签核策略", + "ERP或MES生效接口" + ] + }, + { + "id": "qms-quality-corrective-action", + "serviceModel": "saas", + "domain": "qms", + "title": "检验与质量纠正闭环", + "summary": "把检验结果、不合格处置、原因分析和效果复核连接成质量记录。", + "roles": ["检验员", "质量工程师", "生产或供应商责任人", "质量批准人"], + "entities": [ + "检验计划", + "检验批次", + "检测结果", + "不合格单", + "纠正措施", + "效果验证" + ], + "workflow": [ + "按计划抽样并记录检验结果", + "不合格时隔离受影响批次", + "评审返工、退货或其他处置", + "调查原因并落实纠正措施", + "验证效果后关闭并保留关联证据" + ], + "pages": ["检验任务", "结果录入", "不合格队列", "纠正措施", "质量趋势"], + "extensionPoints": [ + "检验标准与抽样规则", + "仪器结果导入", + "批次库存隔离接口", + "处置审批与电子签名" + ] + }, + { + "id": "eam-maintenance-work-orders", + "serviceModel": "saas", + "domain": "eam", + "title": "设备保养与维修工单", + "summary": "围绕设备台账、维护策略、故障、工单和备件建立维护历史。", + "roles": ["设备操作员", "维修技师", "设备主管", "备件管理员"], + "entities": [ + "设备", + "维护计划", + "故障报修", + "维修工单", + "备件领用", + "停机记录" + ], + "workflow": [ + "登记设备与维护策略", + "由周期触发保养或现场提交故障", + "评估优先级并分派维修", + "记录措施、工时和备件使用", + "确认恢复运行并更新设备履历" + ], + "pages": [ + "设备台账", + "维护日历", + "维修队列", + "工单执行", + "停机与成本分析" + ], + "extensionPoints": [ + "设备传感器或告警接口", + "备件库存与采购接口", + "维保周期计算", + "作业许可与安全确认" + ] + }, + { + "id": "dam-content-assets", + "serviceModel": "saas", + "domain": "dam", + "title": "数字内容资产与审核复用", + "summary": "把素材、版本、授权和审核记录作为复用依据,支持制作任务引用与归档。", + "roles": ["资产管理员", "内容制作人", "审核人", "业务使用人"], + "entities": [ + "内容资产", + "素材版本", + "授权记录", + "制作任务", + "审核记录", + "发布或归档记录" + ], + "workflow": [ + "登记素材来源及使用范围", + "整理标签和版本并建立引用关系", + "为制作任务选择允许使用的素材", + "审核产出并记录退回原因", + "归档通过版本并按授权范围复用" + ], + "pages": [ + "资产库", + "资产版本与授权", + "制作任务", + "审核队列", + "归档与引用关系" + ], + "extensionPoints": [ + "企业资产平台与对象存储", + "检索及转码能力", + "可选生成服务及任务状态接口", + "权限、授权到期与下载策略" + ] + }, + { + "id": "cms-knowledge-publishing", + "serviceModel": "saas", + "domain": "cms", + "title": "内容审核与知识发布", + "summary": "维护内容版本、可见范围、审核发布和定期复审,保留知识责任人。", + "roles": ["内容编辑", "主题专家", "审核人", "知识读者"], + "entities": [ + "内容条目", + "版本", + "分类与标签", + "审核意见", + "发布计划", + "复审任务" + ], + "workflow": [ + "创建条目并明确主题责任人", + "提交版本进行事实与格式审核", + "按范围和时间发布", + "收集读者反馈并修订", + "到期复审或撤下过期内容" + ], + "pages": ["知识目录", "内容编辑", "审核待办", "发布日历", "反馈与复审"], + "extensionPoints": [ + "富文本或文档编辑器", + "搜索与权限过滤", + "发布渠道适配", + "内容保留与到期规则" + ] + }, + { + "id": "oa-office-requests", + "serviceModel": "saas", + "domain": "oa", + "title": "行政申请与审批履约", + "summary": "以服务目录和申请单承接行政需求,连接审批、执行和申请人确认。", + "roles": ["员工申请人", "部门审批人", "行政执行人", "服务管理员"], + "entities": [ + "行政服务目录", + "申请单", + "审批节点", + "执行任务", + "服务回执" + ], + "workflow": [ + "选择服务并填写申请资料", + "依据金额或组织确定审批链", + "审批通过后分派执行任务", + "记录办理结果或退回补充原因", + "申请人确认并归档处理记录" + ], + "pages": ["办公服务入口", "我的申请", "审批待办", "行政任务", "服务配置"], + "extensionPoints": [ + "组织与身份接口", + "审批规则与委托", + "办公库存或采购接口", + "通知渠道和超时提醒" + ] + }, + { + "id": "projects-delivery-coordination", + "serviceModel": "saas", + "domain": "projects", + "title": "项目任务与交付协作", + "summary": "用需求、任务、里程碑、风险和验收记录管理单项目交付。", + "roles": ["项目经理", "项目成员", "需求负责人", "验收人"], + "entities": [ + "项目", + "需求", + "任务", + "里程碑", + "风险与问题", + "交付物与验收" + ], + "workflow": [ + "确认目标、范围与交付节点", + "拆解任务并分配责任和依赖", + "跟踪进度与阻塞并记录范围变更", + "提交交付物并处理验收意见", + "确认完成并归档项目复盘" + ], + "pages": [ + "项目概览", + "需求与任务板", + "里程碑计划", + "风险问题", + "交付验收" + ], + "extensionPoints": [ + "工时与成本接口", + "代码或文档引用", + "任务依赖及进度规则", + "客户协作权限" + ] + }, + { + "id": "erp-order-resource-coordination", + "serviceModel": "saas", + "domain": "erp", + "title": "订单到资源的跨部门协同", + "summary": "以订单为主线汇集采购、库存、生产与财务状态,协调跨部门交接和异常。", + "roles": ["业务订单员", "采购协调人", "履约负责人", "财务协调人"], + "entities": [ + "业务订单", + "资源需求", + "采购或生产引用", + "库存承诺", + "履约节点", + "结算引用" + ], + "workflow": [ + "确认订单和资源需求", + "检查库存与供应承诺", + "派发采购或生产协同任务", + "跟踪交付异常并调整承诺", + "核对履约与结算交接完成" + ], + "pages": [ + "订单总览", + "资源需求", + "供应与履约协同", + "异常处理", + "订单全链路" + ], + "extensionPoints": [ + "各业务系统单据映射", + "主数据编码与单位换算", + "跨系统事件幂等与对账", + "组织及单据可见范围" + ] + }, + { + "id": "itsm-service-restoration", + "serviceModel": "saas", + "domain": "itsm", + "title": "IT 请求与事件处置", + "summary": "管理员工IT请求、事件恢复、问题根因与受控变更的关联。", + "roles": ["员工用户", "服务台坐席", "技术处理人", "变更批准人"], + "entities": [ + "服务请求", + "事件", + "配置项", + "问题记录", + "变更单", + "服务时限" + ], + "workflow": [ + "受理请求或告警并关联受影响服务", + "判断优先级并分派处理", + "记录排查与临时恢复措施", + "需要时发起变更并审批执行", + "确认恢复并沉淀问题及知识记录" + ], + "pages": [ + "IT服务入口", + "事件队列", + "事件详情", + "变更日历", + "服务质量面板" + ], + "extensionPoints": [ + "监控告警与去重接口", + "配置管理库", + "工作日历及升级规则", + "身份和设备管理系统" + ] + }, + { + "id": "analytics-business-review", + "serviceModel": "saas", + "domain": "analytics", + "title": "经营指标与异常复盘", + "summary": "以明确口径的指标和数据快照支持经营查看、异常归因和行动追踪。", + "roles": ["业务负责人", "数据分析师", "指标责任人", "部门管理者"], + "entities": [ + "指标定义", + "数据快照", + "仪表盘", + "异常记录", + "分析结论", + "改进行动" + ], + "workflow": [ + "定义指标口径、范围与责任人", + "接收数据并校验更新时点", + "按组织和周期查看指标变化", + "标记异常并记录分析依据", + "分配改进行动并复查结果" + ], + "pages": ["经营总览", "指标字典", "明细钻取", "异常分析", "行动复盘"], + "extensionPoints": [ + "数据查询与聚合接口", + "组织行级权限", + "指标计算与时间口径", + "报表导出及通知" + ] + } + ] +} diff --git a/catalog/cases.json b/catalog/cases.json index d7c003b..ba3d7c9 100644 --- a/catalog/cases.json +++ b/catalog/cases.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 3, "cases": [ { "id": "character-material-library", @@ -13,7 +13,30 @@ "virtual-try-on" ], "status": "prototype", - "path": "examples/dam/character-material-library" + "path": "examples/saas/dam/character-material-library", + "serviceModel": "saas", + "caseKind": "reference-implementation", + "blueprintId": "dam-content-assets", + "variantId": "digital-human-content-production" + }, + { + "id": "factory-operations", + "title": "工厂运营工作台", + "summary": "通过照片上报、审批配送与断针回收追溯工厂申领,记录销售拜访与跟进,使用可选择的数据和资产服务。", + "domain": "mes", + "tags": [ + "crm", + "factory", + "approvals", + "asset-management", + "multi-provider" + ], + "status": "integrated", + "path": "examples/saas/mes/factory-operations", + "serviceModel": "saas", + "caseKind": "reference-implementation", + "blueprintId": "mes-production-operations", + "variantId": "garment-workshop-consumables" }, { "id": "brand-digital-human-studio", @@ -29,7 +52,11 @@ "voice-cloning" ], "status": "prototype", - "path": "examples/dam/brand-digital-human-studio" + "serviceModel": "saas", + "caseKind": "reference-implementation", + "blueprintId": "dam-content-assets", + "variantId": "digital-human-content-production", + "path": "examples/saas/dam/brand-digital-human-studio" } ] } diff --git a/catalog/catalog.css b/catalog/catalog.css new file mode 100644 index 0000000..429838c --- /dev/null +++ b/catalog/catalog.css @@ -0,0 +1,3375 @@ +:root { + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + "Microsoft YaHei", + sans-serif; + color: #293d35; + background: #f7f8f4; + font-synthesis: none; + --green: #245743; + --muted: #778379; + --line: #e2e7dd; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +button, +input { + font: inherit; +} +button, +a, +input { + -webkit-tap-highlight-color: transparent; +} +button, +a { + touch-action: manipulation; +} +button { + cursor: pointer; + color: inherit; +} +a { + color: inherit; + text-decoration: none; +} +h1, +h2, +h3, +p { + margin: 0; +} +svg { + flex-shrink: 0; +} +button:focus-visible, +a:focus-visible, +input:focus-visible { + outline: 3px solid #9ebd8c; + outline-offset: 3px; +} +input:focus { + outline: none; +} +::selection { + background: #dbe9cd; +} +[hidden] { + display: none !important; +} +.site-header { + height: 76px; + background: #ffffffc9; + border-bottom: 1px solid var(--line); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 4.2%; + gap: 20px; +} +.brand { + display: flex; + align-items: center; + gap: 11px; + font-size: 19px; + font-weight: 650; + letter-spacing: -0.35px; +} +.brand-mark { + height: 32px; + width: 32px; + display: flex; + align-items: center; + justify-content: center; + background: #254d3b; + color: white; + border-radius: 8px; + font-size: 21px; + font-weight: 700; + letter-spacing: -2px; + padding-right: 2px; +} +.brand-mark > span { + color: #c4dba5; + font-size: 22px; +} +.brand-divider { + font-size: 14px; + color: #bac3b3; + font-weight: 400; + margin: 0 14px; +} +.brand small { + font-size: 12px; + color: #75816d; + font-weight: 400; + letter-spacing: 0.5px; +} +.header-links { + display: flex; + gap: 25px; + align-items: center; + font-size: 11px; + color: #78856e; +} +.header-links a:hover { + color: var(--green); +} +.header-links span { + font-size: 15px; + margin-left: 5px; +} +main { + max-width: 1440px; + margin: auto; + padding: 48px 4.2% 0; +} +.hero { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 38px; + gap: 35px; +} +.eyebrow { + font-size: 9px; + letter-spacing: 1.8px; + color: #8e9a80; + font-weight: 500; +} +.hero .eyebrow { + display: flex; + align-items: center; + gap: 8px; + color: #7f9571; +} +.hero .eyebrow > span { + width: 5px; + height: 5px; + background: #73975c; + border-radius: 50%; + box-shadow: 0 0 0 3px #73975c12; +} +.hero h1 { + font-size: 32px; + line-height: 1.6; + letter-spacing: -0.7px; + margin: 13px 0 11px; + font-weight: 650; + color: #344e38; +} +.hero h1 > span { + color: #668446; +} +.hero p { + font-size: 12px; + line-height: 1.9; + color: #89957e; +} +.hero-index { + text-align: right; + padding-right: 5px; + flex-shrink: 0; +} +.hero-index > span { + font-size: 8px; + letter-spacing: 1.7px; + color: #a6b096; +} +.hero-index > div { + font-size: 12px; + color: #839471; + margin: 12px 0; +} +.hero-index i { + font-style: normal; + font-size: 17px; + color: #7d9a63; + margin-right: 16px; +} +.hero-index b { + font-weight: 400; + color: #b9c1ac; + margin: 0 10px; +} +.hero-index small { + font-size: 9px; + color: #a7b297; + letter-spacing: 2px; +} +.mobile-break { + display: none; +} +.section-label { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 14px; +} +.section-label h2 { + font-size: 11px; + font-weight: 550; + color: #778a68; + display: flex; + align-items: center; + gap: 9px; +} +.section-label h2 > span, +.category-heading h2 > span:first-child { + font-size: 9px; + font-weight: 400; + color: #a4b093; + font-variant-numeric: tabular-nums; +} +.section-label > span { + font-size: 9px; + color: #a0ad91; +} +.layer-section { + margin-bottom: 34px; +} +.service-models { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} +.service-card { + display: flex; + flex-direction: column; + border: 1px solid #e1e7d8; + background: #fff; + border-radius: 11px; + padding: 21px 23px; + text-align: left; + transition: + border-color 0.15s, + background 0.15s, + transform 0.15s; + min-width: 0; + position: relative; +} +.service-card:hover { + border-color: #afc298; + transform: translateY(-2px); +} +.service-card.active { + border-color: #8fad6f; + background: #f0f5e7; + box-shadow: 0 0 0 1px #8fad6f15; +} +.service-card-top { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} +.service-icon { + height: 34px; + width: 34px; + border: 1px solid #e7ecdf; + border-radius: 9px; + background: #f7f9f2; + display: flex; + align-items: center; + justify-content: center; + color: #96aa7e; +} +.service-icon svg { + width: 21px; + height: 21px; + fill: none; + stroke: currentColor; + stroke-width: 1.2; + stroke-linejoin: round; + stroke-linecap: round; +} +.active .service-icon { + background: #e5edda; + border-color: #d6e2c5; + color: #729353; +} +.service-order { + font-size: 9px; + color: #aeb99f; + font-variant-numeric: tabular-nums; +} +.service-selected { + margin-left: auto; + width: 17px; + height: 17px; + display: flex; + align-items: center; + justify-content: center; + background: #81a160; + color: #fff; + border-radius: 50%; + font-size: 10px; + visibility: hidden; +} +.active .service-selected { + visibility: visible; +} +.service-card > strong { + font-size: 16px; + font-weight: 550; + letter-spacing: -0.15px; + color: #5e7550; +} +.active > strong { + color: #4c703b; +} +.service-description { + font-size: 10px; + line-height: 1.9; + color: #98a78a; + display: block; + margin: 10px 0 19px; + max-width: 330px; + flex: 1; +} +.active .service-description { + color: #869e70; +} +.service-card-bottom { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 10px; + color: #9aad89; + padding-top: 14px; + border-top: 1px solid #edf0e6; +} +.service-card.active .service-card-bottom { + border-color: #dfe9d2; +} +.service-card-bottom b { + font-weight: 550; + color: #7c9666; + font-size: 12px; + margin-right: 2px; +} +.service-card-bottom i { + font-style: normal; + color: #bec8b1; + padding: 0 8px; +} +.service-arrow { + font-size: 20px; + line-height: 18px; + color: #a1b78b; +} +.active .service-arrow { + color: #72964f; +} +.loading-panel { + padding: 35px; + color: #92a67f; + background: #fff; + border: 1px solid var(--line); + border-radius: 10px; + grid-column: 1/-1; + font-size: 12px; +} +.catalog-error { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + border: 1px solid #efd5bd; + background: #fff6e9; + border-radius: 10px; + color: #a77a4b; + padding: 22px 25px; + margin-bottom: 30px; +} +.catalog-error strong { + font-size: 13px; + font-weight: 550; +} +.catalog-error p { + font-size: 11px; + line-height: 1.8; + margin-top: 7px; +} +.catalog-error button { + border: 1px solid #e7c9a6; + background: #fffdf7; + color: #ad8457; + border-radius: 6px; + padding: 9px 12px; + font-size: 11px; + white-space: nowrap; +} +.catalog-layout { + display: grid; + grid-template-columns: 274px minmax(0, 1fr); + gap: 30px; + align-items: start; +} +.category-panel { + border: 1px solid #e0e7d5; + background: #fff; + border-radius: 11px; + overflow: hidden; +} +.category-heading { + display: flex; + justify-content: space-between; + align-items: center; + padding: 21px 18px 17px; + gap: 12px; +} +.category-heading h2 { + font-size: 12px; + color: #7b9069; + font-weight: 550; + display: flex; + gap: 8px; + align-items: center; +} +.count-pill { + padding: 3px 6px; + font-size: 9px; + color: #9eb08b; + background: #f2f6ea; + border: 1px solid #edf1e4; + border-radius: 4px; +} +.search-box { + display: flex; + align-items: center; + gap: 8px; + background: #fff; + border: 1px solid #e1e8d8; + border-radius: 6px; + padding: 0 11px; + min-width: 0; + height: 37px; + transition: border-color 0.15s; +} +.search-box:focus-within { + border-color: #96b776; + box-shadow: 0 0 0 2px #dce9cd55; +} +.search-box svg { + height: 15px; + width: 15px; + fill: none; + stroke: #9eb58a; + stroke-width: 1.5; + stroke-linecap: round; +} +.search-box input { + border: 0; + background: none; + min-width: 0; + width: 100%; + font-size: 10px; + color: #6e8b56; + padding: 0; + height: 100%; + outline: none; +} +.search-box input::placeholder { + color: #a8b799; +} +.category-search { + margin: 0 13px 14px; + background: #f9fbf5; + height: 33px; +} +.category-search input { + font-size: 10px; +} +.domain-list { + padding: 0 9px 8px; + max-height: 550px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: #dce5ce transparent; + overscroll-behavior: contain; +} +.domain-option { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + border: 1px solid transparent; + background: transparent; + padding: 12px 10px; + text-align: left; + border-radius: 6px; + margin-bottom: 3px; + min-width: 0; +} +.domain-option:hover { + background: #f7faf2; +} +.domain-option.active { + background: #eff5e6; + border-color: #e4edd8; +} +.domain-copy { + flex: 1; + min-width: 0; +} +.domain-copy strong { + display: block; + font-size: 11px; + color: #7b9168; + font-weight: 450; + line-height: 1.5; +} +.domain-copy small { + display: block; + font-size: 9px; + color: #acb99c; + line-height: 1.7; + margin-top: 4px; + overflow-wrap: anywhere; +} +.active .domain-copy strong { + color: #648847; + font-weight: 550; +} +.active .domain-copy small { + color: #9ab480; +} +.domain-count { + font-size: 9px; + color: #b8c3a9; + font-variant-numeric: tabular-nums; + min-width: 17px; + text-align: center; + padding: 2px 4px; + flex-shrink: 0; + border-radius: 4px; +} +.domain-count.has-cases { + background: #f0f6e7; + color: #8daa70; +} +.active .domain-count { + background: #e0ebd1; + color: #7d9f5e; +} +.all-domains { + margin-bottom: 9px; + padding-top: 14px; + padding-bottom: 14px; +} +.all-icon { + font-size: 24px; + line-height: 25px; + color: #a7bd8b; + font-weight: 300; +} +.all-domains .domain-copy strong { + font-size: 11px; +} +.all-domains .domain-copy small { + font-size: 8px; +} +.category-footer { + padding: 15px 18px; + font-size: 8px; + color: #b3c09f; + background: #fcfdf9; + border-top: 1px solid #eef3e5; + display: flex; + align-items: center; + gap: 6px; +} +.category-footer > span { + width: 4px; + height: 4px; + border-radius: 50%; + background: #afc68d; +} +.no-domains { + font-size: 10px; + color: #a1b68d; + line-height: 1.8; + text-align: center; + padding: 25px 18px 35px; +} +.results { + min-width: 0; +} +.results-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin: 1px 0 21px; +} +.breadcrumb { + display: flex; + align-items: center; + gap: 9px; + font-size: 9px; + color: #a3b193; + line-height: 16px; + margin-bottom: 9px; + flex-wrap: wrap; +} +.breadcrumb button { + padding: 0; + border: 0; + background: none; + color: #89a26d; + font-size: 9px; +} +.breadcrumb strong { + font-weight: 500; + color: #8aa56c; +} +.results-heading h2 { + font-size: 22px; + line-height: 1.6; + letter-spacing: -0.3px; + font-weight: 550; + color: #56773f; +} +.results-heading p { + font-size: 10px; + line-height: 1.8; + color: #95a983; + margin-top: 5px; + max-width: 540px; +} +.results-count { + font-size: 10px; + color: #8ca876; + background: #edf4e3; + border: 1px solid #e4edd9; + padding: 6px 9px; + border-radius: 5px; + white-space: nowrap; +} +.results-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 21px; + padding-bottom: 21px; + border-bottom: 1px solid #e4ebda; +} +.case-search { + max-width: 400px; + flex: 1; + background: #fff; +} +.case-search input { + font-size: 11px; +} +.catalog-summary { + font-size: 9px; + color: #a7b697; + white-space: nowrap; +} +.case-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 21px; +} +.case-card { + border: 1px solid #dfe8d3; + background: #fff; + border-radius: 10px; + overflow: hidden; + min-width: 0; + transition: + box-shadow 0.15s, + border-color 0.15s; + display: flex; + flex-direction: column; +} +.case-card:hover { + box-shadow: 0 7px 25px #4b753410; + border-color: #c4d4b3; +} +.case-art { + height: 164px; + position: relative; + overflow: hidden; + background: #eff1ec; + border-bottom: 1px solid #e5eadf; +} +.factory-art { + background: linear-gradient(115deg, #e4eddb, #f1f5e9); +} +.studio-art { + background: linear-gradient(115deg, #e9e8f0, #f3f1f6); +} +.art-label { + position: absolute; + left: 21px; + top: 18px; + color: #a3b38f; + font-size: 7px; + letter-spacing: 1.5px; +} +.studio-art .art-label { + color: #b1a9c1; +} +.mini-workspace { + position: absolute; + left: 31px; + top: 48px; + display: flex; + border: 1px solid #d2dec3; + background: #ffffffc9; + border-radius: 6px; + width: 198px; + height: 129px; + box-shadow: 0 10px 20px #9aad8020; + transform: rotate(-5deg); +} +.studio-art .mini-workspace { + border-color: #dbd4e3; + box-shadow: 0 10px 20px #aea1bd20; +} +.mini-sidebar { + width: 37px; + background: #f4f8ed; + border-right: 1px solid #e4ecd9; + padding: 13px 7px; + display: flex; + flex-direction: column; + gap: 8px; +} +.studio-art .mini-sidebar { + background: #f4f1f8; + border-color: #e9e2ef; +} +.mini-sidebar i { + display: block; + background: #b6c6a2; + border-radius: 3px; + width: 14px; + height: 13px; + margin-bottom: 9px; +} +.mini-sidebar span { + display: block; + background: #e0e9d2; + width: 20px; + height: 4px; + border-radius: 1px; +} +.mini-sidebar span:nth-child(2) { + background: #c7d7b4; +} +.studio-art .mini-sidebar i { + background: #c9bed6; +} +.studio-art .mini-sidebar span { + background: #e7dfee; +} +.mini-content { + padding: 13px; + flex: 1; +} +.mini-bar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 13px; +} +.mini-bar span { + width: 48px; + height: 5px; + background: #d9e4cb; + border-radius: 1px; +} +.mini-bar b { + width: 22px; + height: 9px; + background: #b1c794; + border-radius: 2px; +} +.mini-cards { + display: flex; + gap: 7px; +} +.mini-cards > span { + height: 24px; + flex: 1; + background: #eff5e7; + border: 1px solid #e3edd7; + border-radius: 3px; +} +.mini-cards > span:nth-child(2) { + background: #f7f2e3; + border-color: #f0e8d1; +} +.mini-cards > span:nth-child(3) { + background: #eef2f5; + border-color: #e3e9ed; +} +.mini-lines { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 15px; +} +.mini-lines > i { + height: 4px; + width: 90%; + border-radius: 1px; + background: #edf1e6; +} +.mini-lines > i:nth-child(2) { + width: 70%; +} +.studio-art .mini-bar span { + background: #e1d7e8; +} +.studio-art .mini-bar b { + background: #c6b7d5; +} +.studio-art .mini-cards span { + background: #f2ecf7; + border-color: #e9e0f0; +} +.studio-art .mini-lines > i { + background: #eee8f3; +} +.art-symbol { + position: absolute; + right: 28px; + bottom: 19px; + display: flex; + align-items: center; + justify-content: center; + width: 74px; + height: 77px; + border: 1px solid #cdddb9; + border-radius: 13px; + background: #dce8cb; + box-shadow: 4px 7px 12px #91a37616; + transform: rotate(8deg); + color: #94ad75; +} +.studio-art .art-symbol { + background: #e4dcee; + border-color: #d7cae3; + color: #b09bc2; + transform: rotate(6deg); +} +.art-symbol svg { + width: 57px; + height: 52px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} +.art-footnote { + position: absolute; + right: 15px; + top: 20px; + font-size: 7px; + color: #aebc9b; +} +.studio-art .art-footnote { + color: #b7acc6; +} +.case-body { + padding: 21px; + display: flex; + flex-direction: column; + flex: 1; +} +.case-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 15px; +} +.case-breadcrumb { + display: flex; + align-items: center; + gap: 8px; + color: #88a571; + font-size: 9px; + min-width: 0; +} +.case-breadcrumb i { + font-style: normal; + color: #c1cfb3; +} +.case-status { + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; + background: #eef3f7; + color: #829caf; + padding: 4px 6px; + border-radius: 4px; + font-size: 8px; +} +.case-status > i { + width: 3px; + height: 3px; + background: currentColor; + border-radius: 50%; +} +.case-status.status-integrated, +.case-status.status-verified { + color: #80a163; + background: #eff5e6; +} +.case-status.status-proposal, +.case-status.status-specified { + color: #b49a6d; + background: #faf4e7; +} +.case-body h3 { + font-size: 16px; + line-height: 1.7; + font-weight: 550; + letter-spacing: -0.2px; + color: #597a43; +} +.case-body > p { + font-size: 11px; + line-height: 1.95; + color: #93a881; + margin-top: 11px; + flex: 1; +} +.case-tags { + display: flex; + gap: 5px; + flex-wrap: wrap; + margin-top: 16px; +} +.case-tags > span { + font-size: 8px; + line-height: 1.5; + color: #9ab186; + background: #f5f8ef; + padding: 4px 6px; + border-radius: 4px; + overflow-wrap: anywhere; +} +.case-tags .more-tags { + color: #b0c29b; + background: transparent; + border: 1px solid #e9f0df; +} +.case-body > .manifest-warning { + font-size: 9px; + color: #b58c5c; + margin-top: 10px; + flex: 0; +} +.case-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid #e9efdf; + padding-top: 17px; + margin-top: 20px; +} +.case-footer > a:not(.button) { + font-size: 10px; + color: #8aa76f; + white-space: nowrap; +} +.case-footer > a:not(.button) > span { + margin-left: 4px; +} +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 13px; + border: 1px solid transparent; + border-radius: 6px; + font-size: 10px; + padding: 10px 12px; + white-space: nowrap; + line-height: 1.5; + cursor: pointer; +} +.button.primary { + background: #426f3d; + border-color: #426f3d; + color: #fff; +} +.button.primary:hover { + background: #355b31; +} +.button.secondary { + background: #fff; + border-color: #d4e2c4; + color: #7e9f63; +} +.button.secondary:hover { + background: #f8fbf2; +} +.button > span { + font-size: 14px; + line-height: 14px; +} +.catalog-note { + display: flex; + align-items: flex-start; + gap: 9px; + margin-top: 22px; + color: #a5b994; + padding: 0 1px; +} +.catalog-note svg { + fill: none; + stroke: currentColor; + stroke-width: 1.1; + width: 16px; + height: 16px; +} +.catalog-note p { + font-size: 9px; + line-height: 1.8; + max-width: 650px; +} +.empty-state { + padding: 49px 25px 48px; + border: 1px dashed #d8e4c9; + border-radius: 10px; + background: #fff; + text-align: center; + min-height: 345px; + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; +} +.empty-symbol { + width: 61px; + height: 61px; + display: flex; + align-items: center; + justify-content: center; + color: #a9bd90; + background: #f1f6e9; + border-radius: 19px; + margin-bottom: 22px; +} +.empty-symbol svg { + width: 38px; + height: 38px; + fill: none; + stroke: currentColor; + stroke-linejoin: round; + stroke-linecap: round; + stroke-width: 1.2; +} +.empty-state .eyebrow { + font-size: 7px; + color: #acbe98; + letter-spacing: 1.6px; +} +.empty-state h3 { + font-size: 17px; + line-height: 1.8; + color: #8ca874; + font-weight: 500; + margin-top: 10px; +} +.empty-state > p { + font-size: 11px; + color: #a6b995; + line-height: 1.9; + margin: 10px 0 24px; + max-width: 430px; + overflow-wrap: anywhere; +} +.empty-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: center; +} +.site-footer { + margin-top: 41px; + padding: 24px 0 28px; + border-top: 1px solid #e1e9d5; + display: flex; + justify-content: space-between; + align-items: center; + gap: 18px; + color: #b2c19f; +} +.site-footer > span { + font-size: 8px; + letter-spacing: 1.15px; +} +.site-footer > span > span { + margin: 0 8px; + color: #c6d1b6; +} +.site-footer p { + font-size: 9px; + color: #acbd98; +} +.site-footer > a { + font-size: 9px; + color: #8dab72; +} +.site-footer > a > span { + font-size: 13px; + margin-left: 6px; +} +/* Secondary copy retains sufficient contrast on pale surfaces. */ +.hero p, +.service-description, +.service-card-bottom, +.domain-copy small, +.category-footer, +.breadcrumb, +.results-heading p, +.catalog-summary, +.case-body > p, +.case-tags > span, +.catalog-note, +.empty-state > p, +.site-footer, +.site-footer p, +.search-box input::placeholder { + color: #76846b; +} +.service-card > strong, +.domain-copy strong, +.results-heading h2, +.case-body h3 { + color: #47653c; +} +.active .service-description, +.active .domain-copy small { + color: #6c8457; +} +.case-status.status-integrated, +.case-status.status-verified { + color: #5b7b41; +} +.case-status { + color: #5c7e98; +} +.service-card-bottom b, +.category-heading h2, +.breadcrumb button, +.breadcrumb strong, +.case-breadcrumb, +.case-footer > a:not(.button), +.empty-state h3 { + color: #66864f; +} +.domain-count { + color: #89997b; +} +.domain-count.has-cases { + color: #67884d; +} +.domain-count.active { + color: #5c7d43; +} +.count-pill, +.results-count { + color: #729058; +} +@media (min-width: 1500px) { + main { + padding-left: 32px; + padding-right: 32px; + } + .site-header { + padding-left: max(4.2%, calc((100% - 1376px) / 2)); + padding-right: max(4.2%, calc((100% - 1376px) / 2)); + } + .service-description { + max-width: 365px; + } + .case-art { + height: 180px; + } + .mini-workspace { + width: 225px; + left: 46px; + top: 53px; + } + .art-symbol { + right: 38px; + bottom: 25px; + width: 82px; + height: 83px; + } + .case-body { + padding: 24px; + } + .hero h1 { + font-size: 36px; + } +} +@media (max-width: 1120px) { + main { + padding-top: 35px; + } + .hero { + margin-bottom: 31px; + gap: 20px; + } + .hero h1 { + font-size: 28px; + } + .hero-index { + display: none; + } + .service-card { + padding: 19px; + } + .service-card > strong { + font-size: 14px; + } + .service-description { + font-size: 9px; + } + .catalog-layout { + grid-template-columns: 245px minmax(0, 1fr); + gap: 23px; + } + .case-grid { + gap: 16px; + } + .case-body { + padding: 18px; + } + .case-body h3 { + font-size: 14px; + } + .case-body > p { + font-size: 10px; + } + .case-meta { + align-items: flex-start; + flex-wrap: wrap; + gap: 7px; + } + .case-art { + height: 147px; + } + .mini-workspace { + width: 175px; + left: 21px; + top: 46px; + } + .art-symbol { + right: 15px; + bottom: 15px; + width: 60px; + height: 64px; + } + .art-symbol svg { + width: 48px; + } + .art-label { + font-size: 6px; + left: 16px; + } + .art-footnote { + font-size: 6px; + right: 12px; + } + .case-footer { + gap: 8px; + } + .case-footer .button { + font-size: 9px; + padding: 9px; + gap: 8px; + } + .case-footer > a:not(.button) { + font-size: 9px; + } + .results-heading h2 { + font-size: 20px; + } + .results-heading p { + font-size: 9px; + } + .results-toolbar { + gap: 12px; + } + .catalog-summary { + font-size: 8px; + } + .domain-copy strong { + font-size: 10px; + } + .domain-copy small { + font-size: 8px; + } + .service-models { + gap: 14px; + } + .service-card-bottom { + font-size: 9px; + } + .service-card-bottom b { + font-size: 11px; + } + .domain-list { + max-height: 515px; + } +} +@media (max-width: 860px) { + .site-header { + height: 68px; + padding: 0 25px; + } + main { + padding: 31px 25px 0; + } + .brand { + font-size: 17px; + } + .brand small { + font-size: 11px; + } + .brand-divider { + margin: 0 10px; + } + .header-links { + gap: 17px; + font-size: 10px; + } + .hero h1 { + font-size: 27px; + } + .hero p { + font-size: 11px; + } + .catalog-layout { + grid-template-columns: 215px minmax(0, 1fr); + gap: 21px; + } + .case-grid { + grid-template-columns: 1fr; + } + .case-art { + height: 167px; + } + .mini-workspace { + left: 32px; + top: 49px; + width: 205px; + } + .art-symbol { + right: 27px; + width: 72px; + height: 74px; + } + .art-label { + font-size: 7px; + left: 20px; + } + .art-footnote { + font-size: 7px; + right: 16px; + } + .case-body { + padding: 21px; + } + .case-meta { + flex-wrap: nowrap; + align-items: center; + } + .case-body h3 { + font-size: 16px; + } + .case-body > p { + font-size: 11px; + } + .case-footer .button { + font-size: 10px; + padding: 10px 12px; + gap: 13px; + } + .case-footer > a:not(.button) { + font-size: 10px; + } + .service-models { + gap: 12px; + } + .service-card { + padding: 17px; + } + .service-card-top { + gap: 9px; + margin-bottom: 12px; + } + .service-card > strong { + font-size: 13px; + line-height: 1.6; + } + .service-description { + font-size: 9px; + margin-bottom: 15px; + } + .service-card-bottom { + font-size: 8px; + } + .service-card-bottom i { + padding: 0 4px; + } + .service-icon { + height: 30px; + width: 30px; + } + .service-icon svg { + width: 18px; + } + .service-order { + font-size: 8px; + } + .results-heading { + align-items: flex-start; + } + .results-count { + font-size: 9px; + margin-top: 25px; + padding: 5px 7px; + } + .results-heading h2 { + font-size: 19px; + } + .category-heading { + padding: 19px 14px 15px; + } + .category-heading h2 { + font-size: 11px; + gap: 6px; + } + .count-pill { + font-size: 8px; + } + .category-search { + margin-left: 10px; + margin-right: 10px; + padding: 0 8px; + } + .category-search input { + font-size: 9px; + } + .domain-list { + padding: 0 6px 7px; + } + .domain-option { + padding: 11px 8px; + gap: 6px; + } + .domain-count { + font-size: 8px; + } + .domain-copy small { + line-height: 1.8; + } + .category-footer { + padding: 14px 12px; + font-size: 7px; + } + .results-toolbar { + flex-wrap: wrap; + gap: 10px; + padding-bottom: 17px; + } + .case-search { + flex-basis: 100%; + max-width: none; + } + .catalog-summary { + font-size: 9px; + } + .site-footer p { + display: none; + } + .empty-state { + padding: 40px 20px; + } + .empty-state h3 { + font-size: 15px; + } + .empty-state > p { + font-size: 10px; + } + .empty-actions { + gap: 8px; + } + .empty-actions .button { + font-size: 9px; + } +} +@media (max-width: 640px) { + .site-header { + height: 61px; + padding: 0 19px; + } + .brand { + font-size: 16px; + gap: 8px; + } + .brand-mark { + width: 28px; + height: 28px; + border-radius: 7px; + font-size: 18px; + } + .brand-mark > span { + font-size: 19px; + } + .brand-divider { + margin: 0 8px; + font-size: 12px; + } + .brand small { + font-size: 10px; + } + .header-links { + font-size: 9px; + gap: 10px; + } + .header-links > a:first-child { + display: none; + } + .header-links span { + font-size: 12px; + margin-left: 2px; + } + main { + padding: 28px 19px 0; + } + .hero { + margin-bottom: 27px; + } + .hero .eyebrow { + font-size: 7px; + letter-spacing: 1.35px; + } + .hero h1 { + font-size: 24px; + line-height: 1.75; + margin: 11px 0 9px; + letter-spacing: -0.65px; + } + .hero h1 > span { + white-space: nowrap; + } + .hero p { + font-size: 10px; + line-height: 1.9; + } + .mobile-break { + display: block; + } + .section-label { + margin-bottom: 12px; + } + .section-label h2 { + font-size: 10px; + } + .section-label > span { + font-size: 8px; + } + .service-models { + gap: 9px; + } + .service-card { + padding: 13px 11px 12px; + border-radius: 8px; + } + .service-card-top { + margin-bottom: 10px; + gap: 6px; + } + .service-icon { + width: 27px; + height: 27px; + border-radius: 7px; + } + .service-icon svg { + width: 17px; + height: 17px; + } + .service-order { + display: none; + } + .service-selected { + width: 13px; + height: 13px; + font-size: 8px; + } + .service-card > strong { + font-size: 11px; + line-height: 1.8; + min-height: 39px; + letter-spacing: -0.1px; + } + .service-description { + display: none; + } + .service-card-bottom { + padding-top: 9px; + margin-top: 9px; + font-size: 8px; + line-height: 1.9; + align-items: flex-end; + } + .service-card-bottom > span:first-child { + max-width: 77px; + } + .service-card-bottom b { + font-size: 10px; + } + .service-card-bottom i { + display: none; + } + .service-card-bottom > span:first-child b:nth-child(2) { + display: inline-block; + } + .service-arrow { + display: none; + } + .layer-section { + margin-bottom: 25px; + } + .catalog-layout { + grid-template-columns: minmax(0, 1fr); + gap: 25px; + } + .category-heading { + padding: 17px 15px 14px; + } + .category-heading h2 { + font-size: 12px; + gap: 8px; + } + .count-pill { + font-size: 9px; + } + .category-search { + margin: 0 12px 12px; + height: 35px; + } + .category-search input { + font-size: 10px; + } + .domain-list { + max-height: 205px; + padding: 0 8px 8px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px; + align-items: start; + } + .domain-option { + margin: 0; + padding: 10px; + min-height: 70px; + align-items: flex-start; + height: 100%; + } + .domain-copy strong { + font-size: 10px; + line-height: 1.6; + } + .domain-copy small { + font-size: 8px; + margin-top: 4px; + line-height: 1.8; + } + .domain-count { + margin-top: 3px; + font-size: 8px; + padding: 2px; + } + .all-domains { + grid-column: 1/-1; + min-height: 57px; + align-items: center; + padding: 11px; + } + .all-domains .domain-copy strong { + font-size: 11px; + } + .all-domains .domain-copy small { + font-size: 8px; + } + .all-domains .domain-count { + margin-top: 0; + padding: 3px 6px; + } + .category-footer { + font-size: 8px; + padding: 13px 16px; + } + .results-heading { + margin-bottom: 17px; + gap: 12px; + } + .breadcrumb { + font-size: 8px; + margin-bottom: 7px; + } + .breadcrumb button { + font-size: 8px; + } + .results-heading h2 { + font-size: 21px; + } + .results-heading p { + font-size: 10px; + line-height: 1.9; + } + .results-count { + margin-top: 25px; + } + .results-toolbar { + padding-bottom: 17px; + margin-bottom: 18px; + flex-wrap: nowrap; + } + .case-search { + flex-basis: auto; + height: 37px; + } + .case-search input { + font-size: 10px; + } + .catalog-summary { + font-size: 8px; + max-width: 77px; + white-space: normal; + line-height: 1.9; + text-align: right; + flex-shrink: 0; + } + .case-grid { + gap: 20px; + } + .case-art { + height: 164px; + } + .mini-workspace { + width: 200px; + left: 33px; + top: 48px; + } + .art-symbol { + right: 27px; + bottom: 17px; + } + .case-body { + padding: 21px; + } + .case-body h3 { + font-size: 16px; + } + .case-body > p { + font-size: 11px; + line-height: 1.9; + } + .case-meta { + margin-bottom: 12px; + } + .case-breadcrumb { + font-size: 9px; + } + .case-status { + font-size: 8px; + } + .case-tags > span { + font-size: 8px; + } + .case-footer { + margin-top: 17px; + padding-top: 17px; + } + .catalog-note { + margin-top: 18px; + gap: 7px; + } + .catalog-note p { + font-size: 9px; + line-height: 1.9; + } + .catalog-note svg { + margin-top: 1px; + } + .empty-state { + padding: 38px 20px; + min-height: 325px; + } + .empty-state h3 { + font-size: 16px; + } + .empty-state > p { + font-size: 10px; + } + .empty-actions .button { + font-size: 10px; + } + .site-footer { + margin-top: 30px; + padding: 20px 0 25px; + gap: 12px; + } + .site-footer > span { + font-size: 7px; + letter-spacing: 0.6px; + } + .site-footer > span > span { + margin: 0 4px; + } + .site-footer > a { + font-size: 8px; + white-space: nowrap; + } + .site-footer > a > span { + font-size: 11px; + margin-left: 3px; + } + .catalog-error { + padding: 18px; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; + } + .catalog-error strong { + font-size: 12px; + } + .catalog-error p { + font-size: 10px; + } + .catalog-error button { + font-size: 10px; + } + .loading-panel { + font-size: 11px; + padding: 26px 18px; + } +} +@media (max-width: 360px) { + .brand small { + display: none; + } + .brand-divider { + display: none; + } + .hero h1 { + font-size: 22px; + } + .service-card { + padding: 11px 9px; + } + .service-card > strong { + font-size: 10px; + } + .service-card-bottom { + font-size: 7px; + } + .service-card-bottom b { + font-size: 9px; + } + .domain-list { + grid-template-columns: minmax(0, 1fr); + } + .all-domains { + grid-column: auto; + } + .domain-option { + min-height: 60px; + } + .art-symbol { + right: 17px; + } + .mini-workspace { + left: 25px; + width: 185px; + } + .case-body { + padding: 18px; + } + .case-status { + font-size: 7px; + } + .case-breadcrumb { + font-size: 8px; + } + .site-footer > span { + max-width: 150px; + line-height: 1.9; + } + .empty-actions .button { + font-size: 9px; + } +} +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + scroll-behavior: auto !important; + } +} + +/* Asset library: keep the reusable business specifications near the top. */ +.asset-library main { + padding-top: 27px; +} +.asset-library .hero { + margin-bottom: 23px; +} +.asset-library .hero h1 { + font-size: 29px; + margin: 9px 0 8px; +} +.asset-library .hero p { + font-size: 11px; + line-height: 1.8; +} +.asset-library .hero-contribute { + font-size: 10px; + padding: 10px 13px; + flex-shrink: 0; +} +.asset-library .layer-section { + margin-bottom: 16px; +} +.asset-library .service-mini { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + padding: 15px 19px; +} +.service-mini-copy { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.service-mini-copy strong { + font-size: 13px; + font-weight: 550; + color: #4c6b40; + line-height: 1.4; +} +.service-mini-copy small { + font-size: 9px; + color: #7a8b6c; + line-height: 1.4; +} +.asset-library .service-mini .service-selected { + margin-left: auto; + flex-shrink: 0; +} +.inventory { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + background: #fff; + border: 1px solid #e3e9da; + border-radius: 9px; + margin-bottom: 25px; + padding: 15px 0; +} +.inventory-item { + padding: 0 20px; + display: flex; + align-items: baseline; + gap: 10px; + border-right: 1px solid #e7edde; + min-width: 0; +} +.inventory-item:last-child { + border: 0; +} +.inventory-item > strong { + font-size: 24px; + line-height: 1.2; + font-weight: 500; + color: #547640; + letter-spacing: -0.8px; +} +.inventory-item > span { + font-size: 10px; + white-space: nowrap; + color: #788d67; +} +.inventory-item > small { + font-size: 8px; + color: #a8b797; + background: #f4f7ed; + padding: 3px 4px; + border-radius: 3px; + white-space: nowrap; +} +.asset-library .category-heading h2 > span:first-child { + color: #7a9661; + font-size: 12px; + font-weight: 500; +} +.group-selector { + display: flex; + flex-direction: column; + gap: 7px; + margin: 0 13px 12px; +} +.group-selector > span { + font-size: 9px; + color: #7d946a; +} +.group-selector select, +#industry-select { + border: 1px solid #dce6d0; + background: #fff; + border-radius: 6px; + color: #6a8556; + padding: 9px 10px; + font: inherit; + font-size: 10px; + min-width: 0; + max-width: 100%; +} +.group-selector select:focus-visible, +#industry-select:focus-visible { + outline: 3px solid #d1e2bc; + outline-offset: 1px; +} +.asset-library .domain-list { + max-height: 545px; +} +.group-option { + padding-top: 14px; + padding-bottom: 14px; + border-bottom: 1px solid #f0f4e9; +} +.group-option:hover .domain-copy strong { + color: #527b3c; +} +.group-count { + white-space: nowrap; + color: #9aaf85; + font-size: 8px; + display: flex; + align-items: center; + gap: 7px; + margin-top: 1px; +} +.group-count b { + font-size: 17px; + line-height: 17px; + font-weight: 400; + color: #b1c49c; +} +.asset-library .domain-copy small { + font-size: 9px; +} +.asset-library .results-heading { + margin-bottom: 14px; +} +.asset-library .results-heading h2 { + font-size: 21px; +} +.asset-library .results-heading p { + font-size: 10px; +} +.asset-tabs { + display: flex; + gap: 23px; + border-bottom: 1px solid #dde7d1; + overflow-x: auto; + scrollbar-width: thin; + margin-bottom: 17px; +} +.asset-tabs button { + display: flex; + align-items: center; + gap: 7px; + border: 0; + border-bottom: 2px solid transparent; + padding: 12px 0 13px; + background: none; + color: #80956c; + font-size: 11px; + white-space: nowrap; +} +.asset-tabs button[aria-selected="true"] { + color: #507b35; + border-bottom-color: #82a95e; + font-weight: 550; +} +.asset-tabs button > span { + background: #f0f5e6; + color: #97af7c; + border-radius: 4px; + padding: 2px 5px; + font-size: 8px; + min-width: 18px; + text-align: center; + font-weight: 400; +} +.asset-tabs button[aria-selected="true"] > span { + color: #73994e; + background: #e9f1dd; +} +.asset-library .results-toolbar { + margin-bottom: 13px; + padding-bottom: 0; + border: 0; + gap: 12px; +} +.asset-library .case-search { + max-width: none; + flex: 1; +} +.asset-library #industry-select { + width: 146px; + height: 37px; + flex-shrink: 0; +} +.results-context { + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + margin-bottom: 17px; +} +.results-context > p { + font-size: 9px; + color: #92a37d; + line-height: 1.85; +} +.results-context .catalog-summary { + font-size: 9px; + white-space: nowrap; + flex-shrink: 0; +} +.blueprint-card, +.variant-card { + background: #fff; + border: 1px solid #dfe8d2; + border-radius: 10px; + padding: 20px; + min-width: 0; + display: flex; + flex-direction: column; + transition: + border-color 0.15s, + box-shadow 0.15s; +} +.blueprint-card:hover, +.variant-card:hover { + border-color: #b4c99a; + box-shadow: 0 6px 20px #678b3e0a; +} +.asset-card-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 13px; +} +.asset-code { + color: #8da575; + font-size: 8px; + letter-spacing: 0.5px; + overflow-wrap: anywhere; +} +.spec-badge { + display: inline-flex; + font-size: 8px; + border: 1px solid #e2ebd5; + border-radius: 4px; + padding: 4px 6px; + color: #8aa96b; + background: #f5f8ed; + line-height: 1.4; + white-space: nowrap; +} +.variant-badge { + color: #8f9cb5; + background: #f1f4f8; + border-color: #e4e9f1; +} +.blueprint-card h3, +.variant-card h3 { + font-size: 16px; + font-weight: 550; + color: #52763e; + line-height: 1.7; + letter-spacing: -0.2px; +} +.blueprint-card > p, +.variant-card > p { + font-size: 11px; + line-height: 1.9; + color: #849970; + margin-top: 8px; + margin-bottom: 15px; + overflow-wrap: anywhere; +} +.blueprint-roles { + font-size: 9px; + line-height: 1.8; + color: #8da678; + padding-top: 1px; + margin-top: auto; +} +.blueprint-roles > span { + color: #b0c19a; + display: block; + font-size: 8px; + margin-bottom: 4px; +} +.workflow-preview { + display: flex; + align-items: center; + gap: 5px; + margin-top: 12px; + min-width: 0; + padding: 10px; + background: #f6f9f0; + border-radius: 6px; + border: 1px solid #edf2e4; +} +.workflow-preview > span { + display: flex; + align-items: flex-start; + gap: 5px; + font-size: 7px; + line-height: 1.8; + color: #9eb68a; + flex: 1; + min-width: 0; +} +.workflow-preview b { + font-size: 8px; + line-height: 1.8; + font-weight: 400; + color: #8aa275; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow-wrap: anywhere; +} +.workflow-preview > i { + font-style: normal; + font-size: 10px; + color: #b8c8a7; + flex-shrink: 0; +} +.blueprint-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-top: 16px; + margin-top: 17px; + border-top: 1px solid #e9efdf; +} +.blueprint-card-footer > span { + font-size: 8px; + color: #9eb28b; + line-height: 1.8; +} +.blueprint-card-footer > span i { + font-style: normal; + padding: 0 4px; + color: #bccbad; +} +.text-button { + border: 0; + padding: 0; + background: none; + display: inline-flex; + align-items: center; + gap: 7px; + color: #7a9b5c; + font-size: 10px; + white-space: nowrap; + line-height: 1.7; +} +.text-button:hover { + color: #507d32; +} +.text-button > span { + font-size: 14px; +} +.variant-parent { + margin-top: auto; + padding: 12px 0 0; + font-size: 8px; + color: #b0c29a; + line-height: 1.8; +} +.variant-parent strong { + display: block; + font-size: 10px; + font-weight: 400; + color: #89a270; + margin-top: 3px; + overflow-wrap: anywhere; +} +.detail-pills { + display: flex; + flex-wrap: wrap; + gap: 7px; +} +.detail-pills > span { + font-size: 10px; + line-height: 1.6; + background: #f3f7ec; + border: 1px solid #e7eedc; + color: #8ca777; + border-radius: 5px; + padding: 5px 8px; + overflow-wrap: anywhere; + max-width: 100%; +} +.industry-pills > span { + background: #eff4f7; + border-color: #e5ecf0; + color: #8eacb9; +} +.variant-card .industry-pills { + margin-top: 0; + margin-bottom: 5px; +} +.variant-card .industry-pills > span { + font-size: 8px; + padding: 4px 6px; +} +.implementation-kind { + display: block; + color: #819d68; + font-size: 8px; + letter-spacing: 0.8px; + margin-bottom: 7px; +} +.implementation-lineage { + margin-top: 15px; + background: #f7faf1; + border: 1px solid #edf2e4; + border-radius: 6px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 9px; +} +.implementation-lineage > span { + font-size: 8px; + color: #a7ba94; + display: flex; + flex-direction: column; + gap: 4px; + line-height: 1.6; +} +.implementation-lineage strong { + font-size: 10px; + font-weight: 400; + color: #819d66; + overflow-wrap: anywhere; +} +.material-links { + margin-top: 15px; + border-top: 1px solid #e9efdf; + padding-top: 12px; +} +.material-links > summary { + cursor: pointer; + color: #91ab78; + font-size: 10px; + line-height: 1.8; +} +.material-links > summary > span { + font-size: 8px; + color: #adc397; + margin-left: 6px; +} +.material-links > div { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; + margin-top: 11px; +} +.material-links a { + font-size: 9px; + color: #88a66a; + line-height: 1.8; + padding: 6px 8px; + border: 1px solid #e7efdb; + background: #fbfdf7; + border-radius: 5px; + display: flex; + justify-content: space-between; + gap: 6px; + overflow-wrap: anywhere; +} +.material-links a > span { + flex-shrink: 0; +} +.material-links a:hover { + background: #f1f7e7; +} +.asset-detail { + width: calc(100% - 48px); + max-width: 950px; + max-height: calc(100dvh - 60px); + padding: 31px; + border: 1px solid #d9e6c8; + border-radius: 15px; + box-shadow: 0 22px 90px #23331c55; + color: #587b41; + background: #fbfcf8; + overflow: auto; + position: relative; + scrollbar-width: thin; + scrollbar-color: #d5e2c5 #fbfcf8; + overscroll-behavior: contain; +} +.asset-detail::backdrop { + background: #23302366; + backdrop-filter: blur(3px); +} +.detail-close { + position: sticky; + top: 0; + float: right; + margin: -12px -12px 0 0; + width: 30px; + height: 30px; + z-index: 3; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid #e0e9d5; + border-radius: 7px; + background: #ffffffef; + color: #9db487; + font-size: 21px; + line-height: 1; +} +.detail-heading { + padding-right: 25px; + margin-bottom: 24px; +} +.detail-breadcrumb { + font-size: 9px; + color: #a7bd90; + line-height: 1.8; + margin-bottom: 15px; +} +.detail-heading > .spec-badge { + font-size: 9px; + padding: 4px 7px; + margin-bottom: 10px; +} +.detail-heading h2 { + font-size: 26px; + line-height: 1.6; + letter-spacing: -0.4px; + font-weight: 550; + color: #52763b; + margin-bottom: 8px; +} +.detail-heading > p { + font-size: 12px; + line-height: 1.9; + color: #8ba574; + max-width: 750px; + overflow-wrap: anywhere; +} +.standard-sections { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 21px; + background: #fff; + border: 1px solid #e3ecd7; + border-radius: 10px; + padding: 23px; +} +.standard-sections > section { + min-width: 0; +} +.standard-sections h3, +.variant-adaptations h3 { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 500; + color: #7b9c5e; + line-height: 1.8; + margin-bottom: 12px; +} +.standard-sections h3 > span { + font-size: 8px; + color: #adc795; +} +.wide-section { + grid-column: 1/-1; + padding: 17px 0 18px; + border-top: 1px solid #edf2e5; + border-bottom: 1px solid #edf2e5; +} +.standard-workflow { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + padding: 0; + list-style: none; + margin: 0; +} +.standard-workflow li { + display: flex; + gap: 9px; + padding: 12px; + background: #f5f9ed; + border: 1px solid #e7efd9; + border-radius: 7px; + min-width: 0; +} +.standard-workflow li > span { + font-size: 9px; + color: #adc794; + line-height: 1.8; +} +.standard-workflow p { + font-size: 10px; + line-height: 1.8; + color: #85a369; + overflow-wrap: anywhere; +} +.standard-list { + padding: 0; + list-style: none; + margin: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.standard-list li { + position: relative; + padding-left: 12px; + font-size: 11px; + line-height: 1.9; + color: #8fa979; + overflow-wrap: anywhere; +} +.standard-list li:before { + position: absolute; + content: ""; + height: 3px; + width: 3px; + border-radius: 50%; + background: #bad1a1; + left: 0; + top: 9px; +} +.detail-related { + margin-top: 25px; + padding-top: 23px; + border-top: 1px solid #e2ecd5; +} +.related-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 15px; +} +.related-heading h3 { + font-size: 14px; + color: #7f9d64; + font-weight: 500; + line-height: 1.8; +} +.related-heading h3 > span { + font-size: 9px; + color: #a2bc87; + background: #ecf4e1; + padding: 3px 5px; + border-radius: 4px; + margin-left: 6px; + font-weight: 400; +} +.related-heading > p { + font-size: 9px; + line-height: 1.8; + color: #a7bd90; +} +.related-variants { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 13px; +} +.related-variant { + border: 1px solid #e1ebd5; + background: #fff; + border-radius: 8px; + padding: 17px; + text-align: left; + position: relative; + min-width: 0; +} +.related-variant:hover, +.related-variant.active { + border-color: #b7ce9b; + background: #f4f9ed; +} +.related-variant strong { + display: block; + font-size: 12px; + font-weight: 500; + color: #83a064; + padding-right: 13px; + line-height: 1.8; +} +.related-variant > span { + display: block; + font-size: 8px; + color: #9cb887; + margin-top: 6px; + line-height: 1.8; +} +.related-variant > small { + display: block; + font-size: 10px; + color: #9ab585; + line-height: 1.9; + margin-top: 8px; + overflow-wrap: anywhere; +} +.related-variant > b { + position: absolute; + right: 14px; + top: 18px; + font-size: 15px; + color: #a4c28b; + font-weight: 400; +} +.related-implementations { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 17px; +} +.related-implementations .case-body { + padding: 20px; +} +.related-implementations .case-body h3 { + font-size: 15px; +} +.related-implementations .case-body > p { + font-size: 10px; +} +.inline-empty { + border: 1px dashed #d7e6c4; + background: #f7faef; + color: #a4bc8b; + padding: 21px; + border-radius: 8px; + font-size: 11px; + line-height: 1.9; + grid-column: 1/-1; +} +.variant-origin { + display: flex; + align-items: center; + gap: 13px; + border: 1px solid #dee9d2; + border-radius: 7px; + background: #eef5e4; + padding: 13px 16px; + margin-bottom: 21px; +} +.variant-origin > span { + font-size: 9px; + color: #9cb783; + white-space: nowrap; +} +.variant-origin button { + border: 0; + background: none; + padding: 0; + font-size: 11px; + color: #85a564; + line-height: 1.8; + text-align: left; +} +.variant-origin button > span { + margin-left: 8px; +} +.variant-adaptations { + border: 1px solid #e3ecd7; + background: #fff; + border-radius: 10px; + padding: 22px; + margin-bottom: 20px; +} +.variant-adaptations .industry-pills { + margin-bottom: 21px; +} +.inherited-standard { + background: #fff; + border: 1px solid #e0ead3; + border-radius: 9px; + padding: 17px; +} +.inherited-standard > summary { + font-size: 11px; + color: #88a76e; + cursor: pointer; + line-height: 1.9; +} +.inherited-standard > summary > span { + font-size: 9px; + color: #a8be92; + margin-left: 15px; +} +.inherited-standard > .standard-sections { + border: 0; + padding: 23px 0 0; + margin-top: 16px; + border-top: 1px solid #e7efdc; + border-radius: 0; +} +/* Maintain readable secondary text in dense specification views. */ +.blueprint-card > p, +.variant-card > p, +.blueprint-roles, +.blueprint-card-footer > span, +.variant-parent, +.detail-heading > p, +.standard-list li, +.standard-workflow p, +.related-variant > small, +.related-heading > p, +.inline-empty, +.material-links a, +.implementation-lineage strong, +.results-context > p { + color: #748a62; +} +.asset-code, +.detail-breadcrumb, +.variant-origin > span, +.variant-origin button, +.detail-pills > span, +.standard-sections h3, +.variant-adaptations h3, +.related-heading h3, +.related-variant strong, +.implementation-kind { + color: #6a8c50; +} +.workflow-preview b, +.blueprint-roles > span, +.variant-parent strong, +.material-links > summary { + color: #82996d; +} +.spec-badge { + color: #77985b; +} +.variant-badge, +.industry-pills > span { + color: #69899c; +} +@media (max-width: 1120px) { + .inventory-item { + padding: 0 15px; + gap: 7px; + } + .inventory-item > strong { + font-size: 22px; + } + .inventory-item > span { + font-size: 9px; + } + .inventory-item > small { + display: none; + } + .asset-library .service-mini { + padding: 14px 16px; + gap: 10px; + } + .service-mini-copy strong { + font-size: 12px; + } + .service-mini-copy small { + font-size: 8px; + } + .blueprint-card, + .variant-card { + padding: 18px; + } + .blueprint-card h3, + .variant-card h3 { + font-size: 14px; + } + .blueprint-card > p, + .variant-card > p { + font-size: 10px; + } + .workflow-preview { + padding: 9px 7px; + gap: 3px; + } + .workflow-preview > span { + gap: 3px; + } + .workflow-preview b { + font-size: 7px; + } + .blueprint-card-footer { + gap: 5px; + } + .blueprint-card-footer > span { + font-size: 7px; + } + .blueprint-card-footer .text-button { + font-size: 9px; + } + .asset-tabs { + gap: 20px; + } + .asset-tabs button { + font-size: 10px; + } + .related-heading > p { + font-size: 8px; + } + .asset-library .hero h1 { + font-size: 26px; + } +} +@media (max-width: 860px) { + .asset-library main { + padding-top: 25px; + } + .asset-library .hero h1 { + font-size: 24px; + } + .asset-library .hero-contribute { + display: none; + } + .asset-library .service-mini { + padding: 13px; + gap: 8px; + } + .service-mini-copy strong { + font-size: 11px; + line-height: 1.7; + } + .service-mini-copy small { + font-size: 8px; + } + .asset-library .service-mini .service-icon { + width: 28px; + height: 28px; + } + .asset-library .service-mini .service-selected { + display: none; + } + .inventory { + padding: 14px 0; + } + .inventory-item { + padding: 0 13px; + display: flex; + flex-direction: column; + gap: 5px; + } + .inventory-item > strong { + font-size: 21px; + } + .inventory-item > span { + font-size: 9px; + } + .asset-library .results-heading h2 { + font-size: 19px; + } + .asset-tabs { + gap: 15px; + } + .asset-tabs button { + font-size: 9px; + gap: 5px; + } + .asset-tabs button > span { + font-size: 7px; + padding: 2px 4px; + } + .asset-library .results-toolbar { + flex-wrap: nowrap; + gap: 9px; + } + .asset-library #industry-select { + width: 105px; + font-size: 9px; + padding: 8px; + } + .asset-library .case-search input { + font-size: 10px; + } + .results-context { + gap: 9px; + align-items: flex-start; + } + .results-context > p { + font-size: 8px; + } + .results-context .catalog-summary { + font-size: 8px; + } + .blueprint-card h3, + .variant-card h3 { + font-size: 16px; + } + .blueprint-card > p, + .variant-card > p { + font-size: 11px; + } + .blueprint-card-footer > span { + font-size: 8px; + } + .workflow-preview b { + font-size: 8px; + } + .workflow-preview { + padding: 10px; + } + .asset-detail { + width: calc(100% - 30px); + padding: 25px; + } + .standard-sections { + padding: 20px; + gap: 19px; + } + .standard-workflow { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .standard-list li { + font-size: 10px; + } + .detail-heading h2 { + font-size: 24px; + } + .detail-heading > p { + font-size: 11px; + } + .related-implementations { + grid-template-columns: minmax(0, 1fr); + } + .compact-case .implementation-lineage { + display: grid; + grid-template-columns: 1fr 1fr; + } + .inherited-standard > summary > span { + display: block; + margin: 7px 0 0 15px; + } + .asset-library .domain-copy small { + font-size: 8px; + } +} +@media (max-width: 640px) { + .asset-library main { + padding-top: 22px; + } + .asset-library .hero { + margin-bottom: 19px; + } + .asset-library .hero h1 { + font-size: 23px; + line-height: 1.7; + letter-spacing: -0.7px; + } + .asset-library .hero p { + font-size: 10px; + } + .asset-library .service-mini { + padding: 11px 9px; + flex-direction: column; + align-items: flex-start; + gap: 9px; + } + .asset-library .service-mini .service-icon { + width: 25px; + height: 25px; + } + .service-mini-copy strong { + font-size: 10px; + line-height: 1.8; + min-height: 36px; + } + .service-mini-copy small { + font-size: 7px; + line-height: 1.8; + } + .asset-library .layer-section { + margin-bottom: 12px; + } + .inventory { + padding: 13px 0; + margin-bottom: 21px; + } + .inventory-item { + padding: 0 9px; + gap: 6px; + align-items: center; + } + .inventory-item > strong { + font-size: 20px; + } + .inventory-item > span { + font-size: 8px; + } + .asset-library .catalog-layout { + gap: 23px; + } + .asset-library .category-heading { + padding-bottom: 13px; + } + .group-selector { + margin: 0 12px 12px; + flex-direction: row; + align-items: center; + gap: 12px; + } + .group-selector > span { + font-size: 9px; + white-space: nowrap; + } + .group-selector select { + flex: 1; + padding: 8px; + font-size: 10px; + } + .asset-library .domain-list { + max-height: 202px; + gap: 6px; + } + .asset-library .group-option { + min-height: 69px; + padding: 10px 8px; + } + .group-option .domain-copy strong { + font-size: 10px; + } + .group-count { + font-size: 7px; + gap: 4px; + line-height: 1.7; + margin-top: 3px; + } + .group-count b { + font-size: 14px; + } + .asset-library .domain-copy small { + font-size: 8px; + line-height: 1.7; + } + .asset-library .category-footer { + font-size: 8px; + } + .asset-library .results-heading h2 { + font-size: 20px; + } + .asset-library .results-count { + font-size: 8px; + padding: 5px 6px; + } + .asset-tabs { + gap: 0; + justify-content: space-between; + margin-bottom: 15px; + } + .asset-tabs button { + font-size: 10px; + gap: 5px; + padding-top: 10px; + padding-bottom: 12px; + } + .asset-tabs button > span { + font-size: 8px; + min-width: 16px; + } + .asset-library .results-toolbar { + gap: 9px; + } + .asset-library #industry-select { + width: 106px; + height: 36px; + font-size: 9px; + } + .asset-library .case-search { + height: 36px; + } + .asset-library .case-search input { + font-size: 10px; + } + .results-context { + margin-bottom: 15px; + } + .results-context > p { + font-size: 8px; + line-height: 1.9; + } + .results-context .catalog-summary { + font-size: 8px; + white-space: nowrap; + max-width: none; + } + .blueprint-card, + .variant-card { + padding: 20px; + } + .blueprint-card h3, + .variant-card h3 { + font-size: 16px; + } + .blueprint-card > p, + .variant-card > p { + font-size: 11px; + } + .blueprint-card-footer > span { + font-size: 8px; + } + .blueprint-card-footer .text-button { + font-size: 10px; + } + .workflow-preview b { + font-size: 8px; + } + .blueprint-roles { + font-size: 9px; + } + .workflow-preview { + gap: 5px; + } + .asset-library .case-art { + height: 145px; + } + .asset-library .mini-workspace { + top: 45px; + } + .asset-library .case-body { + padding: 20px; + } + .material-links a { + font-size: 9px; + } + .asset-detail { + width: calc(100% - 20px); + max-height: calc(100dvh - 26px); + padding: 22px 18px; + border-radius: 12px; + } + .detail-close { + margin: -10px -6px 0 0; + width: 28px; + height: 28px; + } + .detail-heading { + padding-right: 19px; + margin-bottom: 20px; + } + .detail-breadcrumb { + font-size: 8px; + margin-bottom: 13px; + } + .detail-heading h2 { + font-size: 21px; + line-height: 1.7; + } + .detail-heading > p { + font-size: 10px; + } + .detail-heading > .spec-badge { + font-size: 8px; + } + .standard-sections { + grid-template-columns: minmax(0, 1fr); + gap: 18px; + padding: 17px; + } + .standard-sections h3, + .variant-adaptations h3 { + font-size: 11px; + margin-bottom: 10px; + } + .detail-pills { + gap: 6px; + } + .detail-pills > span { + font-size: 9px; + padding: 5px 7px; + } + .wide-section { + padding: 17px 0; + } + .standard-workflow { + gap: 8px; + } + .standard-workflow li { + padding: 10px; + gap: 6px; + } + .standard-workflow li > span { + font-size: 8px; + } + .standard-workflow p { + font-size: 9px; + } + .standard-list { + gap: 8px; + } + .standard-list li { + font-size: 10px; + } + .detail-related { + margin-top: 21px; + padding-top: 19px; + } + .related-heading { + align-items: flex-start; + flex-direction: column; + gap: 5px; + margin-bottom: 13px; + } + .related-heading h3 { + font-size: 13px; + } + .related-heading > p { + font-size: 8px; + } + .related-variants { + grid-template-columns: minmax(0, 1fr); + gap: 11px; + } + .related-variant { + padding: 15px; + } + .related-variant strong { + font-size: 12px; + } + .related-variant > small { + font-size: 10px; + } + .related-variant > span { + font-size: 8px; + } + .related-implementations .case-body { + padding: 17px; + } + .related-implementations .case-body h3 { + font-size: 15px; + } + .compact-case .implementation-lineage { + display: flex; + } + .inline-empty { + font-size: 10px; + padding: 17px; + } + .variant-origin { + padding: 12px; + gap: 10px; + } + .variant-origin > span { + font-size: 8px; + } + .variant-origin button { + font-size: 10px; + } + .variant-adaptations { + padding: 17px; + margin-bottom: 18px; + } + .inherited-standard { + padding: 14px; + } + .inherited-standard > summary { + font-size: 10px; + } + .inherited-standard > summary > span { + font-size: 8px; + line-height: 1.8; + } + .inherited-standard > .standard-sections { + margin-top: 14px; + padding-top: 17px; + } + .asset-library .empty-state { + min-height: 300px; + padding: 35px 20px; + } +} +@media (max-width: 360px) { + .inventory-item { + padding: 0 7px; + } + .inventory-item > span { + font-size: 7px; + } + .inventory-item > strong { + font-size: 19px; + } + .asset-tabs button { + font-size: 9px; + gap: 4px; + } + .service-mini-copy strong { + font-size: 9px; + } + .service-mini-copy small { + font-size: 7px; + } + .asset-library .hero h1 { + font-size: 21px; + } + .asset-library .results-toolbar { + flex-wrap: wrap; + } + .asset-library .case-search { + flex-basis: 100%; + } + .asset-library #industry-select { + width: 100%; + } + .asset-library .domain-list { + grid-template-columns: minmax(0, 1fr); + } + .standard-workflow { + grid-template-columns: minmax(0, 1fr); + } + .inventory-item > small { + display: none; + } +} diff --git a/catalog/domain-groups.json b/catalog/domain-groups.json new file mode 100644 index 0000000..a23db12 --- /dev/null +++ b/catalog/domain-groups.json @@ -0,0 +1,77 @@ +{ + "version": 1, + "groups": [ + { + "id": "customer-revenue", + "serviceModel": "saas", + "title": "客户与收入", + "summary": "从销售机会到客户服务,管理客户关系、报价、订阅与续约" + }, + { + "id": "marketing-commerce", + "serviceModel": "saas", + "title": "营销与商业运营", + "summary": "管理触达、客群、商品表达、会员和交易履约" + }, + { + "id": "finance-control", + "serviceModel": "saas", + "title": "财务与经营控制", + "summary": "管理收付、核算、资金、费用、税务与经营计划" + }, + { + "id": "people-learning", + "serviceModel": "saas", + "title": "人力与学习", + "summary": "管理人员关系、招聘、出勤、薪酬、人才发展和培训" + }, + { + "id": "supply-logistics", + "serviceModel": "saas", + "title": "采购与供应物流", + "summary": "管理供需、供应商、采购、库存、仓储、运输和贸易" + }, + { + "id": "product-manufacturing", + "serviceModel": "saas", + "title": "产品与生产", + "summary": "管理设计版本、排程、现场执行、质量、安全与实验室" + }, + { + "id": "asset-facilities", + "serviceModel": "saas", + "title": "资产与空间", + "summary": "管理设备、设施、车辆和不动产的运营生命周期" + }, + { + "id": "content-collaboration", + "serviceModel": "saas", + "title": "内容与协作", + "summary": "管理数字内容、知识、办公流程、项目、组合和会议行动" + }, + { + "id": "governance-legal", + "serviceModel": "saas", + "title": "治理与法务", + "summary": "管理风险、合同、审计、隐私与可持续披露证据" + }, + { + "id": "enterprise-it", + "serviceModel": "saas", + "title": "企业协同与信息服务", + "summary": "管理跨部门资源协同、IT服务、经营分析和业务主数据" + }, + { + "id": "platform-capabilities", + "serviceModel": "paas", + "title": "开发与数据平台", + "summary": "向应用提供开发、运行、集成、数据与AI平台能力" + }, + { + "id": "infrastructure-resources", + "serviceModel": "iaas", + "title": "基础设施资源", + "summary": "管理计算、存储、网络、集群与备份资源" + } + ] +} diff --git a/catalog/domains.json b/catalog/domains.json index b1e8a61..0e3fcd3 100644 --- a/catalog/domains.json +++ b/catalog/domains.json @@ -1,85 +1,516 @@ { - "version": 1, + "version": 3, "domains": [ { "id": "crm", + "serviceModel": "saas", + "groupId": "customer-revenue", "title": "客户与销售 CRM", - "summary": "客户、联系人、商机与跟进" + "summary": "客户、联系人、商机与销售跟进;聚焦成交前的关系和机会推进" }, { - "id": "erp", - "title": "企业资源 ERP", - "summary": "订单、采购、库存与资源核算" + "id": "cpq", + "serviceModel": "saas", + "groupId": "customer-revenue", + "title": "配置报价 CPQ", + "summary": "产品选配、报价版本、折扣授权与报价审批;报价获批后交接订单" }, { - "id": "mes", - "title": "制造执行 MES", - "summary": "工单、工序、在制品与产线" + "id": "customer-success", + "serviceModel": "saas", + "groupId": "customer-revenue", + "title": "客户成功", + "summary": "客户健康度、上线里程碑、价值回顾与续约风险;区别于被动服务工单" }, { - "id": "wms", - "title": "仓储物流 WMS", - "summary": "库位、出入库、盘点与拣配" + "id": "partners", + "serviceModel": "saas", + "groupId": "customer-revenue", + "title": "渠道与伙伴管理 PRM", + "summary": "伙伴准入、商机报备、联合销售与激励;区别于供应商采购关系" }, { - "id": "scm", - "title": "供应链与供应商 SCM", - "summary": "供应商、供应计划与交付" + "id": "subscription", + "serviceModel": "saas", + "groupId": "customer-revenue", + "title": "订阅管理", + "summary": "订阅套餐、权益、续订、升降级与计费事件;收入确认归财务" }, { - "id": "hrm", - "title": "人力资源 HRM", - "summary": "员工、招聘、入职与绩效" + "id": "service", + "serviceModel": "saas", + "groupId": "customer-revenue", + "title": "客户服务", + "summary": "客户请求、问题分派、服务时限与解决反馈;上门履约交现场服务" + }, + { + "id": "field-service", + "serviceModel": "saas", + "groupId": "customer-revenue", + "title": "现场服务 FSM", + "summary": "客户现场派工、技师技能、备件与服务回执;区别于自有设备保养" + }, + { + "id": "marketing", + "serviceModel": "saas", + "groupId": "marketing-commerce", + "title": "营销与内容制作", + "summary": "活动计划、创意制作、渠道投放与线索归因;资产长期治理归 DAM" + }, + { + "id": "commerce", + "serviceModel": "saas", + "groupId": "marketing-commerce", + "title": "电商与零售", + "summary": "线上店铺、门店交易、促销执行与交易售后;跨渠道履约归订单管理" + }, + { + "id": "cdp", + "serviceModel": "saas", + "groupId": "marketing-commerce", + "title": "客户数据与客群 CDP", + "summary": "营销身份合并、同意记录、客群圈选与触达激活;区别于销售跟进" + }, + { + "id": "loyalty", + "serviceModel": "saas", + "groupId": "marketing-commerce", + "title": "会员与忠诚度", + "summary": "会员等级、积分账本、权益发放与核销;区别于付费订阅权益周期" + }, + { + "id": "pim", + "serviceModel": "saas", + "groupId": "marketing-commerce", + "title": "商品信息管理 PIM", + "summary": "商品属性、渠道文案、语言版本与上架完整度;工程设计版本归 PLM" + }, + { + "id": "pricing", + "serviceModel": "saas", + "groupId": "marketing-commerce", + "title": "价格与促销策略", + "summary": "价目表、渠道价格、促销规则与生效期;单笔报价审批归 CPQ" + }, + { + "id": "order-management", + "serviceModel": "saas", + "groupId": "marketing-commerce", + "title": "订单管理 OMS", + "summary": "订单接收、可承诺量、分单履约、退换与异常协调;区别于仓内作业" }, { "id": "finance", + "serviceModel": "saas", + "groupId": "finance-control", "title": "财务与费用", - "summary": "应收应付、费用与预算" + "summary": "应收应付、结算核销与跨部门费用协同;专门报销流程归差旅费用" }, { - "id": "oa", - "title": "办公与审批 OA", - "summary": "审批单、通知与办公申请" + "id": "expense", + "serviceModel": "saas", + "groupId": "finance-control", + "title": "差旅与费用管理", + "summary": "出差申请、费用凭证、标准校验、报销审批与付款交接" }, { - "id": "itsm", - "title": "IT 服务与运维 ITSM", - "summary": "服务单、设备、事件与变更" + "id": "treasury", + "serviceModel": "saas", + "groupId": "finance-control", + "title": "资金管理", + "summary": "银行账户、现金头寸、资金预测与付款计划;区别于业务应收应付" }, { - "id": "projects", - "title": "项目与研发协作", - "summary": "项目、任务、需求与交付" + "id": "tax", + "serviceModel": "saas", + "groupId": "finance-control", + "title": "税务管理", + "summary": "税种规则、税额核对、申报底稿与税务资料归档;区别于总账记账" }, { - "id": "analytics", - "title": "数据分析 BI", - "summary": "指标、数据集与分析结果" + "id": "accounting", + "serviceModel": "saas", + "groupId": "finance-control", + "title": "会计与合并核算", + "summary": "会计凭证、总账、关账、内部抵销与合并报表" + }, + { + "id": "epm", + "serviceModel": "saas", + "groupId": "finance-control", + "title": "预算与绩效计划 EPM", + "summary": "预算版本、滚动预测、经营目标与实际偏差;区别于历史报表分析" + }, + { + "id": "revenue-management", + "serviceModel": "saas", + "groupId": "finance-control", + "title": "收入确认管理", + "summary": "履约义务、交易价格分摊、收入计划与确认记录;区别于订阅计费" + }, + { + "id": "hrm", + "serviceModel": "saas", + "groupId": "people-learning", + "title": "人力资源 HRM", + "summary": "组织、岗位、员工主档与入转调离;招聘、薪酬和考勤由专门领域承接" + }, + { + "id": "recruiting", + "serviceModel": "saas", + "groupId": "people-learning", + "title": "招聘管理 ATS", + "summary": "招聘需求、候选人、面试评价、录用与入职交接" + }, + { + "id": "workforce", + "serviceModel": "saas", + "groupId": "people-learning", + "title": "劳动力与考勤管理 WFM", + "summary": "班次、排班、工时、考勤、休假与异常核对;薪酬核算独立" + }, + { + "id": "payroll", + "serviceModel": "saas", + "groupId": "people-learning", + "title": "薪酬管理", + "summary": "薪资项目、核算周期、薪税扣款、工资单与发放复核" + }, + { + "id": "talent", + "serviceModel": "saas", + "groupId": "people-learning", + "title": "人才与绩效发展", + "summary": "绩效周期、目标、能力盘点、继任计划与发展行动;区别于组织主档" + }, + { + "id": "lms", + "serviceModel": "saas", + "groupId": "people-learning", + "title": "学习与培训 LMS", + "summary": "课程、学习任务、培训记录、考试与到期复训" + }, + { + "id": "scm", + "serviceModel": "saas", + "groupId": "supply-logistics", + "title": "供应链管理 SCM", + "summary": "跨供应、生产与交付的供需协调和异常处置;专门预测归需求计划" + }, + { + "id": "srm", + "serviceModel": "saas", + "groupId": "supply-logistics", + "title": "供应商关系 SRM", + "summary": "供应商准入、资质、关系分层与绩效;采购订单执行归采购管理" + }, + { + "id": "procurement", + "serviceModel": "saas", + "groupId": "supply-logistics", + "title": "采购管理", + "summary": "请购、询比价、采购订单、收货对账与采购支出控制" + }, + { + "id": "inventory", + "serviceModel": "saas", + "groupId": "supply-logistics", + "title": "库存管理", + "summary": "物料库存账、批次、可用量、调拨与补货;库位拣配归 WMS" + }, + { + "id": "wms", + "serviceModel": "saas", + "groupId": "supply-logistics", + "title": "仓储管理 WMS", + "summary": "库位、上架、拣选、包装、出入库与盘点作业" + }, + { + "id": "tms", + "serviceModel": "saas", + "groupId": "supply-logistics", + "title": "运输管理 TMS", + "summary": "运单、承运商、运输调度、签收与运费;自有车辆保养归车队" + }, + { + "id": "trade", + "serviceModel": "saas", + "groupId": "supply-logistics", + "title": "国际贸易管理 GTM", + "summary": "贸易单证、商品归类、报关协作与进出口限制检查" + }, + { + "id": "demand-planning", + "serviceModel": "saas", + "groupId": "supply-logistics", + "title": "需求与补货计划", + "summary": "需求预测版本、促销影响、安全库存与补货建议;现场排程归 APS" + }, + { + "id": "plm", + "serviceModel": "saas", + "groupId": "product-manufacturing", + "title": "产品生命周期 PLM", + "summary": "产品定义、设计版本、物料清单、工程变更与发布基线" + }, + { + "id": "mes", + "serviceModel": "saas", + "groupId": "product-manufacturing", + "title": "制造执行 MES", + "summary": "工单、工序、在制品、工位物料和现场异常的执行追溯" + }, + { + "id": "qms", + "serviceModel": "saas", + "groupId": "product-manufacturing", + "title": "质量管理 QMS", + "summary": "检验计划、不合格品、质量异常与纠正预防措施" + }, + { + "id": "aps", + "serviceModel": "saas", + "groupId": "product-manufacturing", + "title": "高级计划与排程 APS", + "summary": "产能约束、工序顺序、资源日历与排程方案;实际报工归 MES" + }, + { + "id": "ehs", + "serviceModel": "saas", + "groupId": "product-manufacturing", + "title": "环境健康与安全 EHS", + "summary": "危险源、作业许可、事故、隐患整改与职业健康记录" + }, + { + "id": "laboratory", + "serviceModel": "saas", + "groupId": "product-manufacturing", + "title": "实验室管理 LIMS", + "summary": "样品接收、检测任务、仪器记录、结果复核与报告签发" + }, + { + "id": "eam", + "serviceModel": "saas", + "groupId": "asset-facilities", + "title": "企业资产管理 EAM", + "summary": "自有设备台账、巡检、保养、维修与退役;数字内容资产归 DAM" + }, + { + "id": "facilities", + "serviceModel": "saas", + "groupId": "asset-facilities", + "title": "设施与空间运营", + "summary": "办公空间、工位、设施报修、保洁和空间占用;租约归不动产" + }, + { + "id": "fleet", + "serviceModel": "saas", + "groupId": "asset-facilities", + "title": "车队管理", + "summary": "自有车辆、驾驶员、用车、里程、维保与能耗;货运订单调度归 TMS" + }, + { + "id": "real-estate", + "serviceModel": "saas", + "groupId": "asset-facilities", + "title": "不动产与租赁运营", + "summary": "房产、租约、租金、到期续租与租赁履约;日常设施服务独立" }, { "id": "dam", + "serviceModel": "saas", + "groupId": "content-collaboration", "title": "数字资产管理 DAM", - "summary": "人物、图片、视频与素材版本" + "summary": "人物、图片、视频与素材版本、授权范围和复用;知识发布归 CMS" }, { "id": "cms", + "serviceModel": "saas", + "groupId": "content-collaboration", "title": "内容与知识管理 CMS", - "summary": "内容、知识条目与发布版本" + "summary": "内容条目、知识文章、审核、发布版本与过期复审" }, { - "id": "marketing", - "title": "营销与内容制作", - "summary": "活动、创意、渠道与制作任务" + "id": "oa", + "serviceModel": "saas", + "groupId": "content-collaboration", + "title": "办公与审批 OA", + "summary": "行政申请、审批、通知与办公服务;专业业务审批仍归对应领域" }, { - "id": "commerce", - "title": "电商与零售", - "summary": "商品、门店、交易与售后" + "id": "projects", + "serviceModel": "saas", + "groupId": "content-collaboration", + "title": "项目与研发协作", + "summary": "单项目需求、任务、里程碑、风险与交付协作" }, { - "id": "service", - "title": "客户服务", - "summary": "服务请求、知识、反馈与工单" + "id": "ppm", + "serviceModel": "saas", + "groupId": "content-collaboration", + "title": "项目组合管理 PPM", + "summary": "项目提案、优先级、组合预算、资源容量与收益跟踪" + }, + { + "id": "meetings", + "serviceModel": "saas", + "groupId": "content-collaboration", + "title": "会议与行动协同", + "summary": "议题、会议纪要、决议、责任人和行动闭环;日程订阅是可选集成" + }, + { + "id": "grc", + "serviceModel": "saas", + "groupId": "governance-legal", + "title": "治理风险与合规 GRC", + "summary": "风险登记、控制措施、合规义务与整改;独立审计取证归审计管理" + }, + { + "id": "clm", + "serviceModel": "saas", + "groupId": "governance-legal", + "title": "合同生命周期 CLM", + "summary": "条款、合同版本、审签、履约义务与到期;财务结算归财务" + }, + { + "id": "audit", + "serviceModel": "saas", + "groupId": "governance-legal", + "title": "审计管理", + "summary": "审计计划、抽样、工作底稿、发现与整改复核" + }, + { + "id": "privacy", + "serviceModel": "saas", + "groupId": "governance-legal", + "title": "隐私运营管理", + "summary": "处理活动、主体请求、同意证据与保留删除任务;技术访问控制独立" + }, + { + "id": "esg", + "serviceModel": "saas", + "groupId": "governance-legal", + "title": "可持续与 ESG 管理", + "summary": "可持续指标、排放活动数据、证据审核与披露;现场隐患归 EHS" + }, + { + "id": "erp", + "serviceModel": "saas", + "groupId": "enterprise-it", + "title": "企业资源 ERP", + "summary": "跨订单、采购、库存与财务的资源协同;单一专业流程优先归专门领域" + }, + { + "id": "itsm", + "serviceModel": "saas", + "groupId": "enterprise-it", + "title": "IT 服务与运维 ITSM", + "summary": "IT 服务请求、事件、问题、变更与配置关联;云资源控制归 IaaS" + }, + { + "id": "analytics", + "serviceModel": "saas", + "groupId": "enterprise-it", + "title": "数据分析 BI", + "summary": "业务指标、报表、仪表盘与异常分析;数据采集治理平台归 PaaS" + }, + { + "id": "mdm", + "serviceModel": "saas", + "groupId": "enterprise-it", + "title": "主数据管理 MDM", + "summary": "客户、物料等黄金记录、编码、匹配、审批与分发;区别于营销客群" + }, + { + "id": "app-platform", + "serviceModel": "paas", + "groupId": "platform-capabilities", + "title": "应用开发与运行平台", + "summary": "应用构建、运行环境、发布与应用生命周期" + }, + { + "id": "dbaas", + "serviceModel": "paas", + "groupId": "platform-capabilities", + "title": "数据库服务 DBaaS", + "summary": "托管数据库实例、连接、容量与数据库运维" + }, + { + "id": "baas", + "serviceModel": "paas", + "groupId": "platform-capabilities", + "title": "后端服务 BaaS", + "summary": "应用身份、数据接口、函数与后端能力" + }, + { + "id": "integration", + "serviceModel": "paas", + "groupId": "platform-capabilities", + "title": "集成与接口平台", + "summary": "连接器、接口、事件与系统间数据交换" + }, + { + "id": "low-code", + "serviceModel": "paas", + "groupId": "platform-capabilities", + "title": "低代码开发平台", + "summary": "表单、数据模型、流程与应用编排" + }, + { + "id": "devops", + "serviceModel": "paas", + "groupId": "platform-capabilities", + "title": "DevOps 与交付平台", + "summary": "代码集成、构建流水线、制品与发布环境" + }, + { + "id": "ai-platform", + "serviceModel": "paas", + "groupId": "platform-capabilities", + "title": "AI 开发平台", + "summary": "模型接入、推理服务、评测与 AI 应用开发" + }, + { + "id": "data-platform", + "serviceModel": "paas", + "groupId": "platform-capabilities", + "title": "数据工程平台", + "summary": "数据采集、加工、编排、目录与治理" + }, + { + "id": "compute", + "serviceModel": "iaas", + "groupId": "infrastructure-resources", + "title": "计算资源", + "summary": "虚拟机、计算规格、镜像与资源配额" + }, + { + "id": "storage", + "serviceModel": "iaas", + "groupId": "infrastructure-resources", + "title": "存储资源", + "summary": "对象、块与文件存储的容量、访问与生命周期" + }, + { + "id": "networking", + "serviceModel": "iaas", + "groupId": "infrastructure-resources", + "title": "网络资源", + "summary": "虚拟网络、子网、负载均衡与网络连接" + }, + { + "id": "containers", + "serviceModel": "iaas", + "groupId": "infrastructure-resources", + "title": "容器与集群基础设施", + "summary": "容器集群、节点、命名空间与基础资源配额" + }, + { + "id": "backup", + "serviceModel": "iaas", + "groupId": "infrastructure-resources", + "title": "备份与灾难恢复", + "summary": "备份策略、恢复点、恢复任务与灾备演练" } ] } diff --git a/catalog/playwright.config.ts b/catalog/playwright.config.ts new file mode 100644 index 0000000..2204f17 --- /dev/null +++ b/catalog/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "@playwright/test"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + testDir: "./tests", + testMatch: "**/browser.spec.ts", + fullyParallel: false, + use: { + baseURL: "http://127.0.0.1:4174", + headless: true, + viewport: { width: 1440, height: 1000 }, + launchOptions: { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH }, + }, + webServer: { + command: "node scripts/serve.mjs", + cwd: fileURLToPath(new URL("../", import.meta.url)), + env: { PORT: "4174" }, + url: "http://127.0.0.1:4174", + reuseExistingServer: !process.env.CI, + }, + reporter: "list", +}); diff --git a/catalog/service-models.json b/catalog/service-models.json new file mode 100644 index 0000000..64084b2 --- /dev/null +++ b/catalog/service-models.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "serviceModels": [ + { + "id": "saas", + "title": "SaaS · 业务应用", + "summary": "面向业务人员的应用工作台,覆盖客户、企业运营、生产与协作流程。" + }, + { + "id": "paas", + "title": "PaaS · 开发与数据平台", + "summary": "面向开发与数据团队的平台工作台,管理应用构建、集成、运行与数据能力。" + }, + { + "id": "iaas", + "title": "IaaS · 基础设施", + "summary": "面向基础设施团队的资源工作台,管理计算、存储、网络、集群与备份。" + } + ] +} diff --git a/catalog/tests/browser.spec.ts b/catalog/tests/browser.spec.ts new file mode 100644 index 0000000..3062d40 --- /dev/null +++ b/catalog/tests/browser.spec.ts @@ -0,0 +1,423 @@ +import { expect, test, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; + +const readCatalog = (file: string) => + JSON.parse(readFileSync(new URL(`../${file}.json`, import.meta.url), "utf8")); +const { blueprints } = readCatalog("blueprints"); +const { variants } = readCatalog("variants"); +const mes = blueprints.find( + (item: { id: string }) => item.id === "mes-production-operations", +); +const garment = variants.find( + (item: { id: string }) => item.id === "garment-workshop-consumables", +); +const layer = (page: Page, name: "SaaS" | "PaaS" | "IaaS") => + page.getByRole("button", { name: new RegExp(`${name} ·`) }); +const tab = (page: Page, name: string) => + page.getByRole("tab", { name: new RegExp(`^${name}`) }); +const results = (page: Page) => page.locator("#cases"); +const factory = (page: Page) => + page.locator('#cases [data-case="factory-operations"]'); +async function expectQuery( + page: Page, + expected: Record, +) { + await expect + .poll(() => + Object.fromEntries( + Object.keys(expected).map((key) => [ + key, + new URL(page.url()).searchParams.get(key), + ]), + ), + ) + .toEqual(expected); +} +async function noOverflow(page: Page) { + await expect + .poll(() => + page.evaluate( + () => + document.documentElement.scrollWidth <= + document.documentElement.clientWidth, + ), + ) + .toBe(true); +} + +test("默认资产库无登录即可浏览,按真实类型统计60分类22主干44变体3参考实现0项目", async ({ + page, +}) => { + await page.goto("/"); + await expect(layer(page, "SaaS")).toHaveAttribute("aria-pressed", "true"); + const counts = { + domains: 60, + blueprints: 22, + variants: 44, + "reference-implementation": 3, + "project-case": 0, + }; + for (const [key, count] of Object.entries(counts)) + await expect(page.locator(`[data-stat="${key}"] strong`)).toHaveText( + String(count), + ); + await expect(page.locator("#domains [data-group]")).toHaveCount(10); + await expect(results(page).locator("[data-blueprint-card]")).toHaveCount(22); + await expect(tab(page, "标准主干")).toHaveAttribute("aria-selected", "true"); + await expect(page.getByRole("link", { name: "体验工作台" })).toHaveCount(0); + await expect(page.getByRole("button", { name: /登录|注册/ })).toHaveCount(0); + await tab(page, "项目案例").click(); + await expect( + page.getByRole("heading", { name: "项目案例待沉淀" }), + ).toBeVisible(); + await expect(results(page).getByRole("article")).toHaveCount(0); + await expect( + page.getByText( + "当前尚未收录有项目材料支撑的项目案例。参考实现可在相应类型下查阅。", + ), + ).toBeVisible(); +}); + +test("按业务族进入MES主干,浏览五项规格、场景变体和关联参考材料", async ({ + page, + request, +}) => { + await page.goto("/"); + await page.getByLabel("筛选业务族").selectOption("product-manufacturing"); + await page.locator('#domains [data-domain="mes"]').click(); + await expect(results(page).locator("[data-blueprint-card]")).toHaveCount(1); + await expectQuery(page, { group: "product-manufacturing", domain: "mes" }); + await results(page).getByRole("button", { name: "查看主干" }).click(); + const dialog = page.getByRole("dialog"); + await expect( + dialog.getByRole("heading", { name: mes.title, exact: true }), + ).toBeVisible(); + for (const name of ["业务角色", "业务对象", "标准流程", "页面组成", "扩展点"]) + await expect( + dialog.getByRole("heading", { name: new RegExp(name) }), + ).toBeVisible(); + await expect(dialog.locator(".standard-workflow li")).toHaveCount( + mes.workflow.length, + ); + await dialog.locator(`[data-variant="${garment.id}"]`).click(); + await expect( + dialog.getByRole("heading", { name: garment.title, exact: true }), + ).toBeVisible(); + await expectQuery(page, { blueprint: mes.id, variant: garment.id }); + await expect(dialog.locator(".variant-adaptations li")).toHaveCount( + garment.adaptations.length, + ); + await expect( + dialog.getByText("参考实现 1 个 · 项目案例 0 个", { exact: true }), + ).toBeVisible(); + await expect( + dialog.locator('[data-case="factory-operations"]'), + ).toBeVisible(); + await dialog.locator(".material-links summary").click(); + const evidence = dialog.getByRole("link", { name: /专用 QA 环境/ }); + await expect(evidence).toHaveAttribute( + "href", + "/examples/saas/mes/factory-operations/evidence/verification.md", + ); + const response = await request.get( + (await evidence.getAttribute("href")) as string, + ); + expect(response.status()).toBe(200); + await expect(dialog.getByRole("link", { name: "业务流程" })).toBeVisible(); + await page.getByRole("button", { name: "关闭资产详情" }).click(); + await expect(dialog).not.toBeVisible(); +}); + +test("分类缩写、变体适配内容和行业都可检索,主干继承变体命中", async ({ + page, +}) => { + await page.goto("/"); + const search = page.getByRole("searchbox", { name: "搜索案例资产" }); + for (const [query, id] of [ + ["CRM", "crm-sales-pipeline"], + ["MES", mes.id], + ["DAM", "dam-content-assets"], + ]) { + await search.fill(query); + await expect( + results(page).locator(`[data-blueprint-card="${id}"]`), + ).toBeVisible(); + } + await search.fill(garment.adaptations[0]); + await expect( + results(page).locator(`[data-blueprint-card="${mes.id}"]`), + ).toBeVisible(); + await tab(page, "场景变体").click(); + await expect( + results(page).locator(`[data-variant-card="${garment.id}"]`), + ).toBeVisible(); + await search.clear(); + await page.getByLabel("筛选行业").selectOption(garment.industries[0]); + const expected = variants.filter((variant: { industries: string[] }) => + variant.industries.includes(garment.industries[0]), + ); + await expect(results(page).locator("[data-variant-card]")).toHaveCount( + expected.length, + ); + await expectQuery(page, { + type: "variants", + industry: garment.industries[0], + search: null, + }); +}); + +test("分类搜索与资产搜索拥有可恢复的空态", async ({ page }) => { + await page.goto("/"); + await page.getByRole("searchbox", { name: "查找子类" }).fill("MES"); + await expect(page.locator('#domains [data-domain="mes"]')).toBeVisible(); + await page.getByRole("searchbox", { name: "查找子类" }).fill("不存在的分类"); + await expect(page.locator("#no-domains")).toBeVisible(); + await expect(results(page).locator("[data-blueprint-card]")).toHaveCount(22); + await page.getByRole("searchbox", { name: "查找子类" }).clear(); + await expect(page.locator("#domains [data-group]")).toHaveCount(10); + await page + .getByRole("searchbox", { name: "搜索案例资产" }) + .fill("没有这个资产关键词"); + await expect( + page.getByRole("heading", { name: "没有找到匹配资产" }), + ).toBeVisible(); + await page.getByRole("button", { name: "清除检索条件" }).click(); + await expect(results(page).locator("[data-blueprint-card]")).toHaveCount(22); +}); + +test("行业与实现关键词必须来自同一变体分支,避免跨行业假命中", async ({ + page, +}) => { + await page.goto("/"); + await page + .getByRole("searchbox", { name: "搜索案例资产" }) + .fill("Persona Studio"); + await expect( + results(page).locator('[data-blueprint-card="dam-content-assets"]'), + ).toBeVisible(); + await page.getByLabel("筛选行业").selectOption("消费品"); + await expect(results(page).locator("[data-blueprint-card]")).toHaveCount(0); + await expect( + page.getByRole("heading", { name: "没有找到匹配资产" }), + ).toBeVisible(); + await expect(tab(page, "场景变体").locator("span")).toHaveText("0"); + await expect(tab(page, "参考实现").locator("span")).toHaveText("0"); + await page.getByLabel("筛选行业").selectOption("企业营销"); + await expect( + results(page).locator('[data-blueprint-card="dam-content-assets"]'), + ).toBeVisible(); + await expect(tab(page, "参考实现").locator("span")).toHaveText("1"); +}); + +test("直接URL恢复行业、类型、分类与变体详情,前进后退恢复选中关系", async ({ + page, +}) => { + const query = new URLSearchParams({ + serviceModel: "saas", + domain: "mes", + type: "variants", + industry: garment.industries[0], + search: "断针", + blueprint: mes.id, + variant: garment.id, + }); + await page.goto(`/?${query}`); + await expect( + page + .getByRole("dialog") + .getByRole("heading", { name: garment.title, exact: true }), + ).toBeVisible(); + await expectQuery(page, { + group: "product-manufacturing", + domain: "mes", + type: "variants", + industry: garment.industries[0], + search: "断针", + blueprint: mes.id, + variant: garment.id, + }); + await page.reload(); + await expect( + page + .getByRole("dialog") + .getByRole("heading", { name: garment.title, exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "关闭资产详情" }).click(); + await expectQuery(page, { blueprint: null, variant: null }); + await page.goBack(); + await expect(page.getByRole("dialog")).toBeVisible(); + await expectQuery(page, { blueprint: mes.id, variant: garment.id }); + await page.goForward(); + await expect(page.getByRole("dialog")).not.toBeVisible(); + await layer(page, "PaaS").click(); + await expectQuery(page, { + serviceModel: "paas", + group: null, + domain: null, + industry: null, + search: "断针", + type: "variants", + }); + await page.goBack(); + await expect(page.getByLabel("筛选行业")).toHaveValue(garment.industries[0]); + await expect(tab(page, "场景变体")).toHaveAttribute("aria-selected", "true"); +}); + +test("PaaS和IaaS保留分类,主干及项目数量为零且不伪装可运行内容", async ({ + page, +}) => { + await page.goto("/"); + await layer(page, "PaaS").click(); + await expect(page.locator('[data-stat="domains"] strong')).toHaveText("8"); + await expect(page.locator('[data-stat="blueprints"] strong')).toHaveText("0"); + await expect( + page.getByRole("heading", { name: "标准主干待沉淀" }), + ).toBeVisible(); + await page.getByLabel("筛选业务族").selectOption("platform-capabilities"); + await expect( + page.locator('#domains [data-domain]:not([data-domain="all"])'), + ).toHaveCount(8); + await layer(page, "IaaS").click(); + await expect(page.locator('[data-stat="domains"] strong')).toHaveText("5"); + await expect(page.locator('[data-stat="project-case"] strong')).toHaveText( + "0", + ); + await page.getByLabel("筛选业务族").selectOption("infrastructure-resources"); + await expect( + page.locator('#domains [data-domain]:not([data-domain="all"])'), + ).toHaveCount(5); + await expect(page.getByRole("link", { name: "体验工作台" })).toHaveCount(0); +}); + +test("390像素手机下分组、主干、变体详情与长搜索均无横向溢出", async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + await expect(results(page).locator("[data-blueprint-card]")).toHaveCount(22); + await noOverflow(page); + await page.getByRole("searchbox", { name: "查找子类" }).fill("MES"); + await page.locator('#domains [data-domain="mes"]').click(); + await results(page).getByRole("button", { name: "查看主干" }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + expect( + await page + .getByRole("dialog") + .evaluate((element) => element.scrollWidth <= element.clientWidth), + ).toBe(true); + await page + .getByRole("dialog") + .locator(`[data-variant="${garment.id}"]`) + .click(); + expect( + await page + .getByRole("dialog") + .evaluate((element) => element.scrollWidth <= element.clientWidth), + ).toBe(true); + await noOverflow(page); + await page.getByRole("button", { name: "关闭资产详情" }).click(); + await page + .getByRole("searchbox", { name: "搜索案例资产" }) + .fill("long-unmatched-asset-without-spaces-".repeat(4)); + await expect( + page.getByRole("heading", { name: "没有找到匹配资产" }), + ).toBeVisible(); + await noOverflow(page); +}); + +test("三个参考实现保留运行入口,规格与证据无需进入业务登录", async ({ + page, +}) => { + await page.goto("/?serviceModel=saas&type=reference-implementation"); + await expect(results(page).locator("[data-case]")).toHaveCount(3); + await expect( + results(page).getByText("参考实现", { exact: true }), + ).toHaveCount(3); + const factoryLink = factory(page).getByRole("link", { name: "体验工作台" }); + await expect(factoryLink).toHaveAttribute( + "href", + "/examples/saas/mes/factory-operations/workbench/dist/index.html", + ); + await factoryLink.click(); + await expect( + page.getByRole("heading", { name: /欢迎回到工作台|连接你的数据平台/ }), + ).toBeVisible(); + await page.goto("/?serviceModel=saas&type=reference-implementation"); + const personaLink = results(page) + .locator('[data-case="character-material-library"]') + .getByRole("link", { name: "体验工作台" }); + await expect(personaLink).toHaveAttribute( + "href", + "/examples/saas/dam/character-material-library/workbench/dist/index.html", + ); + await personaLink.click(); + await expect(page).toHaveTitle("本色 · Persona Studio"); + await expect(page.locator("#root")).toContainText("人物"); + await page.goto("/?serviceModel=saas&type=reference-implementation"); + const museLink = results(page) + .locator('[data-case="brand-digital-human-studio"]') + .getByRole("link", { name: "体验工作台" }); + await expect(museLink).toHaveAttribute( + "href", + "/examples/saas/dam/brand-digital-human-studio/workbench/dist/index.html", + ); + await museLink.click(); + await expect( + page.getByRole("button", { name: "登录工作台", exact: true }), + ).toBeVisible(); +}); + +test("关系目录加载失败可以重试,单个实现manifest失败仍可浏览规格", async ({ + page, +}) => { + const resource = "**/catalog/variants.json"; + await page.route(resource, (route) => + route.fulfill({ status: 503, body: "Unavailable" }), + ); + await page.goto("/"); + await expect(page.getByRole("alert")).toContainText("资产目录暂时无法加载"); + await page.unroute(resource); + await page.getByRole("button", { name: "重新加载" }).click(); + await expect(page.getByRole("alert")).toBeHidden(); + await expect(results(page).locator("[data-blueprint-card]")).toHaveCount(22); + await page.route( + "**/examples/saas/mes/factory-operations/case.json", + (route) => route.fulfill({ status: 503, body: "Unavailable" }), + ); + await page.goto("/?serviceModel=saas&type=reference-implementation"); + await expect(factory(page)).toContainText( + "运行信息暂不可用,可先查看案例说明。", + ); + await expect( + factory(page).getByRole("link", { name: "体验工作台" }), + ).toHaveCount(0); + await expect( + factory(page).getByRole("link", { name: "查看案例", exact: true }), + ).toHaveAttribute("href", "/examples/saas/mes/factory-operations/README.md"); + await factory(page).locator(".material-links summary").click(); + await expect( + factory(page).getByRole("link", { name: "业务流程" }), + ).toBeVisible(); +}); + +test("无效URL类型与错配变体安全回落,键盘可切换资产类型", async ({ page }) => { + await page.goto("/?serviceModel=saas&type=toString"); + await expect(tab(page, "标准主干")).toHaveAttribute("aria-selected", "true"); + await expectQuery(page, { type: null }); + await page.goto( + `/?serviceModel=saas&type=__proto__&blueprint=${mes.id}&variant=industrial-account-visits`, + ); + await expect(tab(page, "标准主干")).toHaveAttribute("aria-selected", "true"); + await expectQuery(page, { type: null, blueprint: mes.id, variant: null }); + await expect( + page + .getByRole("dialog") + .getByRole("heading", { name: mes.title, exact: true }), + ).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + await tab(page, "标准主干").focus(); + await page.keyboard.press("ArrowRight"); + await expect(tab(page, "场景变体")).toBeFocused(); + await expect(results(page).locator("[data-variant-card]")).toHaveCount(44); +}); diff --git a/catalog/variants.json b/catalog/variants.json new file mode 100644 index 0000000..aecb342 --- /dev/null +++ b/catalog/variants.json @@ -0,0 +1,577 @@ +{ + "version": 1, + "variants": [ + { + "id": "industrial-account-visits", + "blueprintId": "crm-sales-pipeline", + "title": "工业客户拜访与长周期商机", + "summary": "面对多角色采购决策,保留技术问题、报价前置条件及长期跟进行动。", + "industries": ["工业制造", "工业设备"], + "adaptations": [ + "客户下增加工厂与决策联系人角色", + "拜访记录要求问题、下一步行动和日期", + "商机阶段设置技术确认与样品验证门槛", + "按客户归属隔离销售记录,主管查看团队机会" + ] + }, + { + "id": "professional-services-opportunities", + "blueprintId": "crm-sales-pipeline", + "title": "专业服务项目商机", + "summary": "把客户诉求转成服务范围、预估人日和可交付承诺,再交接项目团队。", + "industries": ["咨询服务", "设计服务"], + "adaptations": [ + "商机增加服务范围和预计人日", + "提案阶段需交付团队确认资源容量", + "成交交接生成项目启动清单", + "分离预计合同金额与正式财务收入" + ] + }, + { + "id": "b2b-product-support", + "blueprintId": "service-request-resolution", + "title": "企业产品技术支持", + "summary": "围绕版本、环境和服务合同处理可复现问题及升级。", + "industries": ["企业软件", "工业设备"], + "adaptations": [ + "请求关联产品版本、部署环境和服务合同", + "严重事件记录复现步骤与诊断附件", + "按合同工作日历计算响应与解决时限", + "解决后保留关联缺陷和知识文章" + ] + }, + { + "id": "retail-after-sales", + "blueprintId": "service-request-resolution", + "title": "零售交易售后", + "summary": "根据订单与商品权益处理退换、补发和消费者确认。", + "industries": ["零售", "电商"], + "adaptations": [ + "受理时关联交易订单及收货状态", + "退换规则区分商品类型与时间窗口", + "退款调用支付适配器并记录唯一请求号", + "关闭前核验物流与退款回执" + ] + }, + { + "id": "b2b-event-campaign", + "blueprintId": "marketing-campaign-delivery", + "title": "企业展会营销", + "summary": "围绕展会物料、报名线索和销售接手效果进行活动闭环。", + "industries": ["工业制造", "企业服务"], + "adaptations": [ + "活动增加展位、场次与物料交付日期", + "报名线索按同意范围和去重规则入库", + "活动后给销售分派跟进责任与期限", + "效果区分报名、到场和有效商机" + ] + }, + { + "id": "consumer-brand-launch", + "blueprintId": "marketing-campaign-delivery", + "title": "消费品牌新品传播", + "summary": "管理多渠道创意规格、上市节奏和品牌审批。", + "industries": ["消费品", "美妆"], + "adaptations": [ + "创意任务按渠道比例、时长和语言拆分", + "素材审核加入品牌规范及使用授权检查", + "上市日期控制内容可见及发布窗口", + "效果按渠道、素材版本和活动批次汇总" + ] + }, + { + "id": "multi-store-retail", + "blueprintId": "commerce-store-operations", + "title": "连锁门店运营", + "summary": "跟踪门店价格执行、商品可售和到店售后,允许区域差异。", + "industries": ["连锁零售", "餐饮零售"], + "adaptations": [ + "商品配置门店可售范围与区域价格", + "库存展示区分门店可售和锁定量", + "退换处理需要原交易与门店权限校验", + "经营指标按门店和营业日汇总" + ] + }, + { + "id": "b2b-ordering-portal", + "blueprintId": "commerce-store-operations", + "title": "企业客户订货门户", + "summary": "提供客户协议价、起订量和账期下单的交易运营流程。", + "industries": ["批发分销", "工业耗材"], + "adaptations": [ + "客户分配协议价目表与商品目录", + "订单检查起订量、包装单位和信用额度", + "交易可先审批再交OMS履约", + "结算采用账期对账而非默认即时支付" + ] + }, + { + "id": "distribution-receivables", + "blueprintId": "finance-settlement-control", + "title": "经销回款与账龄", + "summary": "核对经销商订单、收款及折让,关注信用额度和逾期。", + "industries": ["批发分销", "消费品"], + "adaptations": [ + "收款允许一笔对应多张结算单", + "折让和返利需独立复核依据", + "逾期与信用额度联动提示订单风险", + "对账单按经销商及币种生成" + ] + }, + { + "id": "supplier-payables", + "blueprintId": "finance-settlement-control", + "title": "采购应付与付款计划", + "summary": "按采购、收货和发票差异组织应付复核与资金安排。", + "industries": ["制造业", "连锁零售"], + "adaptations": [ + "付款依据关联采购订单、收货和发票", + "数量或价格差异进入人工争议队列", + "付款批次按到期日和资金计划排程", + "经办人与付款复核人职责分离" + ] + }, + { + "id": "office-employee-onboarding", + "blueprintId": "hrm-employee-lifecycle", + "title": "办公员工入职与调动", + "summary": "将人事生效信息与账号、工位和行政服务清单联动。", + "industries": ["企业服务", "科技企业"], + "adaptations": [ + "入职任务按部门岗位生成账号和设备清单", + "直属主管只访问本部门必要字段", + "调动按生效日期变更组织和授权", + "离职交接核对设备归还和账号撤销回执" + ] + }, + { + "id": "multi-site-worker-lifecycle", + "blueprintId": "hrm-employee-lifecycle", + "title": "多工厂员工入转调离", + "summary": "支持工厂归属、岗位资格与跨厂调动的人员档案协作。", + "industries": ["制造业", "物流仓储"], + "adaptations": [ + "主档增加工厂、班组和岗位资格", + "跨厂调动要求接收方确认到岗日期", + "岗位变更联动培训与排班接口", + "各厂人事按组织范围访问,集团查看汇总" + ] + }, + { + "id": "safety-certification-training", + "blueprintId": "lms-training-compliance", + "title": "岗位安全培训与复训", + "summary": "按岗位风险和资格有效期组织实操、考试与到期复训。", + "industries": ["制造业", "物流仓储"], + "adaptations": [ + "课程绑定岗位和资格有效期", + "通过条件包含理论考试和实操签认", + "未通过或到期产生复训任务", + "资格状态通过接口供排班系统核验" + ] + }, + { + "id": "customer-product-academy", + "blueprintId": "lms-training-compliance", + "title": "客户产品培训", + "summary": "向外部客户按产品版本提供学习内容并跟踪上线准备。", + "industries": ["企业软件", "工业设备"], + "adaptations": [ + "学员按客户组织隔离课程与结果", + "课程关联产品版本和上线里程碑", + "客户管理员查看本组织完成率", + "证书仅作为培训记录,不默认外部资质认可" + ] + }, + { + "id": "make-to-order-supply", + "blueprintId": "scm-supply-exception-control", + "title": "订单驱动制造供需协调", + "summary": "从客户交期倒查缺料和产能,记录各方承诺变更。", + "industries": ["装备制造", "定制制造"], + "adaptations": [ + "缺口直接关联客户订单与交期", + "供应替代方案记录质量和设计确认", + "生产插单需记录受影响订单", + "关闭异常前回读采购及生产实际回执" + ] + }, + { + "id": "seasonal-retail-supply", + "blueprintId": "scm-supply-exception-control", + "title": "季节性零售供应协同", + "summary": "在促销窗口内协调供应到货与门店分配,处理滞销和短缺。", + "industries": ["服饰零售", "消费品"], + "adaptations": [ + "需求快照按季节和促销活动分版", + "分配方案考虑门店优先级与可售天数", + "供应延迟需给出替代渠道或取消建议", + "复盘保留缺货与积压的实际差异" + ] + }, + { + "id": "critical-material-suppliers", + "blueprintId": "srm-supplier-qualification", + "title": "关键物料供应商准入", + "summary": "把样品验证、质量资质和持续供货能力作为准入门槛。", + "industries": ["电子制造", "装备制造"], + "adaptations": [ + "资质按物料风险级别配置", + "准入必须包含样品或试供验证结果", + "绩效关联批次质量和交付准时率", + "资质失效暂停新增采购的接口需明确确认" + ] + }, + { + "id": "outsourced-service-suppliers", + "blueprintId": "srm-supplier-qualification", + "title": "外包服务商管理", + "summary": "用服务能力、人员资质和合同服务表现评估合作方。", + "industries": ["物业服务", "企业服务"], + "adaptations": [ + "准入收集服务地域、人员资格与保险资料", + "评分以服务时限、投诉和履约记录为依据", + "整改任务按服务站点分配", + "供应商仅访问自身材料与整改记录" + ] + }, + { + "id": "batch-expiry-warehouse", + "blueprintId": "wms-warehouse-execution", + "title": "批次效期仓储", + "summary": "以批次、效期与隔离状态约束上架、拣选和盘点。", + "industries": ["食品", "日化"], + "adaptations": [ + "收货要求批次和效期信息", + "库存分可用、隔离和待检状态", + "拣选优先效期且拦截过期批次", + "盘点差异按库位与批次复核" + ] + }, + { + "id": "spare-parts-warehouse", + "blueprintId": "wms-warehouse-execution", + "title": "设备备件仓储", + "summary": "管理零件号、替代料、维修领退与可修复件回库。", + "industries": ["工业设备", "维修服务"], + "adaptations": [ + "物料增加兼容设备和替代料关系", + "领料关联维修工单及使用人", + "退回件区分完好、待修和报废", + "盘点结合序列号或高价值单件记录" + ] + }, + { + "id": "regional-distribution-transport", + "blueprintId": "tms-shipment-dispatch", + "title": "区域门店配送", + "summary": "按门店收货窗口和线路汇总多点配送,记录拒收与短少。", + "industries": ["连锁零售", "消费品"], + "adaptations": [ + "装载计划按门店时窗和卸货顺序组织", + "运单支持多站点签收节点", + "短少与拒收关联订单行及回仓计划", + "运费按线路、里程和站点附加费复核" + ] + }, + { + "id": "cross-border-freight-coordination", + "blueprintId": "tms-shipment-dispatch", + "title": "跨境货运协同", + "summary": "追踪多段承运、单证交接与境外节点,保持贸易审批边界。", + "industries": ["跨境电商", "国际贸易"], + "adaptations": [ + "运单拆分起运、干线及目的地运输段", + "每段记录承运商、计划和实际节点", + "单证通过GTM接口引用,不内置法律结论", + "运费区分币种、段费与目的港附加费" + ] + }, + { + "id": "garment-workshop-consumables", + "blueprintId": "mes-production-operations", + "title": "服装车间断针与耗材申领", + "summary": "围绕合同、物料和工位记录照片上报,经主管批准、配送及断针回收确认完成闭环。", + "industries": ["服装制造", "纺织加工"], + "adaptations": [ + "上报关联合同号、物料编号、工位、原因和附件;断针至少一份证据", + "状态采用提交、批准、配送、完成或驳回;断针完成前必须确认回收", + "操作员上报,主管或工作区所有者处理审批和回收", + "销售拜访作为CRM扩展,记录纪要、问题与下一次跟进日期" + ] + }, + { + "id": "assembly-station-reporting", + "blueprintId": "mes-production-operations", + "title": "装配工位报工与异常追踪", + "summary": "以序列号和工序为线索记录完工、返工与缺料,支持工序交接。", + "industries": ["离散制造", "电子装配"], + "adaptations": [ + "执行对象改为工单、工序和产品序列号", + "报工区分合格、返工和报废数量并校验总量", + "异常分派质量或物料责任人,不能仅靠报工自动关闭", + "交接需要上道工序完成条件与下道接收记录" + ] + }, + { + "id": "equipment-design-change", + "blueprintId": "plm-engineering-change", + "title": "装备设计变更", + "summary": "评估设计修订对在制设备、库存和已交付产品的影响。", + "industries": ["装备制造", "机械设计"], + "adaptations": [ + "BOM按设备型号与配置版本管理", + "变更标记在制、在库及已交付影响范围", + "生效方式支持序列号或日期边界", + "发布前要求制造和售后对影响方案会签" + ] + }, + { + "id": "consumer-product-launch", + "blueprintId": "plm-engineering-change", + "title": "消费品新品开发", + "summary": "协调配方或结构、包装规格和上市前的设计冻结。", + "industries": ["消费品", "日化"], + "adaptations": [ + "产品结构区分内容物、包装与标签版本", + "变更记录试样验证及包装兼容性", + "上市门槛包含设计冻结与供应准备确认", + "设计资产引用DAM,商品渠道文案交PIM" + ] + }, + { + "id": "incoming-material-inspection", + "blueprintId": "qms-quality-corrective-action", + "title": "来料检验与供应商整改", + "summary": "对到货批次检验和隔离,形成供应商反馈与复评依据。", + "industries": ["电子制造", "装备制造"], + "adaptations": [ + "检验单关联供应商、采购与到货批次", + "抽样规则按物料风险与供应商等级确定", + "不合格处置控制待检库存释放", + "纠正措施可分派供应商并跟踪复检结果" + ] + }, + { + "id": "process-quality-containment", + "blueprintId": "qms-quality-corrective-action", + "title": "制程质量异常与围堵", + "summary": "识别产线问题影响范围,记录临时围堵、原因和恢复条件。", + "industries": ["汽车零部件", "离散制造"], + "adaptations": [ + "异常关联工序、设备与生产批次", + "围堵范围包含在制、库存和已发运批次引用", + "恢复生产需要处置授权及验证记录", + "效果验证按后续批次结果复核,避免只填完成状态" + ] + }, + { + "id": "factory-equipment-maintenance", + "blueprintId": "eam-maintenance-work-orders", + "title": "工厂设备维修与预防保养", + "summary": "连接设备停机、备件消耗与生产恢复确认。", + "industries": ["制造业", "加工行业"], + "adaptations": [ + "设备台账关联产线、工位和关键程度", + "保养周期可按运行时长触发", + "维修工单记录停机起止及备件去向", + "恢复前由现场责任人确认试运行结果" + ] + }, + { + "id": "distributed-site-maintenance", + "blueprintId": "eam-maintenance-work-orders", + "title": "分布式站点设备维护", + "summary": "支持跨站点设备巡检、外包维修和远程复核。", + "industries": ["连锁商业", "能源服务"], + "adaptations": [ + "设备按区域与站点划分访问范围", + "工单匹配服务区域及可用技师", + "维修回执要求照片、时间与现场确认", + "高风险维修需总部复核,外包方只见受派工单" + ] + }, + { + "id": "digital-human-content-production", + "blueprintId": "dam-content-assets", + "title": "企业数字人内容制作", + "summary": "围绕人物素材、授权、制作需求、生成任务和人工审核组织可复用内容。", + "industries": ["企业营销", "数字内容制作"], + "adaptations": [ + "资产增加人物、肖像或声音授权范围与适用渠道", + "制作需求关联脚本、人物版本和素材引用", + "生成服务通过扩展点接入;任务记录与真实生成能力分别声明", + "产出经人工审核后归档,记录来源、版本和使用限制" + ] + }, + { + "id": "brand-content-library", + "blueprintId": "dam-content-assets", + "title": "多品牌营销素材库", + "summary": "管理品牌、区域和渠道版本,避免过期或未获批素材被复用。", + "industries": ["消费品", "连锁零售"], + "adaptations": [ + "资产按品牌、语言和区域设置标签与权限", + "授权记录渠道范围、有效期与替代版本", + "审核按品牌负责人分派", + "下载前检查发布状态和使用范围并保留引用记录" + ] + }, + { + "id": "internal-service-knowledge", + "blueprintId": "cms-knowledge-publishing", + "title": "内部服务知识库", + "summary": "按员工角色和服务主题维护操作说明与常见问题。", + "industries": ["企业服务", "大型组织"], + "adaptations": [ + "内容按服务类型和适用员工群体分类", + "专家负责事实审核与定期复审", + "读者反馈可生成修订任务", + "敏感操作说明按组织和角色过滤搜索结果" + ] + }, + { + "id": "public-product-documentation", + "blueprintId": "cms-knowledge-publishing", + "title": "产品文档与多版本发布", + "summary": "面向外部读者管理产品版本、语言和发布节奏。", + "industries": ["软件产品", "工业设备"], + "adaptations": [ + "内容同时关联产品版本与语言", + "发布计划与产品版本上市节奏关联", + "修订需检查跨版本链接和迁移说明", + "已发布页面提供可追踪版本及撤回记录" + ] + }, + { + "id": "office-supplies-request", + "blueprintId": "oa-office-requests", + "title": "办公用品申请与领取", + "summary": "按照用品标准和预算完成申请、审批、发放及领取确认。", + "industries": ["企业办公", "教育组织"], + "adaptations": [ + "服务目录改为用品类别与可申请数量", + "超标准或超预算申请升级审批", + "执行任务记录实际发放数量", + "库存接口未连接时只记录领用,不声称自动扣库" + ] + }, + { + "id": "visitor-reception-request", + "blueprintId": "oa-office-requests", + "title": "访客与会议接待", + "summary": "协调来访申请、接待资源和行政执行,控制访客资料范围。", + "industries": ["企业办公", "园区运营"], + "adaptations": [ + "申请增加来访时间、接待人及必要访客资料", + "资源任务包含会议室和接待安排", + "外部访客只查看自身邀请或回执", + "到访后按保留规则处理个人资料" + ] + }, + { + "id": "software-release-project", + "blueprintId": "projects-delivery-coordination", + "title": "软件版本交付", + "summary": "将需求、开发任务、缺陷和发布验收组成版本交付记录。", + "industries": ["软件开发", "企业研发"], + "adaptations": [ + "项目增加版本范围、缺陷和发布候选物", + "任务关联代码变更与构建结果引用", + "验收区分功能、兼容性和回滚准备", + "发布操作依赖实际交付接口,任务完成不等于已部署" + ] + }, + { + "id": "customer-implementation-project", + "blueprintId": "projects-delivery-coordination", + "title": "客户实施交付", + "summary": "根据合同范围和客户里程碑组织配置、迁移、培训与验收。", + "industries": ["企业服务", "软件实施"], + "adaptations": [ + "需求变更记录范围、成本和客户确认", + "交付物按里程碑向客户开放", + "迁移与培训分别保留结果及问题清单", + "最终验收保留客户确认,不用内部完成代替" + ] + }, + { + "id": "distribution-order-to-cash", + "blueprintId": "erp-order-resource-coordination", + "title": "贸易分销订单协同", + "summary": "协调多仓库存、供应采购、发货和客户结算,集中处理交付异常。", + "industries": ["贸易分销", "批发"], + "adaptations": [ + "订单按仓库可用量拆分供应安排", + "缺货时发起采购协同并回写承诺日期", + "履约关联出库和运输单据而不重复仓内任务", + "收款与信用信息从财务接口回读" + ] + }, + { + "id": "engineer-to-order-coordination", + "blueprintId": "erp-order-resource-coordination", + "title": "项目型制造资源协同", + "summary": "围绕定制合同组织设计、长周期采购、制造与阶段结算。", + "industries": ["装备制造", "工程制造"], + "adaptations": [ + "资源需求按合同项目和设计基线分组", + "设计变更触发采购及在制影响确认", + "长周期物料单独维护到货承诺", + "结算节点按里程碑核对,不用发货数量直接推断收入" + ] + }, + { + "id": "employee-it-helpdesk", + "blueprintId": "itsm-service-restoration", + "title": "员工 IT 服务台", + "summary": "受理账号、设备和办公软件问题,提供自助及人工升级。", + "industries": ["企业办公", "教育组织"], + "adaptations": [ + "请求按账号、设备和应用分类", + "身份相关操作由专门权限接口执行", + "高优先级服务中断触发值班升级", + "用户确认解决后沉淀可复用知识" + ] + }, + { + "id": "production-service-incidents", + "blueprintId": "itsm-service-restoration", + "title": "生产系统事件与变更", + "summary": "围绕业务系统中断、恢复、根因和受控变更维护处理证据。", + "industries": ["软件运营", "企业IT"], + "adaptations": [ + "事件关联受影响服务、依赖与业务影响", + "重大事件指定指挥人和更新节奏", + "紧急变更需记录授权、验证与回滚方案", + "恢复后另建问题复盘,避免恢复即视为根因解决" + ] + }, + { + "id": "sales-performance-review", + "blueprintId": "analytics-business-review", + "title": "销售经营复盘", + "summary": "统一目标、商机、签约和回款口径,支持团队与客户维度分析。", + "industries": ["企业销售", "批发分销"], + "adaptations": [ + "指标区分商机预测、合同签约和实际回款", + "组织筛选遵循销售团队数据权限", + "异常可下钻到商机或结算依据", + "行动记录责任人与复查日期,不自动改写交易数据" + ] + }, + { + "id": "manufacturing-performance-review", + "blueprintId": "analytics-business-review", + "title": "工厂经营与执行分析", + "summary": "按班组、产线和周期分析产出、异常及交付表现。", + "industries": ["制造业", "加工行业"], + "adaptations": [ + "指标定义班次边界、合格数量与停机口径", + "来源显示数据更新时间和未回传范围", + "异常可引用生产、质量和设备记录", + "改进前后使用相同口径,保留调整版本" + ] + } + ] +} diff --git a/docs/architecture.md b/docs/architecture.md index 07420f9..e957f79 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,34 +1,55 @@ # 仓库架构与推进方式 -## 组织方式 +## 分类和资产关系 -采用一个案例库仓库,按主业务领域分目录。根目录是发现入口;每个案例拥有自己的规格、数据、演示和证据。案例可以独立演进,不要求共享一个 SaaS 后端。 +统一目录按 **服务模型 → 业务族 → 领域** 查找业务,再按 **标准主干 → 场景变体 → 案例实现** 复用方案。分类回答“解决哪类问题”,资产关系回答“哪些部分可以复用”。 -此阶段使用 Node.js 提供本地静态演示服务,不需要数据库、登录或模型凭据。正式工作台根据其需求选择前后端与部署方式,不把合成原型升级成生产系统。 +| 文件 | 内容与关系 | +| ----------------------------- | ---------------------------------------------------------- | +| `catalog/service-models.json` | SaaS、PaaS、IaaS 三种服务模型 | +| `catalog/domain-groups.json` | 业务族,属于一个服务模型 | +| `catalog/domains.json` | 领域,属于一个服务模型及其业务族 | +| `catalog/blueprints.json` | 标准主干,属于一个领域,定义角色、对象、流程、页面和扩展点 | +| `catalog/variants.json` | 场景变体,引用一个主干,记录适用行业和差异 | +| `catalog/cases.json` | 实现索引,关联主干、变体及唯一代码目录 | -## 与上游组合 +主干和变体是参考设计,允许暂时没有实现。案例目录必须有真实材料,禁止用空目录或重复实现充数量。当前有 60 个 SaaS 分类、22 个主干、44 个变体、三个参考实现;项目案例为零。 -设计系统负责稳定的视觉和交互组件;digital-employee 负责需要 Agent 的执行契约;文档和记忆能力按案例引用;本仓库负责业务语义、工作台与可复现的案例证据。接入记录放在案例 `integrations.md`,代码中只引入实际使用的依赖。 +版本 3 的 `case.json` 与索引声明 `serviceModel`、`domain`、`blueprintId`、`variantId` 和 `caseKind`。案例所属领域必须与主干一致,变体必须属于该主干。目录仍为 `examples////`,不会把代码复制到每个场景分支。跨领域发现用 tags。 -人物素材资产库的主入口 Persona Studio 使用 localStorage 保存本浏览器的演示状态,刷新保留;简版 demo/ 保留内存重置流程。主入口没有真实多用户共享、媒体上传、模型调用、账号绑定或外部写入。 +## 目录浏览与实现运行 -完整工作台以 `examples///workbench/` npm workspace 纳入根锁文件。`npm run build` 构建各工作台,`npm run demo` 构建后启动目录服务;构建输出忽略,不依赖来源机器的二进制或依赖目录。runtime.entry 指向构建入口,校验在构建后执行。 +目录服务是本地静态应用,读取已校验的 JSON,提供业务族、领域、行业、关键词和资产类型筛选。选择主干或变体可以查看规格及关联实现,URL 保存浏览位置。所有目录材料无需登录或数据库。 -## 从客户需求到案例 +`npm run demo` 构建案例后在 提供统一入口。案例的 `runtime.entry` 相对于其目录指向实际构建产物;完整工作台以 `examples/*/*/*/workbench` npm workspace 纳入根锁文件。构建输出不提交。 -1. 需求访谈:了解目标、当前流程、输入输出与成功标准;保留内部获准来源。 -2. 规格抽象:把具体客户需求提炼成可复用案例,区分事实、提议与待确认项。 -3. 原型验证:先用合成数据跑通主流程,获得业务反馈。 -4. 服务接入:连接素材存储、业务 SaaS 或模型,记录接口、配置与失败处理。 -5. 验收与沉淀:在声明环境完成标准,更新状态、证据和案例目录。 +案例按自己的集成声明运行。Persona Studio 是本浏览器合成数据演示,使用 localStorage 保留演示状态;工厂工作台已实现独立身份、业务和资产提供方。目录新增分类、主干或变体不需要新建账号、数据库表或云租户。 -## 首批候选方向 +## 独立维护的维度 -| 优先级 | 候选 | 当前状态 | -| ------ | ------------------- | ---------------------------------------------- | -| 1 | DAM:人物素材资产库 | 已收录 prototype;下一步确认客户素材和生成流程 | -| 2 | CRM:客户与商机跟进 | 分类候选,需要真实需求输入 | -| 3 | ERP:采购与库存协同 | 分类候选,需要真实需求输入 | -| 4 | MES:工单与生产异常 | 分类候选,需要真实需求输入 | +| 维度 | 回答的问题 | 位置 | +| ------------ | ---------------------------------------- | ------------------------------------------------ | +| 分类 | 面向哪一层、哪种业务? | serviceModel、groupId、domain、tags | +| 标准化复用 | 沿用什么主干,改变哪些业务规则? | blueprintId、variantId、主干和变体字典 | +| 来源与成熟度 | 是参考实现还是项目案例,已验证到哪一步? | caseKind、source、status、capabilities、evidence | +| 部署与数据 | 在哪里运行,连接哪些服务? | integrations.md、部署配置、提供方实现 | -候选不会计入已交付案例数量。具体排期与负责人由团队决定,本文件不预设真实人员分工。 +SaaS 分类表示业务应用方向,不承诺提供托管云服务。使用 Supabase 不改变工厂案例的 SaaS / MES 归属。数据库、资产平台和身份系统分别通过 [数据提供方契约](data-provider-contract.md) 接入;MySQL 或其他服务由部署者按契约实现,目录资产格式不绑定厂商。 + +## 沉淀与协作 + +1. 先检索已有主干、变体和案例,明确哪些能力已经实现。 +2. 用获准来源抽象需求,选择现有主干,记录场景差异;没有合适主干时补充其标准内容。 +3. 复用适用的实现,记录实际代码和外部依赖,保留模拟、不可用、已连接能力的区别。 +4. 用合成数据验证主流程,按项目需要接入服务并留下检查记录。 +5. 有可公开项目材料时,按规范收录项目案例;从多个项目中识别稳定共性,回收到主干,保留差异的变体关联。 + +是否拆成新变体取决于角色、流程、数据约束是否真正变化,不取决于换了客户名字、配色或数据库。参考设计本身不证明业务交付能力,项目案例也不自动达到 `verified`。 + +## 校验与上游 + +JSON Schema 校验字段结构,仓库校验器验证分类父子关系、主干和变体引用、索引与 manifest 一致性、文件路径和证据文件。单元测试覆盖无效引用和不一致关系,浏览器测试覆盖筛选、关联浏览、历史、错误恢复和手机布局。 + +`npm run check` 执行本地构建与结构、业务规则验证;`npm run test:e2e` 执行目录与案例浏览器回归。它们不能自动判断客户证据真实性、授权范围或验收结论,需要收录者审查。 + +设计系统提供稳定组件,digital-employee 提供 Agent 执行契约,其他仓库按案例引用。本仓库维护业务语义、复用关系、工作台实现与证据,只有实际使用的依赖才写入接入声明。 diff --git a/docs/case-assets.md b/docs/case-assets.md new file mode 100644 index 0000000..265be2b --- /dev/null +++ b/docs/case-assets.md @@ -0,0 +1,98 @@ +# 案例复用资产 + +本库把可复用设计与实际实现分开组织:**标准主干 → 场景变体 → 实际案例**。标准主干保存共通业务结构,场景变体描述差异,案例保存实现、集成和证据。这里的“标准”指仓库内部统一格式,不代表行业认证或外部标准。 + +当前有 **22 个标准主干、44 个场景变体、3 个参考实现、0 个项目案例**。主干和变体都是参考设计,不能加总为“已运行案例”“客户项目”或“已交付工作台”。参考实现确有代码和各自的验证记录,但并不因此成为客户项目交付证明。 + +## 三种复用资产 + +| 层次 | 回答的问题 | 保留的内容 | 运行与来源声明 | +| ------------------ | ---------------------------------------- | ---------------------------------------------- | ------------------------------------------------------ | +| 标准主干 blueprint | 这一业务领域的共通工作方式是什么? | 角色、实体、主流程、页面、扩展点 | 参考设计,无运行状态 | +| 场景变体 variant | 行业或业务情境具体改变了什么? | 适用行业、字段与约束、角色权限、流程或集成差异 | 参考设计,无客户交付声明 | +| 实际案例 case | 哪部分已经实现,如何运行,验证到了哪里? | 代码、规格、数据、集成、运行声明、验收与证据 | 使用 caseKind、status、source 和 capabilities 分别表达 | + +分类与复用是两个维度:`serviceModel → groupId → domain` 决定目录中的位置;`blueprintId → variantId → case` 决定设计与实现之间的关系。一个主干属于一个服务模型和领域,一个变体属于一个主干,一个案例选择一个主干和变体。案例的领域与服务模型必须与主干一致,变体也必须属于该主干。 + +实际案例继续使用 `examples////`。不为主干或变体创建空的 `examples/` 目录,也不为了多个标签复制实现。跨领域协同通过 tags、流程说明和集成契约表达。 + +## 机器可读约定 + +| 数据 | 当前版本 | 关键字段 | +| --------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------- | +| [domain-groups.json](../catalog/domain-groups.json) | 1 | groups 中每项包含 id、serviceModel、title、summary | +| [domains.json](../catalog/domains.json) | 3 | domains 中每项增加 groupId,与所属组的 serviceModel 一致 | +| [blueprints.json](../catalog/blueprints.json) | 1 | id、serviceModel、domain、title、summary、roles、entities、workflow、pages、extensionPoints | +| [variants.json](../catalog/variants.json) | 1 | id、blueprintId、title、summary、industries、adaptations | +| [cases.json](../catalog/cases.json) 与 case.json | 3 | 在分类与状态之外声明 caseKind、blueprintId、variantId;manifest 用 evidence 引用本案例内的证据文件 | + +所有业务数组必须有具体内容,不能用“待补充”“通用流程”凑数。`workflow` 按操作顺序描述业务步骤;`adaptations` 应至少说明可观察的字段、状态、权限、规则或集成差异,而非只把标题换成一个行业名。ID 保持稳定,文案改进不应破坏已有案例关联。 + +这些 JSON 是结构化设计资料。`adaptations` 目前是差异说明,不是可执行配置补丁;关联主干不会自动生成代码、数据库、权限或页面,也不会自动同步已有案例。改变主干业务语义时,应检查所有关联变体与案例,说明哪些差异需要进入实现。 + +## 从主干落到实现 + +1. 先根据主要业务对象选择领域,按 [分类边界](domains.md) 判断是否已有合适主干。 +2. 复用主干的共通角色、实体、流程和页面,记录哪些内容不在本次范围内。 +3. 选择或新增有实质差异的场景变体。行业名称是辅助标签,不能替代差异设计。 +4. 在实际案例文档中把差异展开成字段约束、状态转换、权限矩阵、失败处理与验收条件。 +5. 实现必要代码和适配器,提供可复现的运行方式,再记录实际验证结果。 +6. 同步 manifest、catalog、能力声明和证据;设计覆盖率、代码完成度、外部集成与客户确认分别说明。 + +同一主干允许多个变体和多个案例,但案例实现范围可以只是主干的一个明确子流程。没有实现的能力要写入不支持范围;不要因继承一个广义 MES、ERP 或 CRM 主干就宣称拥有完整产品能力。分类数量也不要求每个领域立即拥有主干或案例:本轮 60 个 SaaS 领域中只有 22 个已写主干,其余保留分类边界,后续按真实需求补充。 + +共通扩展点应面向业务契约,例如身份、仓库、支付、资产和通知。具体部署采用的数据库、登录方式和资产平台在案例的 integrations.md 中决定。当前工厂案例通过 IdentityProvider、OperationsRepository、AssetStore 分离身份、业务和资产,并提供组合和替换入口;可复用约定见 [数据提供方契约](data-provider-contract.md)。这个实现经验不表示其他主干已经具备同样的代码或集成。 + +## 三个已有案例如何复用 + +| 案例 | 关联关系 | 已有实现与设计边界 | +| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Persona Studio](../examples/saas/dam/character-material-library/README.md) | dam-content-assets → digital-human-content-production → character-material-library | 参考实现、prototype;人物素材与制作审核闭环可运行,生成相关能力保留模拟声明。主干中的企业资产平台和真实生成扩展点不能据此标记 connected。 | +| [MUSE 缪思](../examples/saas/dam/brand-digital-human-studio/README.md) | dam-content-assets → digital-human-content-production → brand-digital-human-studio | 参考实现、prototype;合成品牌素材、候选确认、模拟制作与审核可运行。真实模型适配只有软件测试,未验证账号、质量或客户交付。 | +| [工厂运营工作台](../examples/saas/mes/factory-operations/README.md) | mes-production-operations → garment-workshop-consumables → factory-operations | 参考实现、integrated;已实现车间耗材与断针上报、审批配送、回收闭环和销售拜访扩展,并有真实数据服务验证。没有完整生产排程、报工、库存扣减或客户验收声明。 | + +工厂变体要求断针完成前确认回收;装配工位变体要求序列号、合格数量和工序交接。这是业务差异,不应通过复制同一页面再改标题表示。数字人变体关注人物授权和制作任务,品牌素材库变体关注品牌、区域、渠道与授权到期;两者可共用资产版本、审核和引用关系设计。 + +## 主干覆盖清单 + +下列全部是参考设计。每个主干具有两个差异变体,具体角色、实体、流程、页面与扩展点保存在 JSON 中;上表三个参考实现关联到其中两个主干与变体组合。 + +| 主干 ID | 标题 | 领域 | 两个场景变体 | +| --------------------------------- | ---------------------- | ----------- | ---------------------------------------------- | +| `crm-sales-pipeline` | 客户与商机推进 | `crm` | 工业客户拜访与长周期商机;专业服务项目商机 | +| `service-request-resolution` | 客户请求与服务闭环 | `service` | 企业产品技术支持;零售交易售后 | +| `marketing-campaign-delivery` | 活动策划与内容交付 | `marketing` | 企业展会营销;消费品牌新品传播 | +| `commerce-store-operations` | 店铺交易与售后运营 | `commerce` | 连锁门店运营;企业客户订货门户 | +| `finance-settlement-control` | 应收应付与结算协同 | `finance` | 经销回款与账龄;采购应付与付款计划 | +| `hrm-employee-lifecycle` | 员工入转调离 | `hrm` | 办公员工入职与调动;多工厂员工入转调离 | +| `lms-training-compliance` | 培训分配与考核 | `lms` | 岗位安全培训与复训;客户产品培训 | +| `scm-supply-exception-control` | 供需协同与交付异常 | `scm` | 订单驱动制造供需协调;季节性零售供应协同 | +| `srm-supplier-qualification` | 供应商准入与绩效 | `srm` | 关键物料供应商准入;外包服务商管理 | +| `wms-warehouse-execution` | 仓内收发与盘点 | `wms` | 批次效期仓储;设备备件仓储 | +| `tms-shipment-dispatch` | 运输调度与签收结算 | `tms` | 区域门店配送;跨境货运协同 | +| `mes-production-operations` | 生产现场执行与追溯 | `mes` | 服装车间断针与耗材申领;装配工位报工与异常追踪 | +| `plm-engineering-change` | 产品基线与工程变更 | `plm` | 装备设计变更;消费品新品开发 | +| `qms-quality-corrective-action` | 检验与质量纠正闭环 | `qms` | 来料检验与供应商整改;制程质量异常与围堵 | +| `eam-maintenance-work-orders` | 设备保养与维修工单 | `eam` | 工厂设备维修与预防保养;分布式站点设备维护 | +| `dam-content-assets` | 数字内容资产与审核复用 | `dam` | 企业数字人内容制作;多品牌营销素材库 | +| `cms-knowledge-publishing` | 内容审核与知识发布 | `cms` | 内部服务知识库;产品文档与多版本发布 | +| `oa-office-requests` | 行政申请与审批履约 | `oa` | 办公用品申请与领取;访客与会议接待 | +| `projects-delivery-coordination` | 项目任务与交付协作 | `projects` | 软件版本交付;客户实施交付 | +| `erp-order-resource-coordination` | 订单到资源的跨部门协同 | `erp` | 贸易分销订单协同;项目型制造资源协同 | +| `itsm-service-restoration` | IT 请求与事件处置 | `itsm` | 员工 IT 服务台;生产系统事件与变更 | +| `analytics-business-review` | 经营指标与异常复盘 | `analytics` | 销售经营复盘;工厂经营与执行分析 | + +## 来源、案例性质与证据 + +`caseKind` 与 `status` 表达不同事实: + +- `reference-implementation`:为解释或验证设计而构建的实现,可以有真实集成;不能自动称为已交付客户项目。 +- `project-case`:来自具体项目实践的案例,需要在案例中交代来源,并用非空 evidence 指向可访问的项目实施或验证记录。公开内容仍须使用抽象或授权披露的资料。 +- `status`:继续使用 proposal、specified、prototype、integrated、verified 表达交付成熟度。是否客户确认另看 `source.customerConfirmed`,不能用 integrated 代替验收。 +- `capabilities[].mode`:逐项区分 local、mock、unavailable、connected。目录设计中的扩展点、第三方宣传能力和本地模拟行为都不构成真实连接证据。 + +官方产品资料只用于核对常见业务能力与分类边界。它们不证明本库使用了厂商产品,也不证明某个客户提出或确认了这些设计。官方来源及核对范围集中记录在 [领域分类](domains.md#依据与来源范围);主干与变体的中文流程由本库综合编写,不复制厂商案例,也不使用真实客户名称包装参考设计。 + +来自实际讨论的需求只能发布已抽象的业务内容。私人听记链接、逐字稿、真实账号、项目标识、访问凭证和签名文件地址不得进入目录数据。样例数据使用合成内容;真实集成验证只发布环境类型、操作步骤、结果和必要的去标识证据。 + +完整新增与成熟度要求见 [案例规范](case-standard.md)。关系、数据结构与路径由 `npm run validate` 检查;交互行为、真实后端权限和持久化仍需要各案例自己的检查与证据。通过分类校验不等于业务流程已实现。 diff --git a/docs/case-standard.md b/docs/case-standard.md index e4c40f2..0ac9a60 100644 --- a/docs/case-standard.md +++ b/docs/case-standard.md @@ -1,6 +1,14 @@ -# 工作台案例规范 v1 +# 工作台案例规范 v3 -本规范用于记录可复用的业务案例。案例标识与本仓库 schemas 一起演进,不替代产品或 SaaS 的接口契约。 +本规范用于记录可复用的工作台案例。案例标识与本仓库 schemas 一起演进,不替代业务产品、平台或基础设施的接口契约。 + +## 分层与目录 + +分类按“服务模型 → 业务族 → 领域”组织,复用关系按“标准主干 → 场景变体 → 案例实现”组织。代码目录保留 `examples////`。`serviceModel` 从 [服务模型字典](../catalog/service-models.json) 选择 `saas`、`paas` 或 `iaas`,`domain` 必须属于该服务模型下的 [领域字典](../catalog/domains.json),其 `groupId` 引用同一服务模型的业务族。一个案例只有一个主路径,跨领域使用 tags 发现,不复制案例。 + +服务模型描述工作台面向的目标能力:SaaS 业务应用、PaaS 开发与运行平台、IaaS 基础设施资源。这是案例库的分类维度,不表示仓库代码已作为托管服务交付。部署在本机、自托管或公共云,以及使用哪种数据库、身份系统和资产存储,均独立记录在 `integrations.md`。 + +例如工厂运营工作台归 `saas/mes/factory-operations`,销售拜访通过 `crm` 标签关联;使用 Supabase 后端不会将该业务工作台改归 PaaS。详细分类和边界见 [领域分类](domains.md)。没有真实案例的分类只保留字典条目,不创建空案例目录。 ## 最小交付 @@ -20,7 +28,18 @@ ## 元信息 -`case.json` 按 `schemas/case.schema.json` 校验,包含稳定 id、中文标题、summary、主领域、tags、status、来源、数据模式、交付物路径、运行声明与逐项能力声明。catalog 必须与案例 manifest 完全一致。ID 全仓库唯一,文件路径使用仓库内相对路径。 +`case.json` 的 `version` 为 `3`,按 `schemas/case.schema.json` 校验,包含稳定 id、中文标题、summary、serviceModel、主领域 domain、blueprintId、variantId、caseKind、tags、status、来源、数据模式、交付物路径、运行声明、逐项能力声明与 evidence。catalog 中的 id、标题、summary、serviceModel、domain、blueprintId、variantId、caseKind、tags 和 status 必须与案例 manifest 完全一致;path 必须为 `examples///`。案例 ID 全仓库唯一。 + +`blueprintId` 引用标准主干,其服务模型和领域必须与案例一致;`variantId` 引用该主干下的场景变体。主干记录稳定的角色、业务对象、流程、页面与扩展点;变体记录行业和差异,不重复复制代码。参考设计可以没有实现,不计入案例数量。完整关系见 [案例资产模型](case-assets.md)。 + +`caseKind` 与交付状态独立: + +- `reference-implementation`:用于说明、研究、演示或技术接入验证的参考实现。 +- `project-case`:来自真实项目,至少提供一条可公开的脱敏项目证据。收录者核对项目来源、发布范围和证据真实性;技术验证记录本身不证明客户交付。 + +`evidence` 是 `{title, path}` 数组,path 相对于案例目录;项目案例至少一条,路径必须落在案例目录内,文件必须真实存在。自动校验不代替来源和授权审查。现有 Persona Studio、MUSE 和工厂运营工作台均归参考实现,分别保留模拟流程与真实服务接入的验证材料。 + +`deliverables` 与 `runtime.entry` 使用相对于案例目录的路径,必须落在该案例目录内。分类变更应同步移动目录、修改 catalog 和 manifest、修复文档链接与测试路径;npm workspace 目录约定为 `examples/*/*/*/workbench`。从 v2 迁移时为现有案例补充主干、变体、类型和证据关联,不推断此前没有证明的客户交付。 能力 mode: @@ -45,8 +64,10 @@ ## 演示与验证 +台账整包交换可参考 [台账交换契约](ledger-exchange.md)。通用 schema 只校验结构;工厂案例已提供同工作区恢复、原子提交适配和明确标注的拍照模拟草稿,具体测试及未验证的部署范围见案例证据。其他案例不能据此声明已支持导入或 OCR。数据一致性与来源标注的检查示例见 [factory-smart-ops 复核](factory-smart-ops-review.md)。 + - 运行步骤从干净检出开始,明确依赖、启动 URL、外部调用与重置方法。 - 演示数据可重复加载,使用虚构人物、商品与素材,不依赖私人账号。 - 验收覆盖主流程、空结果、失败状态和关键业务约束。实现了高影响行为才增加针对性自动验证。 - 原型在业务页面直接显示用户需要知道的运行限制,不把内部架构术语塞进业务流程。 -- `npm run check` 校验 schemas、索引、路径、分类、数据和业务约束。它不能证明模型质量、SaaS 权限、媒体授权的法律充分性或客户接受程度。 +- `npm run check` 校验 schemas、索引、路径、分类、资产引用、证据文件、数据和业务约束;交互修改还需运行 `npm run test:e2e`。这些检查不能证明模型质量、真实服务权限、证据来源真实性或客户接受程度。 diff --git a/docs/data-provider-contract.md b/docs/data-provider-contract.md new file mode 100644 index 0000000..8b369f0 --- /dev/null +++ b/docs/data-provider-contract.md @@ -0,0 +1,91 @@ +# 工作台数据提供方契约 + +工作台以业务模型组织页面,以三个独立端口访问外部服务。部署者选择数据库、身份系统和资产平台;Supabase 是一个可运行适配器,不是案例格式或业务模型的必需依赖。 + +当前参考实现为 [工厂运营工作台](../examples/saas/mes/factory-operations/README.md)。准确的 TypeScript 类型以 [contracts.ts](../examples/saas/mes/factory-operations/workbench/src/data/contracts.ts) 为准。此契约是该案例的 v1 接入约定,尚未承诺所有未来案例使用完全相同的业务实体。 + +## 三个端口 + +| 端口 | 责任 | 关键操作 | +| ---------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `IdentityProvider` | 当前身份、登录注册、退出、身份变更通知 | `currentUser`、`signIn`、`signUp`、`signOut`、`onChange` | +| `OperationsRepository` | 工作区与成员、申领与时间线、销售拜访 | `listWorkspaces`、`createWorkspace`、`listMembers`、`addMember`、`listReports`、`createReport`、`transitionReport`、`confirmRecovery`、`listTimeline`、`listVisits`、`createVisit` | +| `AssetStore` | 资产列表、文件上传、授权读取 | `list`、`upload`、`signedUrl` | + +页面依赖 `DataServices`,不导入 Supabase 客户端或直接发出 SQL。三个端口可以分别实现和组合,例如企业身份服务 + MySQL 业务 API + 现有资产平台;组合时应由服务端统一识别用户 ID、工作区 ID 和授权语义。把三个对象拼在一起并不会自动建立不同服务间的信任或 token 交换。 + +内置配置工厂提供 Supabase 与 HTTP 两套完整组合。若需混合提供方,用 [data/index.ts](../examples/saas/mes/factory-operations/workbench/src/data/index.ts) 的 `composeDataServices` 注入遵守这些端口的实现,或通过 `registerDataProvider` 注册新组合,并完成相同权限与失败测试。当前配置界面选择一套组合,不提供三套地址的任意拼接向导。 + +## 配置与状态 + +- `VITE_DATA_PROVIDER=supabase`:使用 `VITE_SUPABASE_URL` 与 `VITE_SUPABASE_PUBLISHABLE_KEY`。 +- `VITE_DATA_PROVIDER=http`:使用 `VITE_API_BASE_URL`。 +- 用户也可在连接设置中选择提供方,当前浏览器保存公开配置并覆盖部署默认;“使用部署默认配置”清除覆盖。 +- 切换提供方后使用该服务的账号登录。切换配置不会复制、迁移或删除原服务中的数据。 +- 数据库密码、管理员密钥、`service_role` 和 Secret key 不属于浏览器配置。MySQL 连接参数留在企业服务端。 +- 配置成功只表示字段有效;只有通过实际认证、读写和授权文件访问检查,才能把具体部署记录为 `connected`。 + +## HTTP 接入 v1 + +参考客户端为 [http.ts](../examples/saas/mes/factory-operations/workbench/src/data/http.ts)。`VITE_API_BASE_URL` 可带 `/api/workbench` 等前缀;下表路由附加在该地址之后。返回业务对象直接使用 `contracts.ts` 的 camelCase 字段,不再包一层 `data`。 + +| 方法与路径 | 请求 | 成功响应 | +| ------------------------------- | ----------------------------------------------- | ------------------------------------------ | +| `GET /session` | 无 | `UserIdentity`;未登录可返回 `null` 或 401 | +| `POST /auth/sign-in` | `{ email, password }` | `UserIdentity` | +| `POST /auth/sign-up` | `{ email, password }` | `{ user, needsEmailConfirmation }` | +| `POST /auth/sign-out` | 无 | 204 | +| `GET /workspaces` | 无 | `Workspace[]`,仅当前用户所属工作区 | +| `POST /workspaces` | `{ name }` | `Workspace`,当前用户为所有者 | +| `GET /workspaces/:id/members` | 无 | `WorkspaceMember[]` | +| `POST /workspaces/:id/members` | 仅所有者操作,`{ email, role }`,不接受 `owner` | 204 | +| `GET /workspaces/:id/reports` | 无 | `OperationReport[]` | +| `POST /workspaces/:id/reports` | `ReportInput` | `OperationReport` | +| `POST /reports/:id/transitions` | `TransitionInput` | 最新 `OperationReport` | +| `POST /reports/:id/recovery` | `RecoveryInput` | 最新 `OperationReport` | +| `GET /reports/:id/timeline` | 无 | `TimelineEntry[]` | +| `GET /workspaces/:id/visits` | 无 | `SalesVisit[]` | +| `POST /workspaces/:id/visits` | `VisitInput` | `SalesVisit` | +| `GET /workspaces/:id/assets` | 无 | `AssetRef[]` | +| `POST /workspaces/:id/assets` | `multipart/form-data`,文件字段名 `file` | `AssetRef` | +| `POST /assets/:id/download-url` | 无 | `{ url }`,短时授权地址 | + +客户端每次发送 `Accept: application/json`、`X-Requested-With: FactoryOperations` 和 `credentials: include`。JSON 请求设置 `Content-Type: application/json`;上传请求由浏览器生成 multipart boundary。身份服务需设置 HttpOnly 会话 Cookie,后端同时验证 Origin 与 CSRF 条件。跨域部署需明确允许前端 Origin 和 credentials,不得使用通配符 Origin 配合凭据。 + +错误返回非 2xx 状态与 `{ "message": "面向用户的错误说明" }`。401 表示未登录,403 表示无权限,409 表示版本冲突。不要返回 SQL、数据库连接串或内部异常堆栈。 + +## 服务端必须保证的行为 + +1. **授权**:身份由服务端会话确定,工作区成员与角色由可信数据读取;不能相信请求中的用户身份、工作区 ID 或前端按钮状态。只有所有者可添加成员;拜访由所有者/主管查看全工作区,销售只查看本人记录,操作员不可读取。 +2. **业务状态机**:服务端校验允许的状态迁移、角色、备注、版本和断针回收条件。仅更新数据库状态字段不满足契约。 +3. **并发和幂等**:状态操作带 `expectedVersion` 和 `requestId`,同时通过 `Idempotency-Key` 请求头传递请求 ID。对同一报告的状态、版本和事件做原子更新;重复请求不得追加重复事件。请求 ID 复用于不同内容时应拒绝。 +4. **引用完整性**:附件必须存在、属于当前工作区并可由当前用户访问。报告不允许挂入其他租户的资产;断针至少关联一张 JPG、PNG 或 WebP 照片。 +5. **资产**:文件正文进入受控资产存储;数据库保留稳定 ID、对象路径、类型、大小和时间。校验文件类型/大小,不把短时 URL 作为长期引用。 +6. **输入和错误**:服务端再次校验输入;客户端校验只用于交互。失败不能写半条状态或返回虚假成功。 + +## 可选台账交换能力 + +`DataServices.exchange?: LedgerExchange` 在三个基础端口之外提供工作区快照、预览和原子发布;类型见 [exchange-contracts.ts](../examples/saas/mes/factory-operations/workbench/src/data/exchange-contracts.ts)。组合提供方可注入自己的交换服务。它协调业务库与资产引用,不改变三个基础端口的职责,也不要求使用 Supabase。 + +| 方法与路径 | 请求 | 成功响应 | +| ------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------- | +| `GET /workspaces/:id/ledger/source` | 无 | `DatasetState`:工作区名称、revision、来源、存储范围、识别模式 | +| `GET /workspaces/:id/ledger/export` | 无 | `LedgerPackage`:一致快照与资产清单 | +| `POST /workspaces/:id/ledger/preview` | `{packageText, mode: "replace", replaceScope}` | `ImportPreview`:计划、目标版本、表数量、错误与警告 | +| `POST /workspaces/:id/ledger/commit` | `{planId, expectedRevision, requestId}` | `ImportResult`:已发布版本、来源与数量 | + +`packageText` 是完整 JSON 原文;服务端在解析为丢失重复键的普通对象前检查重复键。提交的 `requestId` 同时放入 `Idempotency-Key`。客户端不自动重试失败写入;响应丢失后显式重试沿用相同请求 ID,服务端对相同计划返回原结果,复用为不同内容时报冲突。 + +工厂 v1 仅所有者可导出、预览及提交,拒绝跨工作区数据包、未登记或不匹配的资产,以及包内业务引用未声明的资产;资产清单可以是当前已登记资产的子集,恢复保留全部当前资产和范围外行的合法引用;服务端校验最终候选库,以工作区 revision 和一次事务发布。原生写入也要参与相同版本与锁顺序。预览失败返回逐表/行/字段错误,提交权限不足为 403、版本冲突为 409,不能返回虚假成功。无此能力或尚未部署路由时显示未支持/请求失败,不能用普通列表拼接完整备份或循环删写记录。 + +完整范围、来源与资产边界见 [台账交换契约](ledger-exchange.md)。现有 HTTP 客户端仅通过调用契约测试;企业实现须再验证权限、并发、事务失败和文件读取。工厂实现支持同工作区恢复,资产正文仍在原平台;既非跨提供方备份迁移,也不包含已接通的 `/api/vision/ledger` 服务。 + +## MySQL 与现有资产平台 + +企业可用任意服务端技术实现上述 HTTP API。将 `OperationsRepository` 对应实体映射为 MySQL 表和事务,把 `AssetStore` 映射到现有资产平台,把 `IdentityProvider` 映射到账号或企业身份系统。浏览器不承担数据库连接池、SQL 执行或资产服务管理员身份。 + +当前仓库交付 HTTP 客户端与接口约定;不随附 MySQL 后端服务或企业资产平台服务器。部署者实现接口、联调并记录证据之前,MySQL 能力保持 `unavailable`。迁移到其他数据库还需设计数据导入、ID 映射和资产迁移;仅修改提供方配置不会完成迁移。 + +## 新提供方验收 + +使用合成数据检查登录与退出、工作区隔离、全部角色限制、真实写入与刷新回读、状态并发冲突、幂等重试、附件上传和越权拒绝。参考案例 [acceptance.md](../examples/saas/mes/factory-operations/acceptance.md)。通过软件契约测试只说明接口调用符合预期;外部服务仍需自己的集成证据。 diff --git a/docs/domains.md b/docs/domains.md index a4c8af9..9ec9253 100644 --- a/docs/domains.md +++ b/docs/domains.md @@ -1,30 +1,238 @@ -# SaaS 与行业场景分类 - -主分类回答“这项工作主要管理什么业务对象”。每个案例只有一个主领域,跨领域通过 tags 描述。领域目录仅在有实际案例时创建。以下候选方向是分类说明,不代表已交付。 - -| ID | 领域 | 主要业务对象 | 候选工作台 | -| --------- | ------------------ | -------------------------- | ---------------------------- | -| crm | 客户与销售 CRM | 客户、联系人、商机、跟进 | 销售跟进、客户画像、商机推进 | -| erp | 企业资源 ERP | 订单、采购、库存、资源核算 | 采购协同、订单履约、经营管理 | -| mes | 制造执行 MES | 工单、工序、在制品、产线 | 排产、生产异常、质量追溯 | -| wms | 仓储物流 WMS | 库位、出入库、盘点、拣配 | 仓库运营、盘点、拣货 | -| scm | 供应链与供应商 SCM | 供应商、供应计划、交付 | 供应商协同、供需计划 | -| hrm | 人力资源 HRM | 员工、招聘、入职、绩效 | 招聘、入职、人员服务 | -| finance | 财务与费用 | 应收应付、费用、预算 | 回款、报销、费用控制 | -| oa | 办公与审批 OA | 审批单、通知、办公申请 | 审批处理、行政服务 | -| itsm | IT 服务与运维 ITSM | 服务单、设备、事件、变更 | 服务台、设备管理、故障处理 | -| projects | 项目与研发协作 | 项目、任务、需求、交付 | 项目进度、研发交付 | -| analytics | 数据分析 BI | 指标、数据集、分析结果 | 经营指标、异常分析 | -| dam | 数字资产管理 DAM | 人物、图片、视频、素材版本 | 人物素材资产库、品牌素材库 | -| cms | 内容与知识管理 CMS | 内容、知识条目、发布版本 | 内容编辑、知识维护 | -| marketing | 营销与内容制作 | 活动、创意、渠道、制作任务 | 创意生产、投放素材、营销计划 | -| commerce | 电商与零售 | 商品、门店、交易、售后 | 商品运营、门店运营 | -| service | 客户服务 | 服务请求、知识、反馈、工单 | 售后服务、客服质检 | - -## 分类边界 - -- CRM 关注商机与客户关系;service 关注受理和解决服务请求。 -- ERP 涉及整体资源与订单;MES 关注生产执行;WMS 关注仓内作业。 -- DAM 关注资产沉淀、检索、版本与使用范围;marketing 关注活动与制作交付。人物素材资产库归 DAM,带 `marketing`、`video-generation`、`digital-human`、`virtual-try-on` 标签。 -- 行业、角色、技术、部署方式都用 tags,如 `manufacturing`、`sales-manager`、`dingtalk`、`self-hosted`,不复制同一案例到多个主目录。 -- 业务分类不决定接入哪个 SaaS 厂商。同一业务案例可以有多个明确记录的连接器。 +# 工作台分层分类 + +目录按 **服务模型 → 业务族 → 领域** 定位能力,在领域中按 **标准主干 → 场景变体 → 实际案例** 查找可复用设计与实现。行业、角色和部署方式是独立信息,不为每种组合复制一个分类或空案例。 + +SaaS、PaaS、IaaS 的概念依据应用、平台与基础设施的职责划分。微软官方说明分别以业务应用、应用开发运行平台,以及计算、存储和网络资源描述这三层。参见 [Microsoft Azure:IaaS、PaaS 与 SaaS](https://azure.microsoft.com/en-us/resources/cloud-computing-dictionary/what-are-iaas-paas-and-saas)。 + +本库使用这三个名称标记工作台的**目标能力层**,不表示源码模板已被托管为云服务。部署在本机、自托管或公共云,以及采用哪个身份、数据库和资产提供方,独立于业务分类。下面的 **60 个 SaaS 领域、8 个 PaaS 领域、5 个 IaaS 领域** 是本库为发现与复用制定的组织约定,既不是官方完整分类标准,也不是已交付产品数量。 + +## 分类与复用数据 + +| 文件 | 当前内容 | 作用 | +| ----------------------------------------------------- | --------------------------------------------- | -------------------------------------------- | +| [service-models.json](../catalog/service-models.json) | 3 个服务模型 | 标记目标应用、平台或基础设施层 | +| [domain-groups.json](../catalog/domain-groups.json) | 12 个业务族:SaaS 10 个,PaaS 与 IaaS 各 1 个 | 组织同类业务能力,便于浏览 | +| [domains.json](../catalog/domains.json) | 73 个领域,版本 3 | 定义工作台的主要业务对象与边界 | +| [blueprints.json](../catalog/blueprints.json) | 22 个标准主干 | 保存角色、实体、流程、页面和扩展点的参考设计 | +| [variants.json](../catalog/variants.json) | 44 个场景变体,每个主干 2 个 | 说明行业或业务情境带来的具体差异 | +| [cases.json](../catalog/cases.json) | 3 个实际代码案例 | 索引实现、交付状态和运行证据 | + +业务族 ID 与领域 ID 全局唯一;每个领域只归属一个服务模型和一个业务族,二者的服务模型必须一致。案例路径仍为 `examples////`;业务族与复用关系通过目录数据表达,不增加重复目录层。主干和变体不自带运行声明,关系和新增方式见 [案例复用资产](case-assets.md)。 + +## 一级服务模型 + +| ID | 分类 | 主要使用者 | 关注的对象 | +| ------ | --------------------- | -------------------- | -------------------------------- | +| `saas` | SaaS · 业务应用 | 业务人员与业务管理者 | 客户、订单、人员、内容及业务流程 | +| `paas` | PaaS · 开发与数据平台 | 开发、平台与数据团队 | 应用构建、运行、集成和数据能力 | +| `iaas` | IaaS · 基础设施 | 基础设施与运维团队 | 计算、存储、网络、集群和备份资源 | + +## SaaS:10 个业务族、60 个领域 + +业务族把相邻能力放在一起,领域按主要业务对象和操作闭环划分。保留原有 22 个领域 ID,扩展为 60 个;不把 HRM/HCM 等近义名称拆成两类。ERP、SCM 这样的跨流程领域与专门子流程并存,按下文边界选择。 + +| 业务族 ID | 业务族 | 领域数 | 主要范围 | +| ----------------------- | ------------------ | ------ | ---------------------------------------------------- | +| `customer-revenue` | 客户与收入 | 7 | 从销售机会到客户服务,管理客户关系、报价、订阅与续约 | +| `marketing-commerce` | 营销与商业运营 | 7 | 管理触达、客群、商品表达、会员和交易履约 | +| `finance-control` | 财务与经营控制 | 7 | 管理收付、核算、资金、费用、税务与经营计划 | +| `people-learning` | 人力与学习 | 6 | 管理人员关系、招聘、出勤、薪酬、人才发展和培训 | +| `supply-logistics` | 采购与供应物流 | 8 | 管理供需、供应商、采购、库存、仓储、运输和贸易 | +| `product-manufacturing` | 产品与生产 | 6 | 管理设计版本、排程、现场执行、质量、安全与实验室 | +| `asset-facilities` | 资产与空间 | 4 | 管理设备、设施、车辆和不动产的运营生命周期 | +| `content-collaboration` | 内容与协作 | 6 | 管理数字内容、知识、办公流程、项目、组合和会议行动 | +| `governance-legal` | 治理与法务 | 5 | 管理风险、合同、审计、隐私与可持续披露证据 | +| `enterprise-it` | 企业协同与信息服务 | 4 | 管理跨部门资源协同、IT服务、经营分析和业务主数据 | + +### 客户与收入 + +从销售机会到客户服务,管理客户关系、报价、订阅与续约。 + +| ID | 领域 | 收录边界 | +| ------------------ | ------------------ | -------------------------------------------------------------- | +| `crm` | 客户与销售 CRM | 客户、联系人、商机与销售跟进;聚焦成交前的关系和机会推进 | +| `cpq` | 配置报价 CPQ | 产品选配、报价版本、折扣授权与报价审批;报价获批后交接订单 | +| `customer-success` | 客户成功 | 客户健康度、上线里程碑、价值回顾与续约风险;区别于被动服务工单 | +| `partners` | 渠道与伙伴管理 PRM | 伙伴准入、商机报备、联合销售与激励;区别于供应商采购关系 | +| `subscription` | 订阅管理 | 订阅套餐、权益、续订、升降级与计费事件;收入确认归财务 | +| `service` | 客户服务 | 客户请求、问题分派、服务时限与解决反馈;上门履约交现场服务 | +| `field-service` | 现场服务 FSM | 客户现场派工、技师技能、备件与服务回执;区别于自有设备保养 | + +### 营销与商业运营 + +管理触达、客群、商品表达、会员和交易履约。 + +| ID | 领域 | 收录边界 | +| ------------------ | ------------------ | ------------------------------------------------------------ | +| `marketing` | 营销与内容制作 | 活动计划、创意制作、渠道投放与线索归因;资产长期治理归 DAM | +| `commerce` | 电商与零售 | 线上店铺、门店交易、促销执行与交易售后;跨渠道履约归订单管理 | +| `cdp` | 客户数据与客群 CDP | 营销身份合并、同意记录、客群圈选与触达激活;区别于销售跟进 | +| `loyalty` | 会员与忠诚度 | 会员等级、积分账本、权益发放与核销;区别于付费订阅权益周期 | +| `pim` | 商品信息管理 PIM | 商品属性、渠道文案、语言版本与上架完整度;工程设计版本归 PLM | +| `pricing` | 价格与促销策略 | 价目表、渠道价格、促销规则与生效期;单笔报价审批归 CPQ | +| `order-management` | 订单管理 OMS | 订单接收、可承诺量、分单履约、退换与异常协调;区别于仓内作业 | + +### 财务与经营控制 + +管理收付、核算、资金、费用、税务与经营计划。 + +| ID | 领域 | 收录边界 | +| -------------------- | ------------------ | ---------------------------------------------------------- | +| `finance` | 财务与费用 | 应收应付、结算核销与跨部门费用协同;专门报销流程归差旅费用 | +| `expense` | 差旅与费用管理 | 出差申请、费用凭证、标准校验、报销审批与付款交接 | +| `treasury` | 资金管理 | 银行账户、现金头寸、资金预测与付款计划;区别于业务应收应付 | +| `tax` | 税务管理 | 税种规则、税额核对、申报底稿与税务资料归档;区别于总账记账 | +| `accounting` | 会计与合并核算 | 会计凭证、总账、关账、内部抵销与合并报表 | +| `epm` | 预算与绩效计划 EPM | 预算版本、滚动预测、经营目标与实际偏差;区别于历史报表分析 | +| `revenue-management` | 收入确认管理 | 履约义务、交易价格分摊、收入计划与确认记录;区别于订阅计费 | + +### 人力与学习 + +管理人员关系、招聘、出勤、薪酬、人才发展和培训。 + +| ID | 领域 | 收录边界 | +| ------------ | -------------------- | -------------------------------------------------------------- | +| `hrm` | 人力资源 HRM | 组织、岗位、员工主档与入转调离;招聘、薪酬和考勤由专门领域承接 | +| `recruiting` | 招聘管理 ATS | 招聘需求、候选人、面试评价、录用与入职交接 | +| `workforce` | 劳动力与考勤管理 WFM | 班次、排班、工时、考勤、休假与异常核对;薪酬核算独立 | +| `payroll` | 薪酬管理 | 薪资项目、核算周期、薪税扣款、工资单与发放复核 | +| `talent` | 人才与绩效发展 | 绩效周期、目标、能力盘点、继任计划与发展行动;区别于组织主档 | +| `lms` | 学习与培训 LMS | 课程、学习任务、培训记录、考试与到期复训 | + +### 采购与供应物流 + +管理供需、供应商、采购、库存、仓储、运输和贸易。 + +| ID | 领域 | 收录边界 | +| ----------------- | ---------------- | ---------------------------------------------------------- | +| `scm` | 供应链管理 SCM | 跨供应、生产与交付的供需协调和异常处置;专门预测归需求计划 | +| `srm` | 供应商关系 SRM | 供应商准入、资质、关系分层与绩效;采购订单执行归采购管理 | +| `procurement` | 采购管理 | 请购、询比价、采购订单、收货对账与采购支出控制 | +| `inventory` | 库存管理 | 物料库存账、批次、可用量、调拨与补货;库位拣配归 WMS | +| `wms` | 仓储管理 WMS | 库位、上架、拣选、包装、出入库与盘点作业 | +| `tms` | 运输管理 TMS | 运单、承运商、运输调度、签收与运费;自有车辆保养归车队 | +| `trade` | 国际贸易管理 GTM | 贸易单证、商品归类、报关协作与进出口限制检查 | +| `demand-planning` | 需求与补货计划 | 需求预测版本、促销影响、安全库存与补货建议;现场排程归 APS | + +### 产品与生产 + +管理设计版本、排程、现场执行、质量、安全与实验室。 + +| ID | 领域 | 收录边界 | +| ------------ | ------------------ | ------------------------------------------------------ | +| `plm` | 产品生命周期 PLM | 产品定义、设计版本、物料清单、工程变更与发布基线 | +| `mes` | 制造执行 MES | 工单、工序、在制品、工位物料和现场异常的执行追溯 | +| `qms` | 质量管理 QMS | 检验计划、不合格品、质量异常与纠正预防措施 | +| `aps` | 高级计划与排程 APS | 产能约束、工序顺序、资源日历与排程方案;实际报工归 MES | +| `ehs` | 环境健康与安全 EHS | 危险源、作业许可、事故、隐患整改与职业健康记录 | +| `laboratory` | 实验室管理 LIMS | 样品接收、检测任务、仪器记录、结果复核与报告签发 | + +### 资产与空间 + +管理设备、设施、车辆和不动产的运营生命周期。 + +| ID | 领域 | 收录边界 | +| ------------- | ---------------- | ------------------------------------------------------------ | +| `eam` | 企业资产管理 EAM | 自有设备台账、巡检、保养、维修与退役;数字内容资产归 DAM | +| `facilities` | 设施与空间运营 | 办公空间、工位、设施报修、保洁和空间占用;租约归不动产 | +| `fleet` | 车队管理 | 自有车辆、驾驶员、用车、里程、维保与能耗;货运订单调度归 TMS | +| `real-estate` | 不动产与租赁运营 | 房产、租约、租金、到期续租与租赁履约;日常设施服务独立 | + +### 内容与协作 + +管理数字内容、知识、办公流程、项目、组合和会议行动。 + +| ID | 领域 | 收录边界 | +| ---------- | ------------------ | ---------------------------------------------------------- | +| `dam` | 数字资产管理 DAM | 人物、图片、视频与素材版本、授权范围和复用;知识发布归 CMS | +| `cms` | 内容与知识管理 CMS | 内容条目、知识文章、审核、发布版本与过期复审 | +| `oa` | 办公与审批 OA | 行政申请、审批、通知与办公服务;专业业务审批仍归对应领域 | +| `projects` | 项目与研发协作 | 单项目需求、任务、里程碑、风险与交付协作 | +| `ppm` | 项目组合管理 PPM | 项目提案、优先级、组合预算、资源容量与收益跟踪 | +| `meetings` | 会议与行动协同 | 议题、会议纪要、决议、责任人和行动闭环;日程订阅是可选集成 | + +### 治理与法务 + +管理风险、合同、审计、隐私与可持续披露证据。 + +| ID | 领域 | 收录边界 | +| --------- | ------------------ | ------------------------------------------------------------ | +| `grc` | 治理风险与合规 GRC | 风险登记、控制措施、合规义务与整改;独立审计取证归审计管理 | +| `clm` | 合同生命周期 CLM | 条款、合同版本、审签、履约义务与到期;财务结算归财务 | +| `audit` | 审计管理 | 审计计划、抽样、工作底稿、发现与整改复核 | +| `privacy` | 隐私运营管理 | 处理活动、主体请求、同意证据与保留删除任务;技术访问控制独立 | +| `esg` | 可持续与 ESG 管理 | 可持续指标、排放活动数据、证据审核与披露;现场隐患归 EHS | + +### 企业协同与信息服务 + +管理跨部门资源协同、IT服务、经营分析和业务主数据。 + +| ID | 领域 | 收录边界 | +| ----------- | ------------------ | -------------------------------------------------------------- | +| `erp` | 企业资源 ERP | 跨订单、采购、库存与财务的资源协同;单一专业流程优先归专门领域 | +| `itsm` | IT 服务与运维 ITSM | IT 服务请求、事件、问题、变更与配置关联;云资源控制归 IaaS | +| `analytics` | 数据分析 BI | 业务指标、报表、仪表盘与异常分析;数据采集治理平台归 PaaS | +| `mdm` | 主数据管理 MDM | 客户、物料等黄金记录、编码、匹配、审批与分发;区别于营销客群 | + +## PaaS:8 个平台领域 + +本层统一归 `platform-capabilities` 业务族,关注向其他应用提供的能力。使用托管数据库的业务应用仍按自身业务目标分类;只有管理数据库服务本身的工作台才归 `dbaas`。 + +| ID | 领域 | 收录边界 | +| --------------- | ------------------ | -------------------------------------- | +| `app-platform` | 应用开发与运行平台 | 应用构建、运行环境、发布与应用生命周期 | +| `dbaas` | 数据库服务 DBaaS | 托管数据库实例、连接、容量与数据库运维 | +| `baas` | 后端服务 BaaS | 应用身份、数据接口、函数与后端能力 | +| `integration` | 集成与接口平台 | 连接器、接口、事件与系统间数据交换 | +| `low-code` | 低代码开发平台 | 表单、数据模型、流程与应用编排 | +| `devops` | DevOps 与交付平台 | 代码集成、构建流水线、制品与发布环境 | +| `ai-platform` | AI 开发平台 | 模型接入、推理服务、评测与 AI 应用开发 | +| `data-platform` | 数据工程平台 | 数据采集、加工、编排、目录与治理 | + +## IaaS:5 个基础设施领域 + +本层统一归 `infrastructure-resources` 业务族,关注资源的配置、容量、连接与恢复。容器领域指集群、节点和配额等基础设施管理;容器中运行的业务应用不会自动归入该类。跨层平台仍按工作台主要管理对象判断。 + +| ID | 领域 | 收录边界 | +| ------------ | ------------------ | ---------------------------------------- | +| `compute` | 计算资源 | 虚拟机、计算规格、镜像与资源配额 | +| `storage` | 存储资源 | 对象、块与文件存储的容量、访问与生命周期 | +| `networking` | 网络资源 | 虚拟网络、子网、负载均衡与网络连接 | +| `containers` | 容器与集群基础设施 | 容器集群、节点、命名空间与基础资源配额 | +| `backup` | 备份与灾难恢复 | 备份策略、恢复点、恢复任务与灾备演练 | + +## 相邻领域的选择规则 + +- **综合协同与专门流程**:跨订单、采购、库存、财务的资源交接归 ERP;只做采购执行归采购管理。跨供应、生产、交付的异常协调归 SCM;单独做需求预测或排程分别归需求计划、APS。 +- **客户收入链**:CRM 管商机关系,CPQ 管单笔配置报价,定价管理管可复用价目与策略,OMS 管订单履约。订阅管计费与权益周期,收入确认管财务履约义务。客服管请求解决,现场服务管上门执行,客户成功管采用价值与续约风险。 +- **供应与物流**:SRM 管供应商关系和资格,采购管请购到采购订单,库存管理管库存账和可用量,WMS 管库位作业,TMS 管货运过程,车队管自有车辆生命周期。 +- **人力与财务**:HRM 保留员工主档与入转调离,招聘、考勤、薪酬、人才发展和学习各有独立闭环。财务与费用保留应收应付和结算协同,报销、资金、会计、税务、收入确认与预算计划分别按专门对象收录。 +- **设计与生产**:PLM 管设计基线,PIM 管面向销售渠道的商品表达,MES 管现场执行,QMS 管质量处置,LIMS 管样品检测过程;一个检验流程可引用实验室报告,不必复制成两个案例。 +- **资产与内容**:DAM 管可复用数字素材,EAM 管自有设备维护,设施管理管空间与现场服务,不动产管理管房产租约。IaaS 存储管存储资源;使用对象存储不改变 DAM 或 MES 分类。 +- **协作与治理**:项目协作管理一个项目的交付,PPM 管多个项目的优先级和容量。GRC 管持续风险控制,审计管独立检查与取证;合同管理管审签与履约义务。业务审批优先归业务领域,通用行政服务才归 OA。 +- **分析与平台**:BI 面向业务指标和分析行动,MDM 面向业务黄金记录与分发;PaaS 数据工程面向采集、加工、编排和治理技术。ITSM 管服务流程,DevOps 管构建交付平台,IaaS 管底层资源。 + +选择主领域时,以主要用户每天完成的核心任务为准;跨域能力用 tags 和集成关系说明。跨领域扩展只在需要独立业务闭环时拆成新案例,不按页面数、技术栈或数据库数量拆分。 + +## 现有实现的归属 + +| 实际案例 | 主干 → 变体 | 当前实现边界 | +| --------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| [Persona Studio](../examples/saas/dam/character-material-library/README.md) | `dam-content-assets` → `digital-human-content-production` | `saas/dam` 的本地原型;人物素材、制作与审核流程可体验,生成能力以案例中的模拟声明为准 | +| [MUSE 缪思](../examples/saas/dam/brand-digital-human-studio/README.md) | `dam-content-assets` → `digital-human-content-production` | `saas/dam` 的品牌数字人原型;公开模拟资产与制作审核流程,真实模型账号及效果未验证 | +| [工厂运营工作台](../examples/saas/mes/factory-operations/README.md) | `mes-production-operations` → `garment-workshop-consumables` | `saas/mes` 的车间耗材与断针上报、审批配送、回收追溯实现;销售拜访为 CRM 扩展,不代表完整 MES 排程、报工或库存系统 | + +这三个实现的交付状态与已连接能力以各自 manifest 和证据文件为准。标准主干的设计范围可以大于某个案例的实现范围,关联主干不会自动授予功能或成熟度。尚无实现的分类保留可浏览的参考设计或空状态,不创建空案例目录。 + +## 依据与来源范围 + +以下官方页面用于核对常见能力家族和相邻边界;本文的 10 个 SaaS 业务族、60 个领域以及所有主干和变体由本库重新组织和编写,不是厂商认可的标准、授权模板或客户交付证明。核对日期:2026-10-15。 + +- [SAP 产品目录](https://www.sap.com/products.html)与 [Oracle 应用覆盖范围](https://docs.oracle.com/en/cloud/saas/applications-common/scope.html):核对财务、人力、客户、供应链等常见企业应用家族。 +- [Oracle 供应链与制造](https://www.oracle.com/scm/):核对产品生命周期、计划、采购、制造、库存、订单和物流之间的功能范围。 +- [Oracle HCM](https://www.oracle.com/human-capital-management/):核对人力主档、人才、劳动力和薪酬的能力划分。 +- [Adobe 数字资产管理](https://business.adobe.com/products/experience-manager/assets/digital-asset-management.html):核对素材权限、授权、版本、审核和复用的管理对象。 +- [ServiceNow 产品分类](https://www.servicenow.com/products-by-category.html):核对 IT 服务、现场服务、项目组合和风险治理等相邻领域;[隐私管理](https://www.servicenow.com/products/privacy-management.html)补充处理活动与主体请求的运营范围。 +- [IBM 不动产与设施管理](https://www.ibm.com/products/maximo/real-estate-facility-management):核对租赁、空间、设施运营与维护的区别。 + +官方页面中的 AI、自动化、认证或服务承诺不继承到本库。每个实际案例仍需单独实现、配置和验证对应能力。 diff --git a/docs/evidence/catalog-assets-desktop.jpg b/docs/evidence/catalog-assets-desktop.jpg new file mode 100644 index 0000000..fc16f01 Binary files /dev/null and b/docs/evidence/catalog-assets-desktop.jpg differ diff --git a/docs/evidence/catalog-assets-detail.jpg b/docs/evidence/catalog-assets-detail.jpg new file mode 100644 index 0000000..d34c8f9 Binary files /dev/null and b/docs/evidence/catalog-assets-detail.jpg differ diff --git a/docs/evidence/catalog-assets-mobile.jpg b/docs/evidence/catalog-assets-mobile.jpg new file mode 100644 index 0000000..c49d760 Binary files /dev/null and b/docs/evidence/catalog-assets-mobile.jpg differ diff --git a/docs/evidence/catalog-assets.md b/docs/evidence/catalog-assets.md new file mode 100644 index 0000000..9fae038 --- /dev/null +++ b/docs/evidence/catalog-assets.md @@ -0,0 +1,29 @@ +# 案例资产目录验证 + +验证日期:2026-10-15(Asia/Shanghai)。 + +## 本轮范围 + +- SaaS 扩为 60 个领域、10 个业务族;保留 PaaS 8 类和 IaaS 5 类,共 73 个领域、12 个业务族。 +- 新增 22 个标准主干、44 个场景变体,每个主干含具体角色、业务对象、流程、页面及扩展点,每个变体记录行业与差异。 +- 两个现有实现关联到 DAM / 数字人内容制作、MES / 服装车间耗材;两者均为参考实现,项目案例数量为零。 +- 分类与案例 manifest 升级 v3,校验分类、主干、变体、实现、文件及证据之间的一致关系。 +- 首页提供业务族、领域、行业、关键词和资产类型筛选,并可打开主干、变体详情与关联材料。浏览目录不依赖账号或云数据库。 +- 本轮改动聚焦目录、分类数据和规范。现有工作台的身份、业务和资产提供方实现继续按各自接入声明运行。 + +## 已完成检查 + +- `npm ci` 成功,依赖审计未报告漏洞。 +- `npm run check` 全部通过:格式检查、两个工作台的 TypeScript / Vite 构建、结构校验、24 项仓库测试、37 项案例单测与 8 项 POC 测试。 +- `npm run validate` 通过:3 个服务模型、73 个领域、12 个业务族、22 个主干、44 个变体、2 个参考实现。 +- 分类与资产关系的 17 项测试通过,覆盖重复、孤儿、跨服务模型、跨领域、主干变体错配、manifest 漂移以及证据缺失、越界、空文件与模板内容。 +- 在实际内置浏览器走过 MES → 生产现场执行与追溯 → 服装车间断针与耗材申领 → 工厂实现的浏览链路,能查看业务规格、适配点和材料。 +- 手机以 390 像素视口检查分类与正文,页面实际内容宽度和可视宽度均为 375 像素(浏览器滚动条占用其余宽度),没有横向溢出。 +- `npm run test:e2e` 全部通过,共 24 项:资产目录 11 项、Persona Studio 8 项、工厂工作台 5 项。 +- 目录回归覆盖真实计数、主干五项规格、变体和实现关联、材料读取、分类缩写与适配内容搜索、行业交叉筛选、URL 及历史恢复、非法 URL 回落、键盘切换、错误重试、实现 manifest 失败回退和手机布局。 +- 全仓 109 个本地 Markdown 引用检查通过,未发现断链。两个案例的构建入口及规格、证据材料在浏览器回归中读取成功。 +- 桌面与手机目视检查完成,临时视口设置已恢复;案例原有截图没有因本轮回归产生无关变更。 + +界面证据:[首页](catalog-assets-desktop.jpg)、[变体与实现关系](catalog-assets-detail.jpg)、[手机详情](catalog-assets-mobile.jpg)。截图只包含公开设计资料和参考实现说明。 + +工厂工作台构建保留原有的单包超过 500 kB 提示,构建成功;本轮没有修改业务打包方式。参考设计不是可运行实现;以上目录检查不构成新的云端集成或客户验收。 diff --git a/docs/evidence/catalog-desktop.jpg b/docs/evidence/catalog-desktop.jpg new file mode 100644 index 0000000..444c50a Binary files /dev/null and b/docs/evidence/catalog-desktop.jpg differ diff --git a/docs/evidence/catalog-mobile.jpg b/docs/evidence/catalog-mobile.jpg new file mode 100644 index 0000000..7f197d4 Binary files /dev/null and b/docs/evidence/catalog-mobile.jpg differ diff --git a/docs/evidence/catalog-taxonomy.md b/docs/evidence/catalog-taxonomy.md new file mode 100644 index 0000000..a8adf27 --- /dev/null +++ b/docs/evidence/catalog-taxonomy.md @@ -0,0 +1,28 @@ +# 分层目录验证记录 + +验证日期:2026-10-14(Asia/Shanghai)。 + +这是分层目录迁移时的历史记录;后续分类扩充与主干、变体关系见 [案例资产目录验证](catalog-assets.md)。 + +本轮将案例库组织为“服务模型 → 领域 → 案例”。一级分类为 SaaS、PaaS、IaaS,分别包含 22、8、5 个子类。分类依据及具体边界见 [分类说明](../domains.md)。分类数量不代表已交付产品数量,当前实际收录两个 SaaS 案例。 + +## 目录与契约 + +- 两个既有案例分别迁移到 `examples/saas/dam/character-material-library/` 和 `examples/saas/mes/factory-operations/`。 +- `case.json`、案例索引及领域字典升级到 v2;`serviceModel` 为必填,校验父子归属、目录路径和索引一致性。 +- npm workspace、锁文件、CI 证据路径、业务测试输入、案例文档链接已同步。 +- 不创建空案例目录;PaaS、IaaS 和没有案例的 SaaS 子类仅展示定义及空状态。 +- 数据提供方端口、已部署数据库及资产权限没有因分类迁移改变。 + +## 检查结果 + +- `npm ci` 成功,依赖审计未报告漏洞。 +- `npm run check` 通过:两个工作台构建、3 个服务模型 / 35 个领域 / 2 个案例验证、15 项仓库测试、37 项案例单测和 8 项 POC 测试。 +- 8 项新增分类测试覆盖跨父级、重复 ID、错误路径、旧版本、缺失父级、manifest 漂移及空占位目录。 +- 全仓 Markdown 本地链接扫描无断链,两个迁移后的工作台构建入口返回 HTTP 200。 +- `npm run test:e2e` 全部通过:目录 8 项、Persona Studio 8 项、工厂工作台 5 项,共 21 项。目录检查包含层级切换、二级筛选、双搜索、URL 刷新与前进后退、加载失败恢复及迁移后的真实入口。 +- 内置浏览器完成桌面和 390 像素手机目视检查;手机内容宽度与可视宽度均为 375 像素,没有横向溢出。已恢复临时视口尺寸设置。 + +界面证据:[桌面目录](catalog-desktop.jpg)、[手机目录](catalog-mobile.jpg)。首页分类和计数来自实际字典与案例索引,截图不包含用户账号或私人业务数据。 + +本轮没有重新创建云端业务记录,也不把目录结构验证当作新的云端集成或客户验收。此前的业务验证保留在各案例证据目录。 diff --git a/docs/factory-smart-ops-review.md b/docs/factory-smart-ops-review.md new file mode 100644 index 0000000..6d114d7 --- /dev/null +++ b/docs/factory-smart-ops-review.md @@ -0,0 +1,37 @@ +# factory-smart-ops 数据接入复核 + +复核日期:2026-10-15。对象:[公开演示](https://factory-smart-ops.app.workbuddy.host/)及当时页面加载的 `assets/index-2hWI45UD.js`。这里只记录公开页面和已发布前端的行为,不代表已审查其源代码仓库、后端或真实工厂数据。 + +方法:浏览页面确认交互和能力标注;将公开构建产物中的校验、转换、导入与导出函数隔离,用合成数据在离线环境复现。没有向演示站导入、清空或修改业务数据。以下结果针对该版本;构建产物行号不是源码行号,因此不用于 PR 行级评论。 + +## 需要修复的数据问题 + +| 优先级 | 最小复现与结果 | 修复要求 | +| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| P1 | 旧库有机台 `mc-old`;整包仅传员工 `wk-new`,引用 `mc-old`。预校验通过,但覆盖导入清空旧机台,最终留下悬空引用。 | 根据导入模式先构造**最终候选快照**,所有引用只在候选快照内校验。明确缺失数据集与空数组的语义,校验完成后原子提交。 | +| P1 | 源库含已归档申领、销售分析和待办,导出后再覆盖导入:审批、发货、接收时间及 `previousId` 丢失,归档时间被改写;销售风险从 `high` 变成 `low`,分析列表和待办消失。 | 明确“全部数据”范围,用带版本的可逆序列化保留业务事实、流程历史与待办。派生字段可以按声明重算,不能把缺失值默认成低风险。增加完整往返验证。 | +| P2 | 在带示例的机台台账追加一条数据,旧示例仍在,台账却被标为“工厂”;逐数据集追加后总体也可标为工厂数据。 | 来源至少区分合成、用户导入和混合;按记录追踪或正确聚合。用户导入不等于已核实的工厂事实。模拟识别来源不得因人工确认而消失。 | +| P2 | 同一包的两条机台使用相同 ID,校验显示成功 2 条,实际只保留后者。 | 在写入前拒绝包内重复 ID,错误带数据集、行号和字段;已有 ID 的更新须遵守显式模式,不能静默吞行。 | +| P2 | 物料的 `unitPrice: "abc"` 和 `stockQty: "-5"` 通过校验;前者被保存成 `0`,后者未拒绝。 | 严格校验数值类型、有限值及业务范围。明确是否允许负库存、数量精度与单位,不把非法输入转换为有效默认值。 | + +页面还同时出现顶部“示例数据”与工厂档案“由工厂导入”的说明;工厂名称是内置配置,整包没有对应工厂资料。来源标注应覆盖档案和指标。人力节省、回本周期等估算应写明假设,不能作为已验证项目收益。 + +## 值得复用的设计 + +- 同一入口提供整包模板、导出、导入预览和逐行错误,方便迁移与交接。 +- 拍照先生成草稿,再由人核对,最后进入与手工导入相同的业务校验流程。 +- 各页面保持可见的数据来源条,帮助用户识别示例与导入内容。 + +这些设计已抽象到 [台账交换与识别接入约定](ledger-exchange.md)。该约定是参考设计,不能据此宣称现有案例已实现整包导入或真实 OCR。 + +照片入口当前明确标为模拟识别;已发布前端调用本地模拟函数。页面列出的 `POST /api/vision/ledger` 可以作为未来 adapter 接口,但仅展示契约不能证明 HTTP 服务已经接通。本次未验证后端是否存在,也未评估真实识别质量。 + +## 接入本仓库的条件 + +以 [案例规范 v3](case-standard.md)、现有标准主干和场景变体为准。数据库、身份、资产存储采用各案例声明的端口;本机选择 Supabase 不应成为所有用户的固定依赖。 + +工厂耗材、换件和异常处理可对齐 `mes-production-operations / garment-workshop-consumables`。如果另做订单、采购、库存、结算等完整业务,应评估 `erp-order-resource-coordination` 下的独立变体,避免把所有业务塞进现有 MES 记录模型。 + +提供源码分支或 PR 后,再核对实现、依赖、合成 fixtures、失败与重试、并发提交、往返一致性和浏览器验收。没有源码与可复现证据时,公开 demo 作为设计参考,不直接计作已交付项目案例。公开文档只保留抽象需求,不复制原始私人对话。 + +建议验收顺序:先修复最终快照校验与往返丢失,再修复来源、重复 ID 和数值约束;通过自动验证后,验证替换预览、失败不写入和人工核对交互。真实 OCR 的连接状态与质量应单独验收。 diff --git a/docs/ledger-exchange.md b/docs/ledger-exchange.md new file mode 100644 index 0000000..717c26d --- /dev/null +++ b/docs/ledger-exchange.md @@ -0,0 +1,83 @@ +# 台账整包交换契约 v1 + +状态:**通用参考契约,含工厂案例实现**。本文、[JSON Schema](../schemas/ledger-package.schema.json) 和 [空模板](../templates/ledger-package/README.md) 定义交换边界;[工厂工作台](../examples/saas/mes/factory-operations/README.md) 实现了下述受限范围。通过 schema 只证明包的结构,不代表业务有效、已连接服务或可以提交;实际部署证据单独记录。 + +## 包格式与业务注册契约 + +交换包包含 `schemaVersion`、`packageKind`、`packageId`、`datasetContract`、`dataRevision`、`metadata`、`exportScope`、`tables` 和 `assets`。包内不携带登录凭据、成员授权或目标数据库配置。 + +| 字段 | 含义 | +| --------------- | ------------------------------------------------------------------------------------------- | +| schemaVersion | 交换格式版本;本提案为 1,不兼容版本必须拒绝或显式迁移 | +| packageKind | template 为不可执行空模板;snapshot 为准备导入或导出的数据包 | +| packageId | 包标识;正式包使用新标识,不能用模板标识或把它当作提交请求 ID | +| datasetContract | `{id, version}` 引用案例的 dataset registry 契约;unknown/unassigned 不能提交 | +| dataRevision | 导出源的一致快照版本;模板为 null。它不等于目标当前版本 | +| tables | 逻辑表名映射为行数组,每行是 `{id, data, provenance}` | +| assets | 稳定资产引用清单,包含 ID、名称、类型、大小、SHA-256 和 `{storeId, objectId}`;不含文件正文 | + +exportScope 声明 `{completeness: full | partial, tables: [...]}`,表名须与包内 tables 完全一致。full 表示源数据集声明表的全部业务行,不代表未列出的表;角色过滤或条件筛选后的导出必须为 partial。partial 包不能用于表级或整库 replace,若需导入应另行定义合并契约。模板的 exportScope 为 null。 + +dataset registry 由各案例实现,必须明确:允许和必需的表、字段 schema、ID 唯一范围、枚举、日期及数值精度、跨表引用、流程约束、分析/行动记录、资产规则、导入权限和数量上限。它决定具体台账模型;此通用 schema 不硬编码 ERP 表,也不把库存流水转换成耗材申领。工厂身份由该契约的工厂档案及工厂 ID 引用承载,metadata.label 仅供显示。预览须列明来源工厂到目标工厂的映射及冲突,不能靠同名或包内标识自动覆盖其他工厂;目标工作区仍由当前会话确定。 + +数字和日期按字段契约严格处理:禁止自动把空白或非法数字变为 0、截断小数、丢失前导零的标识或默默换算单位。超出安全整数范围的数值、金额和精确小数采用契约声明的十进制字符串或最小单位整数。日期须检查真实年月日和时区规则,不能接受自动滚动成下个月的日期;schema 的时间戳正则只检查外形。JSON 重复对象键、重复行 ID 均报错,禁止采用“最后一条覆盖前一条”。 + +## 来源与识别记录 + +`metadata.contentOrigin` 为 synthetic、user-imported 或 mixed;`metadata.storage` 为 local 或 shared,描述导出源的存储环境。二者独立:示例数据可以存入共享服务,本机数据也可能来自用户文件。导入目标由当前连接和工作区确定,不能由包内 storage 改写。导入合成文件后仍是 synthetic,不因经过用户上传变成真实工厂数据。 + +每行的 `provenance.contentOrigin` 保留行来源。曾经过识别的行还保留 `recognition: {mode, reviewStatus, sourceAssetIds}`:mode 为 mock 或 connected,reviewStatus 为 unreviewed 或 reviewed。**人工确认只改变复核状态,不把 mock 改为 connected,也不删除原识别来源。** 元信息必须与行来源汇总一致;混合包不能一律标成工厂数据。 + +全页来源条读取当前已发布数据集,至少表达名称、来源性质和本机/共享范围;识别草稿另标模拟及待核对。导入成功后才更新来源与版本;失败、切换工作区或收到旧请求响应时,不能把新标签挂到旧数据上。 + +## replace:先形成最终候选库 + +replace 只接受已验证完整范围的 full 快照;不能只信任包自报 full,导出器与接收端须按注册契约核对范围及来源。预览请求显式绑定目标工作区、`mode: replace`、`replaceScope` 和包。replaceScope 是此次替换的逻辑表集合,必须非空、属于注册契约,且与包内 tables 的表名集合完全一致。 + +- 范围外的表保留;范围内漏表是错误,不等于删除。显式 `[]` 表示把该表替换为空,预览必须显示删除数量。 +- 全数据集替换要求范围覆盖注册契约声明的所有业务表,包括流程事件、分析、行动与其关联,不能只导入页面主表。 +- `tables: {}` 是空模板或无操作输入,不能用来清空工作区。assets 清单也不是删除资产库的命令。 +- 在内存或暂存区构造“未替换旧表 + 新表 + 已验证资产映射”的最终候选库,再校验全部引用和业务约束。例如保留的行动不能引用被替换掉的分析,已完成断针不能缺少回收事实与照片。 + +校验错误统一返回 `{table, row, field, code, message}`;field 使用 JSON Pointer,row 使用从 1 开始的输入行号;包级 table/row 或保留数据的 row 可为 null,可额外给出 recordId。预览列出各表替换前后数量、删除影响、错误和警告,不写正式数据。原始行号应保留,不能先去重或丢掉坏行再展示“通过”。 + +## 提交、原子性与导出 + +交换能力是独立的可选 `LedgerExchange`:`getDatasetState(workspaceId)`、`exportSnapshot(workspaceId)`、`previewImport(workspaceId, {packageText, mode, replaceScope})`、`commitImport(workspaceId, {planId, expectedRevision, requestId})`。预览保留 JSON 原文到可信服务端,避免客户端先解析丢失重复键。IdentityProvider、OperationsRepository、AssetStore 的职责保持独立,交换服务通过部署适配器协调它们,不绑定特定数据库。没有此能力的提供方明确显示未支持,不通过前端循环写入替代。 + +预览结果 planId 绑定包摘要、契约版本、替换范围、目标工作区和目标数据版本。提交时再次确认授权与契约有效性,并以 expectedRevision 检查并发:预览后其他成员新增、审批或修改数据,旧计划应冲突退出,不能覆盖其结果。所有影响候选库的正常写入也必须推进目标版本;只检查单条单据 version 不足以保证整包替换。 + +同一 requestId 在该工作区重试同一计划只返回同一结果;用于不同包或范围时报冲突。一次数据库事务发布业务表、引用、导入结果和新 revision。任何校验或提交失败都保留旧版本,不能通过前端循环删除/创建来实现。响应丢失时可按 requestId 查询或重放确认结果,不能另起请求重复导入。 + +文件正文先通过资产适配器准备并校验大小、类型和摘要,再原子发布引用。数据库与对象存储不共享事务,因此失败时允许有未发布暂存文件,但不能出现业务引用缺文件或提前删除旧文件。跨提供方导入必须完成稳定 ID 映射及字节校验;不能把 storagePath 或临时签名 URL 当作可移植资产。此 JSON 只是数据与资产清单,单独文件不构成自包含备份。 + +导出由服务端或支持事务的本地适配器读取同一 revision,并声明导出范围;角色过滤后的局部列表不能冒充完整备份。export → import 应保留业务字段、流程状态与事件、分析、行动、关联和来源,不重新生成演示内容或重放通知、付款等外部动作。ID 变更需有完整映射;历史操作者作为来源引用保留,不能冒充当前用户的新审批。现有案例是否允许恢复历史状态,由其业务契约决定,通用导入不能绕过受控状态机。 + +## 拍照台账适配器 + +`POST /api/vision/ledger` 仍是拟议 HTTP adapter 契约,当前没有可调用的 OCR 服务。工厂工作台已提供 **mock** 拍照核对流程:保存照片、生成固定规则的合成草稿、对照原图编辑、人工确认,再走相同预览与提交;模拟器不读取照片内容。真实实现需另行验证并声明 connected,不通过改按钮文案升级能力。 + +建议请求 `{workspaceId, assetId, ledgerType, requestId}`,返回 `{draftId, schemaVersion, mode, sourceAssetId, rows, warnings}`。rows 保留原图位置、识别值和缺失字段。服务端校验资产可读性;地址来自部署配置。识别只产草稿,经用户核对后送入同一预览/提交流程;照片中的“完成”“付款”或“回收”文字不能自动触发对应业务动作。保留识别来源,人工复核不消除 mock 标记。 + +## 工厂案例已实现的范围 + +注册契约为 `factory-operations` v1,业务表为 `factory`、`reports`、`report-events`、`visits`。工厂档案始终包含在范围内,ID 必须等于当前工作区;当前界面导入和导出四表全量快照。完整字段见 [工厂数据模型](../examples/saas/mes/factory-operations/data-model.md)。这不是完整 ERP 台账,不接受未声明的库存、采购或财务表。 + +- 仅工作区所有者执行导出、预览和提交;新提供方仍需在服务端实现这些权限。JSON 最大 5 MiB、嵌套最多 40 层,每表最多 10000 行;UUID、字段、日期、整数、枚举、事件状态链和引用均严格校验。 +- 仅同工作区恢复,包内资产清单必须是当前已登记资产的有效子集,逐项元信息、稳定引用和 SHA-256 一致;传入业务行所用附件及识别原图必须在包中声明。候选库保留当前全部资产,范围外保留行按当前清单校验,备份后新增的无关资产不阻止恢复。文件正文留在原资产平台,成员、登录信息和连接设置不随包替换;不能用这份 JSON 完成跨工作区或跨提供方迁移。 +- 普通快照可恢复旧业务状态,但必须通过当前数据的预览和并发检查;不会重放审批或通知。照片模拟草稿额外绑定生成时快照版本,生成后已有成员更新时必须重新生成和核对,不能覆盖中间写入。 +- 来源从业务行汇总,`factory` 档案不参与合成/用户提供的汇总。已有同 ID 记录的来源、识别模式及原图引用不可通过替换改写;人工确认保留 mock 与 synthetic。 +- 空格式模板依旧不可执行。需要真实字段骨架时先导出当前工作区;新增申领/拜访还必须满足事件、历史操作者与资产规则,不能只填页面主表。 + +客户端领域与 HTTP 契约已有针对性测试。个人试用所选 Supabase 项目已应用 `20261014171026` 交换迁移,并通过 8 组云端回滚事务与权限检查;具体范围见 [案例验证记录](../examples/saas/mes/factory-operations/evidence/verification.md)。这些数据库检查不等于真实用户的云端 UI 验收,首次应用注册尚待使用者完成。Sites 所有者私有试用站已发布,Supabase 已连接;真实用户注册后的 UI 验收待完成。本机静态预览已验收,当前网络访问线上站点被托管边缘拦截,未进入工作台;其他提供方仍须独立接入与验证。 + +## 最小验收 + +1. 合法快照往返后,流程、分析、行动及其引用不丢失,来源与资产摘要保持一致。 +2. 最后一行错误、重复 ID、无效日期/数值、悬空引用或非法状态,整包零发布,并定位到表/行/字段。 +3. 范围内漏表拒绝,范围外旧表保留,显式空表准确展示删除影响;空模板不能提交。 +4. 预览后并发写入导致冲突;同 requestId 重试不重复写入,不同内容复用 ID 被拒绝。 +5. 文件失败、事务失败、提交响应丢失时无半包可见,旧版本保持可读并能确认最终结果。 +6. mock 识别经过人工确认与导出再导入后仍保留 mock 来源;切换工作区后旧响应不能串入当前页面。 + +实施时同步案例规格、能力声明和证据,遵守 [案例规范](case-standard.md) 与 [提供方契约](data-provider-contract.md)。这些标准材料不增加已运行案例或已接通能力的数量。 diff --git a/examples/dam/brand-digital-human-studio/README.md b/examples/saas/dam/brand-digital-human-studio/README.md similarity index 98% rename from examples/dam/brand-digital-human-studio/README.md rename to examples/saas/dam/brand-digital-human-studio/README.md index c20b1f9..8413558 100644 --- a/examples/dam/brand-digital-human-studio/README.md +++ b/examples/saas/dam/brand-digital-human-studio/README.md @@ -64,9 +64,9 @@ Base URL默认 `https://ai.amaxsmp.com/v1`,可在同一配置面板填写使 ## 独立运行接口验证 -仓库根目录执行 `npm run dev --workspace muse-digital-studio`,工作台 http://127.0.0.1:4318 ,本机代理8788。当前主测试路线为阿里云,默认关闭收费调用;目录4173永远只模拟。 +仓库根目录执行 `npm run dev --workspace muse-digital-studio`,工作台 http://127.0.0.1:4319 ,本机代理8788。当前主测试路线为阿里云,默认关闭收费调用;目录4173永远只模拟。 -在4318选择「阿里云万相真实 API」或「声音复刻与对口型」,点击「配置阿里云」,输入北京地域百炼通用API Key及可选业务空间ID。Key保存于忽略的本机后端文件,不回显、不进浏览器持久存储或配置导出;没有生产凭据加密。保存、刷新、上传录音不发起生成。每个云生成步骤由用户点击,分别计费,不自动充值或连做。 +在4319选择「阿里云万相真实 API」或「声音复刻与对口型」,点击「配置阿里云」,输入北京地域百炼通用API Key及可选业务空间ID。Key保存于忽略的本机后端文件,不回显、不进浏览器持久存储或配置导出;没有生产凭据加密。保存、刷新、上传录音不发起生成。每个云生成步骤由用户点击,分别计费,不自动充值或连做。 万相模型可在页面选择,时长、分辨率、声音、镜头与价格估算同步变化,后端严格提交所选模型: @@ -96,6 +96,6 @@ Qwen可选:`DASHSCOPE_API_KEY`、`QWEN_BASE_URL`、`QWEN_MODEL`,按Token计 ## 最新代码同步范围 -当前代码包含万相官方/兼容网关协议、自定义Base URL和模型别名、原任务结果恢复、真实结果视频帧封面、长报错摘要/详情以及全身表演提示词预设。目录入口只显示模拟模式,不提供Key输入或真实提交;独立4318入口保留页面模型配置和用户手动提交。全身预设是测试脚本,不证明效果达标。 +当前代码包含万相官方/兼容网关协议、自定义Base URL和模型别名、原任务结果恢复、真实结果视频帧封面、长报错摘要/详情以及全身表演提示词预设。目录入口只显示模拟模式,不提供Key输入或真实提交;独立4319入口保留页面模型配置和用户手动提交。全身预设是测试脚本,不证明效果达标。 只同步代码和抽象技术说明,保留已有合成图片与固定示意MP4;没有复制客户资料、录音、真实生成视频、签名URL、报价或本机状态。使用者自己的运行数据写入忽略的`workbench/.local/`。 diff --git a/examples/dam/brand-digital-human-studio/acceptance.md b/examples/saas/dam/brand-digital-human-studio/acceptance.md similarity index 100% rename from examples/dam/brand-digital-human-studio/acceptance.md rename to examples/saas/dam/brand-digital-human-studio/acceptance.md diff --git a/examples/dam/brand-digital-human-studio/case.json b/examples/saas/dam/brand-digital-human-studio/case.json similarity index 94% rename from examples/dam/brand-digital-human-studio/case.json rename to examples/saas/dam/brand-digital-human-studio/case.json index ee5c12b..8c0bb53 100644 --- a/examples/dam/brand-digital-human-studio/case.json +++ b/examples/saas/dam/brand-digital-human-studio/case.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 3, "id": "brand-digital-human-studio", "title": "MUSE 缪思 · 品牌数字人技术验证工作台", "summary": "用虚构素材演示资产、视频、任务与审核复用,提供阿里云万相、声线复刻/TTS/对口型及Higgsfield等本机适配代码,真实效果待验证。", @@ -108,5 +108,15 @@ "mode": "unavailable", "description": "独立Base URL与Key、文生或单图参考、doubao-seedance-2-0-fast-260128、5秒720P、任务与本机媒体;网址版17项Seedance软件检查、桌面/手机隔离代理及6项目录回归通过。真实账号/Base64/下载与效果未验收,无多图/声线/模型导出。" } + ], + "serviceModel": "saas", + "caseKind": "reference-implementation", + "blueprintId": "dam-content-assets", + "variantId": "digital-human-content-production", + "evidence": [ + { + "title": "模拟上游软件检查与公开演示验证记录", + "path": "evidence/verification.md" + } ] } diff --git a/examples/dam/brand-digital-human-studio/comparison.md b/examples/saas/dam/brand-digital-human-studio/comparison.md similarity index 100% rename from examples/dam/brand-digital-human-studio/comparison.md rename to examples/saas/dam/brand-digital-human-studio/comparison.md diff --git a/examples/dam/brand-digital-human-studio/data-model.md b/examples/saas/dam/brand-digital-human-studio/data-model.md similarity index 100% rename from examples/dam/brand-digital-human-studio/data-model.md rename to examples/saas/dam/brand-digital-human-studio/data-model.md diff --git a/examples/dam/brand-digital-human-studio/data/README.md b/examples/saas/dam/brand-digital-human-studio/data/README.md similarity index 100% rename from examples/dam/brand-digital-human-studio/data/README.md rename to examples/saas/dam/brand-digital-human-studio/data/README.md diff --git a/examples/dam/brand-digital-human-studio/evidence/verification.md b/examples/saas/dam/brand-digital-human-studio/evidence/verification.md similarity index 83% rename from examples/dam/brand-digital-human-studio/evidence/verification.md rename to examples/saas/dam/brand-digital-human-studio/evidence/verification.md index ad34c82..d26165f 100644 --- a/examples/dam/brand-digital-human-studio/evidence/verification.md +++ b/examples/saas/dam/brand-digital-human-studio/evidence/verification.md @@ -1,5 +1,15 @@ # 验证记录 +## 2026-10-15:合入 v3 案例资产库 + +- 案例迁移到 `examples/saas/dam/brand-digital-human-studio/`,归入 `dam-content-assets → digital-human-content-production`,类型为 `reference-implementation`,成熟度仍为 `prototype`。下方历史检查继续保留原日期、端口和当时数量,不代表新增客户验收。 +- 公共路径保护同步到新的 `/examples/saas/dam/…/workbench/dist/`,原 API 拦截、公开模式草稿过滤与模拟输出保持;本机开发入口改为 4319,代理仍为 8788,避免与工厂工作台 4318 冲突。开发脚本按 npm workspace 解析 Vite。 +- `npm ci`、三个工作台的 `npm run build` 通过;`npm run validate` 通过,当前为 3 个服务模型、73 个领域、12 个业务族、22 个标准主干、44 个场景变体、3 个参考实现。 +- `npm run test --workspace muse-digital-studio`:68 项通过,使用临时目录和模拟供应商响应,包含提交幂等、提交结果不确定时禁止重复提交、失败恢复、图片/媒体校验和凭据隔离。本轮没有进行真实模型调用或付费验证。 +- MUSE 6 项浏览器回归通过:新目录入口、虚构登录、公开模式无 API 请求与无真实配置控件、草稿预设保持、素材加载、约 22 秒模拟任务、6 秒示意媒体播放与复用、390 像素手机布局。 +- 目录 11 项浏览器回归通过:保留 60 个 SaaS 分类、22 个主干与 44 个变体;3 个参考实现、0 个项目案例;三个运行入口、关系浏览、检索、历史、加载失败恢复与手机布局。 +- 本轮未修改真实模型适配的能力声明;真实账号、模型质量、正式鉴权与客户交付仍需另行验证。仓库整体检查由合并提交统一执行。 + 2026-10-14 收录独立案例。仅使用合成素材和模拟供应商响应,不读取客户密钥、不上传真人素材、不进行付费生成。 ## 环境 @@ -87,3 +97,11 @@ Key、客户素材、录音、报价、签名媒体URL、运行状态和本机 - 同步前后核对案例外102个文件与原示例媒体,字节一致。待提交文本检查无真实凭据或签名URL,.env.example密钥为空、真实开关关闭。没有新增媒体、客户身份、聊天、办公报价文件或本机状态。 本轮证明软件与发布隔离,不证明账号权限、计费、人物/声线一致性、全身剧情或帽子细节。manifest仍prototype,外部适配仍unavailable,正式企业共享、权限与模型权重交付未完成。 + +## 2026-10-15 案例资产规范 v3 整合 + +从主分支已合入的 MUSE 案例迁移至 `examples/saas/dam/brand-digital-human-studio`,挂接 `dam-content-assets / digital-human-content-production`,类型为 reference-implementation。当前全库共 60 个 SaaS 分类、22 个标准主干、44 个变体、3 个参考实现、0 个项目案例;历史记录中的旧计数和旧端口保留为当时证据。 + +本轮 `npm ci`、三工作台构建、分类与引用校验通过。MUSE 的 68 项隔离单测通过;全库浏览器检查 30/30 通过(目录 11、MUSE 6、Persona 8、工厂 5)。公开目录入口已随新路径更新隔离判断,浏览器用例确认不请求 `/api/`;独立开发入口使用 4319,避开工厂工作台的 4318。根目录页面也已在实际浏览器中确认 MUSE 可搜索、可关联 DAM 主干及场景变体。 + +保留保存成功后更新界面、请求编号与配置指纹、提交结果不明时不自动重提、图片类型校验等既有实现。测试使用合成数据和本地模拟上游,未触发真实模型调用。状态仍为 prototype,外部适配仍为 unavailable;整合与测试不证明供应商权限、生成质量或客户交付。 diff --git a/examples/dam/brand-digital-human-studio/integrations.md b/examples/saas/dam/brand-digital-human-studio/integrations.md similarity index 99% rename from examples/dam/brand-digital-human-studio/integrations.md rename to examples/saas/dam/brand-digital-human-studio/integrations.md index 01c5590..f0d5b6a 100644 --- a/examples/dam/brand-digital-human-studio/integrations.md +++ b/examples/saas/dam/brand-digital-human-studio/integrations.md @@ -21,7 +21,7 @@ 官方参考:[Soul 2及v2参考](https://open.higgsfield.ai/models/higgsfield-ai/soul/v2/standard/api-reference)、[Seedance 2.0](https://open.higgsfield.ai/models/bytedance/seedance-2.0/reference-to-video/api-reference)、[Kling O3](https://open.higgsfield.ai/models/kling-video/o3/image-reference/api-reference)、[API与网页及音频区别](https://higgsfield.ai/creator-hub/help-center/integrations/what-is-the-higgsfield-api)、[Soul ID限制](https://higgsfield.ai/creator-hub/help-center/ai-models/how-do-i-create-and-use-a-soul-id-character)。参考日期2026-10-14,仍需运行者核对当前账号合同和权限。 -Higgsfield密钥使用工作台 `.env`,阿里云也支持上述页面配置,默认 `HF_ENABLE_REAL=false`;本机代理绑定127.0.0.1,不应直接发布为公网生产服务。 `.local/` 与 `.env` 被忽略。目录4173入口不调用代理,独立4318入口通过Vite代理至8788。当前没有自动充值或付费批处理。 +Higgsfield密钥使用工作台 `.env`,阿里云也支持上述页面配置,默认 `HF_ENABLE_REAL=false`;本机代理绑定127.0.0.1,不应直接发布为公网生产服务。 `.local/` 与 `.env` 被忽略。目录4173入口不调用代理,独立4319入口通过Vite代理至8788。当前没有自动充值或付费批处理。 ## 演示登录与供应商授权 diff --git a/examples/dam/brand-digital-human-studio/requirements.md b/examples/saas/dam/brand-digital-human-studio/requirements.md similarity index 100% rename from examples/dam/brand-digital-human-studio/requirements.md rename to examples/saas/dam/brand-digital-human-studio/requirements.md diff --git a/examples/dam/brand-digital-human-studio/workbench/.env.example b/examples/saas/dam/brand-digital-human-studio/workbench/.env.example similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/.env.example rename to examples/saas/dam/brand-digital-human-studio/workbench/.env.example diff --git a/examples/dam/brand-digital-human-studio/workbench/.gitignore b/examples/saas/dam/brand-digital-human-studio/workbench/.gitignore similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/.gitignore rename to examples/saas/dam/brand-digital-human-studio/workbench/.gitignore diff --git a/examples/dam/brand-digital-human-studio/workbench/README.md b/examples/saas/dam/brand-digital-human-studio/workbench/README.md similarity index 92% rename from examples/dam/brand-digital-human-studio/workbench/README.md rename to examples/saas/dam/brand-digital-human-studio/workbench/README.md index a4c0261..8405489 100644 --- a/examples/dam/brand-digital-human-studio/workbench/README.md +++ b/examples/saas/dam/brand-digital-human-studio/workbench/README.md @@ -1,6 +1,6 @@ # MUSE 工作台源码 -从仓库根目录执行 `npm ci`,然后 `npm run dev --workspace muse-digital-studio`。开发入口为 http://127.0.0.1:4318 ,代理为 127.0.0.1:8788,默认不调用付费服务。 +从仓库根目录执行 `npm ci`,然后 `npm run dev --workspace muse-digital-studio`。开发入口为 http://127.0.0.1:4319 ,代理为 127.0.0.1:8788,默认不调用付费服务。 根目录 `npm run demo` 的 4173 案例入口始终只运行免费模拟,无代理依赖。配置、真实调用步骤、演示与重置方法见 [案例说明](../README.md)。 @@ -14,7 +14,7 @@ ## Seedance 视频接入 -独立4318入口选择「Seedance 视频」→「配置 Seedance」,填写「接口地址(Base URL)」与独立Key,点击「保存配置」,5秒720P、文生或人物单图参考;由用户点击生成,异步任务回读和本机MP4归档。页面名称不展示聚合平台,默认Base URL为 `https://ai.amaxsmp.com/v1`,可页面配置使用同一视频协议的服务地址,不能当字节官方直连。详情见[接入说明](../integrations.md)。真实账号、Base64和下载网关待联调;无多图/音频驱动/取消/模型导出,阿里云声音配置独立保留。 +独立4319入口选择「Seedance 视频」→「配置 Seedance」,填写「接口地址(Base URL)」与独立Key,点击「保存配置」,5秒720P、文生或人物单图参考;由用户点击生成,异步任务回读和本机MP4归档。页面名称不展示聚合平台,默认Base URL为 `https://ai.amaxsmp.com/v1`,可页面配置使用同一视频协议的服务地址,不能当字节官方直连。详情见[接入说明](../integrations.md)。真实账号、Base64和下载网关待联调;无多图/音频驱动/取消/模型导出,阿里云声音配置独立保留。 网址配置版最终软件结果:本地53项(Seedance17项)、仓库MUSE54项、最终完整check与6项浏览器回归通过。网址规范化、旧任务恢复原配置、本机下载等已用隔离模拟上游验证;真实平台调用为0,真实账号/Key、Base64及内容质量未验收,见[验证记录](../evidence/verification.md)。 @@ -22,4 +22,4 @@ 万相页面支持官方/兼容网关协议、Base URL及可选模型别名;网关模式不支持现有百炼声音链路。真实成品封面读取视频帧,任务错误显示摘要、详情换行,资产库最近成片使用对应结果。创作页新增5秒全身案例,选择及填入不提交生成。 -仓库目录入口不显示Key或真实模式,只运行合成示例与模拟流程;本机4318入口可配置自己的账号。端口4318/8788、浏览器隔离命名空间及`.local/`数据路径保持独立。配置与真实数据均不得提交,`.env.example`只含空密钥占位,所有真实调用开关默认关闭。最新软件检查见上层验证记录。 +仓库目录入口不显示Key或真实模式,只运行合成示例与模拟流程;本机4319入口可配置自己的账号。端口4319/8788、浏览器隔离命名空间及`.local/`数据路径保持独立。配置与真实数据均不得提交,`.env.example`只含空密钥占位,所有真实调用开关默认关闭。最新软件检查见上层验证记录。 diff --git a/examples/dam/brand-digital-human-studio/workbench/index.html b/examples/saas/dam/brand-digital-human-studio/workbench/index.html similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/index.html rename to examples/saas/dam/brand-digital-human-studio/workbench/index.html diff --git a/examples/dam/brand-digital-human-studio/workbench/package.json b/examples/saas/dam/brand-digital-human-studio/workbench/package.json similarity index 88% rename from examples/dam/brand-digital-human-studio/workbench/package.json rename to examples/saas/dam/brand-digital-human-studio/workbench/package.json index 4ee65e8..1e5e1ac 100644 --- a/examples/dam/brand-digital-human-studio/workbench/package.json +++ b/examples/saas/dam/brand-digital-human-studio/workbench/package.json @@ -6,8 +6,8 @@ "scripts": { "dev": "node scripts/dev.mjs", "build": "tsc -b && vite build", - "preview": "vite preview --host 127.0.0.1 --port 4318 --strictPort", - "dev:frontend": "vite --host 127.0.0.1 --port 4318 --strictPort", + "preview": "vite preview --host 127.0.0.1 --port 4319 --strictPort", + "dev:frontend": "vite --host 127.0.0.1 --port 4319 --strictPort", "api": "node server/index.mjs", "test": "node --test server/*.test.mjs", "test:e2e": "playwright test", diff --git a/examples/dam/brand-digital-human-studio/workbench/playwright.config.ts b/examples/saas/dam/brand-digital-human-studio/workbench/playwright.config.ts similarity index 88% rename from examples/dam/brand-digital-human-studio/workbench/playwright.config.ts rename to examples/saas/dam/brand-digital-human-studio/workbench/playwright.config.ts index ef32de0..dba831c 100644 --- a/examples/dam/brand-digital-human-studio/workbench/playwright.config.ts +++ b/examples/saas/dam/brand-digital-human-studio/workbench/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ viewport: { width: 1440, height: 1000 }, }, webServer: { - command: "node ../../../../scripts/serve.mjs", + command: "node ../../../../../scripts/serve.mjs", env: { PORT: "4179" }, url: "http://127.0.0.1:4179", reuseExistingServer: !process.env.CI, diff --git a/examples/dam/brand-digital-human-studio/workbench/public/favicon.svg b/examples/saas/dam/brand-digital-human-studio/workbench/public/favicon.svg similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/public/favicon.svg rename to examples/saas/dam/brand-digital-human-studio/workbench/public/favicon.svg diff --git a/examples/dam/brand-digital-human-studio/workbench/public/media/courtyard.png b/examples/saas/dam/brand-digital-human-studio/workbench/public/media/courtyard.png similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/public/media/courtyard.png rename to examples/saas/dam/brand-digital-human-studio/workbench/public/media/courtyard.png diff --git a/examples/dam/brand-digital-human-studio/workbench/public/media/demo-portrait.mp4 b/examples/saas/dam/brand-digital-human-studio/workbench/public/media/demo-portrait.mp4 similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/public/media/demo-portrait.mp4 rename to examples/saas/dam/brand-digital-human-studio/workbench/public/media/demo-portrait.mp4 diff --git a/examples/dam/brand-digital-human-studio/workbench/public/media/demo-wide.mp4 b/examples/saas/dam/brand-digital-human-studio/workbench/public/media/demo-wide.mp4 similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/public/media/demo-wide.mp4 rename to examples/saas/dam/brand-digital-human-studio/workbench/public/media/demo-wide.mp4 diff --git a/examples/dam/brand-digital-human-studio/workbench/public/media/hat.png b/examples/saas/dam/brand-digital-human-studio/workbench/public/media/hat.png similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/public/media/hat.png rename to examples/saas/dam/brand-digital-human-studio/workbench/public/media/hat.png diff --git a/examples/dam/brand-digital-human-studio/workbench/public/media/portrait.png b/examples/saas/dam/brand-digital-human-studio/workbench/public/media/portrait.png similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/public/media/portrait.png rename to examples/saas/dam/brand-digital-human-studio/workbench/public/media/portrait.png diff --git a/examples/dam/brand-digital-human-studio/workbench/public/media/scarf.png b/examples/saas/dam/brand-digital-human-studio/workbench/public/media/scarf.png similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/public/media/scarf.png rename to examples/saas/dam/brand-digital-human-studio/workbench/public/media/scarf.png diff --git a/examples/dam/brand-digital-human-studio/workbench/public/media/suyao.png b/examples/saas/dam/brand-digital-human-studio/workbench/public/media/suyao.png similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/public/media/suyao.png rename to examples/saas/dam/brand-digital-human-studio/workbench/public/media/suyao.png diff --git a/examples/dam/brand-digital-human-studio/workbench/scripts/dev.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/scripts/dev.mjs similarity index 65% rename from examples/dam/brand-digital-human-studio/workbench/scripts/dev.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/scripts/dev.mjs index 6c7809a..bef1164 100644 --- a/examples/dam/brand-digital-human-studio/workbench/scripts/dev.mjs +++ b/examples/saas/dam/brand-digital-human-studio/workbench/scripts/dev.mjs @@ -1,16 +1,13 @@ import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +const require = createRequire(import.meta.url); +const vite = join(dirname(require.resolve("vite/package.json")), "bin/vite.js"); const children = [ spawn(process.execPath, ["server/index.mjs"], { stdio: "inherit" }), spawn( process.execPath, - [ - "node_modules/vite/bin/vite.js", - "--host", - "127.0.0.1", - "--port", - "4318", - "--strictPort", - ], + [vite, "--host", "127.0.0.1", "--port", "4319", "--strictPort"], { stdio: "inherit" }, ), ]; diff --git a/examples/dam/brand-digital-human-studio/workbench/scripts/xyq.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/scripts/xyq.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/scripts/xyq.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/scripts/xyq.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/server/app.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/server/app.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/server/app.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/server/app.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/server/index.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/server/index.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/server/index.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/server/index.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/server/integration.test.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/server/integration.test.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/server/integration.test.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/server/integration.test.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/server/provider.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/server/provider.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/server/provider.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/server/provider.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/server/seedance.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/server/seedance.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/server/seedance.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/server/seedance.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/server/seedance.test.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/server/seedance.test.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/server/seedance.test.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/server/seedance.test.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/server/speech.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/server/speech.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/server/speech.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/server/speech.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/server/speech.test.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/server/speech.test.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/server/speech.test.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/server/speech.test.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/server/wan.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/server/wan.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/server/wan.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/server/wan.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/server/wan.test.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/server/wan.test.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/server/wan.test.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/server/wan.test.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/server/xiaoyunque.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/server/xiaoyunque.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/server/xiaoyunque.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/server/xiaoyunque.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/server/xiaoyunque.test.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/server/xiaoyunque.test.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/server/xiaoyunque.test.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/server/xiaoyunque.test.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/shared/creation-examples.d.mts b/examples/saas/dam/brand-digital-human-studio/workbench/shared/creation-examples.d.mts similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/shared/creation-examples.d.mts rename to examples/saas/dam/brand-digital-human-studio/workbench/shared/creation-examples.d.mts diff --git a/examples/dam/brand-digital-human-studio/workbench/shared/creation-examples.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/shared/creation-examples.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/shared/creation-examples.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/shared/creation-examples.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/shared/matching.d.mts b/examples/saas/dam/brand-digital-human-studio/workbench/shared/matching.d.mts similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/shared/matching.d.mts rename to examples/saas/dam/brand-digital-human-studio/workbench/shared/matching.d.mts diff --git a/examples/dam/brand-digital-human-studio/workbench/shared/matching.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/shared/matching.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/shared/matching.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/shared/matching.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/shared/seedance-models.d.mts b/examples/saas/dam/brand-digital-human-studio/workbench/shared/seedance-models.d.mts similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/shared/seedance-models.d.mts rename to examples/saas/dam/brand-digital-human-studio/workbench/shared/seedance-models.d.mts diff --git a/examples/dam/brand-digital-human-studio/workbench/shared/seedance-models.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/shared/seedance-models.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/shared/seedance-models.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/shared/seedance-models.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/shared/wan-models.d.mts b/examples/saas/dam/brand-digital-human-studio/workbench/shared/wan-models.d.mts similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/shared/wan-models.d.mts rename to examples/saas/dam/brand-digital-human-studio/workbench/shared/wan-models.d.mts diff --git a/examples/dam/brand-digital-human-studio/workbench/shared/wan-models.mjs b/examples/saas/dam/brand-digital-human-studio/workbench/shared/wan-models.mjs similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/shared/wan-models.mjs rename to examples/saas/dam/brand-digital-human-studio/workbench/shared/wan-models.mjs diff --git a/examples/dam/brand-digital-human-studio/workbench/src/App.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/App.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/App.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/App.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/api.ts b/examples/saas/dam/brand-digital-human-studio/workbench/src/api.ts similarity index 98% rename from examples/dam/brand-digital-human-studio/workbench/src/api.ts rename to examples/saas/dam/brand-digital-human-studio/workbench/src/api.ts index e599f41..e81454e 100644 --- a/examples/dam/brand-digital-human-studio/workbench/src/api.ts +++ b/examples/saas/dam/brand-digital-human-studio/workbench/src/api.ts @@ -4,7 +4,7 @@ import { rankAssets } from "../shared/matching.mjs"; export const PUBLIC_DEMO = import.meta.env.VITE_PUBLIC_DEMO === "true" || location.pathname.startsWith( - "/examples/dam/brand-digital-human-studio/workbench/dist/", + "/examples/saas/dam/brand-digital-human-studio/workbench/dist/", ); export interface ProviderConfig { catalogOnly?: boolean; diff --git a/examples/dam/brand-digital-human-studio/workbench/src/components/DemoAuthGate.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/components/DemoAuthGate.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/components/DemoAuthGate.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/components/DemoAuthGate.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/components/MatchPanel.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/components/MatchPanel.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/components/MatchPanel.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/components/MatchPanel.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/components/SeedancePanel.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/components/SeedancePanel.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/components/SeedancePanel.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/components/SeedancePanel.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/components/Shell.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/components/Shell.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/components/Shell.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/components/Shell.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/components/SoulPanel.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/components/SoulPanel.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/components/SoulPanel.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/components/SoulPanel.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/components/SpeechStudio.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/components/SpeechStudio.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/components/SpeechStudio.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/components/SpeechStudio.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/components/UI.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/components/UI.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/components/UI.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/components/UI.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/components/VideoCover.css b/examples/saas/dam/brand-digital-human-studio/workbench/src/components/VideoCover.css similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/components/VideoCover.css rename to examples/saas/dam/brand-digital-human-studio/workbench/src/components/VideoCover.css diff --git a/examples/dam/brand-digital-human-studio/workbench/src/components/VideoCover.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/components/VideoCover.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/components/VideoCover.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/components/VideoCover.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/components/WanPanel.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/components/WanPanel.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/components/WanPanel.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/components/WanPanel.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/components/XyqPanel.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/components/XyqPanel.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/components/XyqPanel.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/components/XyqPanel.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/data.ts b/examples/saas/dam/brand-digital-human-studio/workbench/src/data.ts similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/data.ts rename to examples/saas/dam/brand-digital-human-studio/workbench/src/data.ts diff --git a/examples/dam/brand-digital-human-studio/workbench/src/demoAuth.ts b/examples/saas/dam/brand-digital-human-studio/workbench/src/demoAuth.ts similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/demoAuth.ts rename to examples/saas/dam/brand-digital-human-studio/workbench/src/demoAuth.ts diff --git a/examples/dam/brand-digital-human-studio/workbench/src/integration.css b/examples/saas/dam/brand-digital-human-studio/workbench/src/integration.css similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/integration.css rename to examples/saas/dam/brand-digital-human-studio/workbench/src/integration.css diff --git a/examples/dam/brand-digital-human-studio/workbench/src/login.css b/examples/saas/dam/brand-digital-human-studio/workbench/src/login.css similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/login.css rename to examples/saas/dam/brand-digital-human-studio/workbench/src/login.css diff --git a/examples/dam/brand-digital-human-studio/workbench/src/main.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/main.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/main.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/main.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/media.ts b/examples/saas/dam/brand-digital-human-studio/workbench/src/media.ts similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/media.ts rename to examples/saas/dam/brand-digital-human-studio/workbench/src/media.ts diff --git a/examples/dam/brand-digital-human-studio/workbench/src/pages/Assets.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/pages/Assets.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/pages/Assets.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/pages/Assets.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/pages/Create.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/pages/Create.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/pages/Create.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/pages/Create.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/pages/Outputs.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/pages/Outputs.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/pages/Outputs.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/pages/Outputs.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/pages/Tasks.tsx b/examples/saas/dam/brand-digital-human-studio/workbench/src/pages/Tasks.tsx similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/pages/Tasks.tsx rename to examples/saas/dam/brand-digital-human-studio/workbench/src/pages/Tasks.tsx diff --git a/examples/dam/brand-digital-human-studio/workbench/src/pages/creation-examples.css b/examples/saas/dam/brand-digital-human-studio/workbench/src/pages/creation-examples.css similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/pages/creation-examples.css rename to examples/saas/dam/brand-digital-human-studio/workbench/src/pages/creation-examples.css diff --git a/examples/dam/brand-digital-human-studio/workbench/src/pages/task-layout.css b/examples/saas/dam/brand-digital-human-studio/workbench/src/pages/task-layout.css similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/pages/task-layout.css rename to examples/saas/dam/brand-digital-human-studio/workbench/src/pages/task-layout.css diff --git a/examples/dam/brand-digital-human-studio/workbench/src/store.ts b/examples/saas/dam/brand-digital-human-studio/workbench/src/store.ts similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/store.ts rename to examples/saas/dam/brand-digital-human-studio/workbench/src/store.ts diff --git a/examples/dam/brand-digital-human-studio/workbench/src/styles.css b/examples/saas/dam/brand-digital-human-studio/workbench/src/styles.css similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/styles.css rename to examples/saas/dam/brand-digital-human-studio/workbench/src/styles.css diff --git a/examples/dam/brand-digital-human-studio/workbench/src/types.ts b/examples/saas/dam/brand-digital-human-studio/workbench/src/types.ts similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/types.ts rename to examples/saas/dam/brand-digital-human-studio/workbench/src/types.ts diff --git a/examples/dam/brand-digital-human-studio/workbench/src/vite-env.d.ts b/examples/saas/dam/brand-digital-human-studio/workbench/src/vite-env.d.ts similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/src/vite-env.d.ts rename to examples/saas/dam/brand-digital-human-studio/workbench/src/vite-env.d.ts diff --git a/examples/dam/brand-digital-human-studio/workbench/tests/browser.spec.ts b/examples/saas/dam/brand-digital-human-studio/workbench/tests/browser.spec.ts similarity index 94% rename from examples/dam/brand-digital-human-studio/workbench/tests/browser.spec.ts rename to examples/saas/dam/brand-digital-human-studio/workbench/tests/browser.spec.ts index a6af9b3..fe20a4a 100644 --- a/examples/dam/brand-digital-human-studio/workbench/tests/browser.spec.ts +++ b/examples/saas/dam/brand-digital-human-studio/workbench/tests/browser.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "@playwright/test"; const entry = - "/examples/dam/brand-digital-human-studio/workbench/dist/index.html"; + "/examples/saas/dam/brand-digital-human-studio/workbench/dist/index.html"; async function signIn(page: import("@playwright/test").Page) { await page.getByLabel("演示邮箱", { exact: true }).fill("admin@muse.demo"); await page.getByLabel("演示密码", { exact: true }).fill("Muse2026!"); @@ -146,7 +146,7 @@ test("full body preset changes the draft only and persists without model request expect(calls).toEqual([]); }); -test("catalog retains both entries; MUSE assets are scoped and live API stays disabled", async ({ +test("v3 catalog retains three references; migrated MUSE assets stay scoped and live API stays disabled", async ({ page, }) => { const calls: string[] = []; @@ -154,18 +154,18 @@ test("catalog retains both entries; MUSE assets are scoped and live API stays di if (new URL(request.url()).pathname.includes("/api/")) calls.push(request.url()); }); - await page.goto("/"); - await expect(page.locator(".case-card")).toHaveCount(2); + await page.goto("/?serviceModel=saas&type=reference-implementation"); + await expect(page.locator("#cases [data-case]")).toHaveCount(3); await expect( - page.locator(".case-card").filter({ hasText: "Persona Studio" }), + page.locator("#cases [data-case]").filter({ hasText: "Persona Studio" }), ).toBeVisible(); await page - .locator(".case-card") + .locator("#cases [data-case]") .filter({ hasText: "MUSE" }) - .getByRole("link", { name: "体验工作台 →" }) + .getByRole("link", { name: "体验工作台", exact: true }) .click(); await expect(page).toHaveURL( - new RegExp("brand-digital-human-studio/workbench/dist/"), + new RegExp("/examples/saas/dam/brand-digital-human-studio/workbench/dist/"), ); await signIn(page); await expect( diff --git a/examples/dam/brand-digital-human-studio/workbench/tsconfig.json b/examples/saas/dam/brand-digital-human-studio/workbench/tsconfig.json similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/tsconfig.json rename to examples/saas/dam/brand-digital-human-studio/workbench/tsconfig.json diff --git a/examples/dam/brand-digital-human-studio/workbench/vite.config.ts b/examples/saas/dam/brand-digital-human-studio/workbench/vite.config.ts similarity index 100% rename from examples/dam/brand-digital-human-studio/workbench/vite.config.ts rename to examples/saas/dam/brand-digital-human-studio/workbench/vite.config.ts diff --git a/examples/dam/brand-digital-human-studio/workflows.md b/examples/saas/dam/brand-digital-human-studio/workflows.md similarity index 98% rename from examples/dam/brand-digital-human-studio/workflows.md rename to examples/saas/dam/brand-digital-human-studio/workflows.md index c082941..40f2aa0 100644 --- a/examples/dam/brand-digital-human-studio/workflows.md +++ b/examples/saas/dam/brand-digital-human-studio/workflows.md @@ -8,7 +8,7 @@ 创作者选材或输入需求查看规则候选,人工确认后编辑分镜、提交、审核和复用。模拟任务queued→generating→completed约22秒,播放内置样片;不是实际AI表演。 -真实操作在独立4318入口由用户提交。平台状态回读,不伪造百分比。只有Higgsfield排队任务可取消;万相、对口型及小云雀暂不支持取消。真实重试带入原配置,等待用户再次点击。 +真实操作在独立4319入口由用户提交。平台状态回读,不伪造百分比。只有Higgsfield排队任务可取消;万相、对口型及小云雀暂不支持取消。真实重试带入原配置,等待用户再次点击。 声音流程:配置Key只保存本机→录音上传试听→点击复刻,目标TTS模型匹配且OK后绑定人物→点击新台词TTS,归档试听→点击图片对口型,异步查询并取回MP4。各收费步骤独立,不能自动执行下一步。修改人物、台词或声线会阻断不匹配语音;语音下载失败可重新取回,不再TTS。 diff --git a/examples/dam/character-material-library/README.md b/examples/saas/dam/character-material-library/README.md similarity index 76% rename from examples/dam/character-material-library/README.md rename to examples/saas/dam/character-material-library/README.md index fc402da..06f790d 100644 --- a/examples/dam/character-material-library/README.md +++ b/examples/saas/dam/character-material-library/README.md @@ -6,6 +6,16 @@ **状态:prototype。** 主入口为 Persona Studio 0.1.1,来自 2026-10-13 的技术交接包。所有人物、结果、费用和角色身份均为演示。小云雀真实生成、素材长期复用、克隆声线、全身剧情与真实试戴尚未验收。 +## 资产复用关系 + +本案例是 **参考实现(reference-implementation)**,沿用以下设计资产: + +- 标准主干:`dam-content-assets`,见 [主干目录](../../../../catalog/blueprints.json)。复用内容资产、版本、使用范围、审核和归档的共同结构。 +- 场景变体:`digital-human-content-production`,见 [变体目录](../../../../catalog/variants.json)。补充人物形象、声线、素材快照及数字人内容制作的差异。 +- 本实现:`character-material-library`。可复用人物与素材版本管理、任务配置和审核页面;真实身份、文件存储、生成渠道与审核策略仍需按部署方替换或接入。 + +主干与变体是参考设计,不代表已部署产品。本案例用本地与 Mock 流程验证参考设计,未形成真实客户项目交付或视频生成验收。[验证记录](evidence/verification.md)只证明其中注明的本地检查,不能当作客户部署证据。 + ## 运行 从仓库根目录执行,Node.js 22.12 或更新版本: diff --git a/examples/dam/character-material-library/acceptance.md b/examples/saas/dam/character-material-library/acceptance.md similarity index 100% rename from examples/dam/character-material-library/acceptance.md rename to examples/saas/dam/character-material-library/acceptance.md diff --git a/examples/dam/character-material-library/case.json b/examples/saas/dam/character-material-library/case.json similarity index 90% rename from examples/dam/character-material-library/case.json rename to examples/saas/dam/character-material-library/case.json index 7d8519b..2b0c6fa 100644 --- a/examples/dam/character-material-library/case.json +++ b/examples/saas/dam/character-material-library/case.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 3, "id": "character-material-library", "title": "本色 Persona Studio · 企业数字人工作台", "summary": "管理人物与素材版本,通过需求解析、模拟视频生成、人工审核和成片归档演示企业内容制作闭环。", @@ -71,5 +71,15 @@ "mode": "unavailable", "description": "工作台可模拟试戴内容的制作流程,真实服饰配饰细节与试戴质量尚未验证。" } + ], + "serviceModel": "saas", + "caseKind": "reference-implementation", + "blueprintId": "dam-content-assets", + "variantId": "digital-human-content-production", + "evidence": [ + { + "path": "evidence/verification.md", + "title": "本地模拟工作台的功能与浏览器验证记录" + } ] } diff --git a/examples/dam/character-material-library/data-model.md b/examples/saas/dam/character-material-library/data-model.md similarity index 100% rename from examples/dam/character-material-library/data-model.md rename to examples/saas/dam/character-material-library/data-model.md diff --git a/examples/dam/character-material-library/data/fixtures.json b/examples/saas/dam/character-material-library/data/fixtures.json similarity index 100% rename from examples/dam/character-material-library/data/fixtures.json rename to examples/saas/dam/character-material-library/data/fixtures.json diff --git a/examples/dam/character-material-library/data/fixtures.schema.json b/examples/saas/dam/character-material-library/data/fixtures.schema.json similarity index 100% rename from examples/dam/character-material-library/data/fixtures.schema.json rename to examples/saas/dam/character-material-library/data/fixtures.schema.json diff --git a/examples/dam/character-material-library/demo/README.md b/examples/saas/dam/character-material-library/demo/README.md similarity index 74% rename from examples/dam/character-material-library/demo/README.md rename to examples/saas/dam/character-material-library/demo/README.md index bfddbb0..447f4f9 100644 --- a/examples/dam/character-material-library/demo/README.md +++ b/examples/saas/dam/character-material-library/demo/README.md @@ -2,6 +2,6 @@ 此入口保留最初的 2 个虚构人物、8 项素材,以及检索、详情、归档、任务输入快照的简化流程。素材卡使用代码占位图,没有真实媒体。 -仓库根目录运行 `npm run demo` 后打开 。本入口只使用内存,刷新重置;任务模拟完成后没有生成文件。 +仓库根目录运行 `npm run demo` 后打开 。本入口只使用内存,刷新重置;任务模拟完成后没有生成文件。 当前案例主入口已切换为 [Persona Studio](../README.md),具有六页工作台和完整模拟制作、审核、归档链路。 diff --git a/examples/dam/character-material-library/demo/app.mjs b/examples/saas/dam/character-material-library/demo/app.mjs similarity index 100% rename from examples/dam/character-material-library/demo/app.mjs rename to examples/saas/dam/character-material-library/demo/app.mjs diff --git a/examples/dam/character-material-library/demo/index.html b/examples/saas/dam/character-material-library/demo/index.html similarity index 100% rename from examples/dam/character-material-library/demo/index.html rename to examples/saas/dam/character-material-library/demo/index.html diff --git a/examples/dam/character-material-library/demo/model.mjs b/examples/saas/dam/character-material-library/demo/model.mjs similarity index 100% rename from examples/dam/character-material-library/demo/model.mjs rename to examples/saas/dam/character-material-library/demo/model.mjs diff --git a/examples/dam/character-material-library/evidence/import.md b/examples/saas/dam/character-material-library/evidence/import.md similarity index 100% rename from examples/dam/character-material-library/evidence/import.md rename to examples/saas/dam/character-material-library/evidence/import.md diff --git a/examples/dam/character-material-library/evidence/persona-library.png b/examples/saas/dam/character-material-library/evidence/persona-library.png similarity index 100% rename from examples/dam/character-material-library/evidence/persona-library.png rename to examples/saas/dam/character-material-library/evidence/persona-library.png diff --git a/examples/dam/character-material-library/evidence/verification.md b/examples/saas/dam/character-material-library/evidence/verification.md similarity index 100% rename from examples/dam/character-material-library/evidence/verification.md rename to examples/saas/dam/character-material-library/evidence/verification.md diff --git a/examples/dam/character-material-library/evidence/workbench.png b/examples/saas/dam/character-material-library/evidence/workbench.png similarity index 100% rename from examples/dam/character-material-library/evidence/workbench.png rename to examples/saas/dam/character-material-library/evidence/workbench.png diff --git a/examples/dam/character-material-library/integrations.md b/examples/saas/dam/character-material-library/integrations.md similarity index 100% rename from examples/dam/character-material-library/integrations.md rename to examples/saas/dam/character-material-library/integrations.md diff --git a/examples/dam/character-material-library/requirements.md b/examples/saas/dam/character-material-library/requirements.md similarity index 100% rename from examples/dam/character-material-library/requirements.md rename to examples/saas/dam/character-material-library/requirements.md diff --git a/examples/dam/character-material-library/workbench/.gitignore b/examples/saas/dam/character-material-library/workbench/.gitignore similarity index 100% rename from examples/dam/character-material-library/workbench/.gitignore rename to examples/saas/dam/character-material-library/workbench/.gitignore diff --git a/examples/dam/character-material-library/workbench/README.md b/examples/saas/dam/character-material-library/workbench/README.md similarity index 100% rename from examples/dam/character-material-library/workbench/README.md rename to examples/saas/dam/character-material-library/workbench/README.md diff --git a/examples/dam/character-material-library/workbench/docs/ACCEPTANCE.md b/examples/saas/dam/character-material-library/workbench/docs/ACCEPTANCE.md similarity index 100% rename from examples/dam/character-material-library/workbench/docs/ACCEPTANCE.md rename to examples/saas/dam/character-material-library/workbench/docs/ACCEPTANCE.md diff --git a/examples/dam/character-material-library/workbench/docs/HANDOFF-20261013.md b/examples/saas/dam/character-material-library/workbench/docs/HANDOFF-20261013.md similarity index 100% rename from examples/dam/character-material-library/workbench/docs/HANDOFF-20261013.md rename to examples/saas/dam/character-material-library/workbench/docs/HANDOFF-20261013.md diff --git a/examples/dam/character-material-library/workbench/docs/IMAGE-ASSET.md b/examples/saas/dam/character-material-library/workbench/docs/IMAGE-ASSET.md similarity index 100% rename from examples/dam/character-material-library/workbench/docs/IMAGE-ASSET.md rename to examples/saas/dam/character-material-library/workbench/docs/IMAGE-ASSET.md diff --git a/examples/dam/character-material-library/workbench/docs/IMPLEMENTATION.md b/examples/saas/dam/character-material-library/workbench/docs/IMPLEMENTATION.md similarity index 100% rename from examples/dam/character-material-library/workbench/docs/IMPLEMENTATION.md rename to examples/saas/dam/character-material-library/workbench/docs/IMPLEMENTATION.md diff --git a/examples/dam/character-material-library/workbench/docs/PRD.md b/examples/saas/dam/character-material-library/workbench/docs/PRD.md similarity index 100% rename from examples/dam/character-material-library/workbench/docs/PRD.md rename to examples/saas/dam/character-material-library/workbench/docs/PRD.md diff --git a/examples/dam/character-material-library/workbench/docs/XIAOYUNQUE-API-RESEARCH.md b/examples/saas/dam/character-material-library/workbench/docs/XIAOYUNQUE-API-RESEARCH.md similarity index 100% rename from examples/dam/character-material-library/workbench/docs/XIAOYUNQUE-API-RESEARCH.md rename to examples/saas/dam/character-material-library/workbench/docs/XIAOYUNQUE-API-RESEARCH.md diff --git a/examples/dam/character-material-library/workbench/docs/XIAOYUNQUE.md b/examples/saas/dam/character-material-library/workbench/docs/XIAOYUNQUE.md similarity index 100% rename from examples/dam/character-material-library/workbench/docs/XIAOYUNQUE.md rename to examples/saas/dam/character-material-library/workbench/docs/XIAOYUNQUE.md diff --git a/examples/dam/character-material-library/workbench/docs/characters-desktop.png b/examples/saas/dam/character-material-library/workbench/docs/characters-desktop.png similarity index 100% rename from examples/dam/character-material-library/workbench/docs/characters-desktop.png rename to examples/saas/dam/character-material-library/workbench/docs/characters-desktop.png diff --git a/examples/dam/character-material-library/workbench/docs/generation-desktop.png b/examples/saas/dam/character-material-library/workbench/docs/generation-desktop.png similarity index 100% rename from examples/dam/character-material-library/workbench/docs/generation-desktop.png rename to examples/saas/dam/character-material-library/workbench/docs/generation-desktop.png diff --git a/examples/dam/character-material-library/workbench/docs/generation-mobile.png b/examples/saas/dam/character-material-library/workbench/docs/generation-mobile.png similarity index 100% rename from examples/dam/character-material-library/workbench/docs/generation-mobile.png rename to examples/saas/dam/character-material-library/workbench/docs/generation-mobile.png diff --git a/examples/dam/character-material-library/workbench/docs/library-desktop.png b/examples/saas/dam/character-material-library/workbench/docs/library-desktop.png similarity index 100% rename from examples/dam/character-material-library/workbench/docs/library-desktop.png rename to examples/saas/dam/character-material-library/workbench/docs/library-desktop.png diff --git a/examples/dam/character-material-library/workbench/docs/overview-desktop.png b/examples/saas/dam/character-material-library/workbench/docs/overview-desktop.png similarity index 100% rename from examples/dam/character-material-library/workbench/docs/overview-desktop.png rename to examples/saas/dam/character-material-library/workbench/docs/overview-desktop.png diff --git a/examples/dam/character-material-library/workbench/docs/overview-mobile.png b/examples/saas/dam/character-material-library/workbench/docs/overview-mobile.png similarity index 100% rename from examples/dam/character-material-library/workbench/docs/overview-mobile.png rename to examples/saas/dam/character-material-library/workbench/docs/overview-mobile.png diff --git a/examples/dam/character-material-library/workbench/index.html b/examples/saas/dam/character-material-library/workbench/index.html similarity index 100% rename from examples/dam/character-material-library/workbench/index.html rename to examples/saas/dam/character-material-library/workbench/index.html diff --git a/examples/dam/character-material-library/workbench/package.json b/examples/saas/dam/character-material-library/workbench/package.json similarity index 100% rename from examples/dam/character-material-library/workbench/package.json rename to examples/saas/dam/character-material-library/workbench/package.json diff --git a/examples/dam/character-material-library/workbench/playwright.config.ts b/examples/saas/dam/character-material-library/workbench/playwright.config.ts similarity index 100% rename from examples/dam/character-material-library/workbench/playwright.config.ts rename to examples/saas/dam/character-material-library/workbench/playwright.config.ts diff --git a/examples/dam/character-material-library/workbench/poc/README.md b/examples/saas/dam/character-material-library/workbench/poc/README.md similarity index 100% rename from examples/dam/character-material-library/workbench/poc/README.md rename to examples/saas/dam/character-material-library/workbench/poc/README.md diff --git a/examples/dam/character-material-library/workbench/poc/request.example.json b/examples/saas/dam/character-material-library/workbench/poc/request.example.json similarity index 100% rename from examples/dam/character-material-library/workbench/poc/request.example.json rename to examples/saas/dam/character-material-library/workbench/poc/request.example.json diff --git a/examples/dam/character-material-library/workbench/poc/xyq-poc.mjs b/examples/saas/dam/character-material-library/workbench/poc/xyq-poc.mjs similarity index 100% rename from examples/dam/character-material-library/workbench/poc/xyq-poc.mjs rename to examples/saas/dam/character-material-library/workbench/poc/xyq-poc.mjs diff --git a/examples/dam/character-material-library/workbench/poc/xyq-poc.test.mjs b/examples/saas/dam/character-material-library/workbench/poc/xyq-poc.test.mjs similarity index 100% rename from examples/dam/character-material-library/workbench/poc/xyq-poc.test.mjs rename to examples/saas/dam/character-material-library/workbench/poc/xyq-poc.test.mjs diff --git a/examples/dam/character-material-library/workbench/public/fonts/dm-sans.ttf b/examples/saas/dam/character-material-library/workbench/public/fonts/dm-sans.ttf similarity index 100% rename from examples/dam/character-material-library/workbench/public/fonts/dm-sans.ttf rename to examples/saas/dam/character-material-library/workbench/public/fonts/dm-sans.ttf diff --git a/examples/dam/character-material-library/workbench/public/fonts/licenses/DM-Sans-OFL.txt b/examples/saas/dam/character-material-library/workbench/public/fonts/licenses/DM-Sans-OFL.txt similarity index 100% rename from examples/dam/character-material-library/workbench/public/fonts/licenses/DM-Sans-OFL.txt rename to examples/saas/dam/character-material-library/workbench/public/fonts/licenses/DM-Sans-OFL.txt diff --git a/examples/dam/character-material-library/workbench/public/fonts/licenses/Noto-Sans-SC-OFL.txt b/examples/saas/dam/character-material-library/workbench/public/fonts/licenses/Noto-Sans-SC-OFL.txt similarity index 100% rename from examples/dam/character-material-library/workbench/public/fonts/licenses/Noto-Sans-SC-OFL.txt rename to examples/saas/dam/character-material-library/workbench/public/fonts/licenses/Noto-Sans-SC-OFL.txt diff --git a/examples/dam/character-material-library/workbench/public/fonts/licenses/Noto-Serif-SC-OFL.txt b/examples/saas/dam/character-material-library/workbench/public/fonts/licenses/Noto-Serif-SC-OFL.txt similarity index 100% rename from examples/dam/character-material-library/workbench/public/fonts/licenses/Noto-Serif-SC-OFL.txt rename to examples/saas/dam/character-material-library/workbench/public/fonts/licenses/Noto-Serif-SC-OFL.txt diff --git a/examples/dam/character-material-library/workbench/public/fonts/noto-sans-sc.ttf b/examples/saas/dam/character-material-library/workbench/public/fonts/noto-sans-sc.ttf similarity index 100% rename from examples/dam/character-material-library/workbench/public/fonts/noto-sans-sc.ttf rename to examples/saas/dam/character-material-library/workbench/public/fonts/noto-sans-sc.ttf diff --git a/examples/dam/character-material-library/workbench/public/fonts/noto-serif-sc.ttf b/examples/saas/dam/character-material-library/workbench/public/fonts/noto-serif-sc.ttf similarity index 100% rename from examples/dam/character-material-library/workbench/public/fonts/noto-serif-sc.ttf rename to examples/saas/dam/character-material-library/workbench/public/fonts/noto-serif-sc.ttf diff --git a/examples/dam/character-material-library/workbench/public/images/portraits.png b/examples/saas/dam/character-material-library/workbench/public/images/portraits.png similarity index 100% rename from examples/dam/character-material-library/workbench/public/images/portraits.png rename to examples/saas/dam/character-material-library/workbench/public/images/portraits.png diff --git a/examples/dam/character-material-library/workbench/src/components/CharacterCard.tsx b/examples/saas/dam/character-material-library/workbench/src/components/CharacterCard.tsx similarity index 100% rename from examples/dam/character-material-library/workbench/src/components/CharacterCard.tsx rename to examples/saas/dam/character-material-library/workbench/src/components/CharacterCard.tsx diff --git a/examples/dam/character-material-library/workbench/src/components/Shell.tsx b/examples/saas/dam/character-material-library/workbench/src/components/Shell.tsx similarity index 100% rename from examples/dam/character-material-library/workbench/src/components/Shell.tsx rename to examples/saas/dam/character-material-library/workbench/src/components/Shell.tsx diff --git a/examples/dam/character-material-library/workbench/src/components/TaskDetail.tsx b/examples/saas/dam/character-material-library/workbench/src/components/TaskDetail.tsx similarity index 100% rename from examples/dam/character-material-library/workbench/src/components/TaskDetail.tsx rename to examples/saas/dam/character-material-library/workbench/src/components/TaskDetail.tsx diff --git a/examples/dam/character-material-library/workbench/src/components/TaskTable.tsx b/examples/saas/dam/character-material-library/workbench/src/components/TaskTable.tsx similarity index 100% rename from examples/dam/character-material-library/workbench/src/components/TaskTable.tsx rename to examples/saas/dam/character-material-library/workbench/src/components/TaskTable.tsx diff --git a/examples/dam/character-material-library/workbench/src/components/ui.tsx b/examples/saas/dam/character-material-library/workbench/src/components/ui.tsx similarity index 100% rename from examples/dam/character-material-library/workbench/src/components/ui.tsx rename to examples/saas/dam/character-material-library/workbench/src/components/ui.tsx diff --git a/examples/dam/character-material-library/workbench/src/domain/catalog.ts b/examples/saas/dam/character-material-library/workbench/src/domain/catalog.ts similarity index 100% rename from examples/dam/character-material-library/workbench/src/domain/catalog.ts rename to examples/saas/dam/character-material-library/workbench/src/domain/catalog.ts diff --git a/examples/dam/character-material-library/workbench/src/domain/model.ts b/examples/saas/dam/character-material-library/workbench/src/domain/model.ts similarity index 100% rename from examples/dam/character-material-library/workbench/src/domain/model.ts rename to examples/saas/dam/character-material-library/workbench/src/domain/model.ts diff --git a/examples/dam/character-material-library/workbench/src/domain/provider.ts b/examples/saas/dam/character-material-library/workbench/src/domain/provider.ts similarity index 100% rename from examples/dam/character-material-library/workbench/src/domain/provider.ts rename to examples/saas/dam/character-material-library/workbench/src/domain/provider.ts diff --git a/examples/dam/character-material-library/workbench/src/domain/seed.ts b/examples/saas/dam/character-material-library/workbench/src/domain/seed.ts similarity index 100% rename from examples/dam/character-material-library/workbench/src/domain/seed.ts rename to examples/saas/dam/character-material-library/workbench/src/domain/seed.ts diff --git a/examples/dam/character-material-library/workbench/src/domain/types.ts b/examples/saas/dam/character-material-library/workbench/src/domain/types.ts similarity index 100% rename from examples/dam/character-material-library/workbench/src/domain/types.ts rename to examples/saas/dam/character-material-library/workbench/src/domain/types.ts diff --git a/examples/dam/character-material-library/workbench/src/main.tsx b/examples/saas/dam/character-material-library/workbench/src/main.tsx similarity index 100% rename from examples/dam/character-material-library/workbench/src/main.tsx rename to examples/saas/dam/character-material-library/workbench/src/main.tsx diff --git a/examples/dam/character-material-library/workbench/src/pages/Characters.tsx b/examples/saas/dam/character-material-library/workbench/src/pages/Characters.tsx similarity index 100% rename from examples/dam/character-material-library/workbench/src/pages/Characters.tsx rename to examples/saas/dam/character-material-library/workbench/src/pages/Characters.tsx diff --git a/examples/dam/character-material-library/workbench/src/pages/Generate.tsx b/examples/saas/dam/character-material-library/workbench/src/pages/Generate.tsx similarity index 100% rename from examples/dam/character-material-library/workbench/src/pages/Generate.tsx rename to examples/saas/dam/character-material-library/workbench/src/pages/Generate.tsx diff --git a/examples/dam/character-material-library/workbench/src/pages/Library.tsx b/examples/saas/dam/character-material-library/workbench/src/pages/Library.tsx similarity index 100% rename from examples/dam/character-material-library/workbench/src/pages/Library.tsx rename to examples/saas/dam/character-material-library/workbench/src/pages/Library.tsx diff --git a/examples/dam/character-material-library/workbench/src/pages/Overview.tsx b/examples/saas/dam/character-material-library/workbench/src/pages/Overview.tsx similarity index 100% rename from examples/dam/character-material-library/workbench/src/pages/Overview.tsx rename to examples/saas/dam/character-material-library/workbench/src/pages/Overview.tsx diff --git a/examples/dam/character-material-library/workbench/src/pages/Settings.tsx b/examples/saas/dam/character-material-library/workbench/src/pages/Settings.tsx similarity index 100% rename from examples/dam/character-material-library/workbench/src/pages/Settings.tsx rename to examples/saas/dam/character-material-library/workbench/src/pages/Settings.tsx diff --git a/examples/dam/character-material-library/workbench/src/pages/Tasks.tsx b/examples/saas/dam/character-material-library/workbench/src/pages/Tasks.tsx similarity index 100% rename from examples/dam/character-material-library/workbench/src/pages/Tasks.tsx rename to examples/saas/dam/character-material-library/workbench/src/pages/Tasks.tsx diff --git a/examples/dam/character-material-library/workbench/src/store.tsx b/examples/saas/dam/character-material-library/workbench/src/store.tsx similarity index 100% rename from examples/dam/character-material-library/workbench/src/store.tsx rename to examples/saas/dam/character-material-library/workbench/src/store.tsx diff --git a/examples/dam/character-material-library/workbench/src/styles.css b/examples/saas/dam/character-material-library/workbench/src/styles.css similarity index 100% rename from examples/dam/character-material-library/workbench/src/styles.css rename to examples/saas/dam/character-material-library/workbench/src/styles.css diff --git a/examples/dam/character-material-library/workbench/tests/browser.spec.ts b/examples/saas/dam/character-material-library/workbench/tests/browser.spec.ts similarity index 100% rename from examples/dam/character-material-library/workbench/tests/browser.spec.ts rename to examples/saas/dam/character-material-library/workbench/tests/browser.spec.ts diff --git a/examples/dam/character-material-library/workbench/tests/domain.test.ts b/examples/saas/dam/character-material-library/workbench/tests/domain.test.ts similarity index 100% rename from examples/dam/character-material-library/workbench/tests/domain.test.ts rename to examples/saas/dam/character-material-library/workbench/tests/domain.test.ts diff --git a/examples/dam/character-material-library/workbench/tsconfig.json b/examples/saas/dam/character-material-library/workbench/tsconfig.json similarity index 100% rename from examples/dam/character-material-library/workbench/tsconfig.json rename to examples/saas/dam/character-material-library/workbench/tsconfig.json diff --git a/examples/dam/character-material-library/workbench/vite.config.ts b/examples/saas/dam/character-material-library/workbench/vite.config.ts similarity index 100% rename from examples/dam/character-material-library/workbench/vite.config.ts rename to examples/saas/dam/character-material-library/workbench/vite.config.ts diff --git a/examples/dam/character-material-library/workflows.md b/examples/saas/dam/character-material-library/workflows.md similarity index 100% rename from examples/dam/character-material-library/workflows.md rename to examples/saas/dam/character-material-library/workflows.md diff --git a/examples/saas/mes/factory-operations/README.md b/examples/saas/mes/factory-operations/README.md new file mode 100644 index 0000000..0baee00 --- /dev/null +++ b/examples/saas/mes/factory-operations/README.md @@ -0,0 +1,97 @@ +# 工厂运营工作台 + +面向工厂现场员工、主管和销售,把断针与耗材申领、审批配送、断针回收、销售拜访和附件追溯放在一个工作台中。业务记录从配置的数据服务真实读取;未连接时显示配置或登录入口,不填入虚构的“已保存”记录。 + +当前状态为 **integrated**:React 工作台已在专用 QA 项目连接真实 Supabase 认证、数据库和私有资产存储,完成申领、回收、拜访及权限验证。标准数据端口与企业 HTTP 适配器可供其他部署复用;具体覆盖与限制见 [验证记录](evidence/verification.md)。MySQL 后端、AI 总结、语音识别和钉钉通知尚未接入,客户正式验收尚未完成。 + +## 资产复用关系 + +本案例是 **参考实现(reference-implementation)**,沿用以下设计资产: + +- 标准主干:`mes-production-operations`,见 [主干目录](../../../../catalog/blueprints.json)。复用现场上报、主管处理、执行回执、附件和过程追溯的结构。 +- 场景变体:`garment-workshop-consumables`,见 [变体目录](../../../../catalog/variants.json)。增加服装车间合同号、物料、工位、断针照片及碎片回收闭环。 +- 本实现:`factory-operations`。将该变体实现为可运行工作台,并扩展销售拜访记录;身份、业务库和资产存储由标准端口适配,其他工厂可替换字段、审批角色与数据提供方。 + +主干与变体是设计参考,销售拜访是本实现的附加范围,不是所有 MES 主干的必需能力。现有[证据](evidence/verification.md)来自合成输入和专用 QA 环境的真实服务验证,尚未收录客户生产部署或项目验收,因此不标记为项目案例(project-case)。 + +## 页面与角色 + +- **概览**:当前工作区申领和拜访概况。 +- **工厂申领**:合同号、物料编号、工位和原因追溯,现场附件,审批、拒绝、配送、回收确认与完结。 +- **销售拜访**:客户、拜访时间、沟通纪要、问题、下一步和跟进日期。 +- **资产资料**:工作区文件列表、上传与授权预览。 +- **工作区设置**:工作区成员和数据连接;所有者可添加已注册且确认邮箱的成员。 +- **数据接入**:所有者导出当前快照、预览 JSON 整包替换、核对删除影响后提交;上传照片可生成明确标注的模拟草稿。所有页面显示当前工作区的数据来源、存储范围和版本。 + +所有者和主管处理申领;操作员上报;销售记录拜访。工作区成员权限由服务端执行,界面按角色显示可用操作。所有者额外负责添加成员。 + +## 从干净检出运行 + +需要 Node.js 22.12 或更新版本。在仓库根目录执行: + +```bash +npm ci +npm run check +npm run factory:dev +``` + +打开 。也可执行 `npm run demo`,打开 ,在 MES 分类选择本案例。统一目录使用构建后的 `workbench/dist/index.html`。 + +工作区位于 Windows `\\wsl.localhost\...` 路径时,在 WSL 中执行 npm 命令。Windows npm 默认脚本 shell 不支持 UNC 工作目录。 + +## 选择数据提供方 + +每个部署可选择自己的数据服务,用户也可在连接设置中为当前浏览器覆盖默认配置。Supabase 是本次参考实现之一。配置界面接受项目公开地址与公开客户端密钥,或企业 HTTP API 地址;不接受数据库连接串。 + +可选用工作台目录下的 `.env.local` 设置构建默认值: + +```dotenv +VITE_DATA_PROVIDER=supabase +VITE_SUPABASE_URL=https://your-project.supabase.co +VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key +``` + +Supabase 部署先执行 [迁移目录](supabase/migrations/) 中的数据库迁移,并在所选项目配置邮箱认证及应用地址;之后通过工作台注册、确认邮件(如要求)并登录。首次登录创建工作区,再用合成输入验证真实写入与附件上传。迁移和认证配置详情见 [集成说明](integrations.md)。 + +接入 MySQL 或企业资产平台时使用: + +```dotenv +VITE_DATA_PROVIDER=http +VITE_API_BASE_URL=https://your-platform.example/api/workbench +``` + +企业服务端需实现 [标准 HTTP 契约](../../../../docs/data-provider-contract.md),负责身份、工作区授权、数据库事务和资产访问。仓库已经提供 HTTP 客户端;不会因选择该选项自动创建 MySQL 服务。 + +身份、业务数据、资产分别通过 `IdentityProvider`、`OperationsRepository`、`AssetStore` 端口接入,也可由部署者组合不同实现。配置和提供方选择不进入业务字段,切换配置不会自动迁移数据。 + +## 首次体验与重置 + +1. 连接所选服务并登录,创建一个用于验证的工作区。 +2. 使用 [合成输入](data/fixtures.json) 创建断针或耗材申领。断针必须先上传至少一张 JPG、PNG 或 WebP 照片;仓库不附带真实现场照片。 +3. 主管或所有者批准、开始配送;断针确认碎片回收后再完结,检查操作时间线。 +4. 创建一条销售拜访,刷新页面确认业务记录和资产均能读回。 + +数据保存在所选服务,刷新不会重置。所有者可在“数据接入”用本工作区快照替换业务数据;显式空业务表会删除对应记录,必须先检查预览中的删除数量。空格式模板不能提交,文件正文和成员不随整包替换删除。测试使用独立工作区,资产清理与跨服务备份仍由部署管理员执行。“使用部署默认配置”只删除当前浏览器的连接覆盖,不删除业务数据。 + +## JSON 与拍照台账 + +个人试用所选 Supabase 项目已应用交换迁移,并完成 8 组云端事务与权限验证;首次真实用户注册及云端 UI 验收尚未进行。其他部署仍需应用迁移并独立验证,选择提供方不会自动部署接口。详见 [集成说明](integrations.md) 与 [验证记录](evidence/verification.md)。 + +1. 所有者在“数据接入”点击“导出完整快照”,得到当前工作区四张业务表及资产清单。先保存当前快照,再处理待恢复的文件。 +2. “下载格式模板”只说明包结构,不能提交。填写数据时以当前导出包为基准,对照 [字段与流程约束](data-model.md),保留工厂 ID、完整事件、历史操作者、来源和资产引用。 +3. 选择 JSON,点击“预览并校验”,查看源/目标工作区、逐表数量、删除影响和字段错误;只有确认替换后服务端才原子发布。出现冲突先重新预览;响应丢失时重试沿用同一提交标识。 +4. 拍照模式先保存照片到资产平台,再生成固定规则的合成申领或拜访草稿。对照原图逐项编辑并核对后走相同预览;它不读取照片内容,人工确认仍保留“模拟识别”及合成来源。 + +本版只支持同一工作区恢复。JSON 不含资产文件正文,包内资产逐项匹配当前已登记文件,传入业务引用必须在包清单中声明。备份后新增的无关资产保留且不阻止恢复;包中引用的原文件缺失或改写则拒绝。本功能不是跨数据库迁移或自包含备份,也不接通 OCR、ERP 库存或采购付款。 + +## 规格与限制 + +详见 [需求](requirements.md)、[流程](workflows.md)、[数据模型](data-model.md)、[集成](integrations.md)、[验收](acceptance.md) 和 [验证记录](evidence/verification.md)。公开夹具均为合成输入;实际服务为空时显示空状态。当前没有离线写入队列、跨服务数据迁移、库存扣减或正式客户验收。 + +## 个人试用部署进度(2026-10-15) + +现有 Supabase 项目已准备好新交换能力,迁移版本为 `20261014171026`。云端验证采用合成输入并在事务中回滚;结束后业务数据、Storage 对象和应用用户均为零。首次应用账号由使用者自行注册,尚未记录真实用户的云端 UI 验收。 + +Sites 所有者私有试用站已发布,Supabase 已连接;发布工具返回 `succeeded`。邮箱确认保持开启,默认回跳地址与精确重定向允许列表均指向在线工厂入口,默认和显式目标的 303 回跳复测均匹配。真实用户注册后的 UI 验收待完成。 + +本机静态预览已验收;当前网络访问线上站点被 Cloudflare 托管边缘拦截,未进入工作台,因此不声明在线 UI 已验收。公开文档不保存私有地址、项目标识或密钥。案例保持 `integrated`、`customerConfirmed: false`;Supabase 是本次个人部署选择,其他用户仍可采用自己的提供方。 diff --git a/examples/saas/mes/factory-operations/acceptance.md b/examples/saas/mes/factory-operations/acceptance.md new file mode 100644 index 0000000..1f03b7b --- /dev/null +++ b/examples/saas/mes/factory-operations/acceptance.md @@ -0,0 +1,38 @@ +# 验收标准 + +以下是需要验证的行为,不代表已经通过。实际结果与环境记录在 [验证记录](evidence/verification.md)。`npm run check` 验证代码、构建和仓库契约;真实登录、权限、数据库回读和文件预览还需要连接环境检查。 + +| 编号 | Given / When / Then | 检查方法 | +| ----- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| AC-01 | 给定未配置数据提供方,当打开工作台时,应显示配置说明且不生成演示业务记录。 | 浏览器,空配置构建 | +| AC-02 | 给定有效身份服务,当注册、确认邮件(如要求)并登录时,应得到真实会话;退出后不能继续读写受保护数据。 | 连接环境、浏览器及未登录 API 请求 | +| AC-03 | 给定两个不同工作区及其成员,当读取单据、拜访和资产时,只能访问所属工作区;猜测其他 ID 也被拒绝。 | 两个测试账号、直接接口请求 | +| AC-04 | 给定工厂角色,当提交完整业务字段和现场照片时,应真实保存单据及资产引用;刷新后可读回。缺失必填项或断针没有照片时应拒绝,耗材允许无附件。 | 浏览器主链路、数据库回读、自动领域测试 | +| AC-05 | 给定待审批单据,当主管批准并推进配送时,状态按顺序变化并形成时间线;操作员直接调用审批接口应被拒绝。 | 浏览器、接口权限测试 | +| AC-06 | 给定配送中的断针单据,当未确认回收就完结时,应拒绝;确认回收并添加备注后才能完结。耗材不需要断针回收。 | 自动领域测试、真实数据库状态机检查 | +| AC-07 | 给定待审批单据,当主管拒绝时,应保留原因和历史,拒绝后不能继续配送。 | 状态机测试、浏览器 | +| AC-08 | 给定同一单据的旧版本,当另一个成员已推进状态后再提交旧版本时,应报告冲突且不覆盖最新版本。 | 并发 API 请求 | +| AC-09 | 给定同一状态操作请求 ID,当网络重试提交两次时,不应产生重复状态事件。 | 请求重放、时间线计数 | +| AC-10 | 给定销售角色,当保存拜访后应刷新可读回;销售只能查看本人拜访,主管和所有者可查看全工作区,操作员不能读取。销售不能审批工厂单据。 | 浏览器、角色 API 测试 | +| AC-11 | 给定工作区资产,当有权限的成员预览时应取得可用授权地址;其他工作区或未登录用户不能读取私有文件。 | 文件上传/下载、权限请求 | +| AC-12 | 给定上传失败、连接中断或身份过期,当用户操作时,应显示错误,不能显示虚假成功;无法确认服务端是否已保存时,刷新回读后再重试。 | 错误注入、浏览器 | +| AC-13 | 给定合法 HTTP 提供方配置,当以相同业务契约执行身份、单据、拜访和资产操作时,前端可使用该接口;配置不完整或服务未实现时不能标成已连接。 | HTTP 适配器测试与部署者服务联调 | +| AC-14 | 给定干净检出,当执行安装、检查和构建时,应通过 schemas/索引一致性检查;统一目录可找到 MES 案例并打开工作台。 | `npm ci`、`npm run check`、目录浏览器检查 | + +当前范围不包括企业 SSO、AI 语音与图片识别、钉钉通知、ERP 库存扣减或客户正式验收。HTTP 客户端通过测试不等于企业 MySQL 服务已经部署;每个提供方必须单独形成连接证据。 + +## 整包交换扩展验收 + +| 编号 | Given / When / Then | 检查方法 | +| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | +| AC-15 | 同工作区完整快照经过导出、预览和恢复,报告、拜访、事件版本、原操作者、请求记录、来源与资产清单保持一致,不重放业务动作。 | 领域测试、数据库事务测试、部署回读 | +| AC-16 | 空模板、范围内漏表、重复键/ID、未知列、非法整数/真实日期、悬空引用或缺回收/照片的完成记录,应整包拒绝并返回表/行/字段;最后一行错误也不能部分发布。 | 领域负例、服务端直接请求及回读 | +| AC-17 | 显式空表显示准确删除数量,范围外表保留且共同参与候选校验;不能删除报告却留下旧事件,空 tables 不清库。 | 候选库与数据库回滚测试 | +| AC-18 | 预览后有人新增、审批、上传登记或修改数据,旧目标 revision 提交失败;同 requestId 重试同计划返回同结果,不同内容复用 ID 拒绝。 | 事务/并发测试、HTTP 错误注入 | +| AC-19 | 非所有者直接请求导出、预览或提交被拒绝;所有成员仅可读取当前工作区来源元信息。 | 服务端角色与工作区测试 | +| AC-20 | 跨工作区包、未知/改写或包内业务未声明的资产拒绝;备份后新增无关资产不阻止恢复,候选库保留当前全部资产;旧资产回读失败时不伪造完整快照。 | 领域测试、资产适配器与部署检查 | +| AC-21 | 上传照片只保存资产并生成合成待核对草稿;编辑确认后仍保留 synthetic/mock/reviewed。生成至预览间版本变化须重新核对,不能覆盖其他成员写入。 | 领域与浏览器测试 | +| AC-22 | 只有服务端发布成功后刷新来源及版本;失败保留原正式数据,切换工作区/连接后旧响应不串入新页面,模拟记录往返后继续标注。 | 浏览器竞态和失败注入 | +| AC-23 | 超过 5 MiB、40 层或业务注册上限的输入明确拒绝;HTTP 保留原文、同幂等键,不自动重试 403/409/服务或网络失败。 | 解析边界与 HTTP 契约测试 | + +新增功能的本地验证、云端回滚事务与真实用户 UI 验收分开记录。个人试用 Supabase 交换迁移已部署并完成数据库与权限检查,所有者私有试用站已发布。邮箱确认开启,默认/显式确认目标的 303 回跳与精确允许列表匹配;首次用户注册后的云端 UI 验收仍待完成,当前网络访问被托管边缘拦截,不能据发布成功判定在线 UI 已验收。其他提供方不能继承此 `connected` 状态。通用 schema、领域测试和客户端契约都不能替代数据库事务与真实提供方验证。 diff --git a/examples/saas/mes/factory-operations/case.json b/examples/saas/mes/factory-operations/case.json new file mode 100644 index 0000000..0d12a0c --- /dev/null +++ b/examples/saas/mes/factory-operations/case.json @@ -0,0 +1,97 @@ +{ + "version": 3, + "id": "factory-operations", + "title": "工厂运营工作台", + "summary": "通过照片上报、审批配送与断针回收追溯工厂申领,记录销售拜访与跟进,使用可选择的数据和资产服务。", + "domain": "mes", + "tags": ["crm", "factory", "approvals", "asset-management", "multi-provider"], + "status": "integrated", + "source": { + "date": "2026-10-14", + "kind": "business-discussion", + "summary": "依据已读取的业务听记及后续数据源可替换要求抽象整理;公开范围仅含业务规格、源码和合成验证输入,不保留私人链接、原始对话、真实客户或账号信息。完整客户验收尚未完成。", + "customerConfirmed": false + }, + "dataMode": "synthetic", + "deliverables": { + "overview": "README.md", + "requirements": "requirements.md", + "workflows": "workflows.md", + "dataModel": "data-model.md", + "integrations": "integrations.md", + "acceptance": "acceptance.md", + "evidence": "evidence/verification.md" + }, + "runtime": { + "mode": "application", + "command": "npm run factory:dev", + "entry": "workbench/dist/index.html", + "reset": "业务记录保存在所选服务,刷新不重置。所有者可预览并确认同工作区整包恢复;资产文件和成员不随包删除。恢复部署默认配置只清除当前浏览器连接覆盖。" + }, + "capabilities": [ + { + "id": "factory-workflow", + "title": "工厂申领与断针追溯", + "mode": "local", + "description": "上报表单、审批配送、回收确认、完结与时间线界面;数据写入由配置的真实服务执行,没有本地模拟成功。" + }, + { + "id": "sales-visits", + "title": "销售拜访记录", + "mode": "local", + "description": "客户拜访、沟通纪要、问题、下一步与跟进日期表单及查询。" + }, + { + "id": "provider-contracts", + "title": "可替换身份、业务与资产端口", + "mode": "local", + "description": "独立的 IdentityProvider、OperationsRepository、AssetStore;Supabase 和 HTTP 组合可配置,端口可另行实现与组合。" + }, + { + "id": "supabase-deployment", + "title": "Supabase 身份、数据库与私有资产", + "mode": "connected", + "description": "在专用 QA 项目完成真实登录、数据库写入与重登回读、工作区权限、私有资产字节回读、断针回收闭环与拜访验证;不随仓库提供账号或项目配置。" + }, + { + "id": "enterprise-data-platform", + "title": "MySQL 与企业资产平台", + "mode": "unavailable", + "description": "提供标准 HTTP 客户端与接口约定;企业后端服务需单独实现和联调,未提供 MySQL 直连。" + }, + { + "id": "ai-and-dingtalk", + "title": "AI 分析与钉钉通知", + "mode": "unavailable", + "description": "自动语音/图片分析、AI 总结和钉钉通知没有接入,不以模拟数据表示已执行。" + }, + { + "id": "ledger-exchange", + "title": "同工作区整包交换", + "mode": "connected", + "description": "个人试用 Supabase 已部署交换迁移并通过 8 组云端回滚事务与权限检查,所有者私有试用站已发布。首次真实用户注册后的云端 UI 验收待完成;资产正文留原平台,其他提供方需独立接入。" + }, + { + "id": "mock-ledger-recognition", + "title": "拍照台账模拟核对", + "mode": "mock", + "description": "上传原图后生成固定规则合成草稿,用户对照编辑与核对后进入整包预览;不读取图片内容,人工确认保留 mock 和合成来源。" + }, + { + "id": "dataset-provenance", + "title": "工作区与记录来源标识", + "mode": "local", + "description": "显示当前数据集来源、存储范围、版本和记录识别来源;成功发布后重新读取,失败或未支持时不猜测来源。" + } + ], + "serviceModel": "saas", + "caseKind": "reference-implementation", + "blueprintId": "mes-production-operations", + "variantId": "garment-workshop-consumables", + "evidence": [ + { + "path": "evidence/verification.md", + "title": "专用 QA 环境的真实服务集成与权限验证记录" + } + ] +} diff --git a/examples/saas/mes/factory-operations/data-model.md b/examples/saas/mes/factory-operations/data-model.md new file mode 100644 index 0000000..c916bef --- /dev/null +++ b/examples/saas/mes/factory-operations/data-model.md @@ -0,0 +1,55 @@ +# 数据模型 + +业务类型见 [contracts.ts](workbench/src/data/contracts.ts),提供方映射和 HTTP 约定见 [数据提供方契约](../../../../docs/data-provider-contract.md)。基础端口将 ID 视为不透明字符串;本案例的 `factory-operations` v1 交换注册契约进一步要求 UUID,以匹配现有业务记录。其他提供方若使用不同 ID,需显式映射并声明兼容契约,不能自动改写历史引用。 + +| 实体 | 关键字段 | 关系与用途 | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| `UserIdentity` | `id`、`email` | 身份服务返回的当前用户;密码不进入业务数据 | +| `Workspace` | `id`、`name`、`role`、`createdAt` | 数据隔离单位;`role` 是当前用户在此工作区的角色 | +| `WorkspaceMember` | `userId`、`email`、`role` | 用户与工作区的成员关系;所有者、主管、操作员、销售 | +| `OperationReport` | `id`、`workspaceId`、`kind`、`contractNumber`、`materialNumber`、`station`、`reason`、`quantity`、`status` | 断针或耗材申领及处理状态 | +| 报告版本与留痕 | `createdBy`、`createdAt`、`updatedAt`、`version`、`fragmentsRecovered` | 创建人、状态并发控制、断针回收条件 | +| `TimelineEntry` | `id`、`reportId`、`actorId`、`action`、`note`、`createdAt` | 与报告关联的操作事件 | +| `SalesVisit` | `id`、`workspaceId`、`createdBy`、`customer`、`visitedAt`、`summary`、`issues`、`nextStep`、`followUpDate`、`createdAt` | 销售拜访与跟进记录 | +| `AssetRef` | `id`、`workspaceId`、`name`、`mediaType`、`size`、`storagePath`、`createdAt` | 资产元数据,文件正文保存在资产存储 | + +报告输入通过 `assetIds` 关联资产;回读时带 `assets` 元信息列表。下载地址在预览时取得,不属于永久字段。合同、物料和客户当前为录入文本,尚未连接 ERP 或客户主数据字典。 + +## 业务约束 + +- 工作区、成员、报告、拜访与资产按工作区授权;授权来自服务端成员关系,不能由浏览器自报。 +- `kind` 为 `broken_needle` 或 `consumable`。`status` 为 `submitted`、`approved`、`delivering`、`completed` 或 `rejected`。 +- 合同号、物料编号与工位不超过 120 字,原因不超过 2000 字;数量为 1 到 100000 的整数。 +- 单据最多关联 10 个不同附件,附件必须属于同一工作区;断针至少一张 JPG、PNG 或 WebP 照片,耗材可不关联。资产允许 JPG、PNG、WebP、PDF、MP4、MP3,大小大于 0 且不超过 20 MB。 +- 状态命令包含 `expectedVersion`、操作 `note` 和 `requestId`。服务端校验版本后原子写入新状态和事件;旧版本不能覆盖新版本。 +- 断针只有完成回收确认后才可进入 `completed`;状态和回收标记由服务端受控命令修改。 +- 拜访的客户名称、沟通纪要、下一步为必填,问题可以为空,跟进日期可以为 `null`。拜访时间使用可解析的时间戳;跟进日期使用 `YYYY-MM-DD`。所有者与主管读取全工作区拜访,销售只读取本人记录,操作员不可读取。 + +## 生命周期 + +新建记录写入所选提供方,刷新和再次登录从该服务读取。切换提供方不会迁移旧数据。普通单据页面不提供删除、撤回或自动归档;所有者可通过完整校验和确认的整包替换恢复同工作区业务表。部署管理员仍应制定文件备份、保留与清理策略。 + +上传文件与创建业务记录是两个步骤。上传成功、单据提交失败时可能留下未关联资产;资产列表仍可查看。生产部署需要按业务政策补充未关联资产巡检与清理,不能仅因客户端失败就删除可能已被引用的对象。 + +## 合成数据 + +[data/fixtures.json](data/fixtures.json) 是公开的表单输入与验收场景,不是生产数据库转储。它没有真实用户、已上传文件、服务地址、认证凭据或签名 URL。当前不在登录后自动注入;创建工作区后可手工使用其中字段验证流程。 + +## `factory-operations` v1 交换注册契约 + +类型见 [exchange-contracts.ts](workbench/src/data/exchange-contracts.ts),业务校验见 [exchange.ts](workbench/src/domain/exchange.ts)。包结构遵守 [通用交换契约](../../../../docs/ledger-exchange.md),具体表与字段如下;未知字段拒绝,不静默删除或转成默认值。 + +| 表 | 每行 `data` 字段 | 核心约束 | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `factory` | `name` | 恰好一行,行 ID 等于目标工作区;必须在包与替换范围中 | +| `reports` | `kind, contractNumber, materialNumber, station, reason, quantity, assetIds, createdBy, createdAt, updatedAt, status, version, fragmentsRecovered` | 数量严格整数;断针有照片,完结有回收;状态/版本可由完整事件链重建 | +| `report-events` | `reportId, actorId, action, note, createdAt, requestId, payload` | 初始 submitted 的 requestId/payload 为 null;其他 payload 为 `{version, action, note}`,请求 ID 唯一且与流程版本一致 | +| `visits` | `customer, visitedAt, summary, issues, nextStep, followUpDate, createdBy, createdAt` | 合法含时区时间戳、真实日历日期或 null;保留客户纪要与下一步 | + +每行另外有 UUID `id` 和 `provenance`。来源包括 `contentOrigin` 及可选的 `{mode, reviewStatus, sourceAssetIds}` 识别记录。已有同 ID 业务行不能通过导入改写来源、识别模式或原图引用;mock 经人工确认后仍为 mock/synthetic。包的来源汇总只统计业务行,不计工厂档案。当前模型没有独立分析/行动表,拜访问题与下一步随 `visits` 保存;未知 ERP 台账不接受。 + +资产清单记录 `id/name/mediaType/size/sha256/reference`。允许是当前工作区已登记资产的子集;包内每项元信息、摘要与稳定引用必须匹配,传入业务行所用附件或识别原图必须在包清单中声明。候选库保留当前全部资产,范围外保留行按当前资产清单验证;备份后新增无关资产不会阻止恢复,也不会被删除。不能传入未登记文件、其他工作区路径或短时 URL;资产正文仍由 AssetStore 保存。原资产缺少摘要时需由可信服务补齐真实字节摘要,不得猜测。业务行、事件操作者与资产 ID 必须保持合法引用。 + +目标工作区有独立数据 `revision`;正常写入与整包发布均推进版本,预览固定目标版本。候选库按“范围外旧表 + 范围内新表”校验,提交结果与新版本一次发布。当前界面采用四表范围,内部契约也允许包含 factory 的显式子范围;缺表与 `[]` 的含义不同。成员关系与文件不属于替换范围。 + +导入上限为 5 MiB JSON、40 层嵌套、每表 10000 行、10000 个资产。数量、日期和 UUID 不自动转换;合同与物料编号是字符串,前导零保留。导出包最大范围仍受上限限制,不能把被截断列表称为完整快照。 diff --git a/examples/saas/mes/factory-operations/data/README.md b/examples/saas/mes/factory-operations/data/README.md new file mode 100644 index 0000000..e71868f --- /dev/null +++ b/examples/saas/mes/factory-operations/data/README.md @@ -0,0 +1,7 @@ +# 合成验证输入 + +`fixtures.json` 提供虚构的工作区名称、工厂上报和销售拜访输入,`fixtures.schema.json` 校验结构。它们用于开发与手工验收,不代表云端已保存记录,也不会自动灌入用户数据源。 + +工厂输入的 `assetIds` 初始为空:断针输入是待补齐的表单模板,提交前必须真实上传自制测试照片,把返回的资产 ID 关联到新单;耗材可以无附件。不要用虚构 ID 冒充已存资产。日期仅为稳定示例,实际跟进时按业务需要填写。 + +这些数据没有真实客户、个人身份、账号密码、网络地址或可访问媒体;每次验证可新建独立工作区。业务数据的清理由部署管理员在实际提供方执行。 diff --git a/examples/saas/mes/factory-operations/data/fixtures.json b/examples/saas/mes/factory-operations/data/fixtures.json new file mode 100644 index 0000000..0995cd9 --- /dev/null +++ b/examples/saas/mes/factory-operations/data/fixtures.json @@ -0,0 +1,33 @@ +{ + "workspaceName": "示例制造协作空间", + "reports": [ + { + "kind": "broken_needle", + "contractNumber": "DEMO-2026-0101", + "materialNumber": "NEEDLE-11-SAMPLE", + "station": "缝制一组 03 工位", + "reason": "合成场景:换线检查发现断针,已暂停作业,申请补充并登记碎片回收。", + "quantity": 1, + "assetIds": [] + }, + { + "kind": "consumable", + "contractNumber": "DEMO-2026-0102", + "materialNumber": "THREAD-WHITE-SAMPLE", + "station": "缝制二组 05 工位", + "reason": "合成场景:本班次白色线轴不足,申请按合同补料。", + "quantity": 6, + "assetIds": [] + } + ], + "visits": [ + { + "customer": "示例青禾服装", + "visitedAt": "2026-10-14T01:30:00.000Z", + "summary": "合成场景:沟通冬季样衣补单计划,对方希望先确认面料和交付批次。", + "issues": "第二批面料色卡仍待确认。", + "nextStep": "整理两套色卡方案并安排样衣复核。", + "followUpDate": "2026-10-16" + } + ] +} diff --git a/examples/saas/mes/factory-operations/data/fixtures.schema.json b/examples/saas/mes/factory-operations/data/fixtures.schema.json new file mode 100644 index 0000000..e43dcea --- /dev/null +++ b/examples/saas/mes/factory-operations/data/fixtures.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": ["workspaceName", "reports", "visits"], + "properties": { + "workspaceName": { "type": "string", "minLength": 1, "maxLength": 120 }, + "reports": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "contractNumber", + "materialNumber", + "station", + "reason", + "quantity", + "assetIds" + ], + "properties": { + "kind": { "enum": ["broken_needle", "consumable"] }, + "contractNumber": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "materialNumber": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "station": { "type": "string", "minLength": 1, "maxLength": 120 }, + "reason": { "type": "string", "minLength": 1, "maxLength": 2000 }, + "quantity": { "type": "integer", "minimum": 1, "maximum": 100000 }, + "assetIds": { + "type": "array", + "maxItems": 10, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + } + }, + "visits": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "customer", + "visitedAt", + "summary", + "issues", + "nextStep", + "followUpDate" + ], + "properties": { + "customer": { "type": "string", "minLength": 1, "maxLength": 200 }, + "visitedAt": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T" + }, + "summary": { "type": "string", "minLength": 1, "maxLength": 5000 }, + "issues": { "type": "string", "maxLength": 5000 }, + "nextStep": { "type": "string", "minLength": 1, "maxLength": 2000 }, + "followUpDate": { + "type": ["string", "null"], + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + } + } + } + } +} diff --git a/examples/saas/mes/factory-operations/evidence/desktop.jpg b/examples/saas/mes/factory-operations/evidence/desktop.jpg new file mode 100644 index 0000000..eede86f Binary files /dev/null and b/examples/saas/mes/factory-operations/evidence/desktop.jpg differ diff --git a/examples/saas/mes/factory-operations/evidence/ledger-mobile.jpg b/examples/saas/mes/factory-operations/evidence/ledger-mobile.jpg new file mode 100644 index 0000000..92dd4d6 Binary files /dev/null and b/examples/saas/mes/factory-operations/evidence/ledger-mobile.jpg differ diff --git a/examples/saas/mes/factory-operations/evidence/ledger-photo-review.jpg b/examples/saas/mes/factory-operations/evidence/ledger-photo-review.jpg new file mode 100644 index 0000000..5089efb Binary files /dev/null and b/examples/saas/mes/factory-operations/evidence/ledger-photo-review.jpg differ diff --git a/examples/saas/mes/factory-operations/evidence/ledger-sources.jpg b/examples/saas/mes/factory-operations/evidence/ledger-sources.jpg new file mode 100644 index 0000000..1c1d496 Binary files /dev/null and b/examples/saas/mes/factory-operations/evidence/ledger-sources.jpg differ diff --git a/examples/saas/mes/factory-operations/evidence/mobile.jpg b/examples/saas/mes/factory-operations/evidence/mobile.jpg new file mode 100644 index 0000000..71a1c39 Binary files /dev/null and b/examples/saas/mes/factory-operations/evidence/mobile.jpg differ diff --git a/examples/saas/mes/factory-operations/evidence/verification.md b/examples/saas/mes/factory-operations/evidence/verification.md new file mode 100644 index 0000000..81ae6a9 --- /dev/null +++ b/examples/saas/mes/factory-operations/evidence/verification.md @@ -0,0 +1,116 @@ +# 集成验证记录 + +## 验证环境(2026-10-14) + +日期按 Asia/Shanghai 记录。环境为 WSL Ubuntu 22.04、Node.js 22.14.0、npm 10.9.2、`@supabase/supabase-js` 2.117.3、Supabase 托管 PostgreSQL 17.11,以及浏览器中的 React 工作台。 + +使用专用 QA 项目、两个已确认邮箱的专用测试账号和合成业务输入。业务来源已从授权读取的听记中抽象,公开文件不含原始对话、真实客户、账号密码、会话 token、项目 ID、项目地址或签名文件 URL。 + +本案例状态为 **integrated**,`customerConfirmed` 保持 `false`。真实服务检查说明实现可连接并完成受控业务闭环,不代表客户验收,也不代表其他部署已自动接入。 + +## 已执行检查 + +| 检查 | 实际结果 | 范围与限制 | +| ---------------------------- | ------------------------------------------ | ------------------------------------------------------------------------- | +| 依赖安装 | `npm ci` 成功 | Node.js / npm 环境与锁文件 | +| 三个数据库迁移 | 全部成功应用到 QA 项目 | 数据表、授权、私有资产策略、事件函数权限与业务冲突状态 | +| 单元与契约测试 | 20 项通过 | 领域边界、HTTP 调用契约、身份失效、配置与网络超时 | +| TypeScript 检查 | `tsc --noEmit` 通过 | 前端与适配器类型 | +| 真实云端 smoke test | 10 组检查全部通过,修复后约 15 秒完成 | 使用真实 SDK、认证、数据库及 Storage,不是模拟后端 | +| 真实浏览器操作 | 登录、创建耗材申领、审批成功 | 1440 像素桌面和 390 像素手机布局;手机内容宽度未超过可视区域 | +| 浏览器回归 | 本案例 5 项、原有案例 8 项全部通过 | 本案例覆盖重试幂等、上传复用、工作区竞态、移动布局及角色隔离 | +| 全仓检查 | `npm run check` 通过 | 格式、两个应用构建、规格验证、7 项仓库测试、37 项案例单测和 8 项 POC 测试 | +| 数据库安全 advisor | 两条自动事件函数权限告警修复后复查消失 | 仍有平台 `auth_leaked_password_protection` 未启用提示,见下方限制 | +| manifest / catalog / fixture | schema、六个同步字段和交付文档路径检查通过 | 仓库规格一致性,不替代外部权限测试 | +| 文档格式 | 全仓 Prettier 检查通过 | 代码、中文文档和 JSON | + +界面证据:[桌面概览](desktop.jpg)、[手机概览](mobile.jpg)。截图中的业务内容全部是本次真实写入服务的合成验收数据。最后增加操作员权限回归后,再次完成本案例构建和 5 项浏览器检查。 + +## 云端 10 组覆盖 + +执行 [cloud-smoke.mjs](../workbench/scripts/cloud-smoke.mjs),`FACTORY_SMOKE_CONFIG` 指向仓库外的本地账号配置。程序逐项检查并回读服务端结果: + +1. 两个独立测试账号真实登录。 +2. 创建工作区,创建者成为所有者,重新查询可读取。 +3. 非成员读取其他工作区无数据,向该工作区写入被拒绝。 +4. 上传私有 PNG,列出资产、授权下载并比对原始字节;非成员无法取得该文件的访问授权。 +5. 断针缺少照片被拒绝,携带真实上传照片的断针申领成功落库。 +6. 同一审批请求重放不重复推进版本;不同请求使用旧版本被拒绝。 +7. 未回收的断针禁止完结;审批、配送、回收、完结全流程成功,含首次提交共五条审计事件。 +8. 写入销售拜访,再查询可读回客户纪要、问题、下一步和跟进日期。 +9. 所有者加入已注册操作员;该操作员不能添加成员、审批或读取销售拜访。 +10. 退出并重新登录后,已完成单据仍能从数据库读回。 + +验证完成后已删除两个专用测试账号、两个测试工作区及其业务数据,并通过 Storage API 删除两个测试文件;回查测试账号、工作区和存储对象均为零。本机测试账号配置也已删除,工作台保留所选部署的公开连接配置,供真实用户自行注册登录。其他部署可按 [集成说明](../integrations.md) 复用检查。 + +## 验证中修复的问题 + +最初版本把业务版本冲突标为 PostgreSQL `40001`(序列化失败),真实检查时冲突请求返回缓慢。第三个迁移改用 `PT409`,将业务冲突明确返回 HTTP 409;适配器关闭默认数据库请求重试,并设置网络超时。修复后重新执行完整云端流程,10 组全部通过。 + +平台预置自动 RLS 事件触发函数不需要由浏览器角色直接调用。第二个迁移收紧其执行权限,两条相关安全 advisor 告警在复查中消失。 + +## 2026-10-14 阶段限制 + +- 已验证登录与会话持久化;未验证外部邮箱注册邮件投递。Supabase 默认发信仅面向组织成员邮箱,对其他用户开放前需配置自有 SMTP,见 [集成说明](../integrations.md)。 +- 安全 advisor 仍报告平台的泄漏密码保护未启用;本次没有把该账号安全能力声明为已启用。 +- HTTP 适配器通过本地契约测试;没有部署 MySQL 服务或接入企业资产平台,不能把它们标为 `connected`。 +- AI 总结、语音/图片识别和钉钉通知未接入,没有伪造执行结果。 +- 当前批量列表读取超过 10000 条会明确报错;尚无服务端筛选/分页浏览界面。资产预览使用 300 秒授权地址,过期后需重新获取。 +- 客户需求完整确认、人工正式验收、业务保留策略与生产运维验收尚未完成。 + +## 整包交换扩展:个人部署前的本地阶段(2026-10-15) + +此节保留个人部署前新增整包、来源与模拟草稿的本地验证记录,不修改上方 2026-10-14 的真实云端结论。该阶段**没有**向原 QA 云项目应用交换迁移,也未声明真实 OCR 已接入;随后云端部署结果另见下节。 + +已执行: + +```bash +npm run test --workspace factory-operations-workbench -- --run tests/exchange.test.ts tests/exchange-http.test.ts tests/supabase-exchange.test.ts +npm run test:sql --workspace factory-operations-workbench +``` + +新增单元与契约测试 **59 项通过**:41 项领域测试、8 项 HTTP 交换测试、10 项 Supabase 适配器测试。包括快照往返、完整事件保留、最终候选库引用、漏表与空表、重复键/ID(含 UUID 大小写)、严格数值与微秒时间、断针照片与回收事实、跨工作区及非法资产清单拒绝、旧备份恢复保留新增资产、包内引用须显式声明、范围外行按当前资产验证、来源不可抹除、模拟草稿核对后仍保留 mock、5 MiB/40 层解析上限,以及 HTTP 原文传递、同幂等键、403/409/503/网络失败无自动写重试、401 身份失效、文件字节摘要和上传响应丢失后的意图复用。 + +单元测试使用 [合成 fixture](../workbench/tests/fixtures/exchange.ts),其中 UUID、客户/车间描述、时间和摘要均为离线测试输入,不作为真实资产证明。该结果验证客户端和领域边界,不证明服务端已部署或云端事务已通过。 + +新能力边界:同工作区业务恢复,四张业务表与经校验的资产声明,恢复保留当前全部资产,文件正文留在原平台;不是跨提供方迁移。拍照为固定规则合成草稿,不读图,不接入 `/api/vision/ledger`。在该本地阶段,已有登录、申领和拜访继续按原有连接证据声明,新增交换与来源能力为 local,模拟识别为 mock;后续云端状态单独记录。 +本地 PostgreSQL 验证:`npm run test:sql --workspace factory-operations-workbench` **43 项通过**。测试在 PGlite 引擎执行全部四个真实迁移,覆盖角色/RLS、最终候选库、完整历史往返、旧计划冲突、相同请求幂等、末表写入失败时整笔回滚、来源保留、资产子集恢复与登记幂等。Auth 和 Storage 仅提供平台边界夹具;没有验证云端 PostgREST、对象存储服务或多个数据库连接之间的并发调度。 + +本轮 `npm ci` 和 `npm run check` 通过:三套应用构建、规格与路径校验、24 项仓库测试、164 项案例单元测试、8 项 POC 测试及上述 43 项 SQL 回归。依赖审计报告 0 个漏洞。 + +完整 `npm run test:e2e` **34 项通过**:目录 11、MUSE 6、Persona Studio 8、工厂 9。工厂回归覆盖导出下载、逐行预检错误、提交重试幂等、mock 核对、草稿过期、工作区切换、同工作区跨版本来源一致性、旧来源端口兼容、角色边界和移动布局。测试使用隔离合成 HTTP 响应,不调用云端或付费视觉服务。 + +人工浏览器验收在独立的本地 HTTP 合成夹具中完成:错误包显示具体行/字段且不能提交;合法包确认后更新台账;下载并回读完整 JSON;上传合成图片、编辑草稿、人工核对并发布;入账后仍显示“合成示例 · 模拟识别 · 已人工核对”。手机视口内容没有横向溢出。界面验证与 SQL 事务验证分别执行,不能把界面夹具当作已部署数据库的证据。 + +截图:[照片核对与预览](ledger-photo-review.jpg)、[入账后来源](ledger-sources.jpg)、[手机来源展示](ledger-mobile.jpg)。所有内容均为本地合成验收数据,未操作云端业务记录。 + +## 个人试用云端部署(2026-10-15) + +现有 Supabase 项目已应用交换迁移。仓库迁移文件现为 [20261014171026_ledger_exchange.sql](../supabase/migrations/20261014171026_ledger_exchange.sql),版本与远端迁移记录对齐;重命名没有改变 SQL 内容。此节是后续实际部署证据,前述本地阶段与原有 QA 结果保留各自范围。 + +云端在 `BEGIN` / `ROLLBACK` 中使用合成输入执行 **8 组检查,全部通过**: + +1. 日常耗材申领、批准、配送与完结状态闭环。 +2. 一致快照与同工作区恢复。 +3. 同 `requestId` 提交幂等。 +4. 重复 JSON 对象键拒绝。 +5. 旧目标 revision 冲突拒绝。 +6. 当前工作区成员读取来源元信息。 +7. 操作员直接请求完整导出被拒绝。 +8. 工作区外用户访问被拒绝。 + +检查结束后业务数据行与 Storage 对象均为零,检查前后数据指纹一致;没有留下合成验收数据。额外匿名 RPC 检查返回 HTTP 401 / SQLSTATE 42501。安全 advisor 没有 WARN 或 ERROR;这是本次检查结果,不将其反写成早期检查已经通过。 + +当前 `auth.users` 为零,首次应用账号由使用者自行注册。上述检查验证已部署数据库的事务和授权行为,**没有进行真实用户注册、登录后的云端 UI 端到端验收**,也不声明本轮重新执行了资产文件传输或多个数据库连接的并发实测。图片识别继续为 mock,真实 OCR 未接入。 + +案例保持 `integrated`、`customerConfirmed: false`。Supabase 交换服务新增实际部署证据,HTTP/MySQL 等其他提供方仍按各自验证范围声明。Sites 所有者私有试用站已发布,Supabase 已连接;发布与浏览器验收的具体范围见下节。本记录不包含私有站点地址、项目标识、连接密钥或账号信息。 + +## 私有试用站发布与回跳验证(2026-10-15) + +Sites 远端发布工具正式返回 **`succeeded`**,并返回已部署地址;站点保持所有者私有。工厂前端使用已部署的 Supabase 组合,未增加或替换应用账号体系,也未改变多提供方契约。此处仅记录发布结果,不保存具体私有地址、项目 ID 或密钥。 + +邮箱确认保持开启;默认确认回跳地址和精确重定向允许列表已配置为在线工厂入口。对默认目标及显式指定目标重新检查 HTTP 303,两个回跳位置均匹配预期工厂入口。该结果证明回跳配置正确,真实用户注册、收取确认邮件及登录后的 UI 验收仍待完成。 + +本机静态预览已完成验收,4318 端口的工厂构建可打开且 Supabase 已配置。当前网络访问线上私有站点被托管边缘拦截:应用内浏览器返回 Cloudflare security blocked,未进入工作台页面;普通 HTTPS HEAD 请求站点根路径同样返回 Cloudflare 403。因此本轮证明远端发布工具成功和本机静态页面可用,**没有完成线上浏览器或真实用户云端 UI 验收**。 + +本轮未改应用代码,重新运行的 **239 项测试全部通过**(24 项仓库测试、164 项案例单元测试、8 项 POC、43 项 SQL 回归)。前述 34 项浏览器回归保留其原有本地执行范围,不作为此次线上访问成功的证据。 diff --git a/examples/saas/mes/factory-operations/integrations.md b/examples/saas/mes/factory-operations/integrations.md new file mode 100644 index 0000000..8fd31ca --- /dev/null +++ b/examples/saas/mes/factory-operations/integrations.md @@ -0,0 +1,109 @@ +# 集成与部署 + +所有业务页面使用 [标准端口](../../../../docs/data-provider-contract.md)。本案例可以采用 Supabase,或由企业通过 HTTP 接入 MySQL、现有资产平台与身份服务。每个部署独立选择;端口也可以分别实现后组合,业务代码不绑定数据库产品。 + +## 交付状态 + +| 能力 | 模式 | 当前说明 | +| ------------------------------------ | ------------- | ------------------------------------------------------------------------------ | +| React 工作台、表单和角色交互 | `local` | 本地应用代码;服务端仍需再次授权和校验 | +| 三个端口与提供方配置 | `local` | 身份、业务、资产独立契约;支持部署默认与当前浏览器覆盖 | +| 原有 Supabase 业务适配器与迁移 | `connected` | 最初三个迁移已应用于专用 QA 项目,原有登录/申领/拜访闭环通过云端验证 | +| Supabase 云端登录、数据库、文件 | `connected` | 真实登录、工作区隔离、数据库回读、私有文件字节回读已验证 | +| 企业 HTTP 客户端 | `local` | 标准路由和对象的契约测试通过,企业后端服务未部署 | +| MySQL 或企业资产平台后端 | `unavailable` | 企业需实现 HTTP API;没有浏览器直连或预置服务 | +| 钉钉通知、AI 总结、真实语音/图片识别 | `unavailable` | 本次没有服务接入和执行结果 | +| Supabase 整包交换与来源服务 | `connected` | 交换迁移已应用,8 组云端回滚事务与权限检查通过;首次真实用户云端 UI 验收待完成 | +| 拍照台账草稿 | `mock` | 原图存入资产平台,固定规则生成合成草稿;不读取图片内容 | + +`connected` 属于经过验证的部署状态。本次 Supabase 连接证据见 [验证记录](evidence/verification.md);换用其他项目或提供方时仍需独立联调。项目创建成功或填写地址不等于业务已经可用。 + +## Supabase 组合 + +依赖:Supabase Auth、Postgres 数据库与 Storage,客户端通过锁定版本的 `@supabase/supabase-js` 访问。所用包版本见 [package.json](workbench/package.json) 与根锁文件。 + +部署步骤: + +1. 部署者在自己选择的组织中创建或使用独立项目。 +2. 应用 [supabase/migrations/](supabase/migrations/) 中的迁移。迁移定义业务表、成员授权、受控业务命令与私有资产访问。具体表和函数名称以 SQL 为准。 +3. 配置邮箱登录/注册策略,以及实际应用访问地址;若启用邮箱确认,用户完成邮件确认后才能登录。 +4. 在工作台连接设置或 `.env.local` 中配置项目 URL 和 publishable key。兼容的 `anon` key 只作公开客户端密钥使用;管理员密钥不进入前端。 +5. 登录、创建工作区、上传合成测试文件、写入申领与拜访,完成跨用户和跨工作区拒绝检查,再记录连接状态。 + +业务记录写入数据库,文件正文进入私有对象存储;报告保留资产 ID,预览时取得授权地址。SQL 的权限规则和状态命令负责阻止跨工作区访问、无权审批及未回收断针完结,前端角色显示不能替代它们。 + +项目创建、运行费用、备份、邮件可达性及运维由部署者管理。仓库不包含真实项目地址、私钥、测试用户密码或已签名文件 URL。 + +首次试用可使用该 Supabase 组织成员的邮箱注册工作台账号;控制台账号与应用账号分开。Supabase 默认邮件服务只向组织成员邮箱发送认证邮件,普通外部用户注册前需配置自有 SMTP,详见 [官方邮件配置说明](https://supabase.com/docs/guides/auth/auth-smtp)。本轮验证使用已确认邮箱的专用账号,未验证外部邮箱投递;部署者应单独验证注册确认邮件及回调地址。 + +## 复用云端验证 + +[cloud-smoke.mjs](workbench/scripts/cloud-smoke.mjs) 调用真实 Supabase 适配器,验证两个独立账号的登录、工作区隔离、私有文件字节回读、断针回收约束、状态幂等与版本冲突、拜访写入、成员权限及退出重登后的持久化。脚本会在所选项目中创建合成测试工作区、文件和业务记录,并把第二个测试账号加入该工作区。 + +准备两个已确认邮箱的专用测试账号,在仓库外保存本地 JSON 文件,结构如下。字段值由部署者填写,不要提交该文件: + +```json +{ + "url": "https://your-project.supabase.co", + "key": "your-publishable-key", + "owner": { + "email": "owner@example.test", + "password": "test-account-password" + }, + "outsider": { + "email": "outsider@example.test", + "password": "another-test-account-password" + } +} +``` + +在 Linux/WSL 中,从仓库根目录运行: + +```bash +FACTORY_SMOKE_CONFIG=/absolute/path/outside-repository/factory-smoke.json node examples/saas/mes/factory-operations/workbench/scripts/cloud-smoke.mjs +``` + +检查命令退出状态、逐项 `PASS` 与本机忽略目录 `workbench/.local/cloud-smoke-result.json`。该结果可能只覆盖失败前已通过的项目,不能只因文件存在就判断全部通过。脚本不自动删除测试业务数据;部署管理员可根据结果中的 ID 清理。不要把账号文件或包含账号 ID 的原始结果作为公开证据提交;公开验证记录仅保存覆盖范围和通过情况。 + +## 企业 HTTP 组合 + +设置 `VITE_DATA_PROVIDER=http` 和 `VITE_API_BASE_URL`,或在连接设置中选“自有数据平台”。所有路由、输入、响应、Cookie 会话、CSRF、版本及请求幂等约定见 [数据提供方契约](../../../../docs/data-provider-contract.md)。 + +后端应实现与 Supabase 组合相同的业务语义:可信身份、工作区隔离、角色权限、原子状态推进和资产授权。MySQL 连接池和凭据放在后端;现有资产平台通过稳定 ID 与受控下载 URL 接入。HTTP 客户端不会把一个任意 URL 自动转换为可用平台,也不会自动部署数据库。 + +## 配置、故障和恢复 + +缺少配置时展示连接说明。认证失败、网络错误、权限不足和版本冲突显示错误;不会偷偷退回本地模拟数据。切换连接需要使用目标服务的账号,目标服务没有记录时显示空状态。 + +浏览器保存的是提供方选择、公开地址和公开客户端密钥。回到部署默认配置不会删除远端数据。所有者可在新交换服务就绪后按预览确认同工作区业务恢复;跨服务搬迁、自包含备份、资产清理和账号生命周期由部署管理员执行。空格式模板不具备清库语义;显式空业务表仍会删除对应范围记录,须核对预览。 + +## 新增交换服务的部署范围 + +[20261014171026_ledger_exchange.sql](supabase/migrations/20261014171026_ledger_exchange.sql) 增加工作区 revision、来源、资产摘要、交换计划和受控 RPC。个人试用所选 Supabase 项目已应用该 SQL,文件名与远端实际迁移版本对齐,SQL 内容未变。新增 8 组云端回滚事务与权限检查独立记录于 [验证记录](evidence/verification.md),不沿用旧的十组检查冒充新增结果;尚未进行真实用户登录后的云端 UI 验收。其他部署须同时更新迁移和适配器。 + +来源接口仅返回名称、版本、来源与存储范围,工作区成员可读取;完整导出、预览和提交仅所有者可用。历史事件 `actorId` 与记录 `createdBy` 必须对应仍存在的身份用户,保留原 ID,不自动变成导入操作者。不存在的历史用户或跨工作区包会导致预览拒绝;本版不提供身份迁移。 + +新上传资产计算实际文件 SHA-256;旧资产缺少摘要时由适配器授权下载真实字节补齐登记,再调用服务端一致快照导出。SQL 校验已登记摘要、对象存在性及存储元信息,不独立读取正文或重算摘要;摘要字节正确性由授权客户端的读取与计算保证。文件不可读时导出失败,不输出不完整清单。文件正文不进入 JSON,整包替换也不删除对象或成员。 + +上传或登记响应不确定时先读取服务端结果,保留文件正文;同一页面内对同一文件、身份和工作区的显式重试复用资产 ID 与路径,服务端核对相同登记内容后返回原资产。不能因超时就删除可能仍在提交的文件。页面重载后的上传意图恢复及未登记文件的延迟清理由部署者的运维策略负责。 + +HTTP 提供方实现 `LedgerExchange` 的四条路由即可复用 UI;未部署接口时显示错误,不假装成功或回退本地存储。`POST /api/vision/ledger` 只是未来真实识别适配约定;当前拍照草稿在本地模拟生成,上传与最终发布仍使用所选服务。 + +本地检查命令(仓库根目录): + +```bash +npm run test --workspace factory-operations-workbench -- --run tests/exchange.test.ts tests/exchange-http.test.ts +npm run test:sql --workspace factory-operations-workbench +``` + +第一条命令验证领域与 HTTP 客户端;第二条在 PGlite PostgreSQL 引擎执行全部真实迁移、权限与事务回归,Auth/Storage 边界使用本地夹具。两者均不会写云端,SQL 回归已加入根目录 `npm run check`。服务端事务测试和 UI 回归的实际结果见 [验证记录](evidence/verification.md)。新提供方须另行验证授权、revision 冲突、同请求幂等、事务回滚和资产字节读取。 + +## 个人试用环境当前状态 + +2026-10-15,交换迁移部署及云端检查完成后,业务数据行与 Storage 对象均为零,检查前后数据指纹一致,`auth.users` 为零;匿名 RPC 返回 401 / 42501。安全 advisor 未返回 WARN 或 ERROR。此结果不证明外部邮箱投递、真实用户端到端操作或生产运维已验收,首次应用注册由使用者自行完成。 + +Sites 所有者私有静态试用站已发布,远端发布工具返回 `succeeded`,Supabase 已连接。静态托管承载目录、前端与公开材料;业务身份、数据及资产仍由当前选择的提供方负责,站点访问权限与工作台应用登录分别管理。 + +Supabase 邮箱确认保持开启。默认确认回跳地址与精确重定向允许列表已指向在线工厂入口,默认和显式目标的 HTTP 303 回跳复测均匹配;这验证了回跳配置,首次真实用户注册、邮件收取与登录后 UI 验收仍由使用者完成。 + +本机静态预览已验收。当前网络访问发布站被 Cloudflare 托管边缘拦截,应用内浏览器未进入页面,普通 HTTPS HEAD 请求亦返回 403;因此远端发布成功与在线 UI 验收分别记录,当前不声明后者已完成。 diff --git a/examples/saas/mes/factory-operations/requirements.md b/examples/saas/mes/factory-operations/requirements.md new file mode 100644 index 0000000..5e6a7c2 --- /dev/null +++ b/examples/saas/mes/factory-operations/requirements.md @@ -0,0 +1,57 @@ +# 需求与范围 + +## 来源与边界 + +2026-10-14 根据已读取的业务听记及后续架构要求抽象整理。公开仓库不保存听记链接、逐字稿、真实客户、联系人或附件。本文中的业务名称与数据均为合成示例,尚未获得完整客户验收。 + +已知需求: + +- 工厂员工通过照片上报断针、物料与耗材需求,关联合同号、物料编号、工位和原因,方便后续追溯。 +- 主管审批后进入配送,处理完成后结束;断针必须确认回收后才能完结。 +- 销售记录客户拜访、沟通纪要、问题、下一步和跟进日期。 +- 用户需要真实登录,业务数据和附件进入部署者选择的数据库与资产平台。 +- 每个部署者可选择不同数据源。Supabase 是本次部署选择,案例库不能把业务模型绑定到 Supabase;MySQL 等实现通过标准接口接入。 +- 后续扩展吸收版本化整包导入/导出、拍照草稿核对和全页来源标识;不把工厂运营范围扩成未定义的整套 ERP 台账。 + +## 本次 MVP + +| 范围 | 交付约定 | +| ------------ | ---------------------------------------------------------------------------- | +| 身份和工作区 | 邮箱登录/注册、创建和选择工作区、工作区成员与角色 | +| 工厂申领 | 带照片的断针或耗材上报,检索合同/物料/工位,审批、拒绝、配送、回收确认和完结 | +| 留痕 | 状态版本校验、操作备注和时间线,拒绝越权与非法状态变更 | +| 销售拜访 | 新建及查看拜访记录、纪要、问题、下一步与跟进日期 | +| 资产 | 上传文件到所选资产存储、保存资产引用、授权预览 | +| 数据源 | 标准身份/业务/资产端口,Supabase 适配器、企业 HTTP 适配器及接入契约 | + +产品划分采用一个主 MES 场景,销售跟进作为跨领域流程,使用 CRM 标签发现。首次注册不等于加入其他工作区;成员需要由该工作区管理员添加。 + +## 实现提议 + +以下是根据需求选择的 MVP 方案,尚不代表业务方确认: + +- 以工作区作为数据隔离边界,定义所有者、主管、操作员、销售四类成员。 +- 所有者通过已注册且确认邮箱的账号添加成员;外部邀请、企业目录同步与单点登录另行实现。 +- 以 `completed` 表示已完结,断针回收是完结的前置条件,单独保留回收事件。 +- 部署时选择 `supabase` 或 `http`;用户也可在当前浏览器连接设置中覆盖默认配置。同一服务内的用户通过工作区隔离;直接填写数据库密码、跨租户越权或自动迁移不在本次范围。 +- MySQL 由企业服务端实现 HTTP 契约,浏览器仅访问受认证的业务接口;数据库凭据留在服务端。现有资产平台也可经同一接口实现文件上传与授权下载。 + +## 未纳入本次交付 + +钉钉消息通知、AI 自动总结、真实语音/图片识别、ERP 库存扣减、采购付款、GPS 拜访打卡、财务核算、MySQL 直连适配器和跨工作区/跨提供方自动迁移均未接入。拍照核对使用明确标注的固定规则合成草稿,绝不将其标为真实识别结果。 + +## 后续待确认 + +1. 断针回收的现场证据、回收人及双人复核要求;是否需要独立安全管理员。 +2. 配送签收是否由申领人确认;拒绝后是重新提交新单,还是保留原单修订。 +3. 现有合同、物料、客户主数据的权威来源及编号校验规则。 +4. 企业资产平台接口、文件保留年限、归档与删除策略。 +5. 后续部署采用的身份系统、数据库、资产存储,以及组织范围、备份和运维责任。 + +## 整包交换扩展 + +注册契约 `factory-operations` v1 包含工厂档案、申领、完整流转/回收事件、拜访和资产清单。所有者可以导出、预览并确认同工作区替换;校验最终候选库,任何字段、日期、数量、引用或流程错误均阻止整包发布。部署服务须提供事务、工作区版本冲突和同请求 ID 幂等,不能前端循环覆盖记录。 + +全页来源条独立显示业务来源(合成、用户提供或混合)与存储范围(本机或共享);模拟识别的来源在编辑、人工确认、导出和恢复后继续保留。拍照流程仅产生草稿,不自动审批、配送、回收或完结。 + +通用空模板不可执行;现有工作区快照作为填写与恢复基准。没有资产正文、账号或成员权限的 JSON 不构成自包含备份。新功能的云端接入以新增独立证据为准,不能沿用原有申领闭环的通过结果。 diff --git a/examples/saas/mes/factory-operations/supabase/migrations/20261014150433_factory_operations.sql b/examples/saas/mes/factory-operations/supabase/migrations/20261014150433_factory_operations.sql new file mode 100644 index 0000000..d5bdcfd --- /dev/null +++ b/examples/saas/mes/factory-operations/supabase/migrations/20261014150433_factory_operations.sql @@ -0,0 +1,190 @@ +-- 工厂运营:工作空间隔离、受控写入、断针回收、私有文件。 +create schema if not exists fo_private; +revoke all on schema fo_private from public, anon; +grant usage on schema fo_private to authenticated; + +create table public.fo_workspaces ( + id uuid primary key default gen_random_uuid(), name text not null check (length(btrim(name)) between 1 and 120), + created_by uuid not null references auth.users(id), created_at timestamptz not null default now() +); +create table public.fo_members ( + workspace_id uuid not null references public.fo_workspaces(id) on delete cascade, + user_id uuid not null references auth.users(id), email text not null, + role text not null check (role in ('owner','manager','operator','sales')), + primary key(workspace_id,user_id) +); +create index fo_members_user_idx on public.fo_members(user_id,workspace_id); +create index fo_workspaces_creator_idx on public.fo_workspaces(created_by); +create table public.fo_assets ( + id uuid primary key, workspace_id uuid not null references public.fo_workspaces(id), + name text not null check(length(name) between 1 and 240), + media_type text not null check(media_type in ('image/jpeg','image/png','image/webp','application/pdf','video/mp4','audio/mpeg')), + size bigint not null check(size between 1 and 20971520), storage_path text not null unique, + created_by uuid not null references auth.users(id), created_at timestamptz not null default now() +); +create index fo_assets_workspace_idx on public.fo_assets(workspace_id,created_at desc); +create index fo_assets_creator_idx on public.fo_assets(created_by); +create table public.fo_reports ( + id uuid primary key default gen_random_uuid(), workspace_id uuid not null references public.fo_workspaces(id), + kind text not null check(kind in ('broken_needle','consumable')), + contract_number text not null check(length(btrim(contract_number)) between 1 and 120), + material_number text not null check(length(btrim(material_number)) between 1 and 120), + station text not null check(length(btrim(station)) between 1 and 120), + reason text not null check(length(btrim(reason)) between 1 and 2000), + quantity integer not null check(quantity between 1 and 100000), asset_ids uuid[] not null default '{}', + status text not null default 'submitted' check(status in ('submitted','approved','delivering','completed','rejected')), + fragments_recovered boolean not null default false, version integer not null default 1 check(version>0), + created_by uuid not null references auth.users(id), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), + check(cardinality(asset_ids)<=10), + check(kind<>'broken_needle' or cardinality(asset_ids)>0), + check(status<>'completed' or kind<>'broken_needle' or fragments_recovered) +); +create index fo_reports_workspace_idx on public.fo_reports(workspace_id,created_at desc); +create index fo_reports_creator_idx on public.fo_reports(created_by); +create index fo_reports_tracking_idx on public.fo_reports(workspace_id,contract_number,material_number); +create table public.fo_report_events ( + id uuid primary key default gen_random_uuid(), report_id uuid not null references public.fo_reports(id), + actor_id uuid not null references auth.users(id), action text not null, note text not null default '', + request_id uuid, payload jsonb, created_at timestamptz not null default now(), unique(report_id,request_id) +); +create index fo_report_events_report_idx on public.fo_report_events(report_id,created_at); +create index fo_report_events_actor_idx on public.fo_report_events(actor_id); +create table public.fo_visits ( + id uuid primary key default gen_random_uuid(), workspace_id uuid not null references public.fo_workspaces(id), + customer text not null check(length(btrim(customer)) between 1 and 200), visited_at timestamptz not null, + summary text not null check(length(btrim(summary)) between 1 and 5000), issues text not null default '' check(length(issues)<=5000), + next_step text not null check(length(btrim(next_step)) between 1 and 2000), follow_up_date date, + created_by uuid not null references auth.users(id), created_at timestamptz not null default now() +); +create index fo_visits_workspace_idx on public.fo_visits(workspace_id,visited_at desc); +create index fo_visits_creator_idx on public.fo_visits(created_by); + +-- 非暴露 schema 中的权限查询,避免成员表 RLS 自递归。 +create function fo_private.workspace_role(p_workspace uuid) returns text +language sql stable security definer set search_path='' as $$ + select m.role from public.fo_members m where m.workspace_id=p_workspace and m.user_id=(select auth.uid()) and (select auth.uid()) is not null +$$; +create function fo_private.object_access(p_name text) returns boolean +language sql stable security definer set search_path='' as $$ + select exists(select 1 from public.fo_members m where m.workspace_id::text=split_part(p_name,'/',1) and m.user_id=(select auth.uid()) and (select auth.uid()) is not null) +$$; +create function fo_private.object_unregistered(p_name text) returns boolean +language sql stable security definer set search_path='' as $$ + select (select auth.uid()) is not null and split_part(p_name,'/',2)=(select auth.uid())::text and not exists(select 1 from public.fo_assets a where a.storage_path=p_name) +$$; + +alter table public.fo_workspaces enable row level security; +alter table public.fo_members enable row level security; +alter table public.fo_assets enable row level security; +alter table public.fo_reports enable row level security; +alter table public.fo_report_events enable row level security; +alter table public.fo_visits enable row level security; +create policy fo_workspaces_read on public.fo_workspaces for select to authenticated using (fo_private.workspace_role(id) is not null); +create policy fo_members_read on public.fo_members for select to authenticated using (fo_private.workspace_role(workspace_id) is not null); +create policy fo_assets_read on public.fo_assets for select to authenticated using (fo_private.workspace_role(workspace_id) is not null); +create policy fo_reports_read on public.fo_reports for select to authenticated using (fo_private.workspace_role(workspace_id) is not null); +create policy fo_events_read on public.fo_report_events for select to authenticated using (exists(select 1 from public.fo_reports r where r.id=report_id)); +create policy fo_visits_read on public.fo_visits for select to authenticated using (fo_private.workspace_role(workspace_id) in ('owner','manager') or (fo_private.workspace_role(workspace_id)='sales' and created_by=(select auth.uid()))); +revoke all on public.fo_workspaces,public.fo_members,public.fo_assets,public.fo_reports,public.fo_report_events,public.fo_visits from anon,authenticated; +grant select on public.fo_workspaces,public.fo_members,public.fo_assets,public.fo_reports,public.fo_report_events,public.fo_visits to authenticated; + +create function fo_private.create_workspace(p_name text) returns public.fo_workspaces +language plpgsql security definer set search_path='' as $$ +declare v public.fo_workspaces; v_email text; +begin + if auth.uid() is null then raise exception '请先登录' using errcode='42501'; end if; + select email into v_email from auth.users where id=auth.uid() and email_confirmed_at is not null; + if v_email is null then raise exception '请先确认邮箱' using errcode='42501'; end if; + insert into public.fo_workspaces(name,created_by) values(btrim(p_name),auth.uid()) returning * into v; + insert into public.fo_members(workspace_id,user_id,email,role) values(v.id,auth.uid(),v_email,'owner'); + return v; +end $$; +create function fo_private.add_member(p_workspace uuid,p_email text,p_role text) returns void +language plpgsql security definer set search_path='' as $$ +declare v_user uuid; v_email text; +begin + if auth.uid() is null or fo_private.workspace_role(p_workspace) is distinct from 'owner' then raise exception '只有所有者可以添加成员' using errcode='42501'; end if; + if p_role not in ('manager','operator','sales') or p_role is null then raise exception '角色无效'; end if; + select id,email into v_user,v_email from auth.users where lower(email)=lower(btrim(p_email)) and email_confirmed_at is not null limit 1; + if v_user is null then raise exception '成员须先注册并确认邮箱'; end if; + insert into public.fo_members(workspace_id,user_id,email,role) values(p_workspace,v_user,v_email,p_role); +end $$; +create function fo_private.register_asset(p_id uuid,p_workspace uuid,p_name text,p_media_type text,p_size bigint,p_path text) returns public.fo_assets +language plpgsql security definer set search_path='' as $$ +declare v public.fo_assets; v_media_type text; v_size bigint; +begin + if auth.uid() is null or fo_private.workspace_role(p_workspace) is null then raise exception '无权上传文件' using errcode='42501'; end if; + if split_part(p_path,'/',1)<>p_workspace::text or split_part(p_path,'/',2)<>auth.uid()::text or split_part(split_part(p_path,'/',3),'.',1)<>p_id::text or array_length(string_to_array(p_path,'/'),1)<>3 then raise exception '文件路径无效'; end if; + select metadata->>'mimetype',(metadata->>'size')::bigint into v_media_type,v_size from storage.objects where bucket_id='factory-operations-assets' and name=p_path; + if v_media_type is null or v_size is null then raise exception '文件尚未上传或缺少存储元信息'; end if; + if p_media_type is distinct from v_media_type or p_size is distinct from v_size then raise exception '文件描述与实际上传内容不一致'; end if; + insert into public.fo_assets(id,workspace_id,name,media_type,size,storage_path,created_by) values(p_id,p_workspace,p_name,v_media_type,v_size,p_path,auth.uid()) returning * into v; + return v; +end $$; +create function fo_private.create_report(p_workspace uuid,p_input jsonb) returns public.fo_reports +language plpgsql security definer set search_path='' as $$ +declare v public.fo_reports; v_assets uuid[]; +begin + if auth.uid() is null or coalesce(fo_private.workspace_role(p_workspace),'') not in ('owner','manager','operator') then raise exception '无权提交上报' using errcode='42501'; end if; + select coalesce(array_agg(x::uuid),'{}') into v_assets from jsonb_array_elements_text(coalesce(p_input->'assetIds','[]'::jsonb)) x; + if cardinality(v_assets)<>(select count(distinct x) from unnest(v_assets) x) or exists(select 1 from unnest(v_assets) x where not exists(select 1 from public.fo_assets a where a.id=x and a.workspace_id=p_workspace)) then raise exception '附件无效或不属于当前工作空间'; end if; + if p_input->>'kind'='broken_needle' and not exists(select 1 from public.fo_assets a where a.id=any(v_assets) and a.workspace_id=p_workspace and a.media_type in ('image/jpeg','image/png','image/webp')) then raise exception '断针上报至少需要一张照片'; end if; + insert into public.fo_reports(workspace_id,kind,contract_number,material_number,station,reason,quantity,asset_ids,created_by) + values(p_workspace,p_input->>'kind',btrim(p_input->>'contractNumber'),btrim(p_input->>'materialNumber'),btrim(p_input->>'station'),btrim(p_input->>'reason'),(p_input->>'quantity')::integer,v_assets,auth.uid()) returning * into v; + insert into public.fo_report_events(report_id,actor_id,action,note) values(v.id,auth.uid(),'submitted','提交上报'); + return v; +end $$; +create function fo_private.change_report(p_report uuid,p_version integer,p_action text,p_note text,p_request uuid) returns public.fo_reports +language plpgsql security definer set search_path='' as $$ +declare v public.fo_reports; prior public.fo_report_events; v_payload jsonb; +begin + if auth.uid() is null then raise exception '请先登录' using errcode='42501'; end if; + if p_request is null or p_version is null or p_action is null then raise exception '缺少请求标识或版本'; end if; + if p_note is null or length(btrim(p_note)) not between 1 and 2000 then raise exception '请填写处理说明(不超过2000字)'; end if; + select * into v from public.fo_reports where id=p_report for update; + if v.id is null or coalesce(fo_private.workspace_role(v.workspace_id),'') not in ('owner','manager') then raise exception '无权处理此上报' using errcode='42501'; end if; + v_payload:=jsonb_build_object('version',p_version,'action',p_action,'note',p_note); + select * into prior from public.fo_report_events where report_id=p_report and request_id=p_request; + if prior.id is not null then + if prior.actor_id<>auth.uid() or prior.payload<>v_payload then raise exception '请求标识已用于其他操作' using errcode='23505'; end if; + return v; + end if; + if v.version<>p_version then raise exception '记录已更新,请刷新后重试' using errcode='40001'; end if; + if p_action='recovery' then + if v.kind<>'broken_needle' or v.fragments_recovered or v.status not in ('submitted','approved','delivering') then raise exception '当前状态无法确认碎片回收'; end if; + update public.fo_reports set fragments_recovered=true,version=version+1,updated_at=now() where id=p_report returning * into v; + else + if not ((v.status='submitted' and p_action in ('approved','rejected')) or (v.status='approved' and p_action='delivering') or (v.status='delivering' and p_action='completed')) then raise exception '当前状态不允许此操作'; end if; + if p_action='completed' and v.kind='broken_needle' and not v.fragments_recovered then raise exception '请先确认断针碎片已全部回收'; end if; + update public.fo_reports set status=p_action,version=version+1,updated_at=now() where id=p_report returning * into v; + end if; + insert into public.fo_report_events(report_id,actor_id,action,note,request_id,payload) values(p_report,auth.uid(),p_action,btrim(p_note),p_request,v_payload); + return v; +end $$; +create function fo_private.create_visit(p_workspace uuid,p_input jsonb) returns public.fo_visits +language plpgsql security definer set search_path='' as $$ +declare v public.fo_visits; +begin + if auth.uid() is null or coalesce(fo_private.workspace_role(p_workspace),'') not in ('owner','manager','sales') then raise exception '无权记录拜访' using errcode='42501'; end if; + insert into public.fo_visits(workspace_id,customer,visited_at,summary,issues,next_step,follow_up_date,created_by) + values(p_workspace,btrim(p_input->>'customer'),(p_input->>'visitedAt')::timestamptz,btrim(p_input->>'summary'),coalesce(p_input->>'issues',''),btrim(p_input->>'nextStep'),nullif(p_input->>'followUpDate','')::date,auth.uid()) returning * into v; + return v; +end $$; + +-- 暴露的 RPC 只使用调用者权限,授权与原子写入集中在私有函数。 +create function public.fo_create_workspace(p_name text) returns public.fo_workspaces language sql security invoker set search_path='' as $$ select fo_private.create_workspace(p_name) $$; +create function public.fo_add_member(p_workspace uuid,p_email text,p_role text) returns void language sql security invoker set search_path='' as $$ select fo_private.add_member(p_workspace,p_email,p_role) $$; +create function public.fo_register_asset(p_id uuid,p_workspace uuid,p_name text,p_media_type text,p_size bigint,p_path text) returns public.fo_assets language sql security invoker set search_path='' as $$ select fo_private.register_asset(p_id,p_workspace,p_name,p_media_type,p_size,p_path) $$; +create function public.fo_create_report(p_workspace uuid,p_input jsonb) returns public.fo_reports language sql security invoker set search_path='' as $$ select fo_private.create_report(p_workspace,p_input) $$; +create function public.fo_change_report(p_report uuid,p_version integer,p_action text,p_note text,p_request uuid) returns public.fo_reports language sql security invoker set search_path='' as $$ select fo_private.change_report(p_report,p_version,p_action,p_note,p_request) $$; +create function public.fo_create_visit(p_workspace uuid,p_input jsonb) returns public.fo_visits language sql security invoker set search_path='' as $$ select fo_private.create_visit(p_workspace,p_input) $$; +revoke execute on all functions in schema fo_private from public,anon; +grant execute on all functions in schema fo_private to authenticated; +revoke execute on function public.fo_create_workspace(text),public.fo_add_member(uuid,text,text),public.fo_register_asset(uuid,uuid,text,text,bigint,text),public.fo_create_report(uuid,jsonb),public.fo_change_report(uuid,integer,text,text,uuid),public.fo_create_visit(uuid,jsonb) from public,anon; +grant execute on function public.fo_create_workspace(text),public.fo_add_member(uuid,text,text),public.fo_register_asset(uuid,uuid,text,text,bigint,text),public.fo_create_report(uuid,jsonb),public.fo_change_report(uuid,integer,text,text,uuid),public.fo_create_visit(uuid,jsonb) to authenticated; + +insert into storage.buckets(id,name,public,file_size_limit,allowed_mime_types) values('factory-operations-assets','factory-operations-assets',false,20971520,array['image/jpeg','image/png','image/webp','application/pdf','video/mp4','audio/mpeg']); +create policy fo_storage_read on storage.objects for select to authenticated using(bucket_id='factory-operations-assets' and fo_private.object_access(name)); +create policy fo_storage_insert on storage.objects for insert to authenticated with check(bucket_id='factory-operations-assets' and fo_private.object_access(name) and split_part(name,'/',2)=(select auth.uid())::text); +-- 仅允许补偿删除尚未登记的本人上传;已关联的审计素材不可覆盖或删除。 +create policy fo_storage_cleanup on storage.objects for delete to authenticated using(bucket_id='factory-operations-assets' and fo_private.object_access(name) and fo_private.object_unregistered(name)); diff --git a/examples/saas/mes/factory-operations/supabase/migrations/20261014151243_harden_optional_auto_rls.sql b/examples/saas/mes/factory-operations/supabase/migrations/20261014151243_harden_optional_auto_rls.sql new file mode 100644 index 0000000..45de65a --- /dev/null +++ b/examples/saas/mes/factory-operations/supabase/migrations/20261014151243_harden_optional_auto_rls.sql @@ -0,0 +1,9 @@ +-- 部分 Supabase 项目预置自动启用 RLS 的事件触发函数。 +-- 事件触发器由数据库执行,浏览器角色不需要手工执行权限。 +do $$ +begin + if to_regprocedure('public.rls_auto_enable()') is not null then + execute 'revoke execute on function public.rls_auto_enable() from public, anon, authenticated'; + end if; +end +$$; diff --git a/examples/saas/mes/factory-operations/supabase/migrations/20261014151422_report_conflict_http_status.sql b/examples/saas/mes/factory-operations/supabase/migrations/20261014151422_report_conflict_http_status.sql new file mode 100644 index 0000000..fdf7b7a --- /dev/null +++ b/examples/saas/mes/factory-operations/supabase/migrations/20261014151422_report_conflict_http_status.sql @@ -0,0 +1,29 @@ +-- 将业务版本冲突映射为 HTTP 409,避免被识别为可重试的序列化失败。 +-- 官方契约:https://docs.postgrest.org/en/stable/references/errors.html#raise-errors-with-http-status-codes +create or replace function fo_private.change_report(p_report uuid,p_version integer,p_action text,p_note text,p_request uuid) returns public.fo_reports +language plpgsql security definer set search_path='' as $$ +declare v public.fo_reports; prior public.fo_report_events; v_payload jsonb; +begin + if auth.uid() is null then raise exception '请先登录' using errcode='42501'; end if; + if p_request is null or p_version is null or p_action is null then raise exception '缺少请求标识或版本'; end if; + if p_note is null or length(btrim(p_note)) not between 1 and 2000 then raise exception '请填写处理说明(不超过2000字)'; end if; + select * into v from public.fo_reports where id=p_report for update; + if v.id is null or coalesce(fo_private.workspace_role(v.workspace_id),'') not in ('owner','manager') then raise exception '无权处理此上报' using errcode='42501'; end if; + v_payload:=jsonb_build_object('version',p_version,'action',p_action,'note',p_note); + select * into prior from public.fo_report_events where report_id=p_report and request_id=p_request; + if prior.id is not null then + if prior.actor_id<>auth.uid() or prior.payload<>v_payload then raise exception '请求标识已用于其他操作' using errcode='23505'; end if; + return v; + end if; + if v.version<>p_version then raise exception '记录已更新,请刷新后重试' using errcode='PT409'; end if; + if p_action='recovery' then + if v.kind<>'broken_needle' or v.fragments_recovered or v.status not in ('submitted','approved','delivering') then raise exception '当前状态无法确认碎片回收'; end if; + update public.fo_reports set fragments_recovered=true,version=version+1,updated_at=now() where id=p_report returning * into v; + else + if not ((v.status='submitted' and p_action in ('approved','rejected')) or (v.status='approved' and p_action='delivering') or (v.status='delivering' and p_action='completed')) then raise exception '当前状态不允许此操作'; end if; + if p_action='completed' and v.kind='broken_needle' and not v.fragments_recovered then raise exception '请先确认断针碎片已全部回收'; end if; + update public.fo_reports set status=p_action,version=version+1,updated_at=now() where id=p_report returning * into v; + end if; + insert into public.fo_report_events(report_id,actor_id,action,note,request_id,payload) values(p_report,auth.uid(),p_action,btrim(p_note),p_request,v_payload); + return v; +end $$; diff --git a/examples/saas/mes/factory-operations/supabase/migrations/20261014171026_ledger_exchange.sql b/examples/saas/mes/factory-operations/supabase/migrations/20261014171026_ledger_exchange.sql new file mode 100644 index 0000000..a53688e --- /dev/null +++ b/examples/saas/mes/factory-operations/supabase/migrations/20261014171026_ledger_exchange.sql @@ -0,0 +1,452 @@ +-- 台账交换:同一工作区的所有者恢复、完整候选校验及原子发布。 +-- 文件正文仍由 Storage 管理;摘要登记由授权客户端读取字节后计算,SQL 校验登记一致性。 +alter table public.fo_workspaces add column revision bigint not null default 0 check(revision>=0); +alter table public.fo_workspaces add column provenance jsonb not null default '{"contentOrigin":"user-imported"}'; +alter table public.fo_reports add column provenance jsonb not null default '{"contentOrigin":"user-imported"}'; +alter table public.fo_report_events add column provenance jsonb not null default '{"contentOrigin":"user-imported"}'; +alter table public.fo_visits add column provenance jsonb not null default '{"contentOrigin":"user-imported"}'; +alter table public.fo_assets add column sha256 text check(sha256 ~ '^[a-f0-9]{64}$'); + +create table fo_private.import_plans ( + id uuid primary key default gen_random_uuid(), workspace_id uuid not null references public.fo_workspaces(id) on delete cascade, + actor_id uuid not null references auth.users(id) on delete cascade, expected_revision bigint not null, + package_hash text not null, candidate jsonb not null, replace_scope text[] not null, counts jsonb not null, + created_at timestamptz not null default now(), expires_at timestamptz not null default(now()+interval '1 hour') +); +create index fo_import_plans_workspace_idx on fo_private.import_plans(workspace_id); +create index fo_import_plans_actor_idx on fo_private.import_plans(actor_id); +create table fo_private.import_results ( + workspace_id uuid not null references public.fo_workspaces(id) on delete cascade, request_id uuid not null, + actor_id uuid not null references auth.users(id) on delete cascade, plan_id uuid not null references fo_private.import_plans(id) on delete cascade, + expected_revision bigint not null, result jsonb not null, created_at timestamptz not null default now(), + primary key(workspace_id,request_id) +); +create index fo_import_results_actor_idx on fo_private.import_results(actor_id); +create index fo_import_results_plan_idx on fo_private.import_results(plan_id); +alter table fo_private.import_plans enable row level security; +alter table fo_private.import_results enable row level security; +revoke all on fo_private.import_plans,fo_private.import_results from public,anon,authenticated; + +-- 固定锁顺序:workspace -> report/asset。工作区行同时是全数据集的版本锁。 +create function fo_private.bump_dataset_revision() returns trigger +language plpgsql security definer set search_path='' as $$ +declare w uuid; +begin + if tg_table_name='fo_report_events' then + select workspace_id into w from public.fo_reports where id=case when tg_op='DELETE' then old.report_id else new.report_id end; + else + w:=case when tg_op='DELETE' then old.workspace_id else new.workspace_id end; + end if; + update public.fo_workspaces set revision=revision+1 where id=w; + if tg_op='DELETE' then return old; end if; + return new; +end $$; +create function fo_private.bump_factory_revision() returns trigger +language plpgsql set search_path='' as $$ begin new.revision:=old.revision+1; return new; end $$; +create trigger fo_factory_revision before update of name,provenance on public.fo_workspaces for each row execute function fo_private.bump_factory_revision(); +create trigger fo_reports_revision before insert or update or delete on public.fo_reports for each row execute function fo_private.bump_dataset_revision(); +create trigger fo_events_revision before insert or update or delete on public.fo_report_events for each row execute function fo_private.bump_dataset_revision(); +create trigger fo_visits_revision before insert or update or delete on public.fo_visits for each row execute function fo_private.bump_dataset_revision(); +create trigger fo_assets_revision before insert or update or delete on public.fo_assets for each row execute function fo_private.bump_dataset_revision(); +create trigger fo_members_revision before insert or update or delete on public.fo_members for each row execute function fo_private.bump_dataset_revision(); + +create function fo_private.issue(t text,r integer,f text,c text,m text) returns jsonb +language sql immutable set search_path='' as $$ select jsonb_build_array(jsonb_build_object('table',t,'row',r,'field',f,'code',c,'message',m)) $$; +create function fo_private.object_keys_valid(v jsonb,allowed text[],required text[]) returns boolean +language sql immutable set search_path='' as $$ + select jsonb_typeof(v)='object' and v ?& required and not exists(select 1 from jsonb_object_keys(case when jsonb_typeof(v)='object' then v else '{}' end) k where not(k=any(allowed))) +$$; +create function fo_private.text_valid(v jsonb,lo integer,hi integer) returns boolean +language sql immutable set search_path='' as $$ select coalesce(jsonb_typeof(v)='string' and length(btrim(v#>>'{}'))>=lo and length(v#>>'{}')<=hi,false) $$; +create function fo_private.uuid_valid(v jsonb) returns boolean +language sql immutable set search_path='' as $$ select coalesce(jsonb_typeof(v)='string' and (v#>>'{}') ~* '^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$',false) $$; +create function fo_private.integer_valid(v jsonb,lo bigint,hi bigint) returns boolean +language plpgsql immutable set search_path='' as $$ +begin return coalesce(jsonb_typeof(v)='number' and (v#>>'{}')::numeric=trunc((v#>>'{}')::numeric) and (v#>>'{}')::numeric between lo and hi,false); +exception when others then return false; end $$; +create function fo_private.date_valid(v jsonb,timestamp_value boolean) returns boolean +language plpgsql immutable set search_path='' as $$ +declare s text; +begin + if jsonb_typeof(v) is distinct from 'string' then return false; end if; s:=v#>>'{}'; + if timestamp_value then + if s !~ '^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d{1,6})?(Z|[+-](0\d|1[0-4]):[0-5]\d)$' then return false; end if; + perform s::timestamptz; + else + if s !~ '^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$' then return false; end if; + perform s::date; + end if; + return true; +exception when others then return false; end $$; + +-- json 类型保留对象重复键;必须先检查,之后才允许转 jsonb。 +create function fo_private.duplicate_json_path(v json,p text default '',depth integer default 0) returns text +language plpgsql immutable set search_path='' as $$ +declare k text; item record; result text; +begin + if depth>40 then raise exception 'JSON嵌套深度超过40层'; end if; + if json_typeof(v)='object' then + select key into k from json_each(v) group by key having count(*)>1 limit 1; + if k is not null then return p||'/'||replace(replace(k,'~','~0'),'/','~1'); end if; + for item in select * from json_each(v) loop + result:=fo_private.duplicate_json_path(item.value,p||'/'||replace(replace(item.key,'~','~0'),'/','~1'),depth+1); + if result is not null then return result; end if; + end loop; + elsif json_typeof(v)='array' then + for item in select value,ordinality from json_array_elements(v) with ordinality loop + result:=fo_private.duplicate_json_path(item.value,p||'/'||(item.ordinality-1),depth+1); + if result is not null then return result; end if; + end loop; + end if; + return null; +end $$; +create function fo_private.origin_for_tables(t jsonb) returns text +language sql immutable set search_path='' as $$ + with origins as ( + select distinct row->'provenance'->>'contentOrigin' origin + from jsonb_each(t) e cross join lateral jsonb_array_elements(e.value) row where e.key<>'factory' + ) select case when count(*)>1 or max(origin)='mixed' then 'mixed' else coalesce(max(origin),'user-imported') end from origins +$$; +create function fo_private.provenance_signature(p jsonb) returns jsonb +language sql immutable set search_path='' as $$ + select jsonb_build_object('origin',p->>'contentOrigin','mode',p->'recognition'->>'mode','sources', + coalesce((select jsonb_agg(x order by x) from (select distinct lower(value) x from jsonb_array_elements_text(case when jsonb_typeof(p->'recognition'->'sourceAssetIds')='array' then p->'recognition'->'sourceAssetIds' else '[]' end)) ids),'[]')) +$$; + +create function fo_private.dataset_snapshot(w uuid) returns jsonb +language sql set search_path='' as $$ + with tabs as (select jsonb_build_object( + 'factory',jsonb_build_array(jsonb_build_object('id',f.id,'data',jsonb_build_object('name',f.name),'provenance',f.provenance)), + 'reports',coalesce((select jsonb_agg(jsonb_build_object('id',r.id,'data',jsonb_build_object( + 'kind',r.kind,'contractNumber',r.contract_number,'materialNumber',r.material_number,'station',r.station,'reason',r.reason,'quantity',r.quantity, + 'assetIds',r.asset_ids,'status',r.status,'version',r.version,'fragmentsRecovered',r.fragments_recovered,'createdBy',r.created_by,'createdAt',r.created_at,'updatedAt',r.updated_at + ),'provenance',r.provenance) order by r.id) from public.fo_reports r where r.workspace_id=w),'[]'), + 'report-events',coalesce((select jsonb_agg(jsonb_build_object('id',e.id,'data',jsonb_build_object( + 'reportId',e.report_id,'actorId',e.actor_id,'action',e.action,'note',e.note,'createdAt',e.created_at,'requestId',e.request_id,'payload',e.payload + ),'provenance',e.provenance) order by e.id) from public.fo_report_events e join public.fo_reports r on r.id=e.report_id where r.workspace_id=w),'[]'), + 'visits',coalesce((select jsonb_agg(jsonb_build_object('id',v.id,'data',jsonb_build_object( + 'customer',v.customer,'visitedAt',v.visited_at,'summary',v.summary,'issues',v.issues,'nextStep',v.next_step,'followUpDate',v.follow_up_date,'createdBy',v.created_by,'createdAt',v.created_at + ),'provenance',v.provenance) order by v.id) from public.fo_visits v where v.workspace_id=w),'[]') + ) t,f.name,f.revision from public.fo_workspaces f where f.id=w) + select jsonb_build_object('schemaVersion',1,'packageKind','snapshot','packageId',gen_random_uuid(), + 'datasetContract',jsonb_build_object('id','factory-operations','version',1),'dataRevision',revision::text, + 'metadata',jsonb_build_object('contentOrigin',fo_private.origin_for_tables(t),'storage','shared','label',name,'exportedAt',to_char(clock_timestamp() at time zone 'UTC','YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + 'exportScope',jsonb_build_object('completeness','full','tables',jsonb_build_array('factory','reports','report-events','visits')),'tables',t, + 'assets',coalesce((select jsonb_agg(jsonb_build_object('id',a.id,'name',a.name,'mediaType',a.media_type,'size',a.size,'sha256',a.sha256, + 'reference',jsonb_build_object('storeId','factory-operations-assets','objectId',a.storage_path)) order by a.id) from public.fo_assets a where a.workspace_id=w),'[]')) from tabs +$$; +create function fo_private.dataset_state(w uuid) returns jsonb +language plpgsql security definer set search_path='' as $$ +declare s jsonb; modes jsonb; +begin + if auth.uid() is null or fo_private.workspace_role(w) is null then raise exception '无权读取工作区' using errcode='42501'; end if; + perform 1 from public.fo_workspaces where id=w for share; + s:=fo_private.dataset_snapshot(w); + select coalesce(jsonb_agg(distinct row->'provenance'->'recognition'->>'mode'),'[]') into modes from jsonb_each(s->'tables') t cross join lateral jsonb_array_elements(t.value) row where t.key<>'factory' and row->'provenance'->'recognition'->>'mode' is not null; + return jsonb_build_object('workspaceId',w,'label',s->'metadata'->>'label','revision',s->>'dataRevision','contentOrigin',s->'metadata'->>'contentOrigin','storage','shared','recognitionModes',modes); +end $$; + +create function fo_private.preview_import(w uuid,package_text text,scope text[]) returns jsonb +language plpgsql security definer set search_path='' as $$ +declare raw json; p jsonb; base jsonb; candidate jsonb; issues jsonb:='[]'; counts jsonb:='[]'; duplicate_path text; + rev bigint; label text; plan uuid; t text; before_rows jsonb; incoming_rows jsonb; removed integer; result jsonb; +begin + if auth.uid() is null or fo_private.workspace_role(w) is distinct from 'owner' then raise exception '只有所有者可以预览替换' using errcode='42501'; end if; + select revision,name into rev,label from public.fo_workspaces where id=w for update; + result:=jsonb_build_object('planId',null,'expectedRevision',rev::text,'sourceWorkspaceId',null,'targetWorkspaceId',w,'sourceLabel','','targetLabel',label,'counts','[]'::jsonb,'issues','[]'::jsonb,'warnings','[]'::jsonb,'canCommit',false); + if package_text is null or octet_length(package_text)>5242880 then return result||jsonb_build_object('issues',fo_private.issue(null,null,'','limit','交换包最多5MiB')); end if; + begin + raw:=package_text::json; + duplicate_path:=fo_private.duplicate_json_path(raw); + if duplicate_path is not null then return result||jsonb_build_object('issues',fo_private.issue(null,null,duplicate_path,'duplicate-key','JSON对象包含重复字段')); end if; + p:=raw::jsonb; + exception when others then return result||jsonb_build_object('issues',fo_private.issue(null,null,'','json','无法解析JSON,或嵌套深度超出限制')); + end; + issues:=fo_private.validate_package(w,p,scope); + result:=result||jsonb_build_object('sourceWorkspaceId',p->'tables'->'factory'->0->>'id','sourceLabel',coalesce(p->'tables'->'factory'->0->'data'->>'name','')); + if jsonb_array_length(issues)>0 then return result||jsonb_build_object('issues',issues); end if; + base:=fo_private.dataset_snapshot(w); + candidate:=jsonb_set(base,'{tables}',(base->'tables')||(p->'tables')); + candidate:=jsonb_set(candidate,'{metadata,contentOrigin}',to_jsonb(fo_private.origin_for_tables(candidate->'tables'))); + -- 传入清单可以是当前资产子集;最终候选保留当前全部资产,不删除新增正文。 + issues:=fo_private.validate_package(w,candidate,array['factory','reports','report-events','visits']); + if jsonb_array_length(issues)>0 then return result||jsonb_build_object('issues',issues); end if; + foreach t in array scope loop + before_rows:=base->'tables'->t; incoming_rows:=p->'tables'->t; + select count(*) into removed from jsonb_array_elements(before_rows) b where not exists(select 1 from jsonb_array_elements(incoming_rows) i where lower(i->>'id')=lower(b->>'id')); + counts:=counts||jsonb_build_array(jsonb_build_object('table',t,'before',jsonb_array_length(before_rows),'incoming',jsonb_array_length(incoming_rows),'after',jsonb_array_length(incoming_rows),'removed',removed)); + end loop; + insert into fo_private.import_plans(workspace_id,actor_id,expected_revision,package_hash,candidate,replace_scope,counts) + values(w,auth.uid(),rev,encode(sha256(convert_to(p::text||to_jsonb(scope)::text,'UTF8')),'hex'),candidate,scope,counts) returning id into plan; + return result||jsonb_build_object('planId',plan,'counts',counts,'canCommit',true,'warnings',jsonb_build_array('此操作替换所选业务记录;账户、成员和资产正文保留。')); +end $$; + +create function fo_private.commit_import(w uuid,plan uuid,expected_revision text,request uuid) returns jsonb +language plpgsql security definer set search_path='' as $$ +declare current_revision bigint; p fo_private.import_plans; prior fo_private.import_results; issues jsonb; result jsonb; +begin + if auth.uid() is null or fo_private.workspace_role(w) is distinct from 'owner' then raise exception '只有所有者可以提交替换' using errcode='42501'; end if; + if request is null or plan is null or expected_revision is null or expected_revision!~'^\d+$' then raise exception '计划、版本或请求ID无效'; end if; + select revision into current_revision from public.fo_workspaces where id=w for update; + select * into prior from fo_private.import_results r where r.workspace_id=w and r.request_id=request; + if prior.request_id is not null then + if prior.actor_id<>auth.uid() or prior.plan_id<>plan or prior.expected_revision::text<>expected_revision then raise exception '请求ID已用于其他计划或版本' using errcode='PT409'; end if; + return prior.result; + end if; + select * into p from fo_private.import_plans where id=plan and workspace_id=w and actor_id=auth.uid() for update; + if p.id is null then raise exception '导入计划不存在或无权访问' using errcode='42501'; end if; + if p.expires_atexpected_revision or p.expected_revision::text<>expected_revision then raise exception '预览后数据已变化,请重新预览' using errcode='PT409'; end if; + issues:=fo_private.validate_package(w,p.candidate,array['factory','reports','report-events','visits']); + if jsonb_array_length(issues)>0 then raise exception '计划中的引用或业务约束已失效,请重新预览' using errcode='PT409',detail=issues::text; end if; + -- 候选是全库一致快照;先事件后单据,随后先单据后事件,满足外键且不禁用约束。 + delete from public.fo_report_events where report_id in(select id from public.fo_reports where workspace_id=w); + delete from public.fo_reports where workspace_id=w; + delete from public.fo_visits where workspace_id=w; + update public.fo_workspaces set name=p.candidate->'tables'->'factory'->0->'data'->>'name',provenance=p.candidate->'tables'->'factory'->0->'provenance' where id=w; + insert into public.fo_reports(id,workspace_id,kind,contract_number,material_number,station,reason,quantity,asset_ids,status,fragments_recovered,version,created_by,created_at,updated_at,provenance) + select (r->>'id')::uuid,w,d->>'kind',d->>'contractNumber',d->>'materialNumber',d->>'station',d->>'reason',(d->>'quantity')::numeric::integer, + array(select x::uuid from jsonb_array_elements_text(d->'assetIds') x),d->>'status',(d->>'fragmentsRecovered')::boolean,(d->>'version')::numeric::integer,(d->>'createdBy')::uuid,(d->>'createdAt')::timestamptz,(d->>'updatedAt')::timestamptz,r->'provenance' + from jsonb_array_elements(p.candidate->'tables'->'reports') r cross join lateral (select r->'data' d) data; + insert into public.fo_report_events(id,report_id,actor_id,action,note,created_at,request_id,payload,provenance) + select (r->>'id')::uuid,(d->>'reportId')::uuid,(d->>'actorId')::uuid,d->>'action',d->>'note',(d->>'createdAt')::timestamptz,(d->>'requestId')::uuid,nullif(d->'payload','null'::jsonb),r->'provenance' + from jsonb_array_elements(p.candidate->'tables'->'report-events') r cross join lateral (select r->'data' d) data; + insert into public.fo_visits(id,workspace_id,customer,visited_at,summary,issues,next_step,follow_up_date,created_by,created_at,provenance) + select (r->>'id')::uuid,w,d->>'customer',(d->>'visitedAt')::timestamptz,d->>'summary',d->>'issues',d->>'nextStep',(d->>'followUpDate')::date,(d->>'createdBy')::uuid,(d->>'createdAt')::timestamptz,r->'provenance' + from jsonb_array_elements(p.candidate->'tables'->'visits') r cross join lateral (select r->'data' d) data; + select revision into current_revision from public.fo_workspaces where id=w; + result:=jsonb_build_object('revision',current_revision::text,'contentOrigin',p.candidate->'metadata'->>'contentOrigin','counts',p.counts); + insert into fo_private.import_results(workspace_id,request_id,actor_id,plan_id,expected_revision,result) values(w,request,auth.uid(),plan,expected_revision::bigint,result); + return result; +end $$; + +create function fo_private.set_asset_hash(p_asset uuid,p_sha256 text) returns public.fo_assets +language plpgsql security definer set search_path='' as $$ +declare a public.fo_assets; w uuid; +begin + if auth.uid() is null then raise exception '请先登录' using errcode='42501'; end if; + if p_sha256 is null or p_sha256!~'^[a-f0-9]{64}$' then raise exception 'SHA-256格式无效'; end if; + select workspace_id into w from public.fo_assets where id=p_asset; + if w is null or fo_private.workspace_role(w) is null then raise exception '无权登记资产摘要' using errcode='42501'; end if; + perform 1 from public.fo_workspaces where id=w for update; + select * into a from public.fo_assets where id=p_asset for update; + if a.created_by<>auth.uid() and fo_private.workspace_role(w)<>'owner' then raise exception '仅上传者或所有者可以补充摘要' using errcode='42501'; end if; + if not exists(select 1 from storage.objects o where o.bucket_id='factory-operations-assets' and o.name=a.storage_path and o.metadata->>'mimetype'=a.media_type and o.metadata->>'size'=a.size::text) then raise exception '资产对象或存储元信息不完整'; end if; + if a.sha256 is not null and a.sha256<>p_sha256 then raise exception '资产摘要已登记且不可改写' using errcode='PT409'; end if; + if a.sha256 is null then update public.fo_assets set sha256=p_sha256 where id=p_asset returning * into a; end if; + return a; +end $$; +create function fo_private.register_asset_with_hash(p_id uuid,p_workspace uuid,p_name text,p_media_type text,p_size bigint,p_path text,p_sha256 text) returns public.fo_assets +language plpgsql security definer set search_path='' as $$ +declare prior public.fo_assets; +begin + if auth.uid() is null or fo_private.workspace_role(p_workspace) is null then raise exception '无权上传文件' using errcode='42501'; end if; + perform 1 from public.fo_workspaces where id=p_workspace for update; + select * into prior from public.fo_assets where id=p_id; + if prior.id is not null then + if prior.workspace_id is distinct from p_workspace or prior.created_by is distinct from auth.uid() or prior.name is distinct from p_name or prior.media_type is distinct from p_media_type or prior.size is distinct from p_size or prior.storage_path is distinct from p_path then raise exception '资产ID已用于其他上传' using errcode='PT409'; end if; + return fo_private.set_asset_hash(p_id,p_sha256); + end if; + perform fo_private.register_asset(p_id,p_workspace,p_name,p_media_type,p_size,p_path); + return fo_private.set_asset_hash(p_id,p_sha256); +end $$; + +create or replace function fo_private.change_report(p_report uuid,p_version integer,p_action text,p_note text,p_request uuid) returns public.fo_reports +language plpgsql security definer set search_path='' as $$ +declare v public.fo_reports; prior public.fo_report_events; v_payload jsonb; w uuid; event_at timestamptz; +begin + if auth.uid() is null then raise exception '请先登录' using errcode='42501'; end if; + if p_request is null or p_version is null or p_action is null then raise exception '缺少请求标识或版本'; end if; + if p_note is null or length(btrim(p_note)) not between 1 and 2000 then raise exception '请填写处理说明(不超过2000字)'; end if; + select workspace_id into w from public.fo_reports where id=p_report; + if w is null or coalesce(fo_private.workspace_role(w),'') not in ('owner','manager') then raise exception '无权处理此上报' using errcode='42501'; end if; + perform 1 from public.fo_workspaces where id=w for update; + select * into v from public.fo_reports where id=p_report and workspace_id=w for update; + if v.id is null then raise exception '上报已被替换,请刷新' using errcode='PT409'; end if; + v_payload:=jsonb_build_object('version',p_version,'action',p_action,'note',p_note); + select * into prior from public.fo_report_events where report_id=p_report and request_id=p_request; + if prior.id is not null then + if prior.actor_id<>auth.uid() or prior.payload<>v_payload then raise exception '请求标识已用于其他操作' using errcode='23505'; end if; + return v; + end if; + if v.version<>p_version then raise exception '记录已更新,请刷新后重试' using errcode='PT409'; end if; + event_at:=greatest(clock_timestamp(),v.updated_at); + if p_action='recovery' then + if v.kind<>'broken_needle' or v.fragments_recovered or v.status not in ('submitted','approved','delivering') then raise exception '当前状态无法确认碎片回收'; end if; + update public.fo_reports set fragments_recovered=true,version=version+1,updated_at=event_at where id=p_report returning * into v; + else + if not ((v.status='submitted' and p_action in ('approved','rejected')) or (v.status='approved' and p_action='delivering') or (v.status='delivering' and p_action='completed')) then raise exception '当前状态不允许此操作'; end if; + if p_action='completed' and v.kind='broken_needle' and not v.fragments_recovered then raise exception '请先确认断针碎片已全部回收'; end if; + update public.fo_reports set status=p_action,version=version+1,updated_at=event_at where id=p_report returning * into v; + end if; + insert into public.fo_report_events(report_id,actor_id,action,note,request_id,payload,provenance,created_at) values(p_report,auth.uid(),p_action,btrim(p_note),p_request,v_payload,v.provenance,event_at); + return v; +end $$; + +create function fo_private.export_snapshot(w uuid) returns jsonb +language plpgsql security definer set search_path='' as $$ +declare result jsonb; +begin + if auth.uid() is null or fo_private.workspace_role(w) is distinct from 'owner' then raise exception '只有所有者可以导出完整快照' using errcode='42501'; end if; + perform 1 from public.fo_workspaces where id=w for update; + if exists(select 1 from public.fo_assets where workspace_id=w and sha256 is null) then raise exception '资产缺少摘要,请重新读取文件后导出'; end if; + if exists(select 1 from public.fo_assets a where a.workspace_id=w and not exists(select 1 from storage.objects o where o.bucket_id='factory-operations-assets' and o.name=a.storage_path and o.metadata->>'mimetype'=a.media_type and (o.metadata->>'size')::bigint=a.size)) then raise exception '已登记资产的对象或元信息不完整'; end if; + result:=fo_private.dataset_snapshot(w); + if octet_length(result::text)>5242880 or jsonb_array_length(result->'assets')>10000 or exists(select 1 from jsonb_each(result->'tables') t where jsonb_array_length(t.value)>10000) then raise exception '完整快照超出5MiB或每表10000行的交换上限,请使用企业接口分批归档'; end if; + return result; +end $$; + +-- 所有验证在正式表写入前完成;遇到坏类型不依靠隐式类型转换补默认值。 +create function fo_private.validate_package(w uuid,p jsonb,scope text[]) returns jsonb +language plpgsql security definer set search_path='' as $$ +declare + issues jsonb:='[]'; t text; k text; rr record; row_value jsonb; d jsonb; prov jsonb; a jsonb; + n integer; row_id uuid; uid uuid; asset public.fo_assets; keys text[]; required text[]; + refs text[]; seen text[]; manifest_ids text[]; report jsonb; event jsonb; events jsonb; prior_provenance jsonb; + current_status text; recovered boolean; version_no integer; event_action text; event_time timestamptz; previous_time timestamptz; + event_data jsonb; payload jsonb; initial_count integer; valid_events boolean; +begin + if not fo_private.object_keys_valid(p,array['schemaVersion','packageKind','packageId','datasetContract','dataRevision','metadata','exportScope','tables','assets'],array['schemaVersion','packageKind','packageId','datasetContract','dataRevision','metadata','exportScope','tables','assets']) then + return fo_private.issue(null,null,'','shape','交换包字段缺失或包含未知字段'); + end if; + if p->'schemaVersion' is distinct from '1'::jsonb or p->>'packageKind' is distinct from 'snapshot' or not fo_private.text_valid(p->'packageId',1,256) or not fo_private.text_valid(p->'dataRevision',1,256) then issues:=issues||fo_private.issue(null,null,'','version','只接受有标识和版本的正式快照'); end if; + if p->'datasetContract'<>jsonb_build_object('id','factory-operations','version',1) then issues:=issues||fo_private.issue(null,null,'/datasetContract','contract','不支持的业务交换契约'); end if; + if not fo_private.object_keys_valid(p->'metadata',array['contentOrigin','storage','label','exportedAt'],array['contentOrigin','storage','exportedAt']) then issues:=issues||fo_private.issue(null,null,'/metadata','shape','元信息字段缺失或未知'); + elsif coalesce(p->'metadata'->>'contentOrigin','') not in ('synthetic','user-imported','mixed') or coalesce(p->'metadata'->>'storage','') not in ('local','shared') or not fo_private.date_valid(p->'metadata'->'exportedAt',true) or (p->'metadata' ? 'label' and not fo_private.text_valid(p->'metadata'->'label',1,240)) then issues:=issues||fo_private.issue(null,null,'/metadata','value','来源、存储范围或导出时间无效'); end if; + if scope is null or cardinality(scope)=0 or cardinality(scope)<>(select count(distinct x) from unnest(scope) x) or not('factory'=any(scope)) or exists(select 1 from unnest(scope) x where x is null or x not in ('factory','reports','report-events','visits')) then return issues||fo_private.issue(null,null,'/replaceScope','scope','替换范围必须唯一且包含工厂档案'); end if; + if jsonb_typeof(p->'tables')<>'object' then return issues||fo_private.issue(null,null,'/tables','shape','tables 必须是对象'); end if; + select array_agg(key order by key) into keys from jsonb_object_keys(p->'tables') key; + if keys is distinct from (select array_agg(x order by x) from unnest(scope) x) then return issues||fo_private.issue(null,null,'/tables','scope','包内表必须与替换范围完全一致,缺表不能视为空表'); end if; + if not fo_private.object_keys_valid(p->'exportScope',array['completeness','tables'],array['completeness','tables']) or p->'exportScope'->>'completeness' is distinct from 'full' or jsonb_typeof(p->'exportScope'->'tables')<>'array' then return issues||fo_private.issue(null,null,'/exportScope','scope','只有完整声明范围的快照可以替换'); end if; + if (select jsonb_agg(x order by x) from jsonb_array_elements(p->'exportScope'->'tables') x) is distinct from (select jsonb_agg(x order by x) from unnest(scope) x) then issues:=issues||fo_private.issue(null,null,'/exportScope/tables','scope','导出范围与包内表不一致'); end if; + if jsonb_typeof(p->'assets')<>'array' then return issues||fo_private.issue(null,null,'/assets','shape','资产清单必须是数组'); end if; + if jsonb_array_length(p->'assets')>10000 then return issues||fo_private.issue(null,null,'/assets','limit','资产清单最多10000项'); end if; + seen:='{}'; + for rr in select value,ordinality::integer idx from jsonb_array_elements(p->'assets') with ordinality loop + a:=rr.value; n:=rr.idx; + if not fo_private.object_keys_valid(a,array['id','name','mediaType','size','sha256','reference'],array['id','name','mediaType','size','sha256','reference']) or not fo_private.uuid_valid(a->'id') then issues:=issues||fo_private.issue('assets',n,'/id','shape','资产字段或ID无效'); continue; end if; + row_id:=(a->>'id')::uuid; + if row_id::text=any(seen) then issues:=issues||fo_private.issue('assets',n,'/id','duplicate','资产ID重复'); end if; seen:=array_append(seen,row_id::text); + select * into asset from public.fo_assets where fo_assets.id=row_id and workspace_id=w; + if asset.id is null then issues:=issues||fo_private.issue('assets',n,'/id','reference','资产未登记在当前工作区'); continue; end if; + if a is distinct from jsonb_build_object('id',asset.id,'name',asset.name,'mediaType',asset.media_type,'size',asset.size,'sha256',asset.sha256,'reference',jsonb_build_object('storeId','factory-operations-assets','objectId',asset.storage_path)) or asset.sha256 is null then issues:=issues||fo_private.issue('assets',n,'','integrity','资产名称、类型、大小、摘要或稳定引用与已登记数据不一致'); end if; + if not exists(select 1 from storage.objects o where o.bucket_id='factory-operations-assets' and o.name=asset.storage_path and o.metadata->>'mimetype'=asset.media_type and o.metadata->>'size'=asset.size::text) then issues:=issues||fo_private.issue('assets',n,'/reference','missing-object','资产正文对象不存在或元信息不符'); end if; + end loop; + manifest_ids:=seen; + + foreach t in array scope loop + if jsonb_typeof(p->'tables'->t)<>'array' then issues:=issues||fo_private.issue(t,null,'','shape','数据集必须是数组'); continue; end if; + if jsonb_array_length(p->'tables'->t)>10000 then issues:=issues||fo_private.issue(t,null,'','limit','每个数据集最多10000行'); continue; end if; + if t='factory' and jsonb_array_length(p->'tables'->t)<>1 then issues:=issues||fo_private.issue(t,null,'','scope','工厂档案必须恰好一行'); end if; + seen:='{}'; + for rr in select value,ordinality::integer idx from jsonb_array_elements(p->'tables'->t) with ordinality loop + row_value:=rr.value; n:=rr.idx; d:=row_value->'data'; prov:=row_value->'provenance'; + if not fo_private.object_keys_valid(row_value,array['id','data','provenance'],array['id','data','provenance']) or not fo_private.uuid_valid(row_value->'id') then issues:=issues||fo_private.issue(t,n,'/id','shape','记录字段缺失、未知或ID无效'); continue; end if; + row_id:=(row_value->>'id')::uuid; + if row_id::text=any(seen) then issues:=issues||fo_private.issue(t,n,'/id','duplicate','记录ID重复'); end if; seen:=array_append(seen,row_id::text); + if t='reports' and exists(select 1 from public.fo_reports r where r.id=row_id and r.workspace_id<>w) or t='visits' and exists(select 1 from public.fo_visits v where v.id=row_id and v.workspace_id<>w) or t='report-events' and exists(select 1 from public.fo_report_events e join public.fo_reports r on r.id=e.report_id where e.id=row_id and r.workspace_id<>w) then issues:=issues||fo_private.issue(t,n,'/id','conflict','记录ID已在其他工作区使用'); end if; + if not fo_private.object_keys_valid(prov,array['contentOrigin','recognition'],array['contentOrigin']) or coalesce(prov->>'contentOrigin','') not in ('synthetic','user-imported','mixed') then issues:=issues||fo_private.issue(t,n,'/provenance','source','来源结构或性质无效'); + elsif prov ? 'recognition' then + a:=prov->'recognition'; + if not fo_private.object_keys_valid(a,array['mode','reviewStatus','sourceAssetIds'],array['mode','reviewStatus','sourceAssetIds']) or coalesce(a->>'mode','') not in ('mock','connected') or a->>'reviewStatus' is distinct from 'reviewed' or jsonb_typeof(a->'sourceAssetIds')<>'array' then issues:=issues||fo_private.issue(t,n,'/provenance/recognition','review','识别记录须完整且经人工核对'); + else + if a->>'mode'='mock' and prov->>'contentOrigin'<>'synthetic' then issues:=issues||fo_private.issue(t,n,'/provenance/contentOrigin','source','模拟识别必须保留合成来源'); end if; + select array_agg(value) into refs from jsonb_array_elements_text(a->'sourceAssetIds'); + if coalesce(cardinality(refs),0)=0 or cardinality(refs)<>(select count(distinct lower(x)) from unnest(refs) x) or exists(select 1 from unnest(refs) x where not exists(select 1 from public.fo_assets z where z.workspace_id=w and z.id::text=lower(x) and z.id::text=any(manifest_ids))) then issues:=issues||fo_private.issue(t,n,'/provenance/recognition/sourceAssetIds','reference','识别来源照片不存在或重复'); end if; + end if; + end if; + prior_provenance:=null; + if t='reports' then select r.provenance into prior_provenance from public.fo_reports r where r.id=row_id and r.workspace_id=w; + elsif t='visits' then select v.provenance into prior_provenance from public.fo_visits v where v.id=row_id and v.workspace_id=w; + elsif t='report-events' then select e.provenance into prior_provenance from public.fo_report_events e join public.fo_reports r on r.id=e.report_id where e.id=row_id and r.workspace_id=w; + end if; + if prior_provenance is not null and fo_private.provenance_signature(prior_provenance) is distinct from fo_private.provenance_signature(prov) then issues:=issues||fo_private.issue(t,n,'/provenance','source','已有记录的原始来源、识别模式和来源资产不能被改写'); end if; + if t='factory' then + if row_id<>w then issues:=issues||fo_private.issue(t,n,'/id','workspace','首版只允许恢复同一个工作区'); end if; + if not fo_private.object_keys_valid(d,array['name'],array['name']) or not fo_private.text_valid(d->'name',1,120) then issues:=issues||fo_private.issue(t,n,'/data/name','value','工厂名称须为1到120字'); end if; + elsif t='reports' then + required:=array['kind','contractNumber','materialNumber','station','reason','quantity','assetIds','status','version','fragmentsRecovered','createdBy','createdAt','updatedAt']; + if not fo_private.object_keys_valid(d,required,required) then issues:=issues||fo_private.issue(t,n,'/data','shape','上报业务字段缺失或未知'); continue; end if; + if coalesce(d->>'kind','') not in ('broken_needle','consumable') or coalesce(d->>'status','') not in ('submitted','approved','delivering','completed','rejected') or jsonb_typeof(d->'fragmentsRecovered')<>'boolean' then issues:=issues||fo_private.issue(t,n,'/data','enum','上报类型、状态或回收标识无效'); end if; + foreach k in array array['contractNumber','materialNumber','station','reason'] loop + if not fo_private.text_valid(d->k,1,case when k='reason' then 2000 else 120 end) then issues:=issues||fo_private.issue(t,n,'/data/'||k,'value','必填文本为空或超出长度限制'); end if; + end loop; + if not fo_private.integer_valid(d->'quantity',1,100000) then issues:=issues||fo_private.issue(t,n,'/data/quantity','number','数量须为1到100000的整数'); end if; + if not fo_private.integer_valid(d->'version',1,2147483647) then issues:=issues||fo_private.issue(t,n,'/data/version','number','版本须为有效正整数'); end if; + if jsonb_typeof(d->'assetIds')<>'array' then issues:=issues||fo_private.issue(t,n,'/data/assetIds','shape','附件须为数组'); + else + select coalesce(array_agg(value),'{}') into refs from jsonb_array_elements_text(d->'assetIds'); + if cardinality(refs)>10 or cardinality(refs)<>(select count(distinct lower(x)) from unnest(refs) x) or exists(select 1 from unnest(refs) x where not exists(select 1 from public.fo_assets z where z.workspace_id=w and z.id::text=lower(x) and z.id::text=any(manifest_ids))) then issues:=issues||fo_private.issue(t,n,'/data/assetIds','reference','附件超过10项、重复或不属于当前工作区'); end if; + if d->>'kind'='broken_needle' and not exists(select 1 from public.fo_assets z where z.workspace_id=w and z.id::text=any(array(select lower(x) from unnest(refs) x)) and z.media_type in ('image/jpeg','image/png','image/webp')) then issues:=issues||fo_private.issue(t,n,'/data/assetIds','photo','断针必须至少有一张已登记照片'); end if; + end if; + if not fo_private.date_valid(d->'createdAt',true) or not fo_private.date_valid(d->'updatedAt',true) then issues:=issues||fo_private.issue(t,n,'/data/createdAt','date','创建及更新时间必须是有效带时区时间'); end if; + if not fo_private.uuid_valid(d->'createdBy') then issues:=issues||fo_private.issue(t,n,'/data/createdBy','actor','创建者ID无效'); elsif not exists(select 1 from auth.users u where u.id=(d->>'createdBy')::uuid) then issues:=issues||fo_private.issue(t,n,'/data/createdBy','actor','历史创建者未映射到当前身份系统'); end if; + elsif t='visits' then + required:=array['customer','visitedAt','summary','issues','nextStep','followUpDate','createdBy','createdAt']; + if not fo_private.object_keys_valid(d,required,required) then issues:=issues||fo_private.issue(t,n,'/data','shape','拜访业务字段缺失或未知'); continue; end if; + foreach k in array array['customer','summary','issues','nextStep'] loop + if not fo_private.text_valid(d->k,case when k='issues' then 0 else 1 end,case when k='customer' then 200 when k='nextStep' then 2000 else 5000 end) then issues:=issues||fo_private.issue(t,n,'/data/'||k,'value','文本为空或超出长度限制'); end if; + end loop; + if not fo_private.date_valid(d->'visitedAt',true) or not fo_private.date_valid(d->'createdAt',true) or (d->'followUpDate'<>'null'::jsonb and not fo_private.date_valid(d->'followUpDate',false)) then issues:=issues||fo_private.issue(t,n,'/data/visitedAt','date','拜访或跟进日期无效'); end if; + if not fo_private.uuid_valid(d->'createdBy') then issues:=issues||fo_private.issue(t,n,'/data/createdBy','actor','创建者ID无效'); elsif not exists(select 1 from auth.users u where u.id=(d->>'createdBy')::uuid) then issues:=issues||fo_private.issue(t,n,'/data/createdBy','actor','历史创建者未映射到当前身份系统'); end if; + elsif t='report-events' then + required:=array['reportId','actorId','action','note','createdAt','requestId','payload']; + if not fo_private.object_keys_valid(d,required,required) then issues:=issues||fo_private.issue(t,n,'/data','shape','事件业务字段缺失或未知'); continue; end if; + if not fo_private.uuid_valid(d->'reportId') or not fo_private.uuid_valid(d->'actorId') or not fo_private.date_valid(d->'createdAt',true) or not fo_private.text_valid(d->'note',case when d->>'action'='submitted' then 0 else 1 end,2000) or coalesce(d->>'action','') not in ('submitted','approved','delivering','completed','rejected','recovery') then issues:=issues||fo_private.issue(t,n,'/data','event','事件引用、操作者、时间、说明或动作无效'); end if; + if fo_private.uuid_valid(d->'actorId') and not exists(select 1 from auth.users u where u.id=(d->>'actorId')::uuid) then issues:=issues||fo_private.issue(t,n,'/data/actorId','actor','历史操作者未映射到当前身份系统'); end if; + if d->>'action'='submitted' then + if d->'requestId'<>'null'::jsonb or d->'payload'<>'null'::jsonb then issues:=issues||fo_private.issue(t,n,'/data/requestId','event','初始事件的请求与payload必须为null'); end if; + else + payload:=d->'payload'; + if not fo_private.uuid_valid(d->'requestId') or not fo_private.object_keys_valid(payload,array['version','action','note'],array['version','action','note']) or not fo_private.integer_valid(payload->'version',1,2147483646) or payload->>'action' is distinct from d->>'action' or not fo_private.text_valid(payload->'note',1,2000) or btrim(payload->>'note') is distinct from d->>'note' then issues:=issues||fo_private.issue(t,n,'/data/payload','event','处理事件须保留有效请求ID及一致的版本/动作/说明'); end if; + end if; + end if; + end loop; + end loop; + if jsonb_array_length(issues)>0 then return issues; end if; + if fo_private.origin_for_tables(p->'tables') is distinct from p->'metadata'->>'contentOrigin' then issues:=issues||fo_private.issue(null,null,'/metadata/contentOrigin','source','包来源必须与业务记录来源聚合一致'); end if; + -- 局部包的跨表引用和事件链在构造全部四表候选快照后再验证。 + if not(array['factory','reports','report-events','visits']<@scope) then return issues; end if; + for rr in select value,ordinality::integer idx from jsonb_array_elements(p->'tables'->'report-events') with ordinality loop + d:=rr.value->'data'; + if not exists(select 1 from jsonb_array_elements(p->'tables'->'reports') r where lower(r->>'id')=lower(d->>'reportId')) then issues:=issues||fo_private.issue('report-events',rr.idx,'/data/reportId','reference','事件引用的上报不存在于最终候选库'); end if; + end loop; + for rr in select value,ordinality::integer idx from jsonb_array_elements(p->'tables'->'reports') with ordinality loop + report:=rr.value; d:=report->'data'; n:=rr.idx; + select coalesce(jsonb_agg(e order by case when e->'data'->>'action'='submitted' then 0 else (e->'data'->'payload'->>'version')::numeric::integer end),'[]') into events from jsonb_array_elements(p->'tables'->'report-events') e where lower(e->'data'->>'reportId')=lower(report->>'id'); + select count(*) into initial_count from jsonb_array_elements(events) e where e->'data'->>'action'='submitted'; + if initial_count<>1 then issues:=issues||fo_private.issue('reports',n,'/data/status','timeline','每个上报必须恰好有一个提交起点'); continue; end if; + if (select count(*) from jsonb_array_elements(events) e where e->'data'->>'requestId' is not null)<>(select count(distinct lower(e->'data'->>'requestId')) from jsonb_array_elements(events) e where e->'data'->>'requestId' is not null) then issues:=issues||fo_private.issue('reports',n,'/data/status','duplicate','同一上报的处理请求ID不能重复'); continue; end if; + current_status:='submitted'; recovered:=false; version_no:=1; previous_time:=null; valid_events:=true; + for event in select value from jsonb_array_elements(events) loop + event_data:=event->'data'; event_action:=event_data->>'action'; event_time:=(event_data->>'createdAt')::timestamptz; + if previous_time is null then + if event_time<>(d->>'createdAt')::timestamptz then valid_events:=false; end if; + else + if event_time'payload'->>'version')::numeric::integer<>version_no then valid_events:=false; end if; + if event_action='recovery' then + if d->>'kind'<>'broken_needle' or recovered or current_status not in ('submitted','approved','delivering') then valid_events:=false; end if; recovered:=true; + else + if not((current_status='submitted' and event_action in ('approved','rejected')) or(current_status='approved' and event_action='delivering') or(current_status='delivering' and event_action='completed')) then valid_events:=false; end if; + if event_action='completed' and d->>'kind'='broken_needle' and not recovered then valid_events:=false; end if; + current_status:=event_action; + end if; + version_no:=version_no+1; + end if; + previous_time:=event_time; + end loop; + if not valid_events or current_status<>d->>'status' or version_no<>(d->>'version')::numeric::integer or recovered<>(d->>'fragmentsRecovered')::boolean or previous_time<>(d->>'updatedAt')::timestamptz then issues:=issues||fo_private.issue('reports',n,'/data/status','timeline','事件链必须连续且重放到相同状态、版本、回收事实和时间'); end if; + end loop; + return issues; +end $$; + +create function public.fo_dataset_state(p_workspace uuid) returns jsonb language sql security invoker set search_path='' as $$ select fo_private.dataset_state(p_workspace) $$; +create function public.fo_export_snapshot(p_workspace uuid) returns jsonb language sql security invoker set search_path='' as $$ select fo_private.export_snapshot(p_workspace) $$; +create function public.fo_preview_import(p_workspace uuid,p_package_text text,p_replace_scope text[]) returns jsonb language sql security invoker set search_path='' as $$ select fo_private.preview_import(p_workspace,p_package_text,p_replace_scope) $$; +create function public.fo_commit_import(p_workspace uuid,p_plan uuid,p_expected_revision text,p_request uuid) returns jsonb language sql security invoker set search_path='' as $$ select fo_private.commit_import(p_workspace,p_plan,p_expected_revision,p_request) $$; +create function public.fo_set_asset_hash(p_asset uuid,p_sha256 text) returns public.fo_assets language sql security invoker set search_path='' as $$ select fo_private.set_asset_hash(p_asset,p_sha256) $$; +create function public.fo_register_asset_with_hash(p_id uuid,p_workspace uuid,p_name text,p_media_type text,p_size bigint,p_path text,p_sha256 text) returns public.fo_assets language sql security invoker set search_path='' as $$ select fo_private.register_asset_with_hash(p_id,p_workspace,p_name,p_media_type,p_size,p_path,p_sha256) $$; +revoke execute on all functions in schema fo_private from public,anon,authenticated; +grant execute on function fo_private.workspace_role(uuid),fo_private.object_access(text),fo_private.object_unregistered(text),fo_private.create_workspace(text),fo_private.add_member(uuid,text,text),fo_private.register_asset(uuid,uuid,text,text,bigint,text),fo_private.create_report(uuid,jsonb),fo_private.change_report(uuid,integer,text,text,uuid),fo_private.create_visit(uuid,jsonb),fo_private.dataset_state(uuid),fo_private.export_snapshot(uuid),fo_private.preview_import(uuid,text,text[]),fo_private.commit_import(uuid,uuid,text,uuid),fo_private.set_asset_hash(uuid,text),fo_private.register_asset_with_hash(uuid,uuid,text,text,bigint,text,text) to authenticated; +revoke execute on function public.fo_dataset_state(uuid),public.fo_export_snapshot(uuid),public.fo_preview_import(uuid,text,text[]),public.fo_commit_import(uuid,uuid,text,uuid),public.fo_set_asset_hash(uuid,text),public.fo_register_asset_with_hash(uuid,uuid,text,text,bigint,text,text) from public,anon; +grant execute on function public.fo_dataset_state(uuid),public.fo_export_snapshot(uuid),public.fo_preview_import(uuid,text,text[]),public.fo_commit_import(uuid,uuid,text,uuid),public.fo_set_asset_hash(uuid,text),public.fo_register_asset_with_hash(uuid,uuid,text,text,bigint,text,text) to authenticated; diff --git a/examples/saas/mes/factory-operations/supabase/tests/ledger-exchange.test.mjs b/examples/saas/mes/factory-operations/supabase/tests/ledger-exchange.test.mjs new file mode 100644 index 0000000..942a29c --- /dev/null +++ b/examples/saas/mes/factory-operations/supabase/tests/ledger-exchange.test.mjs @@ -0,0 +1,538 @@ +import { PGlite } from "@electric-sql/pglite"; +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { readFile, readdir } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; + +// 执行所有真实迁移;auth/storage 仅是本地平台边界夹具,不模拟业务 SQL。 +const db = new PGlite(); +const owner = "10000000-0000-4000-8000-000000000001"; +const member = "10000000-0000-4000-8000-000000000002"; +const outsider = "10000000-0000-4000-8000-000000000003"; +let workspace, otherWorkspace, asset, report, baseline; +const scope = ["factory", "reports", "report-events", "visits"]; +const clone = (value) => structuredClone(value); +const rpc = async (name, ...values) => + ( + await db.query( + `select to_jsonb(public.${name}(${values.map((_, i) => `$${i + 1}`).join(",")})) as value`, + values, + ) + ).rows[0].value; +async function asUser(id) { + await db.exec("reset role; set role authenticated"); + await db.query("select set_config('request.jwt.claim.sub',$1,false)", [id]); +} +const snapshot = () => rpc("fo_export_snapshot", workspace.id); +const preview = (pack, tables = scope) => + rpc( + "fo_preview_import", + workspace.id, + typeof pack === "string" ? pack : JSON.stringify(pack), + tables, + ); +const commit = (plan, request = randomUUID()) => + rpc( + "fo_commit_import", + workspace.id, + plan.planId, + plan.expectedRevision, + request, + ); +const business = (pack) => ({ tables: pack.tables, assets: pack.assets }); +const newVisit = () => + rpc("fo_create_visit", workspace.id, { + customer: "SQL 合成客户", + visitedAt: "2026-10-15T08:00:00Z", + summary: "本地回归", + issues: "", + nextStep: "核对", + followUpDate: "2026-10-16", + }); +async function rejectsPreview(change, matcher) { + const before = await snapshot(), + pack = clone(before); + change(pack); + const result = await preview(pack); + assert.equal(result.canCommit, false, JSON.stringify(result)); + assert.equal(result.planId, null); + assert.ok(result.issues.length > 0); + if (matcher) + assert.ok(result.issues.some(matcher), JSON.stringify(result.issues)); + const after = await snapshot(); + assert.equal(after.dataRevision, before.dataRevision); + assert.deepEqual(business(after), business(before)); +} + +before(async () => { + await db.exec(` + create role anon; create role authenticated; + create schema auth; + create table auth.users(id uuid primary key,email text,email_confirmed_at timestamptz); + create function auth.uid() returns uuid language sql stable as $$select nullif(current_setting('request.jwt.claim.sub',true),'')::uuid$$; + grant usage on schema auth to authenticated; + create schema storage; + create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint,allowed_mime_types text[]); + create table storage.objects(id uuid default gen_random_uuid(),bucket_id text,name text,metadata jsonb); + `); + const migrations = new URL("../migrations/", import.meta.url); + for (const file of (await readdir(migrations)) + .filter((f) => f.endsWith(".sql")) + .sort()) + await db.exec(await readFile(new URL(file, migrations), "utf8")); + for (const [id, email] of [ + [owner, "owner@example.test"], + [member, "member@example.test"], + [outsider, "outsider@example.test"], + ]) + await db.query("insert into auth.users values($1,$2,now())", [id, email]); + await asUser(owner); + workspace = await rpc("fo_create_workspace", "SQL 合成工厂"); + otherWorkspace = await rpc("fo_create_workspace", "另一合成工厂"); + await db.query("select public.fo_add_member($1,$2,$3)", [ + workspace.id, + "member@example.test", + "operator", + ]); + const id = randomUUID(), + path = `${workspace.id}/${owner}/${id}.png`; + await db.exec("reset role"); + await db.query( + "insert into storage.objects(bucket_id,name,metadata) values('factory-operations-assets',$1,$2)", + [path, { mimetype: "image/png", size: 4 }], + ); + await asUser(owner); + asset = await rpc( + "fo_register_asset_with_hash", + id, + workspace.id, + "SQL 照片.png", + "image/png", + 4, + path, + "a".repeat(64), + ); + report = await rpc("fo_create_report", workspace.id, { + kind: "broken_needle", + contractNumber: "SQL-001", + materialNumber: "NEEDLE-01", + station: "A1", + reason: "合成断针", + quantity: 1, + assetIds: [id], + }); + for (const action of ["approved", "recovery", "delivering", "completed"]) + report = await rpc( + "fo_change_report", + report.id, + report.version, + action, + ` ${action} 合成说明 `, + randomUUID(), + ); + await newVisit(); + baseline = await snapshot(); +}); +after(async () => db.close()); + +test("资产登记同 ID 同内容幂等,不同内容拒绝且不改摘要", async () => { + const before = await snapshot(); + const replay = await rpc( + "fo_register_asset_with_hash", + asset.id, + workspace.id, + asset.name, + asset.media_type, + asset.size, + asset.storage_path, + asset.sha256, + ); + assert.equal(replay.id, asset.id); + assert.equal((await snapshot()).dataRevision, before.dataRevision); + await assert.rejects( + rpc( + "fo_register_asset_with_hash", + asset.id, + workspace.id, + "其他文件.png", + asset.media_type, + asset.size, + asset.storage_path, + asset.sha256, + ), + { code: "PT409" }, + ); +}); + +test("全部迁移可执行,导出覆盖业务、历史幂等信息、资产和来源", () => { + assert.equal(baseline.tables.reports[0].data.status, "completed"); + assert.equal(baseline.tables.reports[0].data.fragmentsRecovered, true); + assert.equal(baseline.tables["report-events"].length, 5); + assert.equal(baseline.assets[0].sha256, "a".repeat(64)); + assert.equal(baseline.assets[0].reference.objectId, asset.storage_path); + assert.equal(baseline.metadata.contentOrigin, "user-imported"); +}); +test("完整预览、原子恢复、持久化回读和同请求幂等", async () => { + const before = await snapshot(), + plan = await preview(before); + assert.equal(plan.canCommit, true, JSON.stringify(plan.issues)); + const request = randomUUID(), + result = await commit(plan, request); + assert.deepEqual(await commit(plan, request), result); + const after = await snapshot(); + assert.deepEqual(business(after), business(before)); + assert.ok(BigInt(after.dataRevision) > BigInt(before.dataRevision)); + assert.equal(result.revision, after.dataRevision); + const another = await preview(after); + await assert.rejects(commit(another, request), { code: "PT409" }); +}); +test("预览后普通业务写入使旧计划失效", async () => { + const plan = await preview(await snapshot()); + await newVisit(); + const before = await snapshot(); + await assert.rejects(commit(plan), { code: "PT409" }); + assert.deepEqual(business(await snapshot()), business(before)); +}); +test("成员仅可读取来源,外部用户无来源访问且不能导出预览提交", async () => { + const pack = await snapshot(), + plan = await preview(pack); + for (const id of [member, outsider]) { + await asUser(id); + if (id === member) { + const state = await rpc("fo_dataset_state", workspace.id); + assert.deepEqual( + Object.keys(state).sort(), + [ + "workspaceId", + "label", + "revision", + "contentOrigin", + "storage", + "recognitionModes", + ].sort(), + ); + } else + await assert.rejects(rpc("fo_dataset_state", workspace.id), { + code: "42501", + }); + await assert.rejects(snapshot(), { code: "42501" }); + await assert.rejects(preview(pack), { code: "42501" }); + await assert.rejects(commit(plan), { code: "42501" }); + } + await asUser(owner); +}); +test("私有计划和候选帮助函数不能绕过 RPC 授权", async () => { + await assert.rejects(db.query("select * from fo_private.import_plans"), { + code: "42501", + }); + await assert.rejects( + db.query("select fo_private.dataset_snapshot($1)", [otherWorkspace.id]), + { code: "42501" }, + ); + await assert.rejects( + db.query("update public.fo_workspaces set name='绕过' where id=$1", [ + workspace.id, + ]), + { code: "42501" }, + ); + await db.exec("reset role; set role anon"); + await assert.rejects(snapshot(), { code: "42501" }); + await asUser(owner); +}); +test("重复 JSON 键、无效 JSON、深度和五 MiB 上限在转 jsonb 前拒绝", async () => { + const pack = JSON.stringify(await snapshot()), + revision = (await snapshot()).dataRevision; + for (const raw of [ + pack.replace('"schemaVersion":1', '"schemaVersion":1,"schemaVersion":1'), + "{", + "[".repeat(42) + "0" + "]".repeat(42), + " ".repeat(5242881), + ]) { + const result = await preview(raw); + assert.equal(result.canCommit, false); + assert.ok(result.issues.length); + } + assert.equal((await snapshot()).dataRevision, revision); +}); +for (const [name, change] of [ + ["负数量", (p) => (p.tables.reports[0].data.quantity = -1)], + [ + "纳秒时间拒绝避免舍入", + (p) => + (p.tables.reports[0].data.createdAt = "2026-10-15T08:00:00.1234567Z"), + ], + [ + "超过14小时偏移拒绝", + (p) => (p.tables.reports[0].data.createdAt = "2026-10-15T08:00:00+15:00"), + ], + ["文本数字不隐式转换", (p) => (p.tables.reports[0].data.quantity = "1")], + ["分数数量", (p) => (p.tables.reports[0].data.quantity = 1.2)], + ["不真实日期", (p) => (p.tables.visits[0].data.followUpDate = "2026-02-30")], + [ + "无时区时间", + (p) => (p.tables.reports[0].data.createdAt = "2026-10-15T08:00:00"), + ], + ["未知根字段", (p) => (p.extra = true)], + ["未知行字段", (p) => (p.tables.visits[0].extra = true)], + ["未知业务字段", (p) => (p.tables.reports[0].data.extra = true)], + ["缺失表不视为空表", (p) => delete p.tables.visits], + ["部分导出不能替换", (p) => (p.exportScope.completeness = "partial")], + ["未知身份映射", (p) => (p.tables.visits[0].data.createdBy = randomUUID())], + ["跨工作区恢复", (p) => (p.tables.factory[0].id = otherWorkspace.id)], + ["资产清单缺失", (p) => (p.assets = [])], + ["资产摘要不符", (p) => (p.assets[0].sha256 = "b".repeat(64))], + ["资产稳定引用不符", (p) => (p.assets[0].reference.objectId = "other")], + ["断针无照片", (p) => (p.tables.reports[0].data.assetIds = [])], + [ + "重复行 UUID 大小写", + (p) => + p.tables.reports.push({ + ...clone(p.tables.reports[0]), + id: p.tables.reports[0].id.toUpperCase(), + }), + ], + [ + "坏来源类型返回 issues", + (p) => + (p.tables.reports[0].provenance.recognition = { + mode: "mock", + reviewStatus: "reviewed", + sourceAssetIds: {}, + }), + ], + [ + "现有来源不能洗白", + (p) => (p.tables.reports[0].provenance.contentOrigin = "synthetic"), + ], + ["来源元信息不符", (p) => (p.metadata.contentOrigin = "synthetic")], + [ + "缺提交起点", + (p) => + (p.tables["report-events"] = p.tables["report-events"].filter( + (e) => e.data.action !== "submitted", + )), + ], + [ + "事件引用孤儿", + (p) => (p.tables["report-events"][0].data.reportId = randomUUID()), + ], + [ + "事件版本断层", + (p) => + (p.tables["report-events"].find( + (e) => e.data.payload, + ).data.payload.version = 19), + ], + [ + "事件动作不符", + (p) => + (p.tables["report-events"].find( + (e) => e.data.payload, + ).data.payload.action = "rejected"), + ], + [ + "回收事实与时间线不符", + (p) => (p.tables.reports[0].data.fragmentsRecovered = false), + ], + [ + "一万行上限", + (p) => + (p.tables.visits = Array.from( + { length: 10001 }, + () => p.tables.visits[0], + )), + ], +]) + test(`逐行验证拒绝且无业务写入:${name}`, () => rejectsPreview(change)); + +test("局部替换仍检查最终库,删除上报不能留下旧事件", async () => { + const pack = await snapshot(); + pack.tables = { factory: pack.tables.factory, reports: [] }; + pack.exportScope.tables = ["factory", "reports"]; + const plan = await preview(pack, ["factory", "reports"]); + assert.equal(plan.canCommit, false); + assert.ok(plan.issues.some((i) => i.code === "reference")); +}); +test("空数组显式清除选定拜访表,保留未选表和资产正文", async () => { + const before = await snapshot(), + pack = clone(before); + pack.tables = { factory: pack.tables.factory, visits: [] }; + pack.exportScope.tables = ["factory", "visits"]; + const plan = await preview(pack, ["factory", "visits"]); + assert.equal(plan.canCommit, true, JSON.stringify(plan.issues)); + assert.ok(plan.counts.find((c) => c.table === "visits").removed > 0); + await commit(plan); + const after = await snapshot(); + assert.deepEqual(after.tables.reports, before.tables.reports); + assert.deepEqual( + after.tables["report-events"], + before.tables["report-events"], + ); + assert.deepEqual(after.tables.visits, []); + assert.deepEqual(after.assets, before.assets); +}); +test("1.0 JSON 语义整数可预览及提交,不能延迟到写入时报错", async () => { + const pack = JSON.stringify(await snapshot()).replace( + '"quantity":1', + '"quantity":1.0', + ); + const plan = await preview(pack); + assert.equal(plan.canCommit, true, JSON.stringify(plan.issues)); + await commit(plan); +}); +test("mock 核对后导入保持来源,后续日常事件继承来源,不能洗白", async () => { + const pack = await snapshot(), + rid = randomUUID(), + eid = randomUUID(), + now = "2026-10-15T00:00:00Z"; + const provenance = { + contentOrigin: "synthetic", + recognition: { + mode: "mock", + reviewStatus: "reviewed", + sourceAssetIds: [asset.id], + }, + }; + pack.tables.reports.push({ + id: rid, + data: { + ...pack.tables.reports[0].data, + status: "submitted", + version: 1, + fragmentsRecovered: false, + createdAt: now, + updatedAt: now, + }, + provenance, + }); + pack.tables["report-events"].push({ + id: eid, + data: { + reportId: rid, + actorId: owner, + action: "submitted", + note: "人工核对模拟草稿", + createdAt: now, + requestId: null, + payload: null, + }, + provenance, + }); + pack.metadata.contentOrigin = "mixed"; + const draft = clone(pack); + draft.tables.reports.at(-1).provenance.recognition.reviewStatus = + "unreviewed"; + assert.equal((await preview(draft)).canCommit, false); + const plan = await preview(pack); + assert.equal(plan.canCommit, true, JSON.stringify(plan.issues)); + await commit(plan); + await rpc("fo_change_report", rid, 1, "approved", "核对后批准", randomUUID()); + const after = await snapshot(); + assert.equal(after.metadata.contentOrigin, "mixed"); + const events = after.tables["report-events"].filter( + (e) => e.data.reportId === rid, + ); + assert.equal(events.length, 2); + for (const event of events) assert.deepEqual(event.provenance, provenance); + const washed = clone(after); + washed.tables.reports.find((r) => r.id === rid).provenance = { + contentOrigin: "user-imported", + }; + assert.equal((await preview(washed)).canCommit, false); +}); +test("登记资产摘要不可覆盖且必须存在 storage 对象,旧摘要缺失阻止 SQL 导出", async () => { + await assert.rejects(rpc("fo_set_asset_hash", asset.id, "b".repeat(64)), { + code: "PT409", + }); + await db.exec("reset role"); + await db.query("update public.fo_assets set sha256=null where id=$1", [ + asset.id, + ]); + await asUser(owner); + await assert.rejects(snapshot(), /资产缺少摘要/); + await rpc("fo_set_asset_hash", asset.id, "a".repeat(64)); + await db.exec("reset role"); + await db.query( + "update storage.objects set metadata=metadata||'{\"size\":5}'::jsonb where name=$1", + [asset.storage_path], + ); + await asUser(owner); + await assert.rejects(snapshot(), /元信息不完整/); + await db.exec("reset role"); + await db.query( + "update storage.objects set metadata=metadata||'{\"size\":4}'::jsonb where name=$1", + [asset.storage_path], + ); + await asUser(owner); +}); +test("提交阶段最后一表失败时前面的删除和插入全部回滚", async () => { + const before = await snapshot(), + plan = await preview(before); + assert.equal(plan.canCommit, true, JSON.stringify(plan.issues)); + await db.exec("reset role"); + // 管理员注入末尾故障,模拟非客户端验证可发现的数据库约束/触发器故障。 + await db.exec( + `create function fo_private.test_fail_event() returns trigger language plpgsql as $$begin raise exception 'SQL 回滚探针';end$$;create trigger test_fail_event before insert on public.fo_report_events for each row execute function fo_private.test_fail_event();`, + ); + await asUser(owner); + await assert.rejects(commit(plan), /SQL 回滚探针/); + assert.equal((await snapshot()).dataRevision, before.dataRevision); + assert.deepEqual(business(await snapshot()), business(before)); + await db.exec( + "reset role;drop trigger test_fail_event on public.fo_report_events;drop function fo_private.test_fail_event()", + ); + await asUser(owner); +}); +test("备份后新增无关资产仍可恢复旧快照,新增资产与正文保留", async () => { + const backup = await snapshot(), + id = randomUUID(), + path = `${workspace.id}/${owner}/${id}.png`; + await db.exec("reset role"); + await db.query( + "insert into storage.objects(bucket_id,name,metadata) values('factory-operations-assets',$1,$2)", + [path, { mimetype: "image/png", size: 8 }], + ); + await asUser(owner); + await rpc( + "fo_register_asset_with_hash", + id, + workspace.id, + "新增无关照片.png", + "image/png", + 8, + path, + "c".repeat(64), + ); + const plan = await preview(backup); + assert.equal(plan.canCommit, true, JSON.stringify(plan.issues)); + await commit(plan); + const after = await snapshot(); + assert.deepEqual(after.tables, backup.tables); + assert.equal(after.assets.length, backup.assets.length + 1); + assert.ok(after.assets.some((a) => a.id === id)); + const undeclared = clone(backup); + undeclared.tables.reports[0].data.assetIds = [id]; + const rejected = await preview(undeclared); + assert.equal(rejected.canCommit, false); + assert.ok(rejected.issues.some((i) => i.code === "reference")); +}); +test("恶意字段类型全返回结构化 issues,不冒泡转换错误", async () => { + const pack = await snapshot(); + for (const mutate of [ + (p) => (p.tables = null), + (p) => (p.metadata = null), + (p) => (p.exportScope = null), + (p) => (p.assets = null), + (p) => (p.tables.reports = null), + (p) => (p.tables.reports[0].data = null), + (p) => (p.tables.reports[0].provenance = null), + (p) => (p.tables.reports[0].data.assetIds = [{}]), + (p) => (p.tables.reports[0].provenance.recognition = null), + ]) { + const changed = clone(pack); + mutate(changed); + const result = await preview(changed); + assert.equal(result.canCommit, false); + assert.ok(result.issues.length > 0); + } +}); diff --git a/examples/saas/mes/factory-operations/workbench/.env.example b/examples/saas/mes/factory-operations/workbench/.env.example new file mode 100644 index 0000000..d7e7d01 --- /dev/null +++ b/examples/saas/mes/factory-operations/workbench/.env.example @@ -0,0 +1,6 @@ +VITE_DATA_PROVIDER=supabase +VITE_SUPABASE_URL=https://your-project.supabase.co +VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_replace_me +# 使用企业自己的 MySQL/资产平台网关时改为 http,并配置以下地址。 +# VITE_DATA_PROVIDER=http +# VITE_API_BASE_URL=https://operations.example.com/api/v1 diff --git a/examples/saas/mes/factory-operations/workbench/index.html b/examples/saas/mes/factory-operations/workbench/index.html new file mode 100644 index 0000000..054dabb --- /dev/null +++ b/examples/saas/mes/factory-operations/workbench/index.html @@ -0,0 +1,17 @@ + + + + + + + + 工序 · 工厂运营工作台 + + +
+ + + diff --git a/examples/saas/mes/factory-operations/workbench/package.json b/examples/saas/mes/factory-operations/workbench/package.json new file mode 100644 index 0000000..a001898 --- /dev/null +++ b/examples/saas/mes/factory-operations/workbench/package.json @@ -0,0 +1,32 @@ +{ + "name": "factory-operations-workbench", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1 --port 4318 --strictPort", + "build": "tsc -b && vite build", + "test": "vitest run", + "test:sql": "node --test ../supabase/tests/ledger-exchange.test.mjs", + "test:e2e": "playwright test", + "preview": "vite preview --host 127.0.0.1 --port 4318 --strictPort" + }, + "dependencies": { + "ajv": "8.20.0", + "@supabase/supabase-js": "2.117.3", + "lucide-react": "0.468.0", + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "@electric-sql/pglite": "0.5.10", + "@playwright/test": "1.64.0", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "4.7.0", + "typescript": "5.7.3", + "vite": "6.4.4", + "vitest": "4.1.11" + }, + "license": "Apache-2.0" +} diff --git a/examples/saas/mes/factory-operations/workbench/playwright.config.ts b/examples/saas/mes/factory-operations/workbench/playwright.config.ts new file mode 100644 index 0000000..f45b294 --- /dev/null +++ b/examples/saas/mes/factory-operations/workbench/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "@playwright/test"; + +declare const process: { env: Record }; + +export default defineConfig({ + testDir: "./tests", + testMatch: "**/browser.spec.ts", + fullyParallel: false, + use: { + baseURL: "http://127.0.0.1:4318", + headless: true, + viewport: { width: 1440, height: 1000 }, + launchOptions: { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH }, + }, + webServer: { + command: "npm run dev", + url: "http://127.0.0.1:4318", + reuseExistingServer: true, + }, + reporter: "list", +}); diff --git a/examples/saas/mes/factory-operations/workbench/scripts/cloud-smoke.mjs b/examples/saas/mes/factory-operations/workbench/scripts/cloud-smoke.mjs new file mode 100644 index 0000000..5f24679 --- /dev/null +++ b/examples/saas/mes/factory-operations/workbench/scripts/cloud-smoke.mjs @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { build } from "vite"; + +// 仅针对专用测试账号运行。账号必须已确认邮箱;配置文件不得提交到仓库。 +const root = path.resolve(fileURLToPath(new URL("../", import.meta.url))); +const configPath = process.env.FACTORY_SMOKE_CONFIG; +if (!configPath) + throw new Error( + "请设置 FACTORY_SMOKE_CONFIG,指向包含 url、key、owner、outsider 的本地 JSON。", + ); +const config = JSON.parse(await fs.readFile(configPath, "utf8")); +await build({ + configFile: false, + root, + logLevel: "error", + build: { + ssr: "src/data/supabase.ts", + outDir: ".local", + emptyOutDir: false, + rollupOptions: { output: { entryFileNames: "cloud-provider.mjs" } }, + }, +}); +const { createSupabaseServices } = await import( + pathToFileURL(path.join(root, ".local/cloud-provider.mjs")).href +); +const owner = createSupabaseServices(config.url, config.key); +const outsider = createSupabaseServices(config.url, config.key); +const passed = []; +const result = { + passed, + workspaceId: null, + assetId: null, + reportId: null, + ownerId: null, + outsiderId: null, +}; +const record = (name) => { + passed.push(name); + console.log(`PASS ${name}`); +}; +try { + const first = await owner.identity.signIn( + config.owner.email, + config.owner.password, + ); + const second = await outsider.identity.signIn( + config.outsider.email, + config.outsider.password, + ); + result.ownerId = first.id; + result.outsiderId = second.id; + record("两个独立测试账号真实登录"); + const workspace = await owner.operations.createWorkspace( + `验收测试 ${new Date().toISOString()}`, + ); + result.workspaceId = workspace.id; + assert.equal(workspace.role, "owner"); + assert( + (await owner.operations.listWorkspaces()).some( + (item) => item.id === workspace.id, + ), + ); + record("工作区创建与回读"); + assert( + !(await outsider.operations.listWorkspaces()).some( + (item) => item.id === workspace.id, + ), + ); + assert.equal((await outsider.operations.listReports(workspace.id)).length, 0); + await assert.rejects(() => + outsider.operations.createReport(workspace.id, { + kind: "consumable", + contractNumber: "QA-OTHER", + materialNumber: "QA", + station: "QA", + reason: "越权验证", + quantity: 1, + assetIds: [], + }), + ); + record("非成员不能读取或写入其他工作区"); + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jM1sAAAAASUVORK5CYII=", + "base64", + ); + const asset = await owner.assets.upload( + workspace.id, + new File([png], "验收测试照片.png", { type: "image/png" }), + ); + result.assetId = asset.id; + assert( + (await owner.assets.list(workspace.id)).some( + (item) => item.id === asset.id, + ), + ); + const download = await fetch(await owner.assets.signedUrl(asset)); + assert.equal(download.status, 200); + assert.deepEqual(Buffer.from(await download.arrayBuffer()), png); + await assert.rejects(() => outsider.assets.signedUrl(asset)); + record("私有文件上传、字节回读与跨工作区访问拒绝"); + const input = { + kind: "broken_needle", + contractNumber: "QA-CONTRACT-001", + materialNumber: "QA-NEEDLE", + station: "测试工位", + reason: "合成验收:断针申请替换", + quantity: 1, + assetIds: [asset.id], + }; + await assert.rejects(() => + owner.operations.createReport(workspace.id, { ...input, assetIds: [] }), + ); + let report = await owner.operations.createReport(workspace.id, input); + result.reportId = report.id; + assert.equal(report.assets[0].id, asset.id); + record("断针照片必填与上报落库"); + const approval = { + reportId: report.id, + expectedVersion: report.version, + status: "approved", + note: "验收:同意更换", + requestId: crypto.randomUUID(), + }; + console.log("CHECK 审批写入"); + report = await owner.operations.transitionReport(approval); + console.log("CHECK 相同请求重试"); + const repeated = await owner.operations.transitionReport(approval); + assert.equal(repeated.version, report.version); + console.log("CHECK 过期版本拒绝"); + await assert.rejects(() => + owner.operations.transitionReport({ + ...approval, + requestId: crypto.randomUUID(), + status: "rejected", + }), + ); + record("审批幂等及过期版本冲突"); + report = await owner.operations.transitionReport({ + reportId: report.id, + expectedVersion: report.version, + status: "delivering", + note: "验收:配送新针", + requestId: crypto.randomUUID(), + }); + await assert.rejects(() => + owner.operations.transitionReport({ + reportId: report.id, + expectedVersion: report.version, + status: "completed", + note: "不应成功:未回收", + requestId: crypto.randomUUID(), + }), + ); + report = await owner.operations.confirmRecovery({ + reportId: report.id, + expectedVersion: report.version, + note: "验收:全部碎片已回收", + requestId: crypto.randomUUID(), + }); + report = await owner.operations.transitionReport({ + reportId: report.id, + expectedVersion: report.version, + status: "completed", + note: "验收:完成配送并结单", + requestId: crypto.randomUUID(), + }); + assert.equal(report.status, "completed"); + assert.equal((await owner.operations.listTimeline(report.id)).length, 5); + record("回收约束、完整状态流与审计记录"); + const visit = await owner.operations.createVisit(workspace.id, { + customer: "合成验收客户", + visitedAt: new Date().toISOString(), + summary: "测试沟通纪要", + issues: "待确认交期", + nextStep: "确认报价与交期", + followUpDate: "2026-10-20", + }); + assert( + (await owner.operations.listVisits(workspace.id)).some( + (item) => item.id === visit.id, + ), + ); + record("销售拜访写入与回读"); + await owner.operations.addMember( + workspace.id, + config.outsider.email, + "operator", + ); + assert.equal( + (await outsider.operations.listWorkspaces()).find( + (item) => item.id === workspace.id, + )?.role, + "operator", + ); + await assert.rejects(() => + outsider.operations.addMember(workspace.id, config.owner.email, "manager"), + ); + await assert.rejects(() => + outsider.operations.transitionReport({ + reportId: report.id, + expectedVersion: report.version, + status: "approved", + note: "越权审批", + requestId: crypto.randomUUID(), + }), + ); + assert.equal((await outsider.operations.listVisits(workspace.id)).length, 0); + record("成员加入与员工权限边界"); + await owner.identity.signOut(); + await owner.identity.signIn(config.owner.email, config.owner.password); + assert.equal( + (await owner.operations.listReports(workspace.id)).find( + (item) => item.id === report.id, + )?.status, + "completed", + ); + record("退出重登后数据持久化"); +} finally { + await fs.mkdir(path.join(root, ".local"), { recursive: true }); + await fs.writeFile( + path.join(root, ".local/cloud-smoke-result.json"), + JSON.stringify(result, null, 2), + ); + await Promise.allSettled([ + owner.identity.signOut(), + outsider.identity.signOut(), + ]); +} diff --git a/examples/saas/mes/factory-operations/workbench/src/App.tsx b/examples/saas/mes/factory-operations/workbench/src/App.tsx new file mode 100644 index 0000000..ad541fd --- /dev/null +++ b/examples/saas/mes/factory-operations/workbench/src/App.tsx @@ -0,0 +1,915 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type FormEvent, +} from "react"; +import { + Activity, + ArrowRight, + ArrowUpRight, + CalendarDays, + CheckCheck, + ChevronDown, + ClipboardList, + DatabaseZap, + Factory, + FolderOpen, + LayoutDashboard, + LogOut, + Menu, + Plus, + RefreshCw, + Settings2, + ShieldCheck, + Users, + X, +} from "lucide-react"; +import type { + AssetRef, + DataServices, + OperationReport, + SalesVisit, + UserIdentity, + Workspace, +} from "./data/contracts"; +import { createConfiguredProvider } from "./data"; +import type { DatasetState } from "./data/exchange-contracts"; +import { DataExchangePage } from "./DataExchangePage"; +import { DatasetSourceBar } from "./SourceBadge"; +import { AuthScreen } from "./AuthScreen"; +import { ReportDetail, ReportForm, ReportsPage, ReportTable } from "./Reports"; +import { + AssetPreview, + AssetsPage, + SettingsPage, + VisitForm, + VisitsPage, +} from "./WorkspacePages"; +import { + Empty, + ErrorBanner, + errorMessage, + formatDate, + Loading, + Modal, + roleLabels, +} from "./ui"; + +type Page = + | "overview" + | "reports" + | "visits" + | "assets" + | "exchange" + | "settings"; +const navigation = [ + { id: "overview" as const, title: "工作台概览", icon: LayoutDashboard }, + { id: "reports" as const, title: "工厂申领", icon: ClipboardList }, + { id: "visits" as const, title: "销售拜访", icon: Users }, + { id: "assets" as const, title: "资产资料", icon: FolderOpen }, + { id: "exchange" as const, title: "数据接入", icon: DatabaseZap }, +]; +const configured = createConfiguredProvider(); + +export default function App() { + const [user, setUser] = useState(null); + const [sessionLoading, setSessionLoading] = useState( + Boolean(configured.provider), + ); + const [error, setError] = useState(""); + const services = configured.provider; + useEffect(() => { + if (!services) return; + let active = true; + services.identity + .currentUser() + .then((value) => { + if (active) setUser(value); + }) + .catch((reason) => { + if (active) setError(errorMessage(reason)); + }) + .finally(() => { + if (active) setSessionLoading(false); + }); + const dispose = services.identity.onChange((value) => { + if (active) { + setUser(value); + setSessionLoading(false); + } + }); + return () => { + active = false; + dispose(); + }; + }, [services]); + if (sessionLoading) + return ( +
+ + +
+ ); + if (!services || !user) + return ( + + ); + return ( + setUser(null)} + /> + ); +} + +function WorkspaceApp({ + services, + user, + onSignedOut, +}: { + services: DataServices; + user: UserIdentity; + onSignedOut: () => void; +}) { + const [workspaces, setWorkspaces] = useState([]); + const [workspaceId, setWorkspaceId] = useState(""); + const [initialLoading, setInitialLoading] = useState(true); + const [dataLoading, setDataLoading] = useState(false); + const [reports, setReports] = useState([]); + const [visits, setVisits] = useState([]); + const [assets, setAssets] = useState([]); + const [datasetState, setDatasetState] = useState(null); + const [sourceIssue, setSourceIssue] = useState(""); + const [error, setError] = useState(""); + const [page, setPage] = useState("overview"); + const [mobileMenu, setMobileMenu] = useState(false); + const [modal, setModal] = useState<"workspace" | "report" | "visit" | null>( + null, + ); + const [selectedReport, setSelectedReport] = useState( + null, + ); + const [preview, setPreview] = useState(null); + const workspace = workspaces.find((item) => item.id === workspaceId); + const refreshGeneration = useRef(0); + const activeWorkspaceId = useRef(workspaceId); + activeWorkspaceId.current = workspaceId; + const canCreateReport = Boolean(workspace && workspace.role !== "sales"); + const canCreateVisit = Boolean(workspace && workspace.role !== "operator"); + const loadWorkspaces = useCallback( + async (showLoading = true) => { + if (showLoading) setInitialLoading(true); + setError(""); + try { + const items = await services.operations.listWorkspaces(); + setWorkspaces(items); + const saved = localStorage.getItem(`factory-workspace-${user.id}`); + setWorkspaceId((current) => + items.some((item) => item.id === current) + ? current + : (items.find((item) => item.id === saved)?.id ?? + items[0]?.id ?? + ""), + ); + } catch (reason) { + setError(errorMessage(reason)); + } finally { + if (showLoading) setInitialLoading(false); + } + }, + [services, user.id], + ); + useEffect(() => { + void loadWorkspaces(); + }, [loadWorkspaces]); + const refresh = useCallback(async () => { + if (!workspaceId || activeWorkspaceId.current !== workspaceId) return; + const generation = ++refreshGeneration.current; + const isCurrent = () => + refreshGeneration.current === generation && + activeWorkspaceId.current === workspaceId; + setDataLoading(true); + setError(""); + const readSource = async (): Promise<{ + state: DatasetState | null; + issue: string; + }> => { + if (!services.exchange) + return { state: null, issue: "当前数据平台未提供来源元信息。" }; + try { + const state = await services.exchange.getDatasetState(workspaceId); + return state?.workspaceId === workspaceId && + typeof state.revision === "string" + ? { state, issue: "" } + : { state: null, issue: "当前数据平台未提供有效来源元信息。" }; + } catch { + return { state: null, issue: "来源信息暂时无法读取,刷新后重试。" }; + } + }; + try { + for (let attempt = 0; attempt < 3; attempt++) { + const before = await readSource(); + if (!isCurrent()) return; + const [reportItems, visitItems, assetItems] = await Promise.all([ + services.operations.listReports(workspaceId), + canCreateVisit + ? services.operations.listVisits(workspaceId) + : Promise.resolve([]), + services.assets.list(workspaceId), + ]); + if (!isCurrent()) return; + const after = await readSource(); + if (!isCurrent()) return; + if ( + before.state && + after.state && + before.state.revision !== after.state.revision + ) + continue; + setReports( + reportItems.sort((a, b) => b.createdAt.localeCompare(a.createdAt)), + ); + setVisits( + visitItems.sort((a, b) => b.visitedAt.localeCompare(a.visitedAt)), + ); + setAssets( + assetItems.sort((a, b) => b.createdAt.localeCompare(a.createdAt)), + ); + // Only label records with a source whose revision brackets the entire read. + setDatasetState(before.state && after.state ? after.state : null); + setSourceIssue(before.issue || after.issue); + return; + } + throw new Error("工作区正在更新,暂时无法读取一致数据,请稍后刷新。"); + } catch (reason) { + if (isCurrent()) setError(errorMessage(reason)); + } finally { + if (isCurrent()) setDataLoading(false); + } + }, [services, workspaceId, canCreateVisit]); + useEffect(() => { + ++refreshGeneration.current; + setReports([]); + setVisits([]); + setAssets([]); + setDatasetState(null); + setSourceIssue(""); + setSelectedReport(null); + setPreview(null); + setModal(null); + if (workspaceId) { + localStorage.setItem(`factory-workspace-${user.id}`, workspaceId); + void refresh(); + } + return () => { + ++refreshGeneration.current; + }; + }, [workspaceId, refresh, user.id]); + async function signOut() { + try { + await services.identity.signOut(); + onSignedOut(); + } catch (reason) { + setError(errorMessage(reason)); + } + } + function changeWorkspace(id: string) { + ++refreshGeneration.current; + activeWorkspaceId.current = id; + setReports([]); + setVisits([]); + setAssets([]); + setDatasetState(null); + setSourceIssue(""); + setSelectedReport(null); + setPreview(null); + setModal(null); + setDataLoading(true); + setWorkspaceId(id); + } + function navigate(next: Page) { + setPage(next); + setMobileMenu(false); + } + return ( +
+ {mobileMenu && ( + + ))} + +
+
+ + 团队协作 · 来源可追溯 +

每次操作,都有迹可循。

+
+ +
+ + {user.email.slice(0, 1).toUpperCase()} + +
+ {user.email.split("@")[0]} + {workspace ? roleLabels[workspace.role] : "已登录"} +
+ +
+
+ +
+
+
+ + + 工作空间 /{" "} + + {page === "settings" + ? "工作区设置" + : navigation.find((item) => item.id === page)?.title} + + +
+
+ + + {new Intl.DateTimeFormat("zh-CN", { + month: "long", + day: "numeric", + weekday: "short", + }).format(new Date())} + + {workspace && ( + + )} + + {user.email.slice(0, 1).toUpperCase()} + +
+
+
+ {error && ( + { + void (workspaceId ? refresh() : loadWorkspaces()); + }} + > + {error} + + )} + {initialLoading ? ( + + ) : !workspace ? ( +
+ + + + LET’S GET STARTED +

为你的团队,建一个工作区。

+

+ 集中管理生产申领、客户拜访和资产资料。 +
+ 创建后即可邀请同事,一起开始协作。 +

+ +

+ 加入已有工作区?请所有者通过你的注册邮箱添加。 +

+
+ ) : ( + <> + + {dataLoading && + !reports.length && + !visits.length && + !assets.length && + page !== "exchange" ? ( + + ) : ( + <> + {page === "overview" && ( + setModal("report") : undefined + } + onNewVisit={ + canCreateVisit ? () => setModal("visit") : undefined + } + onSelect={setSelectedReport} + /> + )} + {page === "reports" && ( + setModal("report")} + onSelect={setSelectedReport} + /> + )} + {page === "visits" && ( + setModal("visit")} + /> + )} + {page === "assets" && ( + + )} + {page === "settings" && ( + setModal("workspace")} + /> + )} + {page === "exchange" && ( + { + if (activeWorkspaceId.current !== workspace.id) return; + await loadWorkspaces(false); + await refresh(); + }} + onAssetUploaded={refresh} + /> + )} + + )} + + )} +
+
+ 工序 / FACTORY WORKSPACE + + {workspace?.name ?? "工作区待创建"} + ·让协作发生在每一道工序 + +
+
+ {modal === "workspace" && ( + setModal(null)} + onCreated={async (item) => { + setWorkspaces((items) => [...items, item]); + changeWorkspace(item.id); + }} + /> + )} + {modal === "report" && workspace && ( + setModal(null)} + onCreated={refresh} + /> + )} + {modal === "visit" && workspace && ( + setModal(null)} + onCreated={refresh} + /> + )} + {selectedReport && workspace && ( + setSelectedReport(null)} + onUpdated={refresh} + onPreview={setPreview} + /> + )} + {preview && ( + setPreview(null)} + /> + )} +
+ ); +} + +function Overview({ + workspace, + reports, + visits, + assets, + onNavigate, + onNewReport, + onNewVisit, + onSelect, +}: { + workspace: Workspace; + reports: OperationReport[]; + visits: SalesVisit[]; + assets: AssetRef[]; + onNavigate: (page: Page) => void; + onNewReport?: () => void; + onNewVisit?: () => void; + onSelect: (report: OperationReport) => void; +}) { + const open = reports.filter( + (report) => !["completed", "rejected"].includes(report.status), + ); + const recovery = reports.filter( + (report) => + report.kind === "broken_needle" && + !report.fragmentsRecovered && + !["completed", "rejected"].includes(report.status), + ); + const stats = [ + { + title: "待处理申领", + count: open.length, + hint: `${reports.filter((report) => report.status === "submitted").length} 项等待审批`, + icon: ClipboardList, + color: "green", + }, + { + title: "待回收确认", + count: recovery.length, + hint: "断针回收后完成闭环", + icon: ShieldCheck, + color: "amber", + }, + { + title: "已完成事项", + count: reports.filter((report) => report.status === "completed").length, + hint: "每一步,都有完整记录", + icon: CheckCheck, + color: "blue", + }, + { + title: "客户拜访记录", + count: visits.length, + hint: `${visits.filter((visit) => visit.followUpDate).length} 项设有跟进日期`, + icon: Users, + color: "purple", + }, + ]; + return ( + <> +
+
+ EVERY STEP COUNTS +

+ 工作台概览. +

+

你好,今天也让每件事有着落。

+
+
+ {workspace.name} +
+
+
+
+ + 生产 · 协同 · 闭环 + +

连接现场,推进每一步。

+

+ {open.length + ? `当前有 ${open.length} 项申领正在流转,${recovery.length} 项断针等待回收确认。` + : "从现场申领开始,让进度、资料与责任清晰可见。"} +

+
+ {onNewReport && ( + + )} + {onNewVisit && ( + + )} +
+
+
+
+ {stats.map((stat) => ( +
+
+ {stat.title} + + + +
+ + {stat.count} + 项 + +

{stat.hint}

+
+ ))} +
+
+
+
+
+

最近申领

+

现场事项的最新进展

+
+ +
+ {reports.length ? ( + + ) : ( + + 新建申领 + + ) : undefined + } + /> + )} +
+ +
+ + ); +} + +function WorkspaceForm({ + services, + onClose, + onCreated, +}: { + services: DataServices; + onClose: () => void; + onCreated: (workspace: Workspace) => Promise; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + async function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + setBusy(true); + setError(""); + try { + const workspace = await services.operations.createWorkspace( + String(form.get("name")).trim(), + ); + await onCreated(workspace); + } catch (reason) { + setError(errorMessage(reason)); + } finally { + setBusy(false); + } + } + return ( + { + if (!busy) onClose(); + }} + > +
+ +
+ + 你将成为该工作区的所有者,可添加成员并分配角色。 +
+ {error && {error}} +
+ + +
+
+
+ ); +} diff --git a/examples/saas/mes/factory-operations/workbench/src/AuthScreen.tsx b/examples/saas/mes/factory-operations/workbench/src/AuthScreen.tsx new file mode 100644 index 0000000..24316ac --- /dev/null +++ b/examples/saas/mes/factory-operations/workbench/src/AuthScreen.tsx @@ -0,0 +1,189 @@ +import { useState, type FormEvent } from "react"; +import { + ArrowRight, + Boxes, + Check, + Factory, + Settings2, + ShieldCheck, +} from "lucide-react"; +import type { DataServices, UserIdentity } from "./data/contracts"; +import { ConnectionSettings } from "./ConnectionSettings"; +import { ErrorBanner, errorMessage } from "./ui"; + +export function AuthScreen({ + services, + issue, + onAuthenticated, +}: { + services: DataServices | null; + issue: string | null; + onAuthenticated: (user: UserIdentity) => void; +}) { + const [register, setRegister] = useState(false); + const [configure, setConfigure] = useState(!services); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + async function submit(event: FormEvent) { + event.preventDefault(); + if (!services) return; + const form = new FormData(event.currentTarget); + setBusy(true); + setError(""); + setNotice(""); + try { + const email = String(form.get("email")).trim(); + const password = String(form.get("password")); + if (register) { + const result = await services.identity.signUp(email, password); + if (result.needsEmailConfirmation) + setNotice("注册申请已提交,请打开邮箱中的确认链接,再回到这里登录。"); + else if (result.user) onAuthenticated(result.user); + } else onAuthenticated(await services.identity.signIn(email, password)); + } catch (reason) { + setError(errorMessage(reason)); + } finally { + setBusy(false); + } + } + return ( +
+ +
+
+ YOUR DAILY WORKSPACE +

+ {configure + ? "连接你的数据平台" + : register + ? "创建你的账号" + : "欢迎回到工作台"} +

+

+ {configure + ? "选择适合你的存储方式,业务流程保持一致。" + : "登录后,继续与你的团队协作。"} +

+ {configure ? ( + <> + {issue && !services &&
{issue}
} + setConfigure(false) : undefined} + /> + + ) : ( + <> + {issue && {issue}} +
+ + + {error && {error}} + {notice && ( +
+ {notice} +
+ )} + +
+

+ {register ? "已经有账号?" : "第一次使用?"} + +

+
+ + + 当前数据平台: + {services?.provider === "supabase" + ? "Supabase" + : "标准 HTTP API"} + + +
+ + )} +

+ 数据属于你的工作区 · 账号与访问权限由所选平台管理 +

+
+
+
+ ); +} diff --git a/examples/saas/mes/factory-operations/workbench/src/ConnectionSettings.tsx b/examples/saas/mes/factory-operations/workbench/src/ConnectionSettings.tsx new file mode 100644 index 0000000..39a94c2 --- /dev/null +++ b/examples/saas/mes/factory-operations/workbench/src/ConnectionSettings.tsx @@ -0,0 +1,194 @@ +import { useState, type FormEvent } from "react"; +import { + ArrowRight, + Database, + Globe2, + KeyRound, + ShieldCheck, +} from "lucide-react"; +import type { DataConfiguration, DataServices } from "./data/contracts"; +import { configurationStorageKey, validateConfiguration } from "./data"; +import { ErrorBanner } from "./ui"; + +export function ConnectionSettings({ + currentProvider = "supabase", + services, + onCancel, +}: { + currentProvider?: string; + services?: DataServices | null; + onCancel?: () => void; +}) { + const [provider, setProvider] = useState<"supabase" | "http">( + currentProvider === "http" ? "http" : "supabase", + ); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [pendingSwitch, setPendingSwitch] = useState<{ + config?: DataConfiguration; + } | null>(null); + function applyConfiguration(config?: DataConfiguration) { + if (config) + localStorage.setItem(configurationStorageKey, JSON.stringify(config)); + else localStorage.removeItem(configurationStorageKey); + window.location.reload(); + } + async function save(event: FormEvent) { + event.preventDefault(); + const values = new FormData(event.currentTarget); + const config: DataConfiguration = + provider === "supabase" + ? { + provider, + supabaseUrl: String(values.get("url") ?? "").trim(), + supabasePublishableKey: String(values.get("key") ?? "").trim(), + } + : { provider, apiBaseUrl: String(values.get("url") ?? "").trim() }; + setBusy(true); + setError(""); + setPendingSwitch(null); + try { + validateConfiguration(config); + try { + await services?.identity.signOut(); + } catch (reason) { + setPendingSwitch({ config }); + throw reason; + } + applyConfiguration(config); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "连接配置保存失败。"); + } finally { + setBusy(false); + } + } + async function useDefault() { + setBusy(true); + setError(""); + setPendingSwitch(null); + try { + try { + await services?.identity.signOut(); + } catch (reason) { + setPendingSwitch({}); + throw reason; + } + applyConfiguration(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "连接切换失败。"); + } finally { + setBusy(false); + } + } + return ( +
+
+ + +
+
+ + {provider === "supabase" && ( + + )} +
+ + + {provider === "supabase" + ? "先在项目中执行随案例提供的数据库迁移,并配置认证。项目地址和公开密钥保存在当前浏览器。" + : "服务端需要实现本案例的标准接口,统一处理账号、工作区、业务记录和资产。MySQL 连接凭据只保存在服务端。"} + +
+ {error && {error}} + {pendingSwitch && ( +
+ + 原平台暂时无法退出;继续切换会保留原平台可能尚未过期的会话。 + + +
+ )} +
+ + {onCancel && ( + + )} + +
+
+

+ 更换数据平台后,使用对应平台的账号登录。 +

+
+ ); +} diff --git a/examples/saas/mes/factory-operations/workbench/src/DataExchangePage.tsx b/examples/saas/mes/factory-operations/workbench/src/DataExchangePage.tsx new file mode 100644 index 0000000..8eacd25 --- /dev/null +++ b/examples/saas/mes/factory-operations/workbench/src/DataExchangePage.tsx @@ -0,0 +1,796 @@ +import { useEffect, useRef, useState } from "react"; +import { + Camera, + CheckCircle2, + Download, + FileJson, + FlaskConical, + ScanLine, + ShieldCheck, + Upload, +} from "lucide-react"; +import type { + AssetRef, + DataServices, + UserIdentity, + Workspace, +} from "./data/contracts"; +import { + ledgerTables, + type ImportPreview, + type LedgerPackage, + type LedgerRow, + type RecognitionDraft, +} from "./data/exchange-contracts"; +import { + createLedgerTemplate, + createMockRecognitionDraft, + markDraftReviewed, +} from "./domain/exchange"; +import { validateAsset } from "./domain/rules"; +import { Empty, ErrorBanner, errorMessage } from "./ui"; +import { SourceBadge } from "./SourceBadge"; + +const tableLabels: Record = { + factory: "工厂档案", + reports: "生产申领", + "report-events": "流转与回收记录", + visits: "销售拜访", +}; +const draftFields: Record< + "reports" | "visits", + { key: string; label: string; multiline?: boolean; numeric?: boolean }[] +> = { + reports: [ + { key: "contractNumber", label: "合同号" }, + { key: "materialNumber", label: "物料编号" }, + { key: "station", label: "生产工位" }, + { key: "quantity", label: "申领数量", numeric: true }, + { key: "reason", label: "申领原因", multiline: true }, + ], + visits: [ + { key: "customer", label: "客户名称" }, + { key: "visitedAt", label: "拜访时间(ISO 时间)" }, + { key: "summary", label: "拜访纪要", multiline: true }, + { key: "issues", label: "客户问题", multiline: true }, + { key: "nextStep", label: "下一步行动", multiline: true }, + { key: "followUpDate", label: "跟进日期(YYYY-MM-DD)" }, + ], +}; + +function downloadPackage(value: LedgerPackage, name: string) { + const url = URL.createObjectURL( + new Blob([JSON.stringify(value, null, 2)], { type: "application/json" }), + ); + const link = document.createElement("a"); + link.href = url; + link.download = name; + document.body.append(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +export function DataExchangePage({ + services, + workspace, + user, + onPublished, + onAssetUploaded, +}: { + services: DataServices; + workspace: Workspace; + user: UserIdentity; + onPublished: () => Promise; + onAssetUploaded: () => Promise; +}) { + const exchange = services.exchange; + const owner = workspace.role === "owner"; + const [mode, setMode] = useState<"json" | "photo">("json"); + const [busy, setBusy] = useState(""); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [packageText, setPackageText] = useState(""); + const [fileName, setFileName] = useState(""); + const [photo, setPhoto] = useState(null); + const [photoUrl, setPhotoUrl] = useState(""); + const [ledgerType, setLedgerType] = useState<"reports" | "visits">("reports"); + const [draft, setDraft] = useState(null); + const [reviewed, setReviewed] = useState(false); + const [preview, setPreview] = useState(null); + const [confirmed, setConfirmed] = useState(false); + const [receipt, setReceipt] = useState(""); + const active = useRef(true); + const operation = useRef(0); + const uploaded = useRef<{ file: File; asset: AssetRef } | null>(null); + const commitRequest = useRef<{ + planId: string; + revision: string; + id: string; + } | null>(null); + useEffect(() => { + active.current = true; + return () => { + active.current = false; + ++operation.current; + }; + }, []); + useEffect(() => { + if (!photo) { + setPhotoUrl(""); + return; + } + const url = URL.createObjectURL(photo); + setPhotoUrl(url); + return () => URL.revokeObjectURL(url); + }, [photo]); + function invalidate() { + ++operation.current; + setPreview(null); + setConfirmed(false); + setReceipt(""); + setNotice(""); + setError(""); + commitRequest.current = null; + } + function start(label: string) { + const id = ++operation.current; + setBusy(label); + setError(""); + setNotice(""); + setReceipt(""); + return () => active.current && operation.current === id; + } + async function exportSnapshot() { + if (!exchange || !owner || busy) return; + const current = start("正在读取一致快照…"); + try { + const snapshot = await exchange.exportSnapshot(workspace.id); + if (!current()) return; + downloadPackage( + snapshot, + `factory-${workspace.id}-${snapshot.dataRevision ?? "snapshot"}.json`, + ); + setNotice( + "已导出当前工作区的完整业务快照。JSON 只含资产清单,文件正文仍在原资产平台。", + ); + } catch (reason) { + if (current()) setError(errorMessage(reason)); + } finally { + if (current()) setBusy(""); + } + } + async function readFile(file?: File) { + if (!file || busy) return; + invalidate(); + setDraft(null); + setReviewed(false); + setPackageText(""); + setFileName(""); + const current = start("正在读取 JSON 文件…"); + try { + if (file.size > 5 * 1024 * 1024) + throw new Error("数据包不能超过 5 MB,请缩小文件后重试。"); + const text = await file.text(); + if (!current()) return; + setPackageText(text); + setFileName(file.name); + } catch (reason) { + if (current()) setError(errorMessage(reason)); + } finally { + if (current()) setBusy(""); + } + } + async function recognize() { + if (!exchange || !owner || !photo || busy) return; + invalidate(); + setDraft(null); + setReviewed(false); + const current = start("正在上传照片到资产平台…"); + try { + if (!["image/jpeg", "image/png", "image/webp"].includes(photo.type)) + throw new Error("请选择 JPG、PNG 或 WebP 照片。"); + validateAsset(photo); + let asset = + uploaded.current?.file === photo ? uploaded.current.asset : null; + if (!asset) { + asset = await services.assets.upload(workspace.id, photo); + if (!current()) return; + uploaded.current = { file: photo, asset }; + } + setBusy("正在准备模拟草稿…"); + const snapshot = await exchange.exportSnapshot(workspace.id); + if (!current()) return; + const value = createMockRecognitionDraft({ + workspace, + user, + asset, + ledgerType, + snapshot, + }); + setDraft(value); + await onAssetUploaded(); + } catch (reason) { + if (current()) setError(errorMessage(reason)); + } finally { + if (current()) setBusy(""); + } + } + function updateRow(row: LedgerRow, key: string, value: unknown) { + if (!draft || busy) return; + invalidate(); + setReviewed(false); + setDraft({ + ...draft, + package: { + ...draft.package, + tables: { + ...draft.package.tables, + [draft.ledgerType]: ( + draft.package.tables[draft.ledgerType] ?? [] + ).map((item) => + item.id === row.id + ? { ...item, data: { ...item.data, [key]: value } } + : item, + ), + }, + }, + }); + } + async function inspect() { + if ( + !exchange || + !owner || + busy || + (mode === "photo" && (!draft || !reviewed)) + ) + return; + setPreview(null); + setConfirmed(false); + commitRequest.current = null; + const current = start("正在校验完整候选数据…"); + try { + const text = + mode === "photo" && draft + ? JSON.stringify(markDraftReviewed(draft)) + : packageText; + if (!text.trim()) throw new Error("请先选择数据包。"); + const result = await exchange.previewImport(workspace.id, { + packageText: text, + mode: "replace", + replaceScope: [...ledgerTables], + }); + if (result.targetWorkspaceId !== workspace.id) + throw new Error("预览返回了其他工作区的结果,请重新载入后再试。"); + if (current()) + setPreview( + mode === "photo" && + draft && + result.expectedRevision !== draft.package.dataRevision + ? { + ...result, + canCommit: false, + planId: null, + issues: [ + ...result.issues, + { + table: null, + row: null, + field: "/dataRevision", + code: "STALE_PHOTO_DRAFT", + message: + "生成草稿后工作区已有新数据,请重新生成模拟草稿并再次核对,避免覆盖其他成员的更新。", + }, + ], + } + : result, + ); + } catch (reason) { + if (current()) setError(errorMessage(reason)); + } finally { + if (current()) setBusy(""); + } + } + async function commit() { + if ( + !exchange || + !owner || + !preview?.canCommit || + !preview.planId || + !confirmed || + busy + ) + return; + const current = start("正在原子替换工作区数据…"); + try { + if ( + commitRequest.current?.planId !== preview.planId || + commitRequest.current.revision !== preview.expectedRevision + ) + commitRequest.current = { + planId: preview.planId, + revision: preview.expectedRevision, + id: crypto.randomUUID(), + }; + const result = await exchange.commitImport(workspace.id, { + planId: preview.planId, + expectedRevision: preview.expectedRevision, + requestId: commitRequest.current.id, + }); + if (!current()) return; + setPreview(null); + setConfirmed(false); + setDraft(null); + setReviewed(false); + setPackageText(""); + setFileName(""); + commitRequest.current = null; + setReceipt( + `已发布数据版本 ${result.revision}。工作区来源与业务页面将从服务端重新读取,照片的模拟识别标记继续保留。`, + ); + await onPublished(); + } catch (reason) { + if (current()) + setError( + `${errorMessage(reason)} 已保留原预览与提交标识。可重试确认;若数据版本已变化,请重新预览。`, + ); + } finally { + if (current()) setBusy(""); + } + } + const rows = draft + ? (draft.package.tables[draft.ledgerType] ?? []).filter((row) => + draft.rowIds.includes(row.id), + ) + : []; + return ( + <> +
+
+ DATA EXCHANGE +

数据接入

+

把台账带进工作区,先核对,再发布。

+
+ {owner && exchange && ( + + )} +
+ {!owner ? ( +
+ +
+ ) : !exchange ? ( +
+ +
+ ) : ( +
+ {error && {error}} + {notice && ( +
+ + {notice} +
+ )} + {receipt && ( +
+ +
+ 整包替换已完成 +

{receipt}

+
+
+ )} +
+
+ + +
+

+ {mode === "json" ? ( + <> + + 导入工作区快照 + + ) : ( + <> + + 从照片准备台账草稿 + + )} +

+

+ {mode === "json" + ? "使用本工作区导出的完整快照。读取文件与预览都不会改动当前业务数据;明确确认后才整包替换。" + : "照片先保存到当前资产平台。当前使用固定规则生成合成草稿,不读取或识别照片内容;请逐项编辑核对,模拟来源会一直保留。"} +

+
    +
  1. + 1 + {mode === "json" ? "选择数据包" : "上传照片与编辑草稿"} +
  2. +
  3. + 2预览校验与删除影响 +
  4. +
  5. + 3确认原子替换 +
  6. +
+ {mode === "json" ? ( + <> +
+ +
+ {fileName || "选择完整 JSON 数据包"} +

最多 5 MB · 保留原始文件校验重复键、字段和业务关联

+
+ +
+
+ + +
+

+ 整包范围:工厂档案、申领、流转事件与拜访。空模板只说明格式,不可直接提交;请先导出当前快照,以导出包为填写基准,保留档案、流程历史和资产引用。拍照快速填报可使用上方模拟草稿。JSON + 只含资产引用,不含文件正文;本版仅支持同一工作区还原。 +

+ + ) : ( + <> +
+ +
+
+ +
+ {photo?.name ?? "拍摄或选择台账照片"} +

+ JPG / PNG / WebP · 最大 20 MB · 上传后保存在工作区资产库 +

+
+ +
+
+ +
+ {photoUrl && ( +
+ 待核对的台账原图 +
+ 对照原图逐项填写;模拟器不读取照片内容。 +
+
+ )} + {draft && ( +
+
+ +
+ 模拟识别草稿 · 待人工核对 +
+ 以下内容是合成占位,不能视为照片中的事实。修改或确认都不会把模拟识别升级为真实识别。 +
+
+ {draft.warnings.length > 0 && ( +
    + {draft.warnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+ )} + {rows.map((row, index) => ( +
+
+

草稿第 {index + 1} 行

+ +
+
+ {draftFields[draft.ledgerType].map((field) => ( +