diff --git a/docs/pb_tbl_product_list.md b/docs/pb_tbl_product_list.md new file mode 100644 index 0000000..d453215 --- /dev/null +++ b/docs/pb_tbl_product_list.md @@ -0,0 +1,57 @@ +# pb_tbl_product_list + +> 来源:产品表字段草图(用户提供)与现有 PocketBase 表设计规范 +> 类型:`base` +> 读写规则:公开可读(PocketBase `listRule = ""`、`viewRule = ""`);新增 / 修改 / 删除仅 `ManagePlatform` / 管理角色允许 + +## 表用途 + +用于存储产品主数据,承载型号、分类、方案、通讯、供电、标签、价格等信息,并为前端产品列表检索与筛选提供统一数据源。 + +## 字段清单 + +| 字段名 | 类型 | 必填 | 说明 | +| :--- | :--- | :---: | :--- | +| `id` | `text` | 是 | PocketBase 记录主键 | +| `prod_list_id` | `text` | 是 | 产品列表业务 ID,唯一标识 | +| `prod_list_name` | `text` | 是 | 产品名称 | +| `prod_list_modelnumber` | `text` | 否 | 产品型号 | +| `prod_list_icon` | `text` | 否 | 产品图标,保存 `tbl_attachments.attachments_id` | +| `prod_list_description` | `text` | 否 | 产品说明(editor 内容,建议保存 Markdown 或已净化 HTML) | +| `prod_list_feature` | `text` | 否 | 产品特色 | +| `prod_list_parameters` | `json` | 否 | 产品参数(JSON 对象/数组) | +| `prod_list_plantype` | `text` | 否 | 产品方案 | +| `prod_list_category` | `text` | 是 | 产品分类(必填,单选) | +| `prod_list_sort` | `number` | 否 | 排序值(同分类内按升序) | +| `prod_list_comm_type` | `text` | 否 | 通讯类型 | +| `prod_list_series` | `text` | 否 | 产品系列 | +| `prod_list_power_supply` | `text` | 否 | 供电方式 | +| `prod_list_tags` | `text` | 否 | 产品标签(辅助检索,以 `|` 聚合) | +| `prod_list_status` | `text` | 否 | 产品状态(有效 / 过期 / 主推等) | +| `prod_list_basic_price` | `number` | 否 | 基础价格 | +| `prod_list_remark` | `text` | 否 | 备注 | + +## 索引 + +| 索引名 | 类型 | 说明 | +| :--- | :--- | :--- | +| `idx_tbl_product_list_prod_list_id` | `UNIQUE INDEX` | 保证 `prod_list_id` 唯一 | +| `idx_tbl_product_list_prod_list_name` | `INDEX` | 加速按产品名称检索 | +| `idx_tbl_product_list_prod_list_modelnumber` | `INDEX` | 加速按产品型号检索 | +| `idx_tbl_product_list_prod_list_status` | `INDEX` | 加速按产品状态过滤 | +| `idx_tbl_product_list_prod_list_category` | `INDEX` | 加速按产品分类过滤 | +| `idx_tbl_product_list_prod_list_sort` | `INDEX` | 加速按排序值检索 | +| `idx_tbl_product_list_category_sort` | `INDEX` | 加速分类内排序计算 | +| `idx_tbl_product_list_prod_list_series` | `INDEX` | 加速按产品系列过滤 | +| `idx_tbl_product_list_prod_list_tags` | `INDEX` | 加速标签检索(模糊筛选场景) | + +## 补充约定 + +- `prod_list_icon` 仅保存附件业务 ID,真实文件统一在 `tbl_attachments`。 +- 当前预构建脚本中已将 `listRule` 与 `viewRule` 设置为空字符串(`""`),对应 PocketBase 的“任何人可查看”。 +- `prod_list_parameters` 使用 PocketBase `json` 字段,写入时应直接提交对象/数组,不建议再二次字符串化。 +- `prod_list_description` 作为 editor 内容字段,建议统一为 Markdown 或净化后的 HTML;若允许 HTML,需在写入前做 XSS 白名单过滤。 +- 前端渲染 `prod_list_description` 时应保持和存储格式一致(Markdown 渲染器或受控 HTML 渲染),避免直接注入未净化内容。 +- `prod_list_basic_price` 使用 `number`,如需货币精度策略建议后续统一为“分”或固定小数位。 +- PocketBase 系统字段 `created`、`updated` 仍然存在,只是不在 collection 字段清单里单独声明。 +- 本文档为预构建结构说明,尚未执行线上建表。 diff --git a/openspec/changes/2026-03-31-prebuild-product-list-schema/.openspec.yaml b/openspec/changes/2026-03-31-prebuild-product-list-schema/.openspec.yaml new file mode 100644 index 0000000..8fb8631 --- /dev/null +++ b/openspec/changes/2026-03-31-prebuild-product-list-schema/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-31 diff --git a/openspec/changes/2026-03-31-prebuild-product-list-schema/design.md b/openspec/changes/2026-03-31-prebuild-product-list-schema/design.md new file mode 100644 index 0000000..f9828cf --- /dev/null +++ b/openspec/changes/2026-03-31-prebuild-product-list-schema/design.md @@ -0,0 +1,42 @@ +## Overview + +本次是“预构建变更”,目标是在不改动线上数据的前提下,先形成 `tbl_product_list` 的标准资料与可执行脚本。设计基线来自你给出的字段图,并对齐现有 `tbl_document`、`tbl_system_dict` 等表的命名与索引风格。 + +## Data Model Design + +### Collection + +- 名称:`tbl_product_list` +- 类型:`base` +- 规则:默认公开可读(list/view),写操作由管理角色控制(create/update/delete) + +### Fields + +- `prod_list_id`:业务 ID,唯一 +- `prod_list_name`:产品名称 +- `prod_list_modelnumber`:产品型号 +- `prod_list_icon`:产品图标,对应 `tbl_attachments.attachments_id` +- `prod_list_description`:产品说明(富文本/编辑器内容) +- `prod_list_feature`:产品特色 +- `prod_list_parameters`:产品参数,JSON 字符串 +- `prod_list_plantype`:产品方案索引 +- `prod_list_category`:功能类别索引 +- `prod_list_comm_type`:通讯类型索引 +- `prod_list_series`:产品系列索引 +- `prod_list_power_supply`:供电方式索引 +- `prod_list_tags`:产品标签(辅助检索) +- `prod_list_status`:产品状态(有效/过期/主推等) +- `prod_list_basic_price`:基础价格 +- `prod_list_remark`:备注 + +## Index Strategy + +- 唯一索引:`prod_list_id` +- 普通索引:`prod_list_name`、`prod_list_modelnumber`、`prod_list_status`、`prod_list_category`、`prod_list_series`、`prod_list_tags` + +## Script Strategy + +- 认证方式:`POCKETBASE_AUTH_TOKEN`(与 `script/pocketbase.documents.js` 一致) +- 执行行为:幂等 create-or-update +- 校验行为:回读字段类型/必填状态与索引是否齐全 +- 风险控制:不在脚本中自动执行,仅通过 npm script 暴露手动入口 diff --git a/openspec/changes/2026-03-31-prebuild-product-list-schema/proposal.md b/openspec/changes/2026-03-31-prebuild-product-list-schema/proposal.md new file mode 100644 index 0000000..34f4009 --- /dev/null +++ b/openspec/changes/2026-03-31-prebuild-product-list-schema/proposal.md @@ -0,0 +1,27 @@ +## Why + +当前 `docs/` 中尚未覆盖产品列表实体的 PocketBase 结构定义,且你已提供了 `tbl_product_list` 字段草图。如果不先把目标结构文档化并预置建表脚本,后续接口开发会出现前后端字段口径不一致、索引策略不统一的问题。 + +## What Changes + +- 新增 `docs/pb_tbl_product_list.md`,按现有数据库文档规范沉淀字段、索引、读写规则与补充约定。 +- 新增 `script/pocketbase.product-list.js`,提供 `tbl_product_list` 的幂等建表/更新与结构校验逻辑。 +- 新增 npm script 入口 `init:product-list`,用于后续按需执行(本次不执行)。 +- 新增 OpenSpec 变更,要求产品表文档必须纳入统一的 per-collection 文档体系。 + +## Capabilities + +### Modified Capabilities + +- `pocketbase-schema-docs` + +## Implementation Strategy + +- 继续沿用现有 Node.js + npm 工具链与 PocketBase JS SDK(`pocketbase@^0.26.8`)。 +- 建表脚本复用项目已验证的 `fields` payload 写法(避免 `schema` 兼容性问题)。 +- 索引采用“业务唯一键 + 高频筛选字段普通索引”的策略,以兼容老库查询习惯。 + +## Impact + +- 受影响目录:`docs/`、`script/`、`openspec/` +- 本次只做预构建文件,不触发线上建表或数据迁移 diff --git a/openspec/changes/2026-03-31-prebuild-product-list-schema/specs/pocketbase-schema-docs/spec.md b/openspec/changes/2026-03-31-prebuild-product-list-schema/specs/pocketbase-schema-docs/spec.md new file mode 100644 index 0000000..1aa3005 --- /dev/null +++ b/openspec/changes/2026-03-31-prebuild-product-list-schema/specs/pocketbase-schema-docs/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Product list schema docs SHALL be present before rollout + +When introducing `tbl_product_list`, the project SHALL provide a dedicated per-collection database document before executing any production schema change. + +#### Scenario: Product list collection is planned + +- **WHEN** the team plans to create `tbl_product_list` +- **THEN** `docs/pb_tbl_product_list.md` SHALL exist and describe fields, indexes, access rules, and supplementary constraints + +#### Scenario: Product list doc follows unified format + +- **WHEN** a reader opens the product list schema document +- **THEN** the document SHALL follow the same standard sections used by existing `pb_tbl_*` docs diff --git a/openspec/changes/2026-03-31-prebuild-product-list-schema/tasks.md b/openspec/changes/2026-03-31-prebuild-product-list-schema/tasks.md new file mode 100644 index 0000000..a3320ea --- /dev/null +++ b/openspec/changes/2026-03-31-prebuild-product-list-schema/tasks.md @@ -0,0 +1,17 @@ +## 1. 文档预构建 + +- [x] 1.1 依据字段草图整理 `tbl_product_list` 字段清单。 +- [x] 1.2 以统一模板新增 `docs/pb_tbl_product_list.md`。 +- [x] 1.3 定义索引与补充约定(附件引用、JSON 字段、状态字段)。 + +## 2. 脚本预构建 + +- [x] 2.1 新增 `script/pocketbase.product-list.js`。 +- [x] 2.2 实现幂等创建/更新与结构校验。 +- [x] 2.3 在 `script/package.json` 增加 npm script 入口。 + +## 3. OpenSpec 记录 + +- [x] 3.1 新建 change 提案并补充设计说明。 +- [x] 3.2 为 `pocketbase-schema-docs` 增加 `tbl_product_list` 文档覆盖要求。 +- [ ] 3.3 待你确认后再执行实际建表与验收。 diff --git a/pocket-base/bai-api-main.pb.js b/pocket-base/bai-api-main.pb.js index 7e9e005..b61c1b7 100644 --- a/pocket-base/bai-api-main.pb.js +++ b/pocket-base/bai-api-main.pb.js @@ -36,6 +36,11 @@ require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/detail.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/create.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/update.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/delete.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/product/list.js`) +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/document-history/list.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/context.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/role-save.js`) diff --git a/pocket-base/bai-web-main.pb.js b/pocket-base/bai-web-main.pb.js index 1a223b1..c51b5f1 100644 --- a/pocket-base/bai-web-main.pb.js +++ b/pocket-base/bai-web-main.pb.js @@ -1,5 +1,6 @@ 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/dictionary-manage.js`) require(`${__hooks}/bai_web_pb_hooks/pages/sdk-permission-manage.js`) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/product/create.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/product/create.js new file mode 100644 index 0000000..107194b --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/product/create.js @@ -0,0 +1,25 @@ +routerAdd('POST', '/api/product/create', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const productService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/productService.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.validateProductMutationBody(e, false) + const data = productService.createProduct(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/product/delete.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/product/delete.js new file mode 100644 index 0000000..fcbc97c --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/product/delete.js @@ -0,0 +1,25 @@ +routerAdd('POST', '/api/product/delete', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const productService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/productService.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.validateProductDeleteBody(e) + const data = productService.deleteProduct(authState.openid, payload.prod_list_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/product/detail.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/product/detail.js new file mode 100644 index 0000000..9cb7c99 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/product/detail.js @@ -0,0 +1,24 @@ +routerAdd('POST', '/api/product/detail', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const productService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/productService.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.validateProductDetailBody(e) + const data = productService.getProductDetail(payload.prod_list_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/product/list.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/product/list.js new file mode 100644 index 0000000..556275f --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/product/list.js @@ -0,0 +1,26 @@ +routerAdd('POST', '/api/product/list', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const productService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/productService.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.validateProductListBody(e) + const data = productService.listProducts(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/product/update.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/product/update.js new file mode 100644 index 0000000..bc0e3f0 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/product/update.js @@ -0,0 +1,25 @@ +routerAdd('POST', '/api/product/update', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const productService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/productService.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.validateProductMutationBody(e, true) + const data = productService.updateProduct(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 afab028..621a214 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 @@ -347,6 +347,92 @@ function validateDocumentDeleteBody(e) { return validateDocumentDetailBody(e) } +function normalizeProductParameters(value) { + if (value === null || typeof value === 'undefined' || value === '') { + return {} + } + + if (typeof value !== 'object' || Array.isArray(value)) { + throw createAppError(400, 'prod_list_parameters 必须为对象') + } + + const result = {} + const keys = Object.keys(value) + + for (let i = 0; i < keys.length; i += 1) { + const key = String(keys[i] || '').trim() + if (!key) { + continue + } + + const current = value[keys[i]] + result[key] = current === null || typeof current === 'undefined' ? '' : String(current) + } + + return result +} + +function validateProductListBody(e) { + const payload = parseBody(e) + + return { + keyword: payload.keyword || '', + status: payload.status || '', + prod_list_category: payload.prod_list_category || '', + } +} + +function validateProductDetailBody(e) { + const payload = parseBody(e) + if (!payload.prod_list_id) { + throw createAppError(400, 'prod_list_id 为必填项') + } + + return { + prod_list_id: String(payload.prod_list_id || '').trim(), + } +} + +function validateProductMutationBody(e, isUpdate) { + const payload = parseBody(e) + + if (isUpdate && !payload.prod_list_id) { + throw createAppError(400, 'prod_list_id 为必填项') + } + + if (!payload.prod_list_name) { + throw createAppError(400, 'prod_list_name 为必填项') + } + + if (!payload.prod_list_category) { + throw createAppError(400, 'prod_list_category 为必填项') + } + + return { + prod_list_id: payload.prod_list_id || '', + prod_list_name: payload.prod_list_name || '', + prod_list_modelnumber: payload.prod_list_modelnumber || '', + prod_list_icon: payload.prod_list_icon || '', + prod_list_description: payload.prod_list_description || '', + prod_list_feature: payload.prod_list_feature || '', + prod_list_parameters: normalizeProductParameters(payload.prod_list_parameters), + prod_list_plantype: payload.prod_list_plantype || '', + prod_list_category: payload.prod_list_category || '', + prod_list_sort: typeof payload.prod_list_sort === 'undefined' ? 0 : payload.prod_list_sort, + prod_list_comm_type: payload.prod_list_comm_type || '', + prod_list_series: payload.prod_list_series || '', + prod_list_power_supply: payload.prod_list_power_supply || '', + prod_list_tags: payload.prod_list_tags || '', + prod_list_status: payload.prod_list_status || '', + prod_list_basic_price: typeof payload.prod_list_basic_price === 'undefined' ? '' : payload.prod_list_basic_price, + prod_list_remark: payload.prod_list_remark || '', + } +} + +function validateProductDeleteBody(e) { + return validateProductDetailBody(e) +} + function validateDocumentHistoryListBody(e) { const payload = parseBody(e) @@ -518,6 +604,10 @@ module.exports = { validateDocumentDetailBody, validateDocumentMutationBody, validateDocumentDeleteBody, + validateProductListBody, + validateProductDetailBody, + validateProductMutationBody, + validateProductDeleteBody, validateDocumentHistoryListBody, validateSdkPermissionContextBody, validateSdkPermissionRoleBody, diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/services/productService.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/productService.js new file mode 100644 index 0000000..aabac2f --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/productService.js @@ -0,0 +1,474 @@ +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`) + +function buildBusinessId(prefix) { + return prefix + '-' + new Date().getTime() + '-' + $security.randomString(6) +} + +function normalizeText(value) { + return String(value || '').replace(/^\s+|\s+$/g, '') +} + +function normalizeOptionalNumberValue(value, fieldName) { + if (value === '' || value === null || typeof value === 'undefined') { + return null + } + + const num = Number(value) + if (!Number.isFinite(num)) { + throw createAppError(400, fieldName + ' 必须为数字') + } + + return num +} + +function normalizeSortValue(value) { + if (value === '' || value === null || typeof value === 'undefined') { + return 0 + } + + const num = Number(value) + if (!Number.isFinite(num)) { + throw createAppError(400, 'prod_list_sort 必须为数字') + } + + return Math.floor(num) +} + +function normalizePipeValues(value) { + return String(value || '') + .split('|') + .map(function (item) { + return normalizeText(item) + }) + .filter(function (item) { + return !!item + }) +} + +function joinUniquePipeValues(value) { + const source = Array.isArray(value) ? value : normalizePipeValues(value) + const result = [] + + for (let i = 0; i < source.length; i += 1) { + const current = normalizeText(source[i]) + if (!current || result.indexOf(current) !== -1) { + continue + } + result.push(current) + } + + return result.join('|') +} + +function normalizeRequiredCategory(value) { + const values = normalizePipeValues(value) + if (!values.length) { + throw createAppError(400, 'prod_list_category 为必填项') + } + return values[0] +} + +function buildCategoryRankMap(records) { + const grouped = {} + for (let i = 0; i < records.length; i += 1) { + const category = normalizeText(records[i].prod_list_category) + if (!category) { + continue + } + if (!grouped[category]) { + grouped[category] = [] + } + grouped[category].push(records[i]) + } + + const rankMap = {} + const categories = Object.keys(grouped) + for (let i = 0; i < categories.length; i += 1) { + const category = categories[i] + const items = grouped[category] + items.sort(function (a, b) { + const sortDiff = Number(a.prod_list_sort || 0) - Number(b.prod_list_sort || 0) + if (sortDiff !== 0) { + return sortDiff + } + + const updateDiff = String(b.updated || '').localeCompare(String(a.updated || '')) + if (updateDiff !== 0) { + return updateDiff + } + + return String(a.prod_list_id || '').localeCompare(String(b.prod_list_id || '')) + }) + + for (let j = 0; j < items.length; j += 1) { + rankMap[items[j].prod_list_id] = j + 1 + } + } + + return rankMap +} + +function normalizeParameters(value) { + if (value === null || typeof value === 'undefined' || value === '') { + return {} + } + + if (typeof value !== 'object' || Array.isArray(value)) { + throw createAppError(400, 'prod_list_parameters 必须是对象') + } + + const result = {} + const keys = Object.keys(value) + for (let i = 0; i < keys.length; i += 1) { + const key = normalizeText(keys[i]) + if (!key) { + continue + } + + const current = value[keys[i]] + if (current === null || typeof current === 'undefined') { + result[key] = '' + continue + } + + result[key] = String(current) + } + + return result +} + +function normalizeParametersForOutput(value) { + if (value === null || typeof value === 'undefined' || value === '') { + return {} + } + + let source = value + if (typeof source === 'string') { + try { + source = JSON.parse(source) + } catch (_error) { + const raw = normalizeText(source) + if (raw.indexOf('map[') === 0 && raw.endsWith(']')) { + const body = raw.slice(4, -1).trim() + if (!body) { + return {} + } + const result = {} + const pairs = body.split(/\s+/) + for (let i = 0; i < pairs.length; i += 1) { + const pair = pairs[i] + const separatorIndex = pair.indexOf(':') + if (separatorIndex <= 0) { + continue + } + const key = normalizeText(pair.slice(0, separatorIndex)) + if (!key) { + continue + } + const val = pair.slice(separatorIndex + 1) + result[key] = val === null || typeof val === 'undefined' ? '' : String(val) + } + return result + } + return {} + } + } + + if (Array.isArray(source)) { + const mapped = {} + for (let i = 0; i < source.length; i += 1) { + const item = source[i] && typeof source[i] === 'object' ? source[i] : null + if (!item) { + continue + } + const key = normalizeText(item.key) + if (!key) { + continue + } + mapped[key] = item.value === null || typeof item.value === 'undefined' ? '' : String(item.value) + } + return mapped + } + + if (typeof source !== 'object') { + return {} + } + + // Some PocketBase/Goja map-like values are not directly enumerable; roundtrip to plain object. + try { + source = JSON.parse(JSON.stringify(source)) + } catch (_error) {} + + const result = {} + const keys = Object.keys(source) + for (let i = 0; i < keys.length; i += 1) { + const key = normalizeText(keys[i]) + if (!key) { + continue + } + const current = source[keys[i]] + result[key] = current === null || typeof current === 'undefined' ? '' : String(current) + } + + return result +} + +function ensureAttachmentExists(attachmentId, fieldName) { + const value = normalizeText(attachmentId) + if (!value) { + return + } + + try { + documentService.getAttachmentDetail(value) + } catch (_err) { + throw createAppError(400, fieldName + ' 对应附件不存在:' + value) + } +} + +function findProductRecordByBusinessId(productId) { + const records = $app.findRecordsByFilter('tbl_product_list', 'prod_list_id = {:productId}', '', 1, 0, { + productId: productId, + }) + + return records.length ? records[0] : null +} + +function exportProductRecord(record, extra) { + const iconId = record.getString('prod_list_icon') + let iconAttachment = null + + if (iconId) { + try { + iconAttachment = documentService.getAttachmentDetail(iconId) + } catch (_error) { + iconAttachment = null + } + } + + const parametersText = normalizeText(record.getString('prod_list_parameters')) + const parametersFromText = parametersText ? normalizeParametersForOutput(parametersText) : {} + const parametersRaw = record.get('prod_list_parameters') + const parametersFromRaw = normalizeParametersForOutput(parametersRaw) + const parameters = Object.keys(parametersFromText).length ? parametersFromText : parametersFromRaw + + return { + pb_id: record.id, + prod_list_id: record.getString('prod_list_id'), + prod_list_name: record.getString('prod_list_name'), + prod_list_modelnumber: record.getString('prod_list_modelnumber'), + prod_list_icon: iconId, + prod_list_icon_attachment: iconAttachment, + prod_list_icon_url: iconAttachment ? iconAttachment.attachments_url : '', + prod_list_description: record.getString('prod_list_description'), + prod_list_feature: record.getString('prod_list_feature'), + prod_list_parameters: parameters, + prod_list_plantype: record.getString('prod_list_plantype'), + prod_list_category: record.getString('prod_list_category'), + prod_list_sort: Number(record.get('prod_list_sort') || 0), + prod_list_category_rank: extra && extra.categoryRank ? Number(extra.categoryRank) : 0, + prod_list_comm_type: record.getString('prod_list_comm_type'), + prod_list_series: record.getString('prod_list_series'), + prod_list_power_supply: record.getString('prod_list_power_supply'), + prod_list_tags: record.getString('prod_list_tags'), + prod_list_basic_price: record.get('prod_list_basic_price'), + prod_list_status: record.getString('prod_list_status'), + prod_list_remark: record.getString('prod_list_remark'), + created: String(record.created || ''), + updated: String(record.updated || ''), + } +} + +function listProducts(payload) { + const allRecords = $app.findRecordsByFilter('tbl_product_list', '', '', 500, 0) + const keyword = normalizeText(payload.keyword).toLowerCase() + const status = normalizeText(payload.status) + const category = normalizeText(payload.prod_list_category) + const result = [] + + const allItems = [] + for (let i = 0; i < allRecords.length; i += 1) { + allItems.push(exportProductRecord(allRecords[i])) + } + + const rankMap = buildCategoryRankMap(allItems) + + for (let i = 0; i < allItems.length; i += 1) { + const source = allItems[i] + const item = Object.assign({}, source, { + prod_list_category_rank: rankMap[source.prod_list_id] || 0, + }) + const matchedKeyword = !keyword + || item.prod_list_id.toLowerCase().indexOf(keyword) !== -1 + || item.prod_list_name.toLowerCase().indexOf(keyword) !== -1 + || item.prod_list_modelnumber.toLowerCase().indexOf(keyword) !== -1 + || item.prod_list_tags.toLowerCase().indexOf(keyword) !== -1 + const matchedStatus = !status || item.prod_list_status === status + const matchedCategory = !category || item.prod_list_category === category + + if (matchedKeyword && matchedStatus && matchedCategory) { + result.push(item) + } + } + + result.sort(function (a, b) { + return String(b.updated || '').localeCompare(String(a.updated || '')) + }) + + return result +} + +function getProductDetail(productId) { + const record = findProductRecordByBusinessId(productId) + if (!record) { + throw createAppError(404, '未找到对应产品') + } + + const allRecords = $app.findRecordsByFilter('tbl_product_list', '', '', 500, 0) + const allItems = [] + for (let i = 0; i < allRecords.length; i += 1) { + allItems.push(exportProductRecord(allRecords[i])) + } + const rankMap = buildCategoryRankMap(allItems) + + return exportProductRecord(record, { + categoryRank: rankMap[record.getString('prod_list_id')] || 0, + }) +} + +function createProduct(_userOpenid, payload) { + const targetProductId = normalizeText(payload.prod_list_id) || buildBusinessId('PROD') + const duplicated = findProductRecordByBusinessId(targetProductId) + if (duplicated) { + throw createAppError(400, 'prod_list_id 已存在') + } + + ensureAttachmentExists(payload.prod_list_icon, 'prod_list_icon') + + const collection = $app.findCollectionByNameOrId('tbl_product_list') + const record = new Record(collection) + + record.set('prod_list_id', targetProductId) + record.set('prod_list_name', normalizeText(payload.prod_list_name)) + record.set('prod_list_modelnumber', normalizeText(payload.prod_list_modelnumber)) + record.set('prod_list_icon', normalizeText(payload.prod_list_icon)) + record.set('prod_list_description', normalizeText(payload.prod_list_description)) + record.set('prod_list_feature', normalizeText(payload.prod_list_feature)) + record.set('prod_list_parameters', normalizeParameters(payload.prod_list_parameters)) + record.set('prod_list_plantype', normalizeText(payload.prod_list_plantype)) + record.set('prod_list_category', normalizeRequiredCategory(payload.prod_list_category)) + record.set('prod_list_sort', normalizeSortValue(payload.prod_list_sort)) + record.set('prod_list_comm_type', normalizeText(payload.prod_list_comm_type)) + record.set('prod_list_series', normalizeText(payload.prod_list_series)) + record.set('prod_list_power_supply', normalizeText(payload.prod_list_power_supply)) + record.set('prod_list_tags', joinUniquePipeValues(payload.prod_list_tags)) + record.set('prod_list_status', normalizeText(payload.prod_list_status)) + record.set('prod_list_basic_price', normalizeOptionalNumberValue(payload.prod_list_basic_price, 'prod_list_basic_price')) + record.set('prod_list_remark', normalizeText(payload.prod_list_remark)) + + try { + $app.save(record) + } catch (err) { + throw createAppError(400, '新增产品失败', { + originalMessage: (err && err.message) || '未知错误', + originalData: (err && err.data) || {}, + }) + } + + logger.info('产品创建成功', { + prod_list_id: record.getString('prod_list_id'), + }) + + const rankMap = buildCategoryRankMap(listProducts({})) + return exportProductRecord(record, { + categoryRank: rankMap[record.getString('prod_list_id')] || 0, + }) +} + +function updateProduct(_userOpenid, payload) { + const targetId = normalizeText(payload.prod_list_id) + if (!targetId) { + throw createAppError(400, 'prod_list_id 为必填项') + } + + const record = findProductRecordByBusinessId(targetId) + if (!record) { + throw createAppError(404, '未找到待修改的产品') + } + + ensureAttachmentExists(payload.prod_list_icon, 'prod_list_icon') + + record.set('prod_list_name', normalizeText(payload.prod_list_name)) + record.set('prod_list_modelnumber', normalizeText(payload.prod_list_modelnumber)) + record.set('prod_list_icon', normalizeText(payload.prod_list_icon)) + record.set('prod_list_description', normalizeText(payload.prod_list_description)) + record.set('prod_list_feature', normalizeText(payload.prod_list_feature)) + record.set('prod_list_parameters', normalizeParameters(payload.prod_list_parameters)) + record.set('prod_list_plantype', normalizeText(payload.prod_list_plantype)) + record.set('prod_list_category', normalizeRequiredCategory(payload.prod_list_category)) + record.set('prod_list_sort', normalizeSortValue(payload.prod_list_sort)) + record.set('prod_list_comm_type', normalizeText(payload.prod_list_comm_type)) + record.set('prod_list_series', normalizeText(payload.prod_list_series)) + record.set('prod_list_power_supply', normalizeText(payload.prod_list_power_supply)) + record.set('prod_list_tags', joinUniquePipeValues(payload.prod_list_tags)) + record.set('prod_list_status', normalizeText(payload.prod_list_status)) + record.set('prod_list_basic_price', normalizeOptionalNumberValue(payload.prod_list_basic_price, 'prod_list_basic_price')) + record.set('prod_list_remark', normalizeText(payload.prod_list_remark)) + + try { + $app.save(record) + } catch (err) { + throw createAppError(400, '更新产品失败', { + originalMessage: (err && err.message) || '未知错误', + originalData: (err && err.data) || {}, + }) + } + + logger.info('产品更新成功', { + prod_list_id: targetId, + }) + + const rankMap = buildCategoryRankMap(listProducts({})) + return exportProductRecord(record, { + categoryRank: rankMap[record.getString('prod_list_id')] || 0, + }) +} + +function deleteProduct(_userOpenid, productId) { + const targetId = normalizeText(productId) + if (!targetId) { + throw createAppError(400, 'prod_list_id 为必填项') + } + + const record = findProductRecordByBusinessId(targetId) + if (!record) { + throw createAppError(404, '未找到待删除的产品') + } + + try { + $app.delete(record) + } catch (err) { + throw createAppError(400, '删除产品失败', { + originalMessage: (err && err.message) || '未知错误', + originalData: (err && err.data) || {}, + }) + } + + logger.info('产品删除成功', { + prod_list_id: targetId, + }) + + return { + prod_list_id: targetId, + } +} + +module.exports = { + listProducts, + getProductDetail, + createProduct, + updateProduct, + deleteProduct, +} diff --git a/pocket-base/bai_web_pb_hooks/pages/index.js b/pocket-base/bai_web_pb_hooks/pages/index.js index 45e2f59..3db9d5e 100644 --- a/pocket-base/bai_web_pb_hooks/pages/index.js +++ b/pocket-base/bai_web_pb_hooks/pages/index.js @@ -19,11 +19,14 @@ routerAdd('GET', '/manage', function (e) { .wrap { max-width: 1440px; margin: 0 auto; padding: 24px 14px; } .hero { background: #ffffff; border-radius: 20px; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); padding: 22px; border: 1px solid #e5e7eb; } h1 { margin: 0 0 14px; font-size: 30px; } - .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; } + .grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } .card { background: #f8fafc; border: 1px solid #dbe3f0; border-radius: 16px; padding: 16px; text-align: center; } .card h2 { margin: 0 0 8px; font-size: 19px; } .btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 16px; border-radius: 12px; text-decoration: none; background: #2563eb; color: #fff; font-weight: 600; margin-top: 12px; } .actions { margin-top: 14px; display: flex; justify-content: flex-start; } + @media (max-width: 960px) { + .grid { grid-template-columns: 1fr; } + } @@ -39,6 +42,10 @@ routerAdd('GET', '/manage', function (e) {

文档管理

进入文档管理 +
+

产品管理

+ 进入产品管理 +

SDK 权限管理

进入权限管理 diff --git a/pocket-base/bai_web_pb_hooks/pages/product-manage.js b/pocket-base/bai_web_pb_hooks/pages/product-manage.js new file mode 100644 index 0000000..45eb205 --- /dev/null +++ b/pocket-base/bai_web_pb_hooks/pages/product-manage.js @@ -0,0 +1,1403 @@ +routerAdd('GET', '/manage/product-manage', function (e) { + const html = ` + + + + + 产品管理 + + + + +
+
+

产品管理

+
+ 返回主页 + + + +
+
+
+ +
+
+ + + + +
+
+ +
+
+

新增产品

+
当前模式:新建
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
请先选择产品分类后查看当前分类内排序位次。
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ + +
+
+ +
+
+
+ + +
+
+
+
+
+ +
+ + + + + + + + + +
属性名属性值操作
+
+
+ + +
仅支持 JSON 对象格式。导入时按属性名增量合并:同名覆盖、不同名新增,不会清空当前参数表。
+
+
+ + +
+
+
+ +
+
+ +
暂无图标
+
+
+ +
选择后会在保存时上传,并写入 prod_list_icon。
+
+ +
+
+
+
+
+ + +
+
+
+ + + +
+
+
+ +
+

产品列表

+
+ + + + + + + + + + + + + + +
名称/型号分类信息标签状态/价格更新时间操作
暂无数据,请先查询。
+
+
+
+ +
+
+
+
处理中,请稍候...
+
+
+ + + +` + + return e.html(200, html) +}) diff --git a/pocket-base/spec/openapi-wx.yaml b/pocket-base/spec/openapi-wx.yaml index 039bd76..43c207a 100644 --- a/pocket-base/spec/openapi-wx.yaml +++ b/pocket-base/spec/openapi-wx.yaml @@ -22,6 +22,8 @@ tags: description: 通过 PocketBase 原生 records API 访问 `tbl_company` - name: 附件信息 description: 通过 PocketBase 原生 records API 访问 `tbl_attachments` + - name: 产品信息 + description: 通过 PocketBase 原生 records API 访问 `tbl_product_list` - name: 文档信息 description: 通过 PocketBase 原生 records API 访问 `tbl_document` paths: @@ -645,6 +647,119 @@ paths: application/json: schema: $ref: '#/components/schemas/PocketBaseNativeError' + /pb/api/collections/tbl_product_list/records: + get: + operationId: getPocketBaseProductListRecords + tags: + - 产品信息 + summary: 根据产品分类精确筛选并按分类排序值升序返回产品列表 + description: | + 使用 PocketBase 原生 records list 接口查询 `tbl_product_list`。 + + 当前接口约定: + - 条件:按 `prod_list_category` 精确匹配筛选 + - 排序:按 `prod_list_sort` 从小到大排序 + + 标准调用参数建议: + - `filter=prod_list_category="<产品分类>"` + - `sort=prod_list_sort` + + 注意: + - 这是 PocketBase 原生返回结构,不是 hooks 统一 `{ statusCode, errMsg, data }` 包装 + - 若不传 `sort`,将由 PocketBase 默认排序策略决定返回顺序 + parameters: + - name: filter + in: query + required: true + description: | + PocketBase 标准过滤表达式,当前要求按产品分类精确值筛选。 + + 推荐写法:`prod_list_category="<产品分类>"` + schema: + type: string + example: prod_list_category="<产品分类>" + - name: page + in: query + required: false + description: 页码 + schema: + type: integer + minimum: 1 + default: 1 + - name: perPage + in: query + required: false + description: 每页条数 + schema: + type: integer + minimum: 1 + default: 20 + - name: sort + in: query + required: false + description: | + PocketBase 原生排序表达式。 + + 当前要求使用: + - `prod_list_sort`:按分类排序值从小到大 + schema: + type: string + example: prod_list_sort + responses: + '200': + description: 查询成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PocketBaseProductListListResponse' + examples: + byCategoryAscSort: + value: + page: <页码>| + perPage: <每页条数>| + totalItems: <总记录数>| + totalPages: <总页数>| + items: + - id: | + collectionId: <集合ID>| + collectionName: <集合名称>| + created: <记录创建时间>| + updated: <记录更新时间>| + prod_list_id: <产品列表业务ID>| + prod_list_name: <产品名称>| + prod_list_modelnumber: <产品型号>| + prod_list_icon: <产品图标附件ID>| + prod_list_description: <产品说明>| + prod_list_feature: <产品特色>| + prod_list_parameters: <产品参数JSON>| + prod_list_plantype: <产品方案>| + prod_list_category: <产品分类>| + prod_list_sort: <排序值>| + prod_list_comm_type: <通讯类型>| + prod_list_series: <产品系列>| + prod_list_power_supply: <供电方式>| + prod_list_tags: <产品标签>| + prod_list_status: <产品状态>| + prod_list_basic_price: <基础价格>| + prod_list_remark: <备注>| + '400': + description: 查询参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/PocketBaseNativeError' + '403': + description: 集合规则被锁定或服务端权限设置异常 + content: + application/json: + schema: + $ref: '#/components/schemas/PocketBaseNativeError' + '500': + description: 服务端错误 + content: + application/json: + schema: + $ref: '#/components/schemas/PocketBaseNativeError' /pb/api/collections/tbl_document/records: get: operationId: getPocketBaseDocumentRecords @@ -1485,6 +1600,173 @@ components: attachments_ocr: OCR识别结果 | string attachments_status: 附件状态 | string attachments_remark: 备注 | string + PocketBaseProductListFields: + type: object + properties: + prod_list_id: + type: string + description: 产品列表业务 ID,唯一标识 + example: <产品列表业务ID>| + prod_list_name: + type: string + description: 产品名称 + example: <产品名称>| + prod_list_modelnumber: + type: string + description: 产品型号 + example: <产品型号>| + prod_list_icon: + type: string + description: 产品图标附件 ID,保存 `tbl_attachments.attachments_id` + example: <产品图标附件ID>| + prod_list_description: + type: string + description: 产品说明 + example: <产品说明>| + prod_list_feature: + type: string + description: 产品特色 + example: <产品特色>| + prod_list_parameters: + type: object + additionalProperties: true + description: 产品参数(JSON 对象/数组) + example: + key: <参数值>| + prod_list_plantype: + type: string + description: 产品方案 + example: <产品方案>| + prod_list_category: + type: string + description: 产品分类(必填,单选) + example: <产品分类>| + prod_list_sort: + type: + - number + - integer + description: 排序值(同分类内按升序) + example: <排序值>| + prod_list_comm_type: + type: string + description: 通讯类型 + example: <通讯类型>| + prod_list_series: + type: string + description: 产品系列 + example: <产品系列>| + prod_list_power_supply: + type: string + description: 供电方式 + example: <供电方式>| + prod_list_tags: + type: string + description: 产品标签(辅助检索,以 `|` 聚合) + example: <产品标签>| + prod_list_status: + type: string + description: 产品状态(有效 / 过期 / 主推等) + example: <产品状态>| + prod_list_basic_price: + type: + - number + - integer + description: 基础价格 + example: <基础价格>| + prod_list_remark: + type: string + description: 备注 + example: <备注>| + PocketBaseProductListRecord: + allOf: + - $ref: '#/components/schemas/PocketBaseRecordBase' + - $ref: '#/components/schemas/PocketBaseProductListFields' + example: + id: | + collectionId: <集合ID>| + collectionName: <集合名称>| + created: <记录创建时间>| + updated: <记录更新时间>| + prod_list_id: <产品列表业务ID>| + prod_list_name: <产品名称>| + prod_list_modelnumber: <产品型号>| + prod_list_icon: <产品图标附件ID>| + prod_list_description: <产品说明>| + prod_list_feature: <产品特色>| + prod_list_parameters: + key: <参数值>| + prod_list_plantype: <产品方案>| + prod_list_category: <产品分类>| + prod_list_sort: <排序值>| + prod_list_comm_type: <通讯类型>| + prod_list_series: <产品系列>| + prod_list_power_supply: <供电方式>| + prod_list_tags: <产品标签>| + prod_list_status: <产品状态>| + prod_list_basic_price: <基础价格>| + prod_list_remark: <备注>| + PocketBaseProductListListResponse: + type: object + required: + - page + - perPage + - totalItems + - totalPages + - items + properties: + page: + type: + - integer + - string + example: <页码>| + perPage: + type: + - integer + - string + example: <每页条数>| + totalItems: + type: + - integer + - string + example: <总记录数>| + totalPages: + type: + - integer + - string + example: <总页数>| + items: + type: array + items: + $ref: '#/components/schemas/PocketBaseProductListRecord' + example: + page: <页码>| + perPage: <每页条数>| + totalItems: <总记录数>| + totalPages: <总页数>| + items: + - id: | + collectionId: <集合ID>| + collectionName: <集合名称>| + created: <记录创建时间>| + updated: <记录更新时间>| + prod_list_id: <产品列表业务ID>| + prod_list_name: <产品名称>| + prod_list_modelnumber: <产品型号>| + prod_list_icon: <产品图标附件ID>| + prod_list_description: <产品说明>| + prod_list_feature: <产品特色>| + prod_list_parameters: + key: <参数值>| + prod_list_plantype: <产品方案>| + prod_list_category: <产品分类>| + prod_list_sort: <排序值>| + prod_list_comm_type: <通讯类型>| + prod_list_series: <产品系列>| + prod_list_power_supply: <供电方式>| + prod_list_tags: <产品标签>| + prod_list_status: <产品状态>| + prod_list_basic_price: <基础价格>| + prod_list_remark: <备注>| PocketBaseDocumentFields: type: object properties: diff --git a/script/package.json b/script/package.json index 2e3acc7..2efa242 100644 --- a/script/package.json +++ b/script/package.json @@ -7,6 +7,7 @@ "test": "echo \"Error: no test specified\" && exit 1", "init:newpb": "node pocketbase.newpb.js", "init:documents": "node pocketbase.documents.js", + "init:product-list": "node pocketbase.product-list.js", "init:dictionary": "node pocketbase.dictionary.js", "migrate:file-fields": "node pocketbase.file-fields-to-attachments.js", "test:company-native-api": "node test-tbl-company-native-api.js", diff --git a/script/pocketbase.product-list.js b/script/pocketbase.product-list.js new file mode 100644 index 0000000..a4bdbc5 --- /dev/null +++ b/script/pocketbase.product-list.js @@ -0,0 +1,257 @@ +import { createRequire } from 'module'; +import PocketBase from 'pocketbase'; + +const require = createRequire(import.meta.url); + +let runtimeConfig = {}; +try { + runtimeConfig = require('../pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.js'); +} catch (_error) { + runtimeConfig = {}; +} + +const PB_URL = (process.env.PB_URL || 'https://bai-api.blv-oa.com/pb').replace(/\/+$/, ''); +const AUTH_TOKEN = process.env.POCKETBASE_AUTH_TOKEN || runtimeConfig.POCKETBASE_AUTH_TOKEN || ''; + +if (!AUTH_TOKEN) { + console.error('❌ 缺少 POCKETBASE_AUTH_TOKEN,无法执行建表。'); + process.exit(1); +} + +const pb = new PocketBase(PB_URL); + +const collections = [ + { + name: 'tbl_product_list', + type: 'base', + // Empty rules in PocketBase mean public read access. + listRule: '', + viewRule: '', + createRule: '(@request.auth.users_idtype = "ManagePlatform" || @request.auth.usergroups_id = "ROLE-1774666070666-9dDrTB")', + updateRule: '(@request.auth.users_idtype = "ManagePlatform" || @request.auth.usergroups_id = "ROLE-1774666070666-9dDrTB")', + deleteRule: '(@request.auth.users_idtype = "ManagePlatform" || @request.auth.usergroups_id = "ROLE-1774666070666-9dDrTB")', + fields: [ + { name: 'prod_list_id', type: 'text', required: true }, + { name: 'prod_list_name', type: 'text', required: true }, + { name: 'prod_list_modelnumber', type: 'text' }, + { name: 'prod_list_icon', type: 'text' }, + { name: 'prod_list_description', type: 'text' }, + { name: 'prod_list_feature', type: 'text' }, + { name: 'prod_list_parameters', type: 'json' }, + { name: 'prod_list_plantype', type: 'text' }, + { name: 'prod_list_category', type: 'text', required: true }, + { name: 'prod_list_sort', type: 'number' }, + { name: 'prod_list_comm_type', type: 'text' }, + { name: 'prod_list_series', type: 'text' }, + { name: 'prod_list_power_supply', type: 'text' }, + { name: 'prod_list_tags', type: 'text' }, + { name: 'prod_list_status', type: 'text' }, + { name: 'prod_list_basic_price', type: 'number' }, + { name: 'prod_list_remark', type: 'text' }, + ], + indexes: [ + 'CREATE UNIQUE INDEX idx_tbl_product_list_prod_list_id ON tbl_product_list (prod_list_id)', + 'CREATE INDEX idx_tbl_product_list_prod_list_name ON tbl_product_list (prod_list_name)', + 'CREATE INDEX idx_tbl_product_list_prod_list_modelnumber ON tbl_product_list (prod_list_modelnumber)', + 'CREATE INDEX idx_tbl_product_list_prod_list_status ON tbl_product_list (prod_list_status)', + 'CREATE INDEX idx_tbl_product_list_prod_list_category ON tbl_product_list (prod_list_category)', + 'CREATE INDEX idx_tbl_product_list_prod_list_sort ON tbl_product_list (prod_list_sort)', + 'CREATE INDEX idx_tbl_product_list_category_sort ON tbl_product_list (prod_list_category, prod_list_sort)', + 'CREATE INDEX idx_tbl_product_list_prod_list_series ON tbl_product_list (prod_list_series)', + 'CREATE INDEX idx_tbl_product_list_prod_list_tags ON tbl_product_list (prod_list_tags)', + ], + }, +]; + +function normalizeFieldPayload(field, existingField) { + const payload = existingField + ? Object.assign({}, existingField) + : { + name: field.name, + type: field.type, + }; + + if (existingField && existingField.id) { + payload.id = existingField.id; + } + + payload.name = field.name; + payload.type = field.type; + + if (typeof field.required !== 'undefined') { + payload.required = field.required; + } + + if (field.type === 'autodate') { + payload.onCreate = typeof field.onCreate === 'boolean' ? field.onCreate : true; + payload.onUpdate = typeof field.onUpdate === 'boolean' ? field.onUpdate : false; + } + + return payload; +} + +function buildCollectionPayload(collectionData, existingCollection) { + if (!existingCollection) { + return { + name: collectionData.name, + type: collectionData.type, + listRule: Object.prototype.hasOwnProperty.call(collectionData, 'listRule') ? collectionData.listRule : null, + viewRule: Object.prototype.hasOwnProperty.call(collectionData, 'viewRule') ? collectionData.viewRule : null, + createRule: Object.prototype.hasOwnProperty.call(collectionData, 'createRule') ? collectionData.createRule : null, + updateRule: Object.prototype.hasOwnProperty.call(collectionData, 'updateRule') ? collectionData.updateRule : null, + deleteRule: Object.prototype.hasOwnProperty.call(collectionData, 'deleteRule') ? collectionData.deleteRule : null, + fields: collectionData.fields.map((field) => normalizeFieldPayload(field, null)), + indexes: collectionData.indexes, + }; + } + + const targetFieldMap = new Map(collectionData.fields.map((field) => [field.name, field])); + const fields = (existingCollection.fields || []).map((existingField) => { + const targetField = targetFieldMap.get(existingField.name); + if (!targetField) { + return existingField; + } + + targetFieldMap.delete(existingField.name); + return normalizeFieldPayload(targetField, existingField); + }); + + for (const field of targetFieldMap.values()) { + fields.push(normalizeFieldPayload(field, null)); + } + + return { + name: collectionData.name, + type: collectionData.type, + listRule: Object.prototype.hasOwnProperty.call(collectionData, 'listRule') ? collectionData.listRule : existingCollection.listRule, + viewRule: Object.prototype.hasOwnProperty.call(collectionData, 'viewRule') ? collectionData.viewRule : existingCollection.viewRule, + createRule: Object.prototype.hasOwnProperty.call(collectionData, 'createRule') ? collectionData.createRule : existingCollection.createRule, + updateRule: Object.prototype.hasOwnProperty.call(collectionData, 'updateRule') ? collectionData.updateRule : existingCollection.updateRule, + deleteRule: Object.prototype.hasOwnProperty.call(collectionData, 'deleteRule') ? collectionData.deleteRule : existingCollection.deleteRule, + fields: fields, + indexes: collectionData.indexes, + }; +} + +function normalizeFieldList(fields) { + return (fields || []).map((field) => ({ + name: field.name, + type: field.type, + required: !!field.required, + })); +} + +async function createOrUpdateCollection(collectionData) { + console.log(`🔄 正在处理表: ${collectionData.name} ...`); + + try { + const list = await pb.collections.getFullList({ + sort: '-created', + }); + const existing = list.find((item) => item.name === collectionData.name); + + if (existing) { + await pb.collections.update(existing.id, buildCollectionPayload(collectionData, existing)); + console.log(`♻️ ${collectionData.name} 已存在,已按最新结构更新。`); + return; + } + + await pb.collections.create(buildCollectionPayload(collectionData, null)); + console.log(`✅ ${collectionData.name} 创建完成。`); + } catch (error) { + console.error(`❌ 处理集合 ${collectionData.name} 失败:`, { + status: error.status, + message: error.message, + response: error.response, + }); + throw error; + } +} + +async function getCollectionByName(collectionName) { + const list = await pb.collections.getFullList({ + sort: '-created', + }); + return list.find((item) => item.name === collectionName) || null; +} + +async function verifyCollections(targetCollections) { + console.log('\n🔍 开始校验产品表结构与索引...'); + + for (const target of targetCollections) { + const remote = await getCollectionByName(target.name); + if (!remote) { + throw new Error(`${target.name} 不存在`); + } + const remoteFields = normalizeFieldList(remote.fields); + const targetFields = normalizeFieldList(target.fields); + const remoteFieldMap = new Map(remoteFields.map((field) => [field.name, field.type])); + const remoteRequiredMap = new Map(remoteFields.map((field) => [field.name, field.required])); + const missingFields = []; + const mismatchedTypes = []; + const mismatchedRequired = []; + + for (const field of targetFields) { + if (!remoteFieldMap.has(field.name)) { + missingFields.push(field.name); + continue; + } + + if (remoteFieldMap.get(field.name) !== field.type) { + mismatchedTypes.push(`${field.name}:${remoteFieldMap.get(field.name)}!=${field.type}`); + } + + if (remoteRequiredMap.get(field.name) !== !!field.required) { + mismatchedRequired.push(`${field.name}:${remoteRequiredMap.get(field.name)}!=${!!field.required}`); + } + } + + const remoteIndexes = new Set(remote.indexes || []); + const missingIndexes = target.indexes.filter((indexSql) => !remoteIndexes.has(indexSql)); + + if (remote.type !== target.type) { + throw new Error(`${target.name} 类型不匹配,期望 ${target.type},实际 ${remote.type}`); + } + + if (!missingFields.length && !mismatchedTypes.length && !mismatchedRequired.length && !missingIndexes.length) { + console.log(`✅ ${target.name} 校验通过。`); + continue; + } + + console.log(`❌ ${target.name} 校验失败:`); + if (missingFields.length) { + console.log(` - 缺失字段: ${missingFields.join(', ')}`); + } + if (mismatchedTypes.length) { + console.log(` - 字段类型不匹配: ${mismatchedTypes.join(', ')}`); + } + if (mismatchedRequired.length) { + console.log(` - 字段必填属性不匹配: ${mismatchedRequired.join(', ')}`); + } + if (missingIndexes.length) { + console.log(` - 缺失索引: ${missingIndexes.join(' | ')}`); + } + + throw new Error(`${target.name} 结构与预期不一致`); + } +} + +async function init() { + try { + console.log(`🔄 正在连接 PocketBase: ${PB_URL}`); + pb.authStore.save(AUTH_TOKEN, null); + console.log('✅ 已使用 POCKETBASE_AUTH_TOKEN 载入认证状态。'); + + for (const collectionData of collections) { + await createOrUpdateCollection(collectionData); + } + + await verifyCollections(collections); + console.log('\n🎉 产品表结构初始化并校验完成!'); + } catch (error) { + console.error('❌ 初始化失败:', error.response?.data || error.message); + process.exitCode = 1; + } +} + +init();