From f0ddfcb7d850ad10bc8507008a83fdead208c0e8 Mon Sep 17 00:00:00 2001 From: PeterGuy326 <739820174@qq.com> Date: Wed, 14 Oct 2026 23:27:32 +0800 Subject: [PATCH 1/6] feat(mes): add runnable factory workbench with pluggable data providers --- .github/workflows/check.yml | 2 +- .gitignore | 1 + README.md | 10 +- catalog/cases.json | 15 + docs/data-provider-contract.md | 74 + examples/mes/factory-operations/README.md | 67 + examples/mes/factory-operations/acceptance.md | 22 + examples/mes/factory-operations/case.json | 69 + examples/mes/factory-operations/data-model.md | 36 + .../mes/factory-operations/data/README.md | 7 + .../mes/factory-operations/data/fixtures.json | 33 + .../data/fixtures.schema.json | 78 + .../factory-operations/evidence/desktop.jpg | Bin 0 -> 76146 bytes .../factory-operations/evidence/mobile.jpg | Bin 0 -> 33075 bytes .../evidence/verification.md | 58 + .../mes/factory-operations/integrations.md | 74 + .../mes/factory-operations/requirements.md | 48 + .../20261014150433_factory_operations.sql | 190 ++ ...0261014151243_harden_optional_auto_rls.sql | 9 + ...1014151422_report_conflict_http_status.sql | 29 + .../factory-operations/workbench/.env.example | 6 + .../factory-operations/workbench/index.html | 17 + .../factory-operations/workbench/package.json | 29 + .../workbench/playwright.config.ts | 21 + .../workbench/scripts/cloud-smoke.mjs | 231 ++ .../factory-operations/workbench/src/App.tsx | 832 +++++ .../workbench/src/AuthScreen.tsx | 189 ++ .../workbench/src/ConnectionSettings.tsx | 194 ++ .../workbench/src/Reports.tsx | 720 +++++ .../workbench/src/WorkspacePages.tsx | 663 ++++ .../workbench/src/data/contracts.ts | 136 + .../workbench/src/data/http.ts | 170 + .../workbench/src/data/index.ts | 128 + .../workbench/src/data/network.ts | 20 + .../workbench/src/data/supabase.ts | 423 +++ .../workbench/src/domain/rules.ts | 130 + .../factory-operations/workbench/src/main.tsx | 10 + .../workbench/src/styles.css | 2854 +++++++++++++++++ .../factory-operations/workbench/src/ui.tsx | 167 + .../workbench/tests/browser.spec.ts | 355 ++ .../workbench/tests/configuration.test.ts | 68 + .../workbench/tests/domain.test.ts | 135 + .../workbench/tests/http-contract.test.ts | 170 + .../workbench/tests/network.test.ts | 52 + .../workbench/tests/supabase-contract.test.ts | 35 + .../workbench/tsconfig.json | 15 + .../workbench/vite.config.ts | 8 + examples/mes/factory-operations/workflows.md | 42 + index.html | 2 +- package-lock.json | 139 +- package.json | 1 + 51 files changed, 8772 insertions(+), 12 deletions(-) create mode 100644 docs/data-provider-contract.md create mode 100644 examples/mes/factory-operations/README.md create mode 100644 examples/mes/factory-operations/acceptance.md create mode 100644 examples/mes/factory-operations/case.json create mode 100644 examples/mes/factory-operations/data-model.md create mode 100644 examples/mes/factory-operations/data/README.md create mode 100644 examples/mes/factory-operations/data/fixtures.json create mode 100644 examples/mes/factory-operations/data/fixtures.schema.json create mode 100644 examples/mes/factory-operations/evidence/desktop.jpg create mode 100644 examples/mes/factory-operations/evidence/mobile.jpg create mode 100644 examples/mes/factory-operations/evidence/verification.md create mode 100644 examples/mes/factory-operations/integrations.md create mode 100644 examples/mes/factory-operations/requirements.md create mode 100644 examples/mes/factory-operations/supabase/migrations/20261014150433_factory_operations.sql create mode 100644 examples/mes/factory-operations/supabase/migrations/20261014151243_harden_optional_auto_rls.sql create mode 100644 examples/mes/factory-operations/supabase/migrations/20261014151422_report_conflict_http_status.sql create mode 100644 examples/mes/factory-operations/workbench/.env.example create mode 100644 examples/mes/factory-operations/workbench/index.html create mode 100644 examples/mes/factory-operations/workbench/package.json create mode 100644 examples/mes/factory-operations/workbench/playwright.config.ts create mode 100644 examples/mes/factory-operations/workbench/scripts/cloud-smoke.mjs create mode 100644 examples/mes/factory-operations/workbench/src/App.tsx create mode 100644 examples/mes/factory-operations/workbench/src/AuthScreen.tsx create mode 100644 examples/mes/factory-operations/workbench/src/ConnectionSettings.tsx create mode 100644 examples/mes/factory-operations/workbench/src/Reports.tsx create mode 100644 examples/mes/factory-operations/workbench/src/WorkspacePages.tsx create mode 100644 examples/mes/factory-operations/workbench/src/data/contracts.ts create mode 100644 examples/mes/factory-operations/workbench/src/data/http.ts create mode 100644 examples/mes/factory-operations/workbench/src/data/index.ts create mode 100644 examples/mes/factory-operations/workbench/src/data/network.ts create mode 100644 examples/mes/factory-operations/workbench/src/data/supabase.ts create mode 100644 examples/mes/factory-operations/workbench/src/domain/rules.ts create mode 100644 examples/mes/factory-operations/workbench/src/main.tsx create mode 100644 examples/mes/factory-operations/workbench/src/styles.css create mode 100644 examples/mes/factory-operations/workbench/src/ui.tsx create mode 100644 examples/mes/factory-operations/workbench/tests/browser.spec.ts create mode 100644 examples/mes/factory-operations/workbench/tests/configuration.test.ts create mode 100644 examples/mes/factory-operations/workbench/tests/domain.test.ts create mode 100644 examples/mes/factory-operations/workbench/tests/http-contract.test.ts create mode 100644 examples/mes/factory-operations/workbench/tests/network.test.ts create mode 100644 examples/mes/factory-operations/workbench/tests/supabase-contract.test.ts create mode 100644 examples/mes/factory-operations/workbench/tsconfig.json create mode 100644 examples/mes/factory-operations/workbench/vite.config.ts create mode 100644 examples/mes/factory-operations/workflows.md diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index adcb151..fa48093 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -21,4 +21,4 @@ jobs: if: failure() with: name: browser-test-results - path: examples/dam/character-material-library/workbench/test-results/ + path: examples/*/*/workbench/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/README.md b/README.md index eae132b..071d907 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,10 @@ ## 从这里开始 - [场景分类](docs/domains.md):CRM、ERP、MES、DAM 等 16 个业务领域。 -- [案例目录](catalog/cases.json):机器可读索引,目前包含一个案例。 +- [案例目录](catalog/cases.json):机器可读索引,目前包含两个案例。 - [本色 Persona Studio · 企业数字人工作台](examples/dam/character-material-library/README.md):首个 DAM 案例,含六页工作台、业务规格、合成数据与制作审核闭环。 +- [工厂运营工作台](examples/mes/factory-operations/README.md):MES 与 CRM 协作,含断针/耗材申领、审批配送、断针回收、销售拜访及真实服务接入代码。 +- [数据提供方契约](docs/data-provider-contract.md):身份、业务和资产三个独立端口;部署者可选择 Supabase 或实现企业 HTTP API 接入 MySQL 与资产平台。 - [案例规范](docs/case-standard.md):目录、元信息、交付状态与验收要求。 - [新增案例](CONTRIBUTING.md):从模板开始,提交前执行检查。 @@ -16,7 +18,9 @@ npm run check npm run demo ``` -打开 。目录入口可按领域筛选;首个案例可管理人物与素材、解析需求、模拟视频任务、人工审核和归档溯源。`npm run demo` 先构建工作台再提供统一目录服务,全部使用合成数据,不连接外部 SaaS 或生成模型。 +打开 。目录入口可按领域筛选;`npm run demo` 先构建工作台再提供统一目录服务。Persona Studio 使用合成数据演示素材与模拟视频制作。工厂运营工作台需要配置数据服务并登录,业务记录与附件保存到所选服务;仓库仅提供合成验证输入,不预置真实账号和业务数据。 + +直接开发工厂工作台可执行 `npm run factory:dev`,打开 。不同部署者自由选择数据提供方,Supabase 不是其他案例的必需依赖;MySQL 由企业实现标准 HTTP 后端后接入。 需要 Node.js 22.12 或更新版本。Windows 若使用 `\\wsl.localhost\...` 工作目录,请在 WSL 中执行 npm 命令;Windows npm 的默认脚本 shell 不支持 UNC 工作目录。 @@ -45,7 +49,7 @@ tests/ # 业务边界与仓库检查的验证 ## 案例状态 -`proposal → specified → prototype → integrated → verified`。状态表示已有证据覆盖的最高阶段,详细定义见[案例规范](docs/case-standard.md)。首个案例为 **prototype**:素材管理与任务编排可以演示,视频生成、真人形象数字人和产品试戴尚未接入。 +`proposal → specified → prototype → integrated → verified`。状态表示已有证据覆盖的最高阶段,详细定义见[案例规范](docs/case-standard.md)。Persona Studio 为 **prototype**:素材管理与任务编排可以演示,视频生成、真人形象数字人和产品试戴尚未接入。工厂运营工作台为 **integrated**:Supabase 认证、数据库和私有资产存储已在专用 QA 项目完成真实闭环验证;企业 HTTP 客户端本地可用,MySQL 后端尚未接入。具体覆盖见各案例证据记录,二者均未完成客户正式验收。 没有案例的领域仅列在分类字典中;不会用空目录或占位页面表示已有产品。 diff --git a/catalog/cases.json b/catalog/cases.json index 88d2523..19a4c84 100644 --- a/catalog/cases.json +++ b/catalog/cases.json @@ -14,6 +14,21 @@ ], "status": "prototype", "path": "examples/dam/character-material-library" + }, + { + "id": "factory-operations", + "title": "工厂运营工作台", + "summary": "通过照片上报、审批配送与断针回收追溯工厂申领,记录销售拜访与跟进,使用可选择的数据和资产服务。", + "domain": "mes", + "tags": [ + "crm", + "factory", + "approvals", + "asset-management", + "multi-provider" + ], + "status": "integrated", + "path": "examples/mes/factory-operations" } ] } diff --git a/docs/data-provider-contract.md b/docs/data-provider-contract.md new file mode 100644 index 0000000..b0de922 --- /dev/null +++ b/docs/data-provider-contract.md @@ -0,0 +1,74 @@ +# 工作台数据提供方契约 + +工作台以业务模型组织页面,以三个独立端口访问外部服务。部署者选择数据库、身份系统和资产平台;Supabase 是一个可运行适配器,不是案例格式或业务模型的必需依赖。 + +当前参考实现为 [工厂运营工作台](../examples/mes/factory-operations/README.md)。准确的 TypeScript 类型以 [contracts.ts](../examples/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/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/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. **输入和错误**:服务端再次校验输入;客户端校验只用于交互。失败不能写半条状态或返回虚假成功。 + +## MySQL 与现有资产平台 + +企业可用任意服务端技术实现上述 HTTP API。将 `OperationsRepository` 对应实体映射为 MySQL 表和事务,把 `AssetStore` 映射到现有资产平台,把 `IdentityProvider` 映射到账号或企业身份系统。浏览器不承担数据库连接池、SQL 执行或资产服务管理员身份。 + +当前仓库交付 HTTP 客户端与接口约定;不随附 MySQL 后端服务或企业资产平台服务器。部署者实现接口、联调并记录证据之前,MySQL 能力保持 `unavailable`。迁移到其他数据库还需设计数据导入、ID 映射和资产迁移;仅修改提供方配置不会完成迁移。 + +## 新提供方验收 + +使用合成数据检查登录与退出、工作区隔离、全部角色限制、真实写入与刷新回读、状态并发冲突、幂等重试、附件上传和越权拒绝。参考案例 [acceptance.md](../examples/mes/factory-operations/acceptance.md)。通过软件契约测试只说明接口调用符合预期;外部服务仍需自己的集成证据。 diff --git a/examples/mes/factory-operations/README.md b/examples/mes/factory-operations/README.md new file mode 100644 index 0000000..50aa208 --- /dev/null +++ b/examples/mes/factory-operations/README.md @@ -0,0 +1,67 @@ +# 工厂运营工作台 + +面向工厂现场员工、主管和销售,把断针与耗材申领、审批配送、断针回收、销售拜访和附件追溯放在一个工作台中。业务记录从配置的数据服务真实读取;未连接时显示配置或登录入口,不填入虚构的“已保存”记录。 + +当前状态为 **integrated**:React 工作台已在专用 QA 项目连接真实 Supabase 认证、数据库和私有资产存储,完成申领、回收、拜访及权限验证。标准数据端口与企业 HTTP 适配器可供其他部署复用;具体覆盖与限制见 [验证记录](evidence/verification.md)。MySQL 后端、AI 总结、语音识别和钉钉通知尚未接入,客户正式验收尚未完成。 + +## 页面与角色 + +- **概览**:当前工作区申领和拜访概况。 +- **工厂申领**:合同号、物料编号、工位和原因追溯,现场附件,审批、拒绝、配送、回收确认与完结。 +- **销售拜访**:客户、拜访时间、沟通纪要、问题、下一步和跟进日期。 +- **资产资料**:工作区文件列表、上传与授权预览。 +- **工作区设置**:工作区成员和数据连接;所有者可添加已注册且确认邮箱的成员。 + +所有者和主管处理申领;操作员上报;销售记录拜访。工作区成员权限由服务端执行,界面按角色显示可用操作。所有者额外负责添加成员。 + +## 从干净检出运行 + +需要 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. 创建一条销售拜访,刷新页面确认业务记录和资产均能读回。 + +数据保存在所选服务,刷新不会重置。应用不提供清空云端数据按钮;测试时使用独立工作区,清理和备份由部署管理员在服务端执行。“使用部署默认配置”只删除当前浏览器的连接覆盖,不删除业务数据。 + +## 规格与限制 + +详见 [需求](requirements.md)、[流程](workflows.md)、[数据模型](data-model.md)、[集成](integrations.md)、[验收](acceptance.md) 和 [验证记录](evidence/verification.md)。公开夹具均为合成输入;实际服务为空时显示空状态。当前没有离线写入队列、跨服务数据迁移、库存扣减或正式客户验收。 diff --git a/examples/mes/factory-operations/acceptance.md b/examples/mes/factory-operations/acceptance.md new file mode 100644 index 0000000..9ca3856 --- /dev/null +++ b/examples/mes/factory-operations/acceptance.md @@ -0,0 +1,22 @@ +# 验收标准 + +以下是需要验证的行为,不代表已经通过。实际结果与环境记录在 [验证记录](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 服务已经部署;每个提供方必须单独形成连接证据。 diff --git a/examples/mes/factory-operations/case.json b/examples/mes/factory-operations/case.json new file mode 100644 index 0000000..6d8e222 --- /dev/null +++ b/examples/mes/factory-operations/case.json @@ -0,0 +1,69 @@ +{ + "version": 1, + "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 总结和钉钉通知没有接入,不以模拟数据表示已执行。" + } + ] +} diff --git a/examples/mes/factory-operations/data-model.md b/examples/mes/factory-operations/data-model.md new file mode 100644 index 0000000..e9fc9ad --- /dev/null +++ b/examples/mes/factory-operations/data-model.md @@ -0,0 +1,36 @@ +# 数据模型 + +业务类型见 [contracts.ts](workbench/src/data/contracts.ts),提供方映射和 HTTP 约定见 [数据提供方契约](../../../docs/data-provider-contract.md)。所有 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。当前不在登录后自动注入;创建工作区后可手工使用其中字段验证流程。 diff --git a/examples/mes/factory-operations/data/README.md b/examples/mes/factory-operations/data/README.md new file mode 100644 index 0000000..e71868f --- /dev/null +++ b/examples/mes/factory-operations/data/README.md @@ -0,0 +1,7 @@ +# 合成验证输入 + +`fixtures.json` 提供虚构的工作区名称、工厂上报和销售拜访输入,`fixtures.schema.json` 校验结构。它们用于开发与手工验收,不代表云端已保存记录,也不会自动灌入用户数据源。 + +工厂输入的 `assetIds` 初始为空:断针输入是待补齐的表单模板,提交前必须真实上传自制测试照片,把返回的资产 ID 关联到新单;耗材可以无附件。不要用虚构 ID 冒充已存资产。日期仅为稳定示例,实际跟进时按业务需要填写。 + +这些数据没有真实客户、个人身份、账号密码、网络地址或可访问媒体;每次验证可新建独立工作区。业务数据的清理由部署管理员在实际提供方执行。 diff --git a/examples/mes/factory-operations/data/fixtures.json b/examples/mes/factory-operations/data/fixtures.json new file mode 100644 index 0000000..0995cd9 --- /dev/null +++ b/examples/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/mes/factory-operations/data/fixtures.schema.json b/examples/mes/factory-operations/data/fixtures.schema.json new file mode 100644 index 0000000..e43dcea --- /dev/null +++ b/examples/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/mes/factory-operations/evidence/desktop.jpg b/examples/mes/factory-operations/evidence/desktop.jpg new file mode 100644 index 0000000000000000000000000000000000000000..eede86fc634d120d7a3a7ee9271b1ccdc0957c39 GIT binary patch literal 76146 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, + 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/mes/factory-operations/supabase/migrations/20261014151243_harden_optional_auto_rls.sql b/examples/mes/factory-operations/supabase/migrations/20261014151243_harden_optional_auto_rls.sql new file mode 100644 index 0000000..45de65a --- /dev/null +++ b/examples/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/mes/factory-operations/supabase/migrations/20261014151422_report_conflict_http_status.sql b/examples/mes/factory-operations/supabase/migrations/20261014151422_report_conflict_http_status.sql new file mode 100644 index 0000000..fdf7b7a --- /dev/null +++ b/examples/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/mes/factory-operations/workbench/.env.example b/examples/mes/factory-operations/workbench/.env.example new file mode 100644 index 0000000..d7e7d01 --- /dev/null +++ b/examples/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/mes/factory-operations/workbench/index.html b/examples/mes/factory-operations/workbench/index.html new file mode 100644 index 0000000..054dabb --- /dev/null +++ b/examples/mes/factory-operations/workbench/index.html @@ -0,0 +1,17 @@ + + + + + + + + 工序 · 工厂运营工作台 + + +

+ + + diff --git a/examples/mes/factory-operations/workbench/package.json b/examples/mes/factory-operations/workbench/package.json new file mode 100644 index 0000000..bca54af --- /dev/null +++ b/examples/mes/factory-operations/workbench/package.json @@ -0,0 +1,29 @@ +{ + "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:e2e": "playwright test", + "preview": "vite preview --host 127.0.0.1 --port 4318 --strictPort" + }, + "dependencies": { + "@supabase/supabase-js": "2.117.3", + "lucide-react": "0.468.0", + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "@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/mes/factory-operations/workbench/playwright.config.ts b/examples/mes/factory-operations/workbench/playwright.config.ts new file mode 100644 index 0000000..f45b294 --- /dev/null +++ b/examples/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/mes/factory-operations/workbench/scripts/cloud-smoke.mjs b/examples/mes/factory-operations/workbench/scripts/cloud-smoke.mjs new file mode 100644 index 0000000..5f24679 --- /dev/null +++ b/examples/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/mes/factory-operations/workbench/src/App.tsx b/examples/mes/factory-operations/workbench/src/App.tsx new file mode 100644 index 0000000..9bbb68b --- /dev/null +++ b/examples/mes/factory-operations/workbench/src/App.tsx @@ -0,0 +1,832 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type FormEvent, +} from "react"; +import { + Activity, + ArrowRight, + ArrowUpRight, + CalendarDays, + CheckCheck, + ChevronDown, + ClipboardList, + 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 { 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" | "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 }, +]; +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 [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 () => { + 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 { + 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(""); + try { + 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; + 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)), + ); + } catch (reason) { + if (isCurrent()) setError(errorMessage(reason)); + } finally { + if (isCurrent()) setDataLoading(false); + } + }, [services, workspaceId, canCreateVisit]); + useEffect(() => { + ++refreshGeneration.current; + setReports([]); + setVisits([]); + setAssets([]); + 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([]); + 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 === "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")} + /> + )} + + )} + + )} +
+
+ 工序 / 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/mes/factory-operations/workbench/src/AuthScreen.tsx b/examples/mes/factory-operations/workbench/src/AuthScreen.tsx new file mode 100644 index 0000000..24316ac --- /dev/null +++ b/examples/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/mes/factory-operations/workbench/src/ConnectionSettings.tsx b/examples/mes/factory-operations/workbench/src/ConnectionSettings.tsx new file mode 100644 index 0000000..39a94c2 --- /dev/null +++ b/examples/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/mes/factory-operations/workbench/src/Reports.tsx b/examples/mes/factory-operations/workbench/src/Reports.tsx new file mode 100644 index 0000000..9ccbff4 --- /dev/null +++ b/examples/mes/factory-operations/workbench/src/Reports.tsx @@ -0,0 +1,720 @@ +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { + ArrowRight, + ArrowUpRight, + CheckCircle2, + ClipboardList, + ImagePlus, + Package, + Plus, + Search, + ShieldCheck, + Scissors, + X, +} from "lucide-react"; +import type { + AssetRef, + DataServices, + OperationReport, + ReportKind, + ReportStatus, + TimelineEntry, + Workspace, +} from "./data/contracts"; +import { + canCloseReport, + Empty, + ErrorBanner, + errorMessage, + filterReports, + formatDate, + Loading, + Modal, + StatusBadge, + statusLabels, +} from "./ui"; +import { maxAssetBytes, validateAsset, validateReport } from "./domain/rules"; + +export function ReportTable({ + reports, + onSelect, +}: { + reports: OperationReport[]; + onSelect: (report: OperationReport) => void; +}) { + return ( +
+ + + + + + + + + + + + + {reports.map((report) => ( + onSelect(report)}> + + + + + + + + ))} + +
事项 / 物料合同号工位提交时间当前状态 + 查看 +
+ + {report.contractNumber}{report.station}{formatDate(report.createdAt, true)} + + + +
+
+ ); +} + +export function ReportsPage({ + reports, + canCreate, + onCreate, + onSelect, +}: { + reports: OperationReport[]; + canCreate: boolean; + onCreate: () => void; + onSelect: (report: OperationReport) => void; +}) { + const [query, setQuery] = useState(""); + const [status, setStatus] = useState("all"); + const [kind, setKind] = useState("all"); + const filtered = filterReports(reports, query, status, kind); + return ( + <> +
+
+ PRODUCTION OPERATIONS +

工厂申领

+

从现场上报到回收完成,每一步都有记录。

+
+ {canCreate && ( + + )} +
+
+
+
+ + setQuery(event.target.value)} + /> +
+ + +
+
+ + {( + [ + "submitted", + "approved", + "delivering", + "completed", + ] as ReportStatus[] + ).map((value) => ( + + ))} +
+ {filtered.length ? ( + + ) : ( + + 新建申领 + + ) : undefined + } + /> + )} +
+ 共 {filtered.length} 条记录{" "} + 按提交时间倒序 · 数据来自当前工作区 +
+
+ + ); +} + +export function ReportForm({ + services, + workspace, + onClose, + onCreated, +}: { + services: DataServices; + workspace: Workspace; + onClose: () => void; + onCreated: () => Promise; +}) { + const [kind, setKind] = useState("broken_needle"); + const [files, setFiles] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [stage, setStage] = useState(""); + const uploadedFiles = useRef(new Map()); + async function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + setBusy(true); + setError(""); + try { + if (kind === "broken_needle" && !files.length) + throw new Error("断针补领至少需要一张现场照片。"); + const fields = { + kind, + contractNumber: String(form.get("contractNumber")).trim(), + materialNumber: String(form.get("materialNumber")).trim(), + station: String(form.get("station")).trim(), + reason: String(form.get("reason")).trim(), + quantity: Number(form.get("quantity")), + }; + validateReport({ + ...fields, + assetIds: files.map((_, index) => `pending-${index}`), + }); + const uploaded: AssetRef[] = []; + for (let index = 0; index < files.length; index += 1) { + setStage(`正在上传图片 ${index + 1}/${files.length}…`); + let asset = uploadedFiles.current.get(files[index]); + if (!asset) { + asset = await services.assets.upload(workspace.id, files[index]); + uploadedFiles.current.set(files[index], asset); + } + uploaded.push(asset); + } + setStage("正在提交申领…"); + await services.operations.createReport(workspace.id, { + ...fields, + assetIds: uploaded.map((asset) => asset.id), + }); + await onCreated(); + onClose(); + } catch (reason) { + setError(errorMessage(reason)); + } finally { + setBusy(false); + } + } + return ( + { + if (!busy) onClose(); + }} + > +
+
+ + +
+
+ + + + +
+