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 对象格式。导入时按属性名增量合并:同名覆盖、不同名新增,不会清空当前参数表。
+
+
+
+
+
+
+
+
+
+
+
![icon]()
+
暂无图标
+
+
+
+
选择后会在保存时上传,并写入 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>|