From 0bdaf54eedcd38793c3b36c2bc7f54f4949bb075 Mon Sep 17 00:00:00 2001 From: XuJiacheng Date: Wed, 8 Apr 2026 20:14:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20PocketBase=20?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=AB=AF=E4=B8=8E=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=20hooks=20=E7=9A=84=20API=20=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 openapi.yaml 文件,定义管理端与自定义 hooks 的接口文档,包括系统、微信认证、平台认证、字典管理、附件管理、文档管理、购物车和订单等接口。 - 新增 order.yaml 文件,定义订单相关的接口,包括查询订单列表、查询订单详情、新增订单记录、修改订单记录和删除订单记录的请求和响应结构。 - 新增 openapi-wx/openapi.yaml 文件,定义 PocketBase 原生 API 文档,包含企业信息、附件信息、产品信息、文档信息、购物车和订单的接口。 - 新增 pocketbase.scheme.js 文件,包含 PocketBase 集合的创建和更新逻辑,定义了多个集合的字段、索引和权限规则。 --- docs/pb_collection_permission_overview.md | 152 +++ docs/pb_tbl_cart.md | 17 +- docs/pb_tbl_scheme.md | 73 ++ docs/pb_tbl_scheme_share.md | 71 ++ docs/pb_tbl_scheme_template.md | 59 + pocket-base/bai-api-main.pb.js | 10 + pocket-base/bai-web-main.pb.js | 1 + .../bai_api_routes/scheme-template/create.js | 25 + .../bai_api_routes/scheme-template/delete.js | 25 + .../bai_api_routes/scheme-template/detail.js | 24 + .../bai_api_routes/scheme-template/list.js | 26 + .../bai_api_routes/scheme-template/update.js | 25 + .../bai_api_routes/scheme/create.js | 25 + .../bai_api_routes/scheme/delete.js | 25 + .../bai_api_routes/scheme/detail.js | 24 + .../bai_api_routes/scheme/list.js | 26 + .../bai_api_routes/scheme/update.js | 25 + .../middlewares/requestGuards.js | 128 ++ .../services/cartOrderService.js | 35 +- .../bai_api_shared/services/schemeService.js | 755 +++++++++++ .../bai_web_pb_hooks/pages/scheme-manage.js | 9 + .../views/cart-order-manage.html | 176 ++- pocket-base/bai_web_pb_hooks/views/index.html | 4 + .../bai_web_pb_hooks/views/scheme-manage.html | 1126 +++++++++++++++++ pocket-base/spec/openapi-manage/cart.yaml | 372 ++++++ pocket-base/spec/openapi-manage/openapi.yaml | 103 ++ pocket-base/spec/openapi-manage/order.yaml | 371 ++++++ pocket-base/spec/openapi-wx.yaml | 182 +-- pocket-base/spec/openapi-wx/cart.yaml | 647 +++------- pocket-base/spec/openapi-wx/openapi.yaml | 48 + pocket-base/spec/openapi-wx/order.yaml | 596 +++------ pocket-base/spec/openapi-wx/system.yaml | 253 ---- pocket-base/spec/openapi-wx/wechat-auth.yaml | 1003 --------------- script/package.json | 1 + script/pocketbase.cart-order.js | 39 +- script/pocketbase.scheme.js | 328 +++++ 36 files changed, 4391 insertions(+), 2418 deletions(-) create mode 100644 docs/pb_collection_permission_overview.md create mode 100644 docs/pb_tbl_scheme.md create mode 100644 docs/pb_tbl_scheme_share.md create mode 100644 docs/pb_tbl_scheme_template.md create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/create.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/delete.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/detail.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/list.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/update.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/create.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/delete.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/detail.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/list.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/update.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_shared/services/schemeService.js create mode 100644 pocket-base/bai_web_pb_hooks/pages/scheme-manage.js create mode 100644 pocket-base/bai_web_pb_hooks/views/scheme-manage.html create mode 100644 pocket-base/spec/openapi-manage/cart.yaml create mode 100644 pocket-base/spec/openapi-manage/openapi.yaml create mode 100644 pocket-base/spec/openapi-manage/order.yaml create mode 100644 pocket-base/spec/openapi-wx/openapi.yaml delete mode 100644 pocket-base/spec/openapi-wx/system.yaml delete mode 100644 pocket-base/spec/openapi-wx/wechat-auth.yaml create mode 100644 script/pocketbase.scheme.js diff --git a/docs/pb_collection_permission_overview.md b/docs/pb_collection_permission_overview.md new file mode 100644 index 0000000..9dd39d9 --- /dev/null +++ b/docs/pb_collection_permission_overview.md @@ -0,0 +1,152 @@ +# PocketBase Collection 权限总览 + +> 范围:当前仓库 `docs/pb_tbl_*.md` 已存在表结构文档 + 本次新增的 3 张方案相关草案表 +> 目标:把“每张表该如何做 PocketBase 原生权限控制”整理成一份便于确认、评审和后续写脚本的总览 +> 状态:`working draft` + +## 阅读说明 + +- 本文档分为“现状规则”和“本次新增建议规则”两部分。 +- “现状规则”优先参考现有 `docs/pb_tbl_*.md` 与 `script/pocketbase*.js`。 +- “新增建议规则”对应你这次要加的 3 张表,当前仅为文档设计,尚未执行。 +- 本文所有 owner 行级规则都默认使用 `@request.auth.openid` 作为业务身份锚点。 +- 软删除约定统一为:`is_delete = 0` 才视为可见、可操作数据。 + +## 一、推荐统一规则模板 + +### 1. owner 私有数据表 + +适用场景: + +- 购物车 +- 订单 +- 本次新增的方案 / 方案分享 / 方案模板 + +推荐模板: + +| Rule | 推荐表达式 | +| :--- | :--- | +| `listRule` | `@request.auth.id != "" && = @request.auth.openid && is_delete = 0` | +| `viewRule` | `@request.auth.id != "" && = @request.auth.openid && is_delete = 0` | +| `createRule` | `@request.auth.id != "" && @request.body. = @request.auth.openid` | +| `updateRule` | `@request.auth.id != "" && = @request.auth.openid && is_delete = 0` | +| `deleteRule` | `@request.auth.id != "" && = @request.auth.openid && is_delete = 0` | + +### 2. 公开可读、管理写 + +适用场景: + +- 字典 +- 产品 +- 文档 +- 附件 + +典型模式: + +| Rule | 推荐表达式 | +| :--- | :--- | +| `listRule` | `is_delete = 0` | +| `viewRule` | `is_delete = 0` | +| `createRule` | `@request.auth.users_idtype = "ManagePlatform" \|\| @request.auth.usergroups_id = "<管理角色ID>"` | +| `updateRule` | `@request.auth.users_idtype = "ManagePlatform" \|\| @request.auth.usergroups_id = "<管理角色ID>"` | +| `deleteRule` | `@request.auth.users_idtype = "ManagePlatform" \|\| @request.auth.usergroups_id = "<管理角色ID>"` | + +### 3. 管理/权限元数据表 + +适用场景: + +- 角色 +- 资源 +- 角色权限 +- 用户覆盖权限 +- 行级范围 +- 文档操作历史 + +典型模式: + +- 默认不建议公开给普通用户直接访问。 +- 推荐仅管理端 / 管理角色通过 hooks 页面或内部脚本维护。 + +## 二、现有表规则总表 + +## 2.1 owner 私有数据 + +| 表名 | owner 字段 | list/view | create | update/delete | 说明 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| `tbl_cart` | `cart_owner` | `@request.auth.id != "" && cart_owner = @request.auth.openid && is_delete = 0` | `@request.body.cart_owner != ""` | `@request.auth.id != "" && cart_owner = @request.auth.openid` | 已在 `script/pocketbase.cart-order.js` 明确;当前 create 已放宽为只要显式提交非空 owner 即可 | +| `tbl_order` | `order_owner` | `@request.auth.id != "" && order_owner = @request.auth.openid && is_delete = 0` | `@request.auth.id != "" && @request.body.order_owner = @request.auth.openid` | `@request.auth.id != "" && order_owner = @request.auth.openid` | 已在 `script/pocketbase.cart-order.js` 明确 | + +## 2.2 公开可读、管理写 + +| 表名 | list/view | create/update/delete | 说明 | +| :--- | :--- | :--- | :--- | +| `tbl_system_dict` | `is_delete = 0` | `ManagePlatform` 或管理角色 | `script/pocketbase.dictionary.js` 已明确 | +| `tbl_product_list` | `is_delete = 0` | `ManagePlatform` 或管理角色 | `script/pocketbase.product-list.js` 已明确 | +| `tbl_document` | `is_delete = 0` | 文档当前脚本未显式开放原生写;实际业务通过 hooks 管理端写入 | `script/pocketbase.documents.js` + 现有文档口径 | +| `tbl_attachments` | `is_delete = 0` | 原生写默认不开放,实际通过 hooks / 管理端上传 | `script/pocketbase.documents.js` + 现有文档口径 | + +## 2.3 管理端/权限模型表 + +| 表名 | 当前口径 | 说明 | +| :--- | :--- | :--- | +| `tbl_auth_resources` | 管理用途,不建议公开给普通用户直连 | 现有文档写为“仅管理员 / 管理角色允许” | +| `tbl_auth_roles` | 管理用途,不建议公开给普通用户直连 | 同上 | +| `tbl_auth_role_perms` | 管理用途,不建议公开给普通用户直连 | 同上 | +| `tbl_auth_user_overrides` | 管理用途,不建议公开给普通用户直连 | 同上 | +| `tbl_auth_row_scopes` | 管理用途,不建议公开给普通用户直连 | 同上 | +| `tbl_document_operation_history` | 管理用途,不建议公开给普通用户直连 | 同上 | + +## 2.4 特殊表 + +| 表名 | 当前口径 | 备注 | +| :--- | :--- | :--- | +| `tbl_auth_users` | 当前文档口径为“公开允许新增与修改;列表 / 详情 / 删除仅管理员 / 管理角色允许” | 这是 auth collection,既受 PB auth 机制影响,也受 hooks 认证流程影响 | +| `tbl_company` | 当前文档口径为“公开可创建、公开可列出;详情 / 更新 / 删除仅管理员或管理后台用户允许” | 更偏业务接入表,不属于 owner 私有表 | + +## 三、本次新增 3 表建议规则 + +| 表名 | owner 字段 | list/view | create | update/delete | 备注 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| `tbl_scheme` | `scheme_owner` | `@request.auth.id != "" && scheme_owner = @request.auth.openid && is_delete = 0` | `@request.auth.id != "" && @request.body.scheme_owner = @request.auth.openid` | `@request.auth.id != "" && scheme_owner = @request.auth.openid && is_delete = 0` | 私有方案表 | +| `tbl_scheme_share` | `scheme_share_owner` | `@request.auth.id != "" && scheme_share_owner = @request.auth.openid && is_delete = 0` | `@request.auth.id != "" && @request.body.scheme_share_owner = @request.auth.openid` | `@request.auth.id != "" && scheme_share_owner = @request.auth.openid && is_delete = 0` | 按你的要求,分享表也采用 owner 私有规则,因此接收方默认不能直连查看 | +| `tbl_scheme_template` | `scheme_template_owner` | `@request.auth.id != "" && scheme_template_owner = @request.auth.openid && is_delete = 0` | `@request.auth.id != "" && @request.body.scheme_template_owner = @request.auth.openid` | `@request.auth.id != "" && scheme_template_owner = @request.auth.openid && is_delete = 0` | 私有模板表 | + +## 四、这次设计里需要你重点确认的点 + +### 1. `tbl_scheme_share` 是否接受新增 `scheme_share_owner` + +这是本次最关键的结构补充。 + +原因: + +- 你要求 3 张表统一按 `xxx_owner = token 对应 openid` 做 PB 行级限制。 +- 原始草图里的分享表没有 owner 字段,无法直接套用这套规则。 + +如果你确认这条设计,后续脚本会把它作为正式字段创建。 + +### 2. `createRule` 是否必须校验 `@request.body. = @request.auth.openid` + +当前文档建议“要校验”。 + +原因: + +- 这样可以防止普通用户伪造别人的 owner 值直接创建越权数据。 +- 这也是现有 `tbl_cart` / `tbl_order` 的做法,风格一致。 + +### 3. 分享表是否允许“被分享人”直接走 PB 原生 API 查看 + +当前文档按你的要求,先设计成: + +- 只有 `scheme_share_owner` 能删改查 + +这最严格,但也意味着: + +- `scheme_share_to` 对应的用户不能直接通过 PB 原生 `list/view` 看到分享记录 + +如果你的实际业务是“被分享人需要能直接看到收到的分享”,那这张表的 `listRule` / `viewRule` 需要改成双边可见,而不是纯 owner 模式。 + +## 五、对应文档索引 + +- [pb_tbl_scheme.md](/E:/Project_Class/BAI/Web_BAI_Manage_ApiServer/docs/pb_tbl_scheme.md) +- [pb_tbl_scheme_share.md](/E:/Project_Class/BAI/Web_BAI_Manage_ApiServer/docs/pb_tbl_scheme_share.md) +- [pb_tbl_scheme_template.md](/E:/Project_Class/BAI/Web_BAI_Manage_ApiServer/docs/pb_tbl_scheme_template.md) diff --git a/docs/pb_tbl_cart.md b/docs/pb_tbl_cart.md index 97202e2..21c04e4 100644 --- a/docs/pb_tbl_cart.md +++ b/docs/pb_tbl_cart.md @@ -20,13 +20,13 @@ | :--- | :--- | :---: | :--- | | `id` | `text` | 是 | PocketBase 记录主键 | | `cart_id` | `text` | 是 | 购物车项业务 ID,唯一标识 | -| `cart_number` | `text` | 是 | 购物车名称 / 分组号,默认可按“用户名+年月日时分秒”生成 | +| `cart_number` | `text` | 否 | 购物车名称 / 分组号,可为空;如需展示编号建议在 hooks / 业务侧补齐 | | `cart_create` | `autodate` | 否 | 购物车项创建时间,由数据库自动生成 | | `cart_owner` | `text` | 是 | 生成者 openid,约定保存 `tbl_auth_users.openid` | -| `cart_product_id` | `text` | 是 | 产品 ID,建议保存 `tbl_product_list.prod_list_id` | -| `cart_product_quantity` | `number` | 是 | 产品数量,建议业务侧约束为正整数 | -| `cart_status` | `text` | 是 | 购物车状态,建议值:`有效` / `无效` | -| `cart_at_price` | `number` | 是 | 加入购物车时的价格,用于后续降价提醒或对比 | +| `cart_product_id` | `relation` | 是 | 原生关联 `tbl_product_list`,单选,保存的是目标产品记录的 PocketBase `recordId` | +| `cart_product_quantity` | `number` | 否 | 产品数量,可为空;如传值,建议业务侧约束为正整数 | +| `cart_status` | `text` | 否 | 购物车状态,可为空;如传值,建议值:`有效` / `无效` | +| `cart_at_price` | `number` | 否 | 加入购物车时的价格,可为空;如传值用于后续降价提醒或对比 | | `cart_remark` | `text` | 否 | 备注 | | `is_delete` | `number` | 否 | 软删除标记,`0` 表示未删除,`1` 表示已删除,默认 `0` | @@ -45,7 +45,12 @@ ## 补充约定 -- `cart_owner`、`cart_product_id` 当前按文本字段保存业务 ID,不直接建立 relation,便于兼容现有 hooks 业务模型。 +- `cart_owner` 当前按文本字段保存业务 ID。 +- `cart_product_id` 已改为 PocketBase 原生 `relation` 字段,关联目标集合为 `tbl_product_list`,且 `maxSelect = 1`。 +- 调用原生 records create / update 接口时,`cart_product_id` 必须传 **`tbl_product_list` 的 PocketBase 记录主键 `id`**,不能再传业务 ID `prod_list_id`。 +- 如前端当前拿到的是业务 ID `prod_list_id`,需先调用 `GET /pb/api/collections/tbl_product_list/records?filter=prod_list_id="..."&perPage=1&page=1` 查出对应 `recordId`,再把该 `recordId` 作为 `cart_product_id` 提交。 +- 当前原生 collection 层面,创建时仅要求客户端显式提交非空 `cart_owner`;不再强制要求与当前 token 对应 `openid` 相等。 +- 当前表结构层面,除 `cart_id`、`cart_owner`、`cart_product_id` 外,其余业务字段均允许为空。 - `cart_owner` 统一保存 `tbl_auth_users.openid`,便于直接使用微信登录返回 token 做原生访问控制。 - `is_delete` 用于软删除控制,购物车项删除时建议优先标记为 `1`。 - 集合默认查询规则已内置 `is_delete = 0`,常规列表/详情不会返回已软删除数据。 diff --git a/docs/pb_tbl_scheme.md b/docs/pb_tbl_scheme.md new file mode 100644 index 0000000..fa4bbb4 --- /dev/null +++ b/docs/pb_tbl_scheme.md @@ -0,0 +1,73 @@ +# pb_tbl_scheme + +> 来源:用户提供的结构草图(2026-04-08)与现有 `tbl_cart` / `tbl_order` owner 行级权限模式 +> 类型:`base` +> 状态:`draft`,仅文档设计,尚未执行建表 +> 读写规则:任意已登录用户可新增,但仅可访问 `scheme_owner = 当前 token 对应 openid` 且 `is_delete = 0` 的记录 + +## 表用途 + +用于存储用户自己的方案主表,承载方案名称、适用酒店类型、方案筛选条件、房型配置、设备偏好以及引用的高/中/低端模板。 + +## 字段清单 + +| 字段名 | 类型 | 必填 | 说明 | +| :--- | :--- | :---: | :--- | +| `id` | `text` | 是 | PocketBase 记录主键 | +| `scheme_id` | `text` | 是 | 方案业务 ID,唯一标识 | +| `scheme_name` | `text` | 是 | 方案名称 | +| `scheme_owner` | `text` | 是 | 方案所有者 openid,保存 `tbl_auth_users.openid` | +| `scheme_share_status` | `text` | 否 | 方案分享状态,便于快速识别是否已分享 | +| `scheme_expires_at` | `date` | 否 | 方案有效期 | +| `scheme_hotel_type` | `text` | 否 | 酒店类型,如经济型 / 中高端 / 连锁型 / 特色名宿 / 特色酒店 | +| `scheme_solution_type` | `text` | 否 | 方案类型,建议保存枚举或 `|` 聚合值 | +| `scheme_solution_feature` | `text` | 否 | 方案特点,建议保存枚举或 `|` 聚合值 | +| `scheme_room_type` | `json` | 否 | 房型配置,建议格式:`[{"room_type":"大床房","qty":20}]` | +| `scheme_curtains` | `text` | 否 | 窗帘类型 | +| `scheme_voice_device` | `text` | 否 | 语音设备 | +| `scheme_ac_type` | `text` | 否 | 空调类型 | +| `scheme_template_highend` | `text` | 否 | 高端模板 ID,建议保存 `tbl_scheme_template.scheme_template_id` | +| `scheme_template_midend` | `text` | 否 | 中端模板 ID,建议保存 `tbl_scheme_template.scheme_template_id` | +| `scheme_template_lowend` | `text` | 否 | 地端模板 ID,建议保存 `tbl_scheme_template.scheme_template_id` | +| `scheme_status` | `text` | 否 | 方案状态,如草稿 / 生效 / 失效 | +| `scheme_remark` | `text` | 否 | 备注 | +| `is_delete` | `number` | 否 | 软删除标记,`0` 表示未删除,`1` 表示已删除,默认 `0` | + +## 索引 + +| 索引名 | 类型 | 说明 | +| :--- | :--- | :--- | +| `idx_tbl_scheme_scheme_id` | `UNIQUE INDEX` | 保证 `scheme_id` 唯一 | +| `idx_tbl_scheme_scheme_owner` | `INDEX` | 加速按方案所有者查询 | +| `idx_tbl_scheme_scheme_name` | `INDEX` | 加速按方案名称检索 | +| `idx_tbl_scheme_scheme_share_status` | `INDEX` | 加速按分享状态过滤 | +| `idx_tbl_scheme_scheme_expires_at` | `INDEX` | 加速按有效期过滤 | +| `idx_tbl_scheme_scheme_hotel_type` | `INDEX` | 加速按酒店类型过滤 | +| `idx_tbl_scheme_scheme_solution_type` | `INDEX` | 加速按方案类型过滤 | +| `idx_tbl_scheme_scheme_solution_feature` | `INDEX` | 加速按方案特点过滤 | +| `idx_tbl_scheme_scheme_status` | `INDEX` | 加速按方案状态过滤 | +| `idx_tbl_scheme_owner_status` | `INDEX` | 加速同一用户下按状态查询 | + +## 建议 PocketBase 原生权限规则 + +说明: + +- 采用和 `tbl_cart` / `tbl_order` 一致的 owner 行级隔离模式。 +- 这里按“任意已登录用户可创建自己的方案”设计,因此 `createRule` 仍要求携带有效 token。 +- 若后续由 hooks 自动回填 `scheme_owner`,也建议保留 `@request.body.scheme_owner = @request.auth.openid` 约束,避免越权代建。 + +| Rule | 建议表达式 | +| :--- | :--- | +| `listRule` | `@request.auth.id != "" && scheme_owner = @request.auth.openid && is_delete = 0` | +| `viewRule` | `@request.auth.id != "" && scheme_owner = @request.auth.openid && is_delete = 0` | +| `createRule` | `@request.auth.id != "" && @request.body.scheme_owner = @request.auth.openid` | +| `updateRule` | `@request.auth.id != "" && scheme_owner = @request.auth.openid && is_delete = 0` | +| `deleteRule` | `@request.auth.id != "" && scheme_owner = @request.auth.openid && is_delete = 0` | + +## 补充约定 + +- `scheme_template_highend` / `scheme_template_midend` / `scheme_template_lowend` 当前建议先保存模板业务 ID,不直接建立 relation,便于兼容现有 hooks 风格。 +- `scheme_room_type` 推荐使用 `json` 数组,避免后续字符串解析成本。 +- `scheme_solution_type` 与 `scheme_solution_feature` 如果后续要做模板筛选,建议统一保存字典枚举值,而不是自由文本。 +- `is_delete` 用于软删除控制;对外列表、详情、修改、删除规则都建议默认排除已删除数据。 +- PocketBase 系统字段 `created`、`updated` 仍然存在,只是不在 collection 字段清单里单独声明。 diff --git a/docs/pb_tbl_scheme_share.md b/docs/pb_tbl_scheme_share.md new file mode 100644 index 0000000..6f4012e --- /dev/null +++ b/docs/pb_tbl_scheme_share.md @@ -0,0 +1,71 @@ +# pb_tbl_scheme_share + +> 来源:用户提供的结构草图(2026-04-08)与本次 owner 行级权限要求 +> 类型:`base` +> 状态:`draft`,仅文档设计,尚未执行建表 +> 读写规则:任意已登录用户可新增,但仅可访问 `scheme_share_owner = 当前 token 对应 openid` 且 `is_delete = 0` 的记录 + +## 表用途 + +用于存储方案分享记录,描述某个方案被分享给谁、分享权限、有效期以及接收状态。 + +## 关键设计说明 + +原始草图里没有 owner 字段,但你要求这 3 张表统一采用 `xxx_owner = 当前 token 对应 openid` 的 PB 行级约束。 + +因此本表文档中补充新增: + +- `scheme_share_owner` + +该字段用于表示“谁发起了这条分享记录”,并作为 PocketBase 原生访问控制的 owner 锚点。 + +## 字段清单 + +| 字段名 | 类型 | 必填 | 说明 | +| :--- | :--- | :---: | :--- | +| `id` | `text` | 是 | PocketBase 记录主键 | +| `scheme_share_id` | `text` | 是 | 分享业务 ID,唯一标识 | +| `scheme_id` | `text` | 是 | 关联方案 ID,建议保存 `tbl_scheme.scheme_id` | +| `scheme_share_owner` | `text` | 是 | 分享发起人 openid,用于 owner 行级权限控制 | +| `scheme_share_to` | `text` | 是 | 被分享目标用户标识,建议保存目标用户 openid | +| `scheme_share_acceptance_status` | `text` | 否 | 分享接收状态,如待接收 / 已接收 / 已参与 / 已拒绝 | +| `scheme_share_expires_at` | `date` | 否 | 分享有效期 | +| `scheme_share_permission` | `text` | 否 | 分享权限,如只读 / 可复制 / 可编辑 | +| `scheme_share_remark` | `text` | 否 | 分享备注 | +| `is_delete` | `number` | 否 | 软删除标记,`0` 表示未删除,`1` 表示已删除,默认 `0` | + +## 索引 + +| 索引名 | 类型 | 说明 | +| :--- | :--- | :--- | +| `idx_tbl_scheme_share_scheme_share_id` | `UNIQUE INDEX` | 保证 `scheme_share_id` 唯一 | +| `idx_tbl_scheme_share_scheme_id` | `INDEX` | 加速按方案查询分享记录 | +| `idx_tbl_scheme_share_scheme_share_owner` | `INDEX` | 加速按分享发起人查询 | +| `idx_tbl_scheme_share_scheme_share_to` | `INDEX` | 加速按被分享目标查询 | +| `idx_tbl_scheme_share_acceptance_status` | `INDEX` | 加速按接收状态过滤 | +| `idx_tbl_scheme_share_expires_at` | `INDEX` | 加速按有效期过滤 | +| `idx_tbl_scheme_share_owner_to` | `INDEX` | 加速同一发起人向某目标用户的查询 | +| `idx_tbl_scheme_share_unique_map` | `UNIQUE INDEX` | 保证同一发起人对同一方案给同一目标的分享记录唯一 | + +## 建议 PocketBase 原生权限规则 + +说明: + +- 严格按你的要求,所有删改查都只允许 owner 访问。 +- 这意味着“被分享人”不能直接通过 PocketBase 原生 records API 读取这张表;如果后续要支持被分享方直连访问,需要另行放宽 `listRule` / `viewRule` 或改由 hooks 中转。 + +| Rule | 建议表达式 | +| :--- | :--- | +| `listRule` | `@request.auth.id != "" && scheme_share_owner = @request.auth.openid && is_delete = 0` | +| `viewRule` | `@request.auth.id != "" && scheme_share_owner = @request.auth.openid && is_delete = 0` | +| `createRule` | `@request.auth.id != "" && @request.body.scheme_share_owner = @request.auth.openid` | +| `updateRule` | `@request.auth.id != "" && scheme_share_owner = @request.auth.openid && is_delete = 0` | +| `deleteRule` | `@request.auth.id != "" && scheme_share_owner = @request.auth.openid && is_delete = 0` | + +## 补充约定 + +- `scheme_share_to` 当前建议保存目标用户 `openid`,这样后续如果要做 hooks 查询或消息通知,字段可直接复用。 +- 若后续要支持“接收方修改 `scheme_share_acceptance_status`”,则当前 owner-only 原生规则不够,需要追加 hooks 或重新设计 PB 规则。 +- 推荐把 `scheme_share_permission` 控制在有限枚举内,例如:`readonly`、`copyable`、`editable`。 +- `is_delete` 用于软删除控制,撤销分享时建议优先标记为 `1`。 +- PocketBase 系统字段 `created`、`updated` 仍然存在,只是不在 collection 字段清单里单独声明。 diff --git a/docs/pb_tbl_scheme_template.md b/docs/pb_tbl_scheme_template.md new file mode 100644 index 0000000..687f949 --- /dev/null +++ b/docs/pb_tbl_scheme_template.md @@ -0,0 +1,59 @@ +# pb_tbl_scheme_template + +> 来源:用户提供的结构草图(2026-04-08)与现有附件 / 产品字段设计规范 +> 类型:`base` +> 状态:`draft`,仅文档设计,尚未执行建表 +> 读写规则:任意已登录用户可新增,但仅可访问 `scheme_template_owner = 当前 token 对应 openid` 且 `is_delete = 0` 的记录 + +## 表用途 + +用于存储方案模板,作为方案配置时可复用的标准模板库,承载图标、标签、方案适配条件以及模板内产品清单。 + +## 字段清单 + +| 字段名 | 类型 | 必填 | 说明 | +| :--- | :--- | :---: | :--- | +| `id` | `text` | 是 | PocketBase 记录主键 | +| `scheme_template_id` | `text` | 是 | 模板业务 ID,唯一标识 | +| `scheme_template_icon` | `text` | 否 | 模板图标,建议保存 `tbl_attachments.attachments_id` | +| `scheme_template_label` | `text` | 否 | 模板标签,便于关键词检索 | +| `scheme_template_name` | `text` | 是 | 模板名称 | +| `scheme_template_owner` | `text` | 是 | 模板所有者 openid,保存 `tbl_auth_users.openid` | +| `scheme_template_status` | `text` | 否 | 模板状态,如有效 / 主推 / 过期 | +| `scheme_template_solution_type` | `text` | 否 | 适用方案类型 | +| `scheme_template_solution_feature` | `text` | 否 | 适用方案特点 | +| `scheme_template_product_list` | `json` | 否 | 产品清单,建议格式:`[{"product_id":"PROD-xxx","qty":5,"note":"客厅使用"}]` | +| `scheme_template_description` | `text` | 否 | 模板说明 | +| `scheme_template_remark` | `text` | 否 | 备注 | +| `is_delete` | `number` | 否 | 软删除标记,`0` 表示未删除,`1` 表示已删除,默认 `0` | + +## 索引 + +| 索引名 | 类型 | 说明 | +| :--- | :--- | :--- | +| `idx_tbl_scheme_template_scheme_template_id` | `UNIQUE INDEX` | 保证 `scheme_template_id` 唯一 | +| `idx_tbl_scheme_template_scheme_template_owner` | `INDEX` | 加速按模板所有者查询 | +| `idx_tbl_scheme_template_scheme_template_name` | `INDEX` | 加速按模板名称检索 | +| `idx_tbl_scheme_template_scheme_template_label` | `INDEX` | 加速按模板标签检索 | +| `idx_tbl_scheme_template_scheme_template_status` | `INDEX` | 加速按模板状态过滤 | +| `idx_tbl_scheme_template_solution_type` | `INDEX` | 加速按适用方案类型过滤 | +| `idx_tbl_scheme_template_solution_feature` | `INDEX` | 加速按适用方案特点过滤 | +| `idx_tbl_scheme_template_owner_status` | `INDEX` | 加速同一用户下按状态查询 | + +## 建议 PocketBase 原生权限规则 + +| Rule | 建议表达式 | +| :--- | :--- | +| `listRule` | `@request.auth.id != "" && scheme_template_owner = @request.auth.openid && is_delete = 0` | +| `viewRule` | `@request.auth.id != "" && scheme_template_owner = @request.auth.openid && is_delete = 0` | +| `createRule` | `@request.auth.id != "" && @request.body.scheme_template_owner = @request.auth.openid` | +| `updateRule` | `@request.auth.id != "" && scheme_template_owner = @request.auth.openid && is_delete = 0` | +| `deleteRule` | `@request.auth.id != "" && scheme_template_owner = @request.auth.openid && is_delete = 0` | + +## 补充约定 + +- `scheme_template_icon` 建议延续现有项目做法,仅保存 `attachments_id`,真实文件统一走 `tbl_attachments`。 +- `scheme_template_product_list` 建议使用 `json` 数组,单项推荐结构:`product_id`、`qty`、`note`;不要保存自由格式长字符串。 +- 若模板将来需要“官方模板 + 用户私有模板”并存,则需要额外引入发布状态字段或放宽公共模板读取规则;当前文档严格按 owner 私有模板设计。 +- `is_delete` 用于软删除控制,模板删除时建议优先标记为 `1`。 +- PocketBase 系统字段 `created`、`updated` 仍然存在,只是不在 collection 字段清单里单独声明。 diff --git a/pocket-base/bai-api-main.pb.js b/pocket-base/bai-api-main.pb.js index 19b8819..6b1cb43 100644 --- a/pocket-base/bai-api-main.pb.js +++ b/pocket-base/bai-api-main.pb.js @@ -41,6 +41,16 @@ require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/product/detail.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/product/create.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/product/update.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/product/delete.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/scheme-template/list.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/scheme-template/detail.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/scheme-template/create.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/scheme-template/update.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/scheme-template/delete.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/scheme/list.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/scheme/detail.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/scheme/create.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/scheme/update.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/scheme/delete.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/cart/list.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/cart/detail.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/cart/create.js`) diff --git a/pocket-base/bai-web-main.pb.js b/pocket-base/bai-web-main.pb.js index ab0fad4..2b5011d 100644 --- a/pocket-base/bai-web-main.pb.js +++ b/pocket-base/bai-web-main.pb.js @@ -2,6 +2,7 @@ require(`${__hooks}/bai_web_pb_hooks/pages/index.js`) require(`${__hooks}/bai_web_pb_hooks/pages/login.js`) require(`${__hooks}/bai_web_pb_hooks/pages/document-manage.js`) require(`${__hooks}/bai_web_pb_hooks/pages/product-manage.js`) +require(`${__hooks}/bai_web_pb_hooks/pages/scheme-manage.js`) require(`${__hooks}/bai_web_pb_hooks/pages/dictionary-manage.js`) require(`${__hooks}/bai_web_pb_hooks/pages/sdk-permission-manage.js`) require(`${__hooks}/bai_web_pb_hooks/pages/cart-order-manage.js`) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/create.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/create.js new file mode 100644 index 0000000..fbf730e --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/create.js @@ -0,0 +1,25 @@ +routerAdd('POST', '/api/scheme-template/create', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const schemeService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/schemeService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success, fail } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + const authState = guards.requireManagePlatformUser(e) + guards.duplicateGuard(e) + + const payload = guards.validateSchemeTemplateMutationBody(e, false) + const data = schemeService.createSchemeTemplate(authState.openid, payload) + + return success(e, '新增方案模板成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('新增方案模板失败', { + status: status, + errMsg: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return fail(e, (err && err.message) || '操作失败', (err && err.data) || {}, status) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/delete.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/delete.js new file mode 100644 index 0000000..910654c --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/delete.js @@ -0,0 +1,25 @@ +routerAdd('POST', '/api/scheme-template/delete', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const schemeService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/schemeService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success, fail } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + const authState = guards.requireManagePlatformUser(e) + guards.duplicateGuard(e) + + const payload = guards.validateSchemeTemplateDeleteBody(e) + const data = schemeService.deleteSchemeTemplate(authState.openid, payload.scheme_template_id) + + return success(e, '删除方案模板成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('删除方案模板失败', { + status: status, + errMsg: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return fail(e, (err && err.message) || '操作失败', (err && err.data) || {}, status) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/detail.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/detail.js new file mode 100644 index 0000000..3f8abd1 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/detail.js @@ -0,0 +1,24 @@ +routerAdd('POST', '/api/scheme-template/detail', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const schemeService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/schemeService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success, fail } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + guards.requireManagePlatformUser(e) + + const payload = guards.validateSchemeTemplateDetailBody(e) + const data = schemeService.getSchemeTemplateDetail(payload.scheme_template_id) + + return success(e, '查询方案模板详情成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('查询方案模板详情失败', { + status: status, + errMsg: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return fail(e, (err && err.message) || '操作失败', (err && err.data) || {}, status) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/list.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/list.js new file mode 100644 index 0000000..821473b --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/list.js @@ -0,0 +1,26 @@ +routerAdd('POST', '/api/scheme-template/list', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const schemeService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/schemeService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success, fail } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + guards.requireManagePlatformUser(e) + + const payload = guards.validateSchemeTemplateListBody(e) + const data = schemeService.listSchemeTemplates(payload) + + return success(e, '查询方案模板列表成功', { + items: data, + }) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('查询方案模板列表失败', { + status: status, + errMsg: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return fail(e, (err && err.message) || '操作失败', (err && err.data) || {}, status) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/update.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/update.js new file mode 100644 index 0000000..b3cab0d --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme-template/update.js @@ -0,0 +1,25 @@ +routerAdd('POST', '/api/scheme-template/update', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const schemeService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/schemeService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success, fail } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + const authState = guards.requireManagePlatformUser(e) + guards.duplicateGuard(e) + + const payload = guards.validateSchemeTemplateMutationBody(e, true) + const data = schemeService.updateSchemeTemplate(authState.openid, payload) + + return success(e, '更新方案模板成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('更新方案模板失败', { + status: status, + errMsg: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return fail(e, (err && err.message) || '操作失败', (err && err.data) || {}, status) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/create.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/create.js new file mode 100644 index 0000000..eb5d2f8 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/create.js @@ -0,0 +1,25 @@ +routerAdd('POST', '/api/scheme/create', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const schemeService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/schemeService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success, fail } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + const authState = guards.requireManagePlatformUser(e) + guards.duplicateGuard(e) + + const payload = guards.validateSchemeMutationBody(e, false) + const data = schemeService.createScheme(authState.openid, payload) + + return success(e, '新增方案成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('新增方案失败', { + status: status, + errMsg: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return fail(e, (err && err.message) || '操作失败', (err && err.data) || {}, status) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/delete.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/delete.js new file mode 100644 index 0000000..a541e09 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/delete.js @@ -0,0 +1,25 @@ +routerAdd('POST', '/api/scheme/delete', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const schemeService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/schemeService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success, fail } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + const authState = guards.requireManagePlatformUser(e) + guards.duplicateGuard(e) + + const payload = guards.validateSchemeDeleteBody(e) + const data = schemeService.deleteScheme(authState.openid, payload.scheme_id) + + return success(e, '删除方案成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('删除方案失败', { + status: status, + errMsg: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return fail(e, (err && err.message) || '操作失败', (err && err.data) || {}, status) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/detail.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/detail.js new file mode 100644 index 0000000..a2a32c7 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/detail.js @@ -0,0 +1,24 @@ +routerAdd('POST', '/api/scheme/detail', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const schemeService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/schemeService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success, fail } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + guards.requireManagePlatformUser(e) + + const payload = guards.validateSchemeDetailBody(e) + const data = schemeService.getSchemeDetail(payload.scheme_id) + + return success(e, '查询方案详情成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('查询方案详情失败', { + status: status, + errMsg: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return fail(e, (err && err.message) || '操作失败', (err && err.data) || {}, status) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/list.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/list.js new file mode 100644 index 0000000..00de1f7 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/list.js @@ -0,0 +1,26 @@ +routerAdd('POST', '/api/scheme/list', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const schemeService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/schemeService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success, fail } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + guards.requireManagePlatformUser(e) + + const payload = guards.validateSchemeListBody(e) + const data = schemeService.listSchemes(payload) + + return success(e, '查询方案列表成功', { + items: data, + }) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('查询方案列表失败', { + status: status, + errMsg: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return fail(e, (err && err.message) || '操作失败', (err && err.data) || {}, status) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/update.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/update.js new file mode 100644 index 0000000..1221355 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/scheme/update.js @@ -0,0 +1,25 @@ +routerAdd('POST', '/api/scheme/update', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const schemeService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/schemeService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success, fail } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + const authState = guards.requireManagePlatformUser(e) + guards.duplicateGuard(e) + + const payload = guards.validateSchemeMutationBody(e, true) + const data = schemeService.updateScheme(authState.openid, payload) + + return success(e, '更新方案成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('更新方案失败', { + status: status, + errMsg: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return fail(e, (err && err.message) || '操作失败', (err && err.data) || {}, status) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js index 5e6cc1c..06a781c 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js @@ -548,6 +548,126 @@ function validateProductDeleteBody(e) { return validateProductDetailBody(e) } +function validateSchemeTemplateListBody(e) { + const payload = parseBody(e) + + return { + keyword: payload.keyword || '', + scheme_template_owner: payload.scheme_template_owner || '', + scheme_template_status: payload.scheme_template_status || '', + scheme_template_solution_type: payload.scheme_template_solution_type || '', + scheme_template_solution_feature: payload.scheme_template_solution_feature || '', + } +} + +function validateSchemeTemplateDetailBody(e) { + const payload = parseBody(e) + if (!payload.scheme_template_id) { + throw createAppError(400, 'scheme_template_id 为必填项') + } + + return { + scheme_template_id: String(payload.scheme_template_id || '').trim(), + } +} + +function validateSchemeTemplateMutationBody(e, isUpdate) { + const payload = parseBody(e) + + if (isUpdate && !payload.scheme_template_id) { + throw createAppError(400, 'scheme_template_id 为必填项') + } + + if (!payload.scheme_template_name) { + throw createAppError(400, 'scheme_template_name 为必填项') + } + + if (!payload.scheme_template_owner) { + throw createAppError(400, 'scheme_template_owner 为必填项') + } + + return { + scheme_template_id: payload.scheme_template_id || '', + scheme_template_icon: Object.prototype.hasOwnProperty.call(payload, 'scheme_template_icon') ? payload.scheme_template_icon : '', + scheme_template_label: payload.scheme_template_label || '', + scheme_template_name: payload.scheme_template_name || '', + scheme_template_owner: payload.scheme_template_owner || '', + scheme_template_status: payload.scheme_template_status || '', + scheme_template_solution_type: payload.scheme_template_solution_type || '', + scheme_template_solution_feature: payload.scheme_template_solution_feature || '', + scheme_template_product_list: Object.prototype.hasOwnProperty.call(payload, 'scheme_template_product_list') ? payload.scheme_template_product_list : [], + scheme_template_description: payload.scheme_template_description || '', + scheme_template_remark: payload.scheme_template_remark || '', + } +} + +function validateSchemeTemplateDeleteBody(e) { + return validateSchemeTemplateDetailBody(e) +} + +function validateSchemeListBody(e) { + const payload = parseBody(e) + + return { + keyword: payload.keyword || '', + scheme_owner: payload.scheme_owner || '', + scheme_status: payload.scheme_status || '', + scheme_share_status: payload.scheme_share_status || '', + scheme_hotel_type: payload.scheme_hotel_type || '', + } +} + +function validateSchemeDetailBody(e) { + const payload = parseBody(e) + if (!payload.scheme_id) { + throw createAppError(400, 'scheme_id 为必填项') + } + + return { + scheme_id: String(payload.scheme_id || '').trim(), + } +} + +function validateSchemeMutationBody(e, isUpdate) { + const payload = parseBody(e) + + if (isUpdate && !payload.scheme_id) { + throw createAppError(400, 'scheme_id 为必填项') + } + + if (!payload.scheme_name) { + throw createAppError(400, 'scheme_name 为必填项') + } + + if (!payload.scheme_owner) { + throw createAppError(400, 'scheme_owner 为必填项') + } + + return { + scheme_id: payload.scheme_id || '', + scheme_name: payload.scheme_name || '', + scheme_owner: payload.scheme_owner || '', + scheme_share_status: payload.scheme_share_status || '', + scheme_expires_at: payload.scheme_expires_at || '', + scheme_hotel_type: payload.scheme_hotel_type || '', + scheme_solution_type: payload.scheme_solution_type || '', + scheme_solution_feature: payload.scheme_solution_feature || '', + scheme_room_type: Object.prototype.hasOwnProperty.call(payload, 'scheme_room_type') ? payload.scheme_room_type : [], + scheme_curtains: payload.scheme_curtains || '', + scheme_voice_device: payload.scheme_voice_device || '', + scheme_ac_type: payload.scheme_ac_type || '', + scheme_template_highend: payload.scheme_template_highend || '', + scheme_template_midend: payload.scheme_template_midend || '', + scheme_template_lowend: payload.scheme_template_lowend || '', + scheme_status: payload.scheme_status || '', + scheme_remark: payload.scheme_remark || '', + } +} + +function validateSchemeDeleteBody(e) { + return validateSchemeDetailBody(e) +} + function validateCartListBody(e) { const payload = parseBody(e) @@ -874,6 +994,14 @@ module.exports = { validateProductDetailBody, validateProductMutationBody, validateProductDeleteBody, + validateSchemeTemplateListBody, + validateSchemeTemplateDetailBody, + validateSchemeTemplateMutationBody, + validateSchemeTemplateDeleteBody, + validateSchemeListBody, + validateSchemeDetailBody, + validateSchemeMutationBody, + validateSchemeDeleteBody, validateCartListBody, validateCartDetailBody, validateCartMutationBody, diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/services/cartOrderService.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/cartOrderService.js index ad64c12..ac9b865 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_shared/services/cartOrderService.js +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/cartOrderService.js @@ -208,6 +208,19 @@ function findProductRecordByBusinessId(productId) { return records.length ? records[0] : null } +function findProductRecordByRecordId(recordId) { + const targetId = normalizeText(recordId) + if (!targetId) { + return null + } + + try { + return $app.findRecordById('tbl_product_list', targetId) + } catch (_error) { + return null + } +} + function buildProductInfo(record) { if (!record) { return { @@ -232,9 +245,9 @@ function ensureProductExists(productId) { throw createAppError(400, 'cart_product_id 为必填项') } - const record = findProductRecordByBusinessId(targetId) + const record = findProductRecordByRecordId(targetId) if (!record) { - throw createAppError(400, 'cart_product_id 对应产品不存在:' + targetId) + throw createAppError(400, 'cart_product_id 必须为 tbl_product_list 的 recordId,且对应产品必须存在:' + targetId) } return record @@ -264,7 +277,9 @@ function ensureRecordOwner(record, fieldName, authOpenid, resourceLabel) { } function exportCartRecord(record, productRecord) { - const productInfo = buildProductInfo(productRecord || findProductRecordByBusinessId(record.getString('cart_product_id'))) + const relationValue = record.getString('cart_product_id') + const productSource = productRecord || findProductRecordByRecordId(relationValue) + const productInfo = buildProductInfo(productSource) return { pb_id: record.id, @@ -272,7 +287,8 @@ function exportCartRecord(record, productRecord) { cart_number: record.getString('cart_number'), cart_create: String(record.get('cart_create') || ''), cart_owner: record.getString('cart_owner'), - cart_product_id: record.getString('cart_product_id'), + cart_product_id: productSource ? productSource.id : relationValue, + cart_product_business_id: productInfo.prod_list_id, cart_product_quantity: Number(record.get('cart_product_quantity') || 0), cart_status: record.getString('cart_status'), cart_at_price: Number(record.get('cart_at_price') || 0), @@ -307,7 +323,8 @@ function exportAdminCartRecord(record) { const productId = record && typeof record.getString === 'function' ? record.getString('cart_product_id') : String(record && record.cart_product_id || '') - const productInfo = buildProductInfo(findProductRecordByBusinessId(productId)) + const productSource = findProductRecordByRecordId(productId) + const productInfo = buildProductInfo(productSource) return { pb_id: String(record && record.id || ''), @@ -315,7 +332,8 @@ function exportAdminCartRecord(record) { cart_number: record && typeof record.getString === 'function' ? record.getString('cart_number') : String(record && record.cart_number || ''), cart_create: record && typeof record.get === 'function' ? String(record.get('cart_create') || '') : String(record && record.cart_create || ''), cart_owner: record && typeof record.getString === 'function' ? record.getString('cart_owner') : String(record && record.cart_owner || ''), - cart_product_id: productId, + cart_product_id: productSource ? productSource.id : productId, + cart_product_business_id: productInfo.prod_list_id, cart_product_quantity: record && typeof record.get === 'function' ? Number(record.get('cart_product_quantity') || 0) : Number(record && record.cart_product_quantity || 0), cart_status: record && typeof record.getString === 'function' ? record.getString('cart_status') : String(record && record.cart_status || ''), cart_at_price: record && typeof record.get === 'function' ? Number(record.get('cart_at_price') || 0) : Number(record && record.cart_at_price || 0), @@ -419,6 +437,7 @@ function listCarts(authOpenid, payload) { || item.cart_id.toLowerCase().indexOf(keyword) !== -1 || item.cart_number.toLowerCase().indexOf(keyword) !== -1 || item.cart_product_id.toLowerCase().indexOf(keyword) !== -1 + || normalizeText(item.cart_product_business_id).toLowerCase().indexOf(keyword) !== -1 || normalizeText(item.product_name).toLowerCase().indexOf(keyword) !== -1 const matchedStatus = !cartStatus || item.cart_status === cartStatus const matchedNumber = !cartNumber || item.cart_number === cartNumber @@ -449,7 +468,7 @@ function createCart(authState, payload) { record.set('cart_id', buildBusinessId('CART')) record.set('cart_number', normalizeText(payload.cart_number) || buildDisplayNumber('CART', authState.authRecord, authState.openid)) record.set('cart_owner', authState.openid) - record.set('cart_product_id', productRecord.getString('prod_list_id')) + record.set('cart_product_id', productRecord.id) record.set('cart_product_quantity', normalizePositiveIntegerValue(payload.cart_product_quantity, 'cart_product_quantity')) record.set('cart_status', normalizeCartStatus(payload.cart_status)) record.set('cart_at_price', normalizeNumberValue(payload.cart_at_price, 'cart_at_price')) @@ -486,7 +505,7 @@ function updateCart(authOpenid, payload) { } if (typeof payload.cart_product_id !== 'undefined') { productRecord = ensureProductExists(payload.cart_product_id) - record.set('cart_product_id', productRecord.getString('prod_list_id')) + record.set('cart_product_id', productRecord.id) } if (typeof payload.cart_product_quantity !== 'undefined') { record.set('cart_product_quantity', normalizePositiveIntegerValue(payload.cart_product_quantity, 'cart_product_quantity')) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/services/schemeService.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/schemeService.js new file mode 100644 index 0000000..16722e5 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/schemeService.js @@ -0,0 +1,755 @@ +const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) +const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`) +const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`) + +const TEMPLATE_COLLECTION = 'tbl_scheme_template' +const SCHEME_COLLECTION = 'tbl_scheme' +const USER_COLLECTION = 'tbl_auth_users' +const PRODUCT_COLLECTION = 'tbl_product_list' + +function tryFindCollection(collectionName) { + try { + return $app.findCollectionByNameOrId(collectionName) + } catch (_error) { + return null + } +} + +function ensureCollectionReady(collectionName) { + const collection = tryFindCollection(collectionName) + if (!collection) { + throw createAppError(400, '集合未初始化:' + collectionName + ',请先执行方案建表脚本') + } + + return collection +} + +function buildBusinessId(prefix) { + return prefix + '-' + new Date().getTime() + '-' + $security.randomString(6) +} + +function normalizeText(value) { + return String(value || '').replace(/^\s+|\s+$/g, '') +} + +function normalizeDateValue(value) { + const text = normalizeText(value) + if (!text) return null + + if (/^\d{4}-\d{2}-\d{2}$/.test(text)) { + return text + ' 00:00:00.000Z' + } + + if (/^\d{4}-\d{2}-\d{2}\s/.test(text) || text.indexOf('T') !== -1) { + return text + } + + throw createAppError(400, '日期字段格式错误') +} + +function parseJsonInput(value, fieldName) { + if (value === null || typeof value === 'undefined' || value === '') { + return [] + } + + if (Array.isArray(value)) { + try { + return JSON.parse(JSON.stringify(value)) + } catch (_error) { + throw createAppError(400, fieldName + ' 必须是可序列化的 JSON 数组') + } + } + + if (typeof value === 'object') { + try { + return JSON.parse(JSON.stringify(value)) + } catch (_error) { + throw createAppError(400, fieldName + ' 必须是可序列化的 JSON 对象或数组') + } + } + + if (typeof value === 'string') { + try { + return JSON.parse(value) + } catch (_error) { + throw createAppError(400, fieldName + ' 必须为合法 JSON') + } + } + + throw createAppError(400, fieldName + ' 必须为 JSON') +} + +function parseJsonForOutput(value, fallback) { + if (value === null || typeof value === 'undefined' || value === '') { + return typeof fallback === 'undefined' ? [] : fallback + } + + if (Array.isArray(value)) { + try { + return JSON.parse(JSON.stringify(value)) + } catch (_error) { + return typeof fallback === 'undefined' ? [] : fallback + } + } + + if (typeof value === 'object') { + try { + return JSON.parse(JSON.stringify(value)) + } catch (_error) { + return typeof fallback === 'undefined' ? [] : fallback + } + } + + if (typeof value === 'string') { + try { + return JSON.parse(value) + } catch (_error) { + return typeof fallback === 'undefined' ? [] : fallback + } + } + + return typeof fallback === 'undefined' ? [] : fallback +} + +function getUserRecordByOpenid(openid) { + const value = normalizeText(openid) + if (!value) return null + if (!tryFindCollection(USER_COLLECTION)) return null + + const records = $app.findRecordsByFilter(USER_COLLECTION, 'openid = {:openid}', '', 1, 0, { + openid: value, + }) + + if (!records.length) return null + if (Number(records[0].get('is_delete') || 0) === 1) return null + return records[0] +} + +function ensureUserExists(openid, fieldName) { + const value = normalizeText(openid) + if (!value) { + throw createAppError(400, fieldName + ' 为必填项') + } + + const record = getUserRecordByOpenid(value) + if (!record) { + throw createAppError(400, fieldName + ' 对应用户不存在:' + value) + } + + return record +} + +function ensureAttachmentExists(attachmentId, fieldName) { + const value = normalizeText(attachmentId) + if (!value) { + return '' + } + + try { + documentService.getAttachmentDetail(value) + } catch (_error) { + throw createAppError(400, fieldName + ' 对应附件不存在:' + value) + } + + return value +} + +function getProductRecordById(productId) { + const value = normalizeText(productId) + if (!value) return null + if (!tryFindCollection(PRODUCT_COLLECTION)) return null + + const records = $app.findRecordsByFilter(PRODUCT_COLLECTION, 'prod_list_id = {:productId}', '', 1, 0, { + productId: value, + }) + + if (!records.length) return null + if (Number(records[0].get('is_delete') || 0) === 1) return null + return records[0] +} + +function ensureProductExists(productId, fieldName) { + const value = normalizeText(productId) + if (!value) { + throw createAppError(400, fieldName + ' 不能为空') + } + + const record = getProductRecordById(value) + if (!record) { + throw createAppError(400, fieldName + ' 对应产品不存在:' + value) + } + + return record +} + +function findTemplateRecordByTemplateId(templateId) { + const value = normalizeText(templateId) + if (!value) return null + if (!tryFindCollection(TEMPLATE_COLLECTION)) return null + + const records = $app.findRecordsByFilter(TEMPLATE_COLLECTION, 'scheme_template_id = {:templateId}', '', 1, 0, { + templateId: value, + }) + + if (!records.length) return null + if (Number(records[0].get('is_delete') || 0) === 1) return null + return records[0] +} + +function ensureTemplateExists(templateId, fieldName) { + const value = normalizeText(templateId) + if (!value) { + return null + } + + const record = findTemplateRecordByTemplateId(value) + if (!record) { + throw createAppError(400, fieldName + ' 对应模板不存在:' + value) + } + + return record +} + +function findSchemeRecordBySchemeId(schemeId) { + const value = normalizeText(schemeId) + if (!value) return null + if (!tryFindCollection(SCHEME_COLLECTION)) return null + + const records = $app.findRecordsByFilter(SCHEME_COLLECTION, 'scheme_id = {:schemeId}', '', 1, 0, { + schemeId: value, + }) + + if (!records.length) return null + if (Number(records[0].get('is_delete') || 0) === 1) return null + return records[0] +} + +function normalizeTemplateProductList(value) { + const parsed = parseJsonInput(value, 'scheme_template_product_list') + if (!Array.isArray(parsed)) { + throw createAppError(400, 'scheme_template_product_list 必须为 JSON 数组') + } + + const result = [] + for (let i = 0; i < parsed.length; i += 1) { + const item = parsed[i] && typeof parsed[i] === 'object' ? parsed[i] : null + if (!item) { + continue + } + + const productId = normalizeText(item.product_id) + if (!productId) { + throw createAppError(400, 'scheme_template_product_list 第 ' + (i + 1) + ' 项 product_id 为必填项') + } + ensureProductExists(productId, 'scheme_template_product_list.product_id') + + const qty = Number(item.qty) + if (!Number.isFinite(qty) || qty <= 0) { + throw createAppError(400, 'scheme_template_product_list 第 ' + (i + 1) + ' 项 qty 必须为正数') + } + + result.push({ + product_id: productId, + qty: qty, + note: normalizeText(item.note), + }) + } + + return result +} + +function normalizeSchemeRoomType(value) { + const parsed = parseJsonInput(value, 'scheme_room_type') + if (!Array.isArray(parsed)) { + throw createAppError(400, 'scheme_room_type 必须为 JSON 数组') + } + + const result = [] + for (let i = 0; i < parsed.length; i += 1) { + const item = parsed[i] && typeof parsed[i] === 'object' ? parsed[i] : null + if (!item) { + continue + } + + const roomType = normalizeText(item.room_type || item.name) + if (!roomType) { + throw createAppError(400, 'scheme_room_type 第 ' + (i + 1) + ' 项 room_type 为必填项') + } + + const qty = Number(item.qty) + if (!Number.isFinite(qty) || qty <= 0) { + throw createAppError(400, 'scheme_room_type 第 ' + (i + 1) + ' 项 qty 必须为正数') + } + + result.push({ + room_type: roomType, + qty: qty, + }) + } + + return result +} + +function resolveAttachment(attachmentId) { + const value = normalizeText(attachmentId) + if (!value) { + return { + id: '', + url: '', + attachment: null, + } + } + + try { + const attachment = documentService.getAttachmentDetail(value) + return { + id: value, + url: attachment ? attachment.attachments_url : '', + attachment: attachment, + } + } catch (_error) { + return { + id: value, + url: '', + attachment: null, + } + } +} + +function buildTemplateSummaryMap() { + if (!tryFindCollection(TEMPLATE_COLLECTION)) { + logger.warn('方案模板集合未初始化,方案模板摘要映射将返回空结果', { + collection: TEMPLATE_COLLECTION, + }) + return {} + } + + const records = $app.findRecordsByFilter(TEMPLATE_COLLECTION, 'is_delete = 0', '-updated', 1000, 0) + const map = {} + + for (let i = 0; i < records.length; i += 1) { + const templateId = normalizeText(records[i].getString('scheme_template_id')) + if (!templateId) { + continue + } + + map[templateId] = { + scheme_template_id: templateId, + scheme_template_name: records[i].getString('scheme_template_name'), + scheme_template_owner: records[i].getString('scheme_template_owner'), + scheme_template_status: records[i].getString('scheme_template_status'), + } + } + + return map +} + +function exportTemplateRecord(record) { + const icon = resolveAttachment(record.getString('scheme_template_icon')) + + return { + pb_id: record.id, + scheme_template_id: record.getString('scheme_template_id'), + scheme_template_icon: icon.id, + scheme_template_icon_url: icon.url, + scheme_template_icon_attachment: icon.attachment, + scheme_template_label: record.getString('scheme_template_label'), + scheme_template_name: record.getString('scheme_template_name'), + scheme_template_owner: record.getString('scheme_template_owner'), + scheme_template_status: record.getString('scheme_template_status'), + scheme_template_solution_type: record.getString('scheme_template_solution_type'), + scheme_template_solution_feature: record.getString('scheme_template_solution_feature'), + scheme_template_product_list: parseJsonForOutput(record.get('scheme_template_product_list'), []), + scheme_template_description: record.getString('scheme_template_description'), + scheme_template_remark: record.getString('scheme_template_remark'), + is_delete: Number(record.get('is_delete') || 0), + created: String(record.created || ''), + updated: String(record.updated || ''), + } +} + +function exportSchemeRecord(record, templateMap) { + const highendId = record.getString('scheme_template_highend') + const midendId = record.getString('scheme_template_midend') + const lowendId = record.getString('scheme_template_lowend') + const templates = templateMap || {} + + return { + pb_id: record.id, + scheme_id: record.getString('scheme_id'), + scheme_name: record.getString('scheme_name'), + scheme_owner: record.getString('scheme_owner'), + scheme_share_status: record.getString('scheme_share_status'), + scheme_expires_at: String(record.get('scheme_expires_at') || ''), + scheme_hotel_type: record.getString('scheme_hotel_type'), + scheme_solution_type: record.getString('scheme_solution_type'), + scheme_solution_feature: record.getString('scheme_solution_feature'), + scheme_room_type: parseJsonForOutput(record.get('scheme_room_type'), []), + scheme_curtains: record.getString('scheme_curtains'), + scheme_voice_device: record.getString('scheme_voice_device'), + scheme_ac_type: record.getString('scheme_ac_type'), + scheme_template_highend: highendId, + scheme_template_highend_name: templates[highendId] ? templates[highendId].scheme_template_name : '', + scheme_template_midend: midendId, + scheme_template_midend_name: templates[midendId] ? templates[midendId].scheme_template_name : '', + scheme_template_lowend: lowendId, + scheme_template_lowend_name: templates[lowendId] ? templates[lowendId].scheme_template_name : '', + scheme_status: record.getString('scheme_status'), + scheme_remark: record.getString('scheme_remark'), + is_delete: Number(record.get('is_delete') || 0), + created: String(record.created || ''), + updated: String(record.updated || ''), + } +} + +function applyTemplatePayload(record, payload) { + ensureUserExists(payload.scheme_template_owner, 'scheme_template_owner') + + record.set('scheme_template_name', normalizeText(payload.scheme_template_name)) + record.set('scheme_template_owner', normalizeText(payload.scheme_template_owner)) + record.set('scheme_template_icon', ensureAttachmentExists(payload.scheme_template_icon, 'scheme_template_icon')) + record.set('scheme_template_label', normalizeText(payload.scheme_template_label)) + record.set('scheme_template_status', normalizeText(payload.scheme_template_status)) + record.set('scheme_template_solution_type', normalizeText(payload.scheme_template_solution_type)) + record.set('scheme_template_solution_feature', normalizeText(payload.scheme_template_solution_feature)) + record.set('scheme_template_product_list', normalizeTemplateProductList(payload.scheme_template_product_list)) + record.set('scheme_template_description', normalizeText(payload.scheme_template_description)) + record.set('scheme_template_remark', normalizeText(payload.scheme_template_remark)) +} + +function applySchemePayload(record, payload) { + ensureUserExists(payload.scheme_owner, 'scheme_owner') + ensureTemplateExists(payload.scheme_template_highend, 'scheme_template_highend') + ensureTemplateExists(payload.scheme_template_midend, 'scheme_template_midend') + ensureTemplateExists(payload.scheme_template_lowend, 'scheme_template_lowend') + + record.set('scheme_name', normalizeText(payload.scheme_name)) + record.set('scheme_owner', normalizeText(payload.scheme_owner)) + record.set('scheme_share_status', normalizeText(payload.scheme_share_status)) + record.set('scheme_expires_at', normalizeDateValue(payload.scheme_expires_at)) + record.set('scheme_hotel_type', normalizeText(payload.scheme_hotel_type)) + record.set('scheme_solution_type', normalizeText(payload.scheme_solution_type)) + record.set('scheme_solution_feature', normalizeText(payload.scheme_solution_feature)) + record.set('scheme_room_type', normalizeSchemeRoomType(payload.scheme_room_type)) + record.set('scheme_curtains', normalizeText(payload.scheme_curtains)) + record.set('scheme_voice_device', normalizeText(payload.scheme_voice_device)) + record.set('scheme_ac_type', normalizeText(payload.scheme_ac_type)) + record.set('scheme_template_highend', normalizeText(payload.scheme_template_highend)) + record.set('scheme_template_midend', normalizeText(payload.scheme_template_midend)) + record.set('scheme_template_lowend', normalizeText(payload.scheme_template_lowend)) + record.set('scheme_status', normalizeText(payload.scheme_status)) + record.set('scheme_remark', normalizeText(payload.scheme_remark)) +} + +function listSchemeTemplates(payload) { + if (!tryFindCollection(TEMPLATE_COLLECTION)) { + logger.warn('方案模板集合未初始化,模板列表返回空结果', { + collection: TEMPLATE_COLLECTION, + }) + return [] + } + + const records = $app.findRecordsByFilter(TEMPLATE_COLLECTION, 'is_delete = 0', '-updated', 1000, 0) + const keyword = normalizeText(payload.keyword).toLowerCase() + const owner = normalizeText(payload.scheme_template_owner) + const status = normalizeText(payload.scheme_template_status) + const solutionType = normalizeText(payload.scheme_template_solution_type) + const solutionFeature = normalizeText(payload.scheme_template_solution_feature) + const result = [] + + for (let i = 0; i < records.length; i += 1) { + const item = exportTemplateRecord(records[i]) + const matchedKeyword = !keyword + || normalizeText(item.scheme_template_id).toLowerCase().indexOf(keyword) !== -1 + || normalizeText(item.scheme_template_name).toLowerCase().indexOf(keyword) !== -1 + || normalizeText(item.scheme_template_label).toLowerCase().indexOf(keyword) !== -1 + || normalizeText(item.scheme_template_owner).toLowerCase().indexOf(keyword) !== -1 + const matchedOwner = !owner || item.scheme_template_owner === owner + const matchedStatus = !status || item.scheme_template_status === status + const matchedSolutionType = !solutionType || item.scheme_template_solution_type === solutionType + const matchedSolutionFeature = !solutionFeature || item.scheme_template_solution_feature === solutionFeature + + if (matchedKeyword && matchedOwner && matchedStatus && matchedSolutionType && matchedSolutionFeature) { + result.push(item) + } + } + + return result +} + +function getSchemeTemplateDetail(templateId) { + ensureCollectionReady(TEMPLATE_COLLECTION) + + const record = findTemplateRecordByTemplateId(templateId) + if (!record) { + throw createAppError(404, '未找到对应方案模板') + } + + return exportTemplateRecord(record) +} + +function createSchemeTemplate(userOpenid, payload) { + const collection = ensureCollectionReady(TEMPLATE_COLLECTION) + const templateId = normalizeText(payload.scheme_template_id) || buildBusinessId('SCHTPL') + if (findTemplateRecordByTemplateId(templateId)) { + throw createAppError(400, 'scheme_template_id 已存在') + } + + const record = new Record(collection) + + record.set('scheme_template_id', templateId) + record.set('is_delete', 0) + applyTemplatePayload(record, payload) + + try { + $app.save(record) + } catch (err) { + throw createAppError(400, '新增方案模板失败', { + originalMessage: (err && err.message) || '未知错误', + originalData: (err && err.data) || {}, + }) + } + + logger.info('方案模板创建成功', { + scheme_template_id: templateId, + operator_openid: userOpenid || '', + }) + + return exportTemplateRecord(record) +} + +function updateSchemeTemplate(userOpenid, payload) { + ensureCollectionReady(TEMPLATE_COLLECTION) + + const templateId = normalizeText(payload.scheme_template_id) + if (!templateId) { + throw createAppError(400, 'scheme_template_id 为必填项') + } + + const record = findTemplateRecordByTemplateId(templateId) + if (!record) { + throw createAppError(404, '未找到待修改的方案模板') + } + + applyTemplatePayload(record, payload) + + try { + $app.save(record) + } catch (err) { + throw createAppError(400, '更新方案模板失败', { + originalMessage: (err && err.message) || '未知错误', + originalData: (err && err.data) || {}, + }) + } + + logger.info('方案模板更新成功', { + scheme_template_id: templateId, + operator_openid: userOpenid || '', + }) + + return exportTemplateRecord(record) +} + +function deleteSchemeTemplate(userOpenid, templateId) { + ensureCollectionReady(TEMPLATE_COLLECTION) + ensureCollectionReady(SCHEME_COLLECTION) + + const value = normalizeText(templateId) + if (!value) { + throw createAppError(400, 'scheme_template_id 为必填项') + } + + const record = findTemplateRecordByTemplateId(value) + if (!record) { + throw createAppError(404, '未找到待删除的方案模板') + } + + const usedSchemes = $app.findRecordsByFilter( + SCHEME_COLLECTION, + 'is_delete = 0 && (scheme_template_highend = {:templateId} || scheme_template_midend = {:templateId} || scheme_template_lowend = {:templateId})', + '', + 1, + 0, + { templateId: value } + ) + + if (usedSchemes.length) { + throw createAppError(400, '方案模板已被方案引用,无法删除') + } + + record.set('is_delete', 1) + + try { + $app.save(record) + } catch (err) { + throw createAppError(400, '删除方案模板失败', { + originalMessage: (err && err.message) || '未知错误', + originalData: (err && err.data) || {}, + }) + } + + logger.info('方案模板删除成功', { + scheme_template_id: value, + operator_openid: userOpenid || '', + }) + + return { + scheme_template_id: value, + } +} + +function listSchemes(payload) { + if (!tryFindCollection(SCHEME_COLLECTION)) { + logger.warn('方案集合未初始化,方案列表返回空结果', { + collection: SCHEME_COLLECTION, + }) + return [] + } + + const templateMap = buildTemplateSummaryMap() + const records = $app.findRecordsByFilter(SCHEME_COLLECTION, 'is_delete = 0', '-updated', 1000, 0) + const keyword = normalizeText(payload.keyword).toLowerCase() + const owner = normalizeText(payload.scheme_owner) + const status = normalizeText(payload.scheme_status) + const shareStatus = normalizeText(payload.scheme_share_status) + const hotelType = normalizeText(payload.scheme_hotel_type) + const result = [] + + for (let i = 0; i < records.length; i += 1) { + const item = exportSchemeRecord(records[i], templateMap) + const matchedKeyword = !keyword + || normalizeText(item.scheme_id).toLowerCase().indexOf(keyword) !== -1 + || normalizeText(item.scheme_name).toLowerCase().indexOf(keyword) !== -1 + || normalizeText(item.scheme_owner).toLowerCase().indexOf(keyword) !== -1 + const matchedOwner = !owner || item.scheme_owner === owner + const matchedStatus = !status || item.scheme_status === status + const matchedShareStatus = !shareStatus || item.scheme_share_status === shareStatus + const matchedHotelType = !hotelType || item.scheme_hotel_type === hotelType + + if (matchedKeyword && matchedOwner && matchedStatus && matchedShareStatus && matchedHotelType) { + result.push(item) + } + } + + return result +} + +function getSchemeDetail(schemeId) { + ensureCollectionReady(SCHEME_COLLECTION) + + const record = findSchemeRecordBySchemeId(schemeId) + if (!record) { + throw createAppError(404, '未找到对应方案') + } + + return exportSchemeRecord(record, buildTemplateSummaryMap()) +} + +function createScheme(userOpenid, payload) { + const collection = ensureCollectionReady(SCHEME_COLLECTION) + const schemeId = normalizeText(payload.scheme_id) || buildBusinessId('SCHEME') + if (findSchemeRecordBySchemeId(schemeId)) { + throw createAppError(400, 'scheme_id 已存在') + } + + const record = new Record(collection) + + record.set('scheme_id', schemeId) + record.set('is_delete', 0) + applySchemePayload(record, payload) + + try { + $app.save(record) + } catch (err) { + throw createAppError(400, '新增方案失败', { + originalMessage: (err && err.message) || '未知错误', + originalData: (err && err.data) || {}, + }) + } + + logger.info('方案创建成功', { + scheme_id: schemeId, + operator_openid: userOpenid || '', + }) + + return exportSchemeRecord(record, buildTemplateSummaryMap()) +} + +function updateScheme(userOpenid, payload) { + ensureCollectionReady(SCHEME_COLLECTION) + + const schemeId = normalizeText(payload.scheme_id) + if (!schemeId) { + throw createAppError(400, 'scheme_id 为必填项') + } + + const record = findSchemeRecordBySchemeId(schemeId) + if (!record) { + throw createAppError(404, '未找到待修改的方案') + } + + applySchemePayload(record, payload) + + try { + $app.save(record) + } catch (err) { + throw createAppError(400, '更新方案失败', { + originalMessage: (err && err.message) || '未知错误', + originalData: (err && err.data) || {}, + }) + } + + logger.info('方案更新成功', { + scheme_id: schemeId, + operator_openid: userOpenid || '', + }) + + return exportSchemeRecord(record, buildTemplateSummaryMap()) +} + +function deleteScheme(userOpenid, schemeId) { + ensureCollectionReady(SCHEME_COLLECTION) + + const value = normalizeText(schemeId) + if (!value) { + throw createAppError(400, 'scheme_id 为必填项') + } + + const record = findSchemeRecordBySchemeId(value) + if (!record) { + throw createAppError(404, '未找到待删除的方案') + } + + record.set('is_delete', 1) + + try { + $app.save(record) + } catch (err) { + throw createAppError(400, '删除方案失败', { + originalMessage: (err && err.message) || '未知错误', + originalData: (err && err.data) || {}, + }) + } + + logger.info('方案删除成功', { + scheme_id: value, + operator_openid: userOpenid || '', + }) + + return { + scheme_id: value, + } +} + +module.exports = { + listSchemeTemplates, + getSchemeTemplateDetail, + createSchemeTemplate, + updateSchemeTemplate, + deleteSchemeTemplate, + listSchemes, + getSchemeDetail, + createScheme, + updateScheme, + deleteScheme, +} diff --git a/pocket-base/bai_web_pb_hooks/pages/scheme-manage.js b/pocket-base/bai_web_pb_hooks/pages/scheme-manage.js new file mode 100644 index 0000000..144a233 --- /dev/null +++ b/pocket-base/bai_web_pb_hooks/pages/scheme-manage.js @@ -0,0 +1,9 @@ +routerAdd('GET', '/manage/scheme', function (e) { + const html = $template.loadFiles( + __hooks + '/bai_web_pb_hooks/views/scheme-manage.html', + __hooks + '/bai_web_pb_hooks/shared/theme-head.html', + __hooks + '/bai_web_pb_hooks/shared/theme-body.html' + ).render({}) + + return e.html(200, html) +}) diff --git a/pocket-base/bai_web_pb_hooks/views/cart-order-manage.html b/pocket-base/bai_web_pb_hooks/views/cart-order-manage.html index 8dd4b57..70557d3 100644 --- a/pocket-base/bai_web_pb_hooks/views/cart-order-manage.html +++ b/pocket-base/bai_web_pb_hooks/views/cart-order-manage.html @@ -46,25 +46,43 @@ .summary-label { color: #64748b; font-size: 13px; } .summary-value { margin-top: 8px; font-size: 22px; font-weight: 700; color: #0f172a; } .profile-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 14px; } - .preview-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-bottom: 14px; } + .preview-form { border: 1px solid #dbe3f0; border-radius: 14px; background: #f8fbff; overflow: hidden; margin-bottom: 10px; } + .preview-form-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; } + .preview-form-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; flex: 1; min-width: 0; } + .preview-summary-item { min-width: 0; } + .preview-summary-label { font-size: 11px; color: #64748b; margin-bottom: 2px; } + .preview-summary-value { font-size: 13px; font-weight: 700; color: #0f172a; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .preview-toggle { min-width: 88px; } + .preview-form-body { display: grid; grid-template-rows: 0fr; opacity: 0; transition: grid-template-rows 220ms ease, opacity 220ms ease; } + .preview-form-body.open { grid-template-rows: 1fr; opacity: 1; } + .preview-form-inner { min-height: 0; overflow: hidden; padding: 0 12px 0; } + .preview-form-body.open .preview-form-inner { padding-bottom: 12px; } + .preview-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; padding-top: 2px; } .field-block { display: grid; gap: 6px; } .field-block.full { grid-column: 1 / -1; } - .field-label { font-size: 13px; color: #64748b; } - .preview-card { border: 1px solid #dbe3f0; border-radius: 14px; padding: 10px 12px; background: #f8fbff; min-width: 0; } - .preview-value { color: #0f172a; font-size: 14px; font-weight: 600; line-height: 1.5; word-break: break-word; } + .field-label { font-size: 12px; color: #64748b; } + .preview-card { border: 1px solid #dbe3f0; border-radius: 12px; padding: 8px 10px; background: rgba(255,255,255,0.65); min-width: 0; display: grid; gap: 3px; } + .preview-value { color: #0f172a; font-size: 13px; font-weight: 700; line-height: 1.35; word-break: break-word; } .user-profile-shell { display: grid; gap: 12px; } - .edit-panel { border-top: 1px dashed #dbe3f0; padding-top: 14px; } - .attachment-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 14px; } + .profile-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; } + .edit-hint { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; } + .edit-hint.clean { background: #ecfdf5; color: #047857; border: 1px solid #a7f3d0; } + .edit-hint.dirty { background: #fff7ed; color: #c2410c; border: 1px solid #fdba74; } + .edit-panel { border-top: 1px dashed #dbe3f0; display: grid; grid-template-rows: 0fr; opacity: 0; transform: translateY(-4px); transition: grid-template-rows 220ms ease, opacity 220ms ease, transform 220ms ease, padding-top 220ms ease, margin-top 220ms ease; padding-top: 0; margin-top: 0; overflow: hidden; } + .edit-panel.open { grid-template-rows: 1fr; opacity: 1; transform: translateY(0); padding-top: 14px; margin-top: 2px; } + .edit-panel-inner { min-height: 0; overflow: hidden; } + .attachment-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 12px; } + .attachment-grid.editing { grid-template-columns: repeat(2, minmax(0, 1fr)); } .attachment-panel { border: 1px solid #dbe3f0; border-radius: 14px; padding: 12px; background: #f8fbff; } .attachment-panel.compact { padding: 10px; min-width: 0; } .attachment-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } .attachment-actions .btn { padding: 8px 12px; font-size: 13px; } - .attachment-preview { margin-top: 10px; display: flex; align-items: center; gap: 12px; min-height: 72px; } - .attachment-thumb { width: 72px; height: 72px; border-radius: 12px; border: 1px solid #dbe3f0; background: #fff; object-fit: cover; } - .attachment-empty-thumb { width: 72px; height: 72px; border-radius: 12px; border: 1px solid #dbe3f0; background: #fff; color: #94a3b8; display: flex; align-items: center; justify-content: center; font-size: 12px; text-align: center; padding: 8px; } + .attachment-preview { margin-top: 8px; display: flex; align-items: center; gap: 10px; min-height: 60px; } + .attachment-thumb { width: 60px; height: 60px; border-radius: 10px; border: 1px solid #dbe3f0; background: #fff; object-fit: cover; } + .attachment-empty-thumb { width: 60px; height: 60px; border-radius: 10px; border: 1px solid #dbe3f0; background: #fff; color: #94a3b8; display: flex; align-items: center; justify-content: center; font-size: 11px; text-align: center; padding: 6px; } .attachment-meta { min-width: 0; display: grid; gap: 4px; } .attachment-meta.compact { gap: 2px; } - .attachment-link { color: #2563eb; text-decoration: none; font-size: 13px; font-weight: 600; word-break: break-all; } + .attachment-link { color: #2563eb; text-decoration: none; font-size: 12px; font-weight: 600; word-break: break-all; } .attachment-hidden-input { display: none; } .detail-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; } .section + .section { margin-top: 14px; } @@ -78,21 +96,27 @@ @media (max-width: 1080px) { .layout { grid-template-columns: 1fr; } .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .preview-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .preview-form-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .preview-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } .attachment-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .attachment-grid.editing { grid-template-columns: repeat(2, minmax(0, 1fr)); } .profile-grid { grid-template-columns: 1fr; } } @media (max-width: 720px) { .toolbar { grid-template-columns: 1fr; } .summary-grid { grid-template-columns: 1fr; } - .preview-grid { grid-template-columns: 1fr; } + .preview-form-head { flex-direction: column; align-items: stretch; } + .preview-form-summary { grid-template-columns: 1fr; } + .preview-fields { grid-template-columns: 1fr; } .attachment-grid { grid-template-columns: 1fr; } + .attachment-grid.editing { grid-template-columns: 1fr; } table, thead, tbody, th, td, tr { display: block; } thead { display: none; } tr { border-bottom: 1px solid #e5e7eb; } td { display: flex; justify-content: space-between; gap: 10px; } td::before { content: attr(data-label); font-weight: 700; color: #475569; } } + html[data-theme="dark"] .preview-form, html[data-theme="dark"] .preview-card, html[data-theme="dark"] .attachment-panel { background: rgba(0, 0, 0, 0.88) !important; @@ -100,6 +124,9 @@ color: #e5e7eb !important; box-shadow: 0 18px 50px rgba(2, 6, 23, 0.18) !important; } + html[data-theme="dark"] .preview-summary-value { + color: #f8fafc !important; + } html[data-theme="dark"] .preview-value, html[data-theme="dark"] .attachment-link { color: #f8fafc !important; @@ -113,6 +140,16 @@ html[data-theme="dark"] .edit-panel { border-top-color: rgba(148, 163, 184, 0.22) !important; } + html[data-theme="dark"] .edit-hint.clean { + background: rgba(16, 185, 129, 0.14) !important; + color: #86efac !important; + border-color: rgba(110, 231, 183, 0.28) !important; + } + html[data-theme="dark"] .edit-hint.dirty { + background: rgba(249, 115, 22, 0.14) !important; + color: #fdba74 !important; + border-color: rgba(251, 146, 60, 0.28) !important; + } {{ template "theme_head" . }} @@ -154,6 +191,7 @@ const state = { users: [], selectedOpenid: '', + previewExpanded: false, isEditMode: false, userEditDraft: null, userLevelOptions: [], @@ -258,6 +296,40 @@ return state.userEditDraft } + function normalizeDraftSnapshot(source) { + const current = source || {} + return { + openid: normalizeText(current.openid), + users_name: normalizeText(current.users_name), + users_phone: normalizeText(current.users_phone), + users_level: normalizeText(current.users_level), + users_type: normalizeText(current.users_type), + users_status: normalizeText(current.users_status), + users_rank_level: normalizeText(current.users_rank_level), + users_auth_type: normalizeText(current.users_auth_type), + company_id: normalizeText(current.company_id), + users_tag: normalizeText(current.users_tag), + users_parent_id: normalizeText(current.users_parent_id), + users_promo_code: normalizeText(current.users_promo_code), + usergroups_id: normalizeText(current.usergroups_id), + users_id_number: normalizeText(current.users_id_number), + users_picture: normalizeText(current.users_picture), + users_id_pic_a: normalizeText(current.users_id_pic_a), + users_id_pic_b: normalizeText(current.users_id_pic_b), + users_title_picture: normalizeText(current.users_title_picture), + } + } + + function hasUnsavedChanges(user) { + if (!state.isEditMode || !user) { + return false + } + + const original = normalizeDraftSnapshot(buildUserDraft(user)) + const current = normalizeDraftSnapshot(state.userEditDraft || buildUserDraft(user)) + return JSON.stringify(original) !== JSON.stringify(current) + } + function syncEditDraftFromDom() { if (!state.isEditMode || !state.userEditDraft) { return @@ -440,6 +512,39 @@ + '' } + function renderPreviewForm(user) { + return '
' + + '
' + + '
' + + '
用户名称
' + escapeHtml(user.users_name || '-') + '
' + + '
手机号
' + escapeHtml(user.users_phone || '-') + '
' + + '
会员等级
' + escapeHtml(user.users_level_name || user.users_level || '-') + '
' + + '
用户类型
' + escapeHtml(user.users_type || '-') + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + renderPreviewField('用户状态', user.users_status) + + renderPreviewField('用户星级', user.users_rank_level) + + renderPreviewField('账户类型', user.users_auth_type) + + renderPreviewField('公司ID', user.company_id) + + renderPreviewField('用户标签', user.users_tag) + + renderPreviewField('上级用户ID', user.users_parent_id) + + renderPreviewField('推广码', user.users_promo_code) + + renderPreviewField('用户组ID', user.usergroups_id) + + renderPreviewField('证件号', user.users_id_number) + + '
' + + '
' + + renderAttachmentCard('userUsersPicture', '头像附件ID', user.users_picture || '', false) + + renderAttachmentCard('userUsersIdPicA', '证件正面附件ID', user.users_id_pic_a || '', false) + + renderAttachmentCard('userUsersIdPicB', '证件反面附件ID', user.users_id_pic_b || '', false) + + renderAttachmentCard('userUsersTitlePicture', '资质附件ID', user.users_title_picture || '', false) + + '
' + + '
' + + '
' + } + function renderUserList() { if (!state.users.length) { userListEl.innerHTML = '
暂无匹配用户。
' @@ -473,7 +578,7 @@ + '' + items.map(function (item) { return '' - + '
' + escapeHtml(item.product_name || item.cart_product_id || '-') + '
' + escapeHtml(item.cart_product_id || '-') + '
' + + '
' + escapeHtml(item.product_name || item.cart_product_business_id || item.cart_product_id || '-') + '
recordId:' + escapeHtml(item.cart_product_id || '-') + '
业务ID:' + escapeHtml(item.cart_product_business_id || '-') + '
' + '' + escapeHtml(item.product_modelnumber || '-') + '' + '' + escapeHtml(item.cart_product_quantity || 0) + '' + '¥' + escapeHtml(item.cart_at_price || 0) + '' @@ -503,35 +608,26 @@ function renderUserProfileForm(user) { const draft = state.isEditMode ? ensureEditDraft(user) : null const source = draft || user + const dirty = hasUnsavedChanges(user) - return '