feat: 添加产品列表集合初始化脚本,包含字段定义、索引创建及校验逻辑
This commit is contained in:
57
docs/pb_tbl_product_list.md
Normal file
57
docs/pb_tbl_product_list.md
Normal file
@@ -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 字段清单里单独声明。
|
||||
- 本文档为预构建结构说明,尚未执行线上建表。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-31
|
||||
@@ -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 暴露手动入口
|
||||
@@ -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/`
|
||||
- 本次只做预构建文件,不触发线上建表或数据迁移
|
||||
@@ -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
|
||||
@@ -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 待你确认后再执行实际建表与验收。
|
||||
@@ -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`)
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
26
pocket-base/bai_api_pb_hooks/bai_api_routes/product/list.js
Normal file
26
pocket-base/bai_api_pb_hooks/bai_api_routes/product/list.js
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -39,6 +42,10 @@ routerAdd('GET', '/manage', function (e) {
|
||||
<h2>文档管理</h2>
|
||||
<a class="btn" href="/pb/manage/document-manage">进入文档管理</a>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>产品管理</h2>
|
||||
<a class="btn" href="/pb/manage/product-manage">进入产品管理</a>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>SDK 权限管理</h2>
|
||||
<a class="btn" href="/pb/manage/sdk-permission-manage">进入权限管理</a>
|
||||
|
||||
1403
pocket-base/bai_web_pb_hooks/pages/product-manage.js
Normal file
1403
pocket-base/bai_web_pb_hooks/pages/product-manage.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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: <页码>|<integer>
|
||||
perPage: <每页条数>|<integer>
|
||||
totalItems: <总记录数>|<integer>
|
||||
totalPages: <总页数>|<integer>
|
||||
items:
|
||||
- id: <PocketBase记录主键>|<string>
|
||||
collectionId: <集合ID>|<string>
|
||||
collectionName: <集合名称>|<string>
|
||||
created: <记录创建时间>|<string>
|
||||
updated: <记录更新时间>|<string>
|
||||
prod_list_id: <产品列表业务ID>|<string>
|
||||
prod_list_name: <产品名称>|<string>
|
||||
prod_list_modelnumber: <产品型号>|<string>
|
||||
prod_list_icon: <产品图标附件ID>|<string>
|
||||
prod_list_description: <产品说明>|<string>
|
||||
prod_list_feature: <产品特色>|<string>
|
||||
prod_list_parameters: <产品参数JSON>|<object>
|
||||
prod_list_plantype: <产品方案>|<string>
|
||||
prod_list_category: <产品分类>|<string>
|
||||
prod_list_sort: <排序值>|<number>
|
||||
prod_list_comm_type: <通讯类型>|<string>
|
||||
prod_list_series: <产品系列>|<string>
|
||||
prod_list_power_supply: <供电方式>|<string>
|
||||
prod_list_tags: <产品标签>|<string>
|
||||
prod_list_status: <产品状态>|<string>
|
||||
prod_list_basic_price: <基础价格>|<number>
|
||||
prod_list_remark: <备注>|<string>
|
||||
'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>|<string>
|
||||
prod_list_name:
|
||||
type: string
|
||||
description: 产品名称
|
||||
example: <产品名称>|<string>
|
||||
prod_list_modelnumber:
|
||||
type: string
|
||||
description: 产品型号
|
||||
example: <产品型号>|<string>
|
||||
prod_list_icon:
|
||||
type: string
|
||||
description: 产品图标附件 ID,保存 `tbl_attachments.attachments_id`
|
||||
example: <产品图标附件ID>|<string>
|
||||
prod_list_description:
|
||||
type: string
|
||||
description: 产品说明
|
||||
example: <产品说明>|<string>
|
||||
prod_list_feature:
|
||||
type: string
|
||||
description: 产品特色
|
||||
example: <产品特色>|<string>
|
||||
prod_list_parameters:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: 产品参数(JSON 对象/数组)
|
||||
example:
|
||||
key: <参数值>|<string>
|
||||
prod_list_plantype:
|
||||
type: string
|
||||
description: 产品方案
|
||||
example: <产品方案>|<string>
|
||||
prod_list_category:
|
||||
type: string
|
||||
description: 产品分类(必填,单选)
|
||||
example: <产品分类>|<string>
|
||||
prod_list_sort:
|
||||
type:
|
||||
- number
|
||||
- integer
|
||||
description: 排序值(同分类内按升序)
|
||||
example: <排序值>|<number>
|
||||
prod_list_comm_type:
|
||||
type: string
|
||||
description: 通讯类型
|
||||
example: <通讯类型>|<string>
|
||||
prod_list_series:
|
||||
type: string
|
||||
description: 产品系列
|
||||
example: <产品系列>|<string>
|
||||
prod_list_power_supply:
|
||||
type: string
|
||||
description: 供电方式
|
||||
example: <供电方式>|<string>
|
||||
prod_list_tags:
|
||||
type: string
|
||||
description: 产品标签(辅助检索,以 `|` 聚合)
|
||||
example: <产品标签>|<string>
|
||||
prod_list_status:
|
||||
type: string
|
||||
description: 产品状态(有效 / 过期 / 主推等)
|
||||
example: <产品状态>|<string>
|
||||
prod_list_basic_price:
|
||||
type:
|
||||
- number
|
||||
- integer
|
||||
description: 基础价格
|
||||
example: <基础价格>|<number>
|
||||
prod_list_remark:
|
||||
type: string
|
||||
description: 备注
|
||||
example: <备注>|<string>
|
||||
PocketBaseProductListRecord:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/PocketBaseRecordBase'
|
||||
- $ref: '#/components/schemas/PocketBaseProductListFields'
|
||||
example:
|
||||
id: <PocketBase记录主键>|<string>
|
||||
collectionId: <集合ID>|<string>
|
||||
collectionName: <集合名称>|<string>
|
||||
created: <记录创建时间>|<string>
|
||||
updated: <记录更新时间>|<string>
|
||||
prod_list_id: <产品列表业务ID>|<string>
|
||||
prod_list_name: <产品名称>|<string>
|
||||
prod_list_modelnumber: <产品型号>|<string>
|
||||
prod_list_icon: <产品图标附件ID>|<string>
|
||||
prod_list_description: <产品说明>|<string>
|
||||
prod_list_feature: <产品特色>|<string>
|
||||
prod_list_parameters:
|
||||
key: <参数值>|<string>
|
||||
prod_list_plantype: <产品方案>|<string>
|
||||
prod_list_category: <产品分类>|<string>
|
||||
prod_list_sort: <排序值>|<number>
|
||||
prod_list_comm_type: <通讯类型>|<string>
|
||||
prod_list_series: <产品系列>|<string>
|
||||
prod_list_power_supply: <供电方式>|<string>
|
||||
prod_list_tags: <产品标签>|<string>
|
||||
prod_list_status: <产品状态>|<string>
|
||||
prod_list_basic_price: <基础价格>|<number>
|
||||
prod_list_remark: <备注>|<string>
|
||||
PocketBaseProductListListResponse:
|
||||
type: object
|
||||
required:
|
||||
- page
|
||||
- perPage
|
||||
- totalItems
|
||||
- totalPages
|
||||
- items
|
||||
properties:
|
||||
page:
|
||||
type:
|
||||
- integer
|
||||
- string
|
||||
example: <页码>|<integer>
|
||||
perPage:
|
||||
type:
|
||||
- integer
|
||||
- string
|
||||
example: <每页条数>|<integer>
|
||||
totalItems:
|
||||
type:
|
||||
- integer
|
||||
- string
|
||||
example: <总记录数>|<integer>
|
||||
totalPages:
|
||||
type:
|
||||
- integer
|
||||
- string
|
||||
example: <总页数>|<integer>
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/PocketBaseProductListRecord'
|
||||
example:
|
||||
page: <页码>|<integer>
|
||||
perPage: <每页条数>|<integer>
|
||||
totalItems: <总记录数>|<integer>
|
||||
totalPages: <总页数>|<integer>
|
||||
items:
|
||||
- id: <PocketBase记录主键>|<string>
|
||||
collectionId: <集合ID>|<string>
|
||||
collectionName: <集合名称>|<string>
|
||||
created: <记录创建时间>|<string>
|
||||
updated: <记录更新时间>|<string>
|
||||
prod_list_id: <产品列表业务ID>|<string>
|
||||
prod_list_name: <产品名称>|<string>
|
||||
prod_list_modelnumber: <产品型号>|<string>
|
||||
prod_list_icon: <产品图标附件ID>|<string>
|
||||
prod_list_description: <产品说明>|<string>
|
||||
prod_list_feature: <产品特色>|<string>
|
||||
prod_list_parameters:
|
||||
key: <参数值>|<string>
|
||||
prod_list_plantype: <产品方案>|<string>
|
||||
prod_list_category: <产品分类>|<string>
|
||||
prod_list_sort: <排序值>|<number>
|
||||
prod_list_comm_type: <通讯类型>|<string>
|
||||
prod_list_series: <产品系列>|<string>
|
||||
prod_list_power_supply: <供电方式>|<string>
|
||||
prod_list_tags: <产品标签>|<string>
|
||||
prod_list_status: <产品状态>|<string>
|
||||
prod_list_basic_price: <基础价格>|<number>
|
||||
prod_list_remark: <备注>|<string>
|
||||
PocketBaseDocumentFields:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -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",
|
||||
|
||||
257
script/pocketbase.product-list.js
Normal file
257
script/pocketbase.product-list.js
Normal file
@@ -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();
|
||||
Reference in New Issue
Block a user