feat: 更新产品参数处理逻辑,支持数组格式;修改相关 API 文档;添加产品参数迁移脚本
This commit is contained in:
@@ -19,7 +19,7 @@
|
|||||||
| `prod_list_icon` | `text` | 否 | 产品图标,保存 `tbl_attachments.attachments_id` |
|
| `prod_list_icon` | `text` | 否 | 产品图标,保存 `tbl_attachments.attachments_id` |
|
||||||
| `prod_list_description` | `text` | 否 | 产品说明(editor 内容,建议保存 Markdown 或已净化 HTML) |
|
| `prod_list_description` | `text` | 否 | 产品说明(editor 内容,建议保存 Markdown 或已净化 HTML) |
|
||||||
| `prod_list_feature` | `text` | 否 | 产品特色 |
|
| `prod_list_feature` | `text` | 否 | 产品特色 |
|
||||||
| `prod_list_parameters` | `json` | 否 | 产品参数(JSON 对象/数组) |
|
| `prod_list_parameters` | `json` | 否 | 产品参数(JSON 数组,格式为 `[ { "name": "属性名", "value": "属性值" } ]`) |
|
||||||
| `prod_list_plantype` | `text` | 否 | 产品方案 |
|
| `prod_list_plantype` | `text` | 否 | 产品方案 |
|
||||||
| `prod_list_category` | `text` | 是 | 产品分类(必填,单选) |
|
| `prod_list_category` | `text` | 是 | 产品分类(必填,单选) |
|
||||||
| `prod_list_sort` | `number` | 否 | 排序值(同分类内按升序) |
|
| `prod_list_sort` | `number` | 否 | 排序值(同分类内按升序) |
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
|
|
||||||
- `prod_list_icon` 仅保存附件业务 ID,真实文件统一在 `tbl_attachments`。
|
- `prod_list_icon` 仅保存附件业务 ID,真实文件统一在 `tbl_attachments`。
|
||||||
- 当前预构建脚本中已将 `listRule` 与 `viewRule` 设置为空字符串(`""`),对应 PocketBase 的“任何人可查看”。
|
- 当前预构建脚本中已将 `listRule` 与 `viewRule` 设置为空字符串(`""`),对应 PocketBase 的“任何人可查看”。
|
||||||
- `prod_list_parameters` 使用 PocketBase `json` 字段,写入时应直接提交对象/数组,不建议再二次字符串化。
|
- `prod_list_parameters` 使用 PocketBase `json` 字段,写入时应直接提交数组结构:`[{"name":"属性名","value":"属性值"}]`。
|
||||||
- `prod_list_description` 作为 editor 内容字段,建议统一为 Markdown 或净化后的 HTML;若允许 HTML,需在写入前做 XSS 白名单过滤。
|
- `prod_list_description` 作为 editor 内容字段,建议统一为 Markdown 或净化后的 HTML;若允许 HTML,需在写入前做 XSS 白名单过滤。
|
||||||
- 前端渲染 `prod_list_description` 时应保持和存储格式一致(Markdown 渲染器或受控 HTML 渲染),避免直接注入未净化内容。
|
- 前端渲染 `prod_list_description` 时应保持和存储格式一致(Markdown 渲染器或受控 HTML 渲染),避免直接注入未净化内容。
|
||||||
- `prod_list_basic_price` 使用 `number`,如需货币精度策略建议后续统一为“分”或固定小数位。
|
- `prod_list_basic_price` 使用 `number`,如需货币精度策略建议后续统一为“分”或固定小数位。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module.exports = {
|
|||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
APP_VERSION: '0.1.21',
|
APP_VERSION: '0.1.21',
|
||||||
APP_BASE_URL: 'https://bai-api.blv-oa.com',
|
APP_BASE_URL: 'https://bai-api.blv-oa.com',
|
||||||
POCKETBASE_API_URL: 'http://127.0.0.1:8090',
|
POCKETBASE_API_URL: 'https://bai-api.blv-oa.com/pb',
|
||||||
POCKETBASE_AUTH_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTgwNTk2MzM4NywiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.R34MTotuFBpevqbbUtvQ3GPa0Z1mpY8eQwZgDdmfhMo',
|
POCKETBASE_AUTH_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTgwNTk2MzM4NywiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.R34MTotuFBpevqbbUtvQ3GPa0Z1mpY8eQwZgDdmfhMo',
|
||||||
WECHAT_APPID: 'wx3bd7a7b19679da7a',
|
WECHAT_APPID: 'wx3bd7a7b19679da7a',
|
||||||
WECHAT_SECRET: '57e40438c2a9151257b1927674db10e1',
|
WECHAT_SECRET: '57e40438c2a9151257b1927674db10e1',
|
||||||
|
|||||||
@@ -349,24 +349,51 @@ function validateDocumentDeleteBody(e) {
|
|||||||
|
|
||||||
function normalizeProductParameters(value) {
|
function normalizeProductParameters(value) {
|
||||||
if (value === null || typeof value === 'undefined' || value === '') {
|
if (value === null || typeof value === 'undefined' || value === '') {
|
||||||
return {}
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
const result = []
|
||||||
throw createAppError(400, 'prod_list_parameters 必须为对象')
|
const indexByName = {}
|
||||||
|
|
||||||
|
function upsert(nameValue, rawValue) {
|
||||||
|
const name = String(nameValue || '').trim()
|
||||||
|
if (!name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValue = rawValue === null || typeof rawValue === 'undefined' ? '' : String(rawValue)
|
||||||
|
const existingIndex = indexByName[name]
|
||||||
|
if (typeof existingIndex === 'number') {
|
||||||
|
result[existingIndex].value = normalizedValue
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
indexByName[name] = result.length
|
||||||
|
result.push({
|
||||||
|
name: name,
|
||||||
|
value: normalizedValue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (let i = 0; i < value.length; i += 1) {
|
||||||
|
const item = value[i] && typeof value[i] === 'object' ? value[i] : null
|
||||||
|
if (!item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
upsert(item.name || item.key, item.value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'object') {
|
||||||
|
throw createAppError(400, 'prod_list_parameters 必须为数组或对象')
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = {}
|
|
||||||
const keys = Object.keys(value)
|
const keys = Object.keys(value)
|
||||||
|
|
||||||
for (let i = 0; i < keys.length; i += 1) {
|
for (let i = 0; i < keys.length; i += 1) {
|
||||||
const key = String(keys[i] || '').trim()
|
upsert(keys[i], value[keys[i]])
|
||||||
if (!key) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = value[keys[i]]
|
|
||||||
result[key] = current === null || typeof current === 'undefined' ? '' : String(current)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -112,28 +112,50 @@ function buildCategoryRankMap(records) {
|
|||||||
|
|
||||||
function normalizeParameters(value) {
|
function normalizeParameters(value) {
|
||||||
if (value === null || typeof value === 'undefined' || value === '') {
|
if (value === null || typeof value === 'undefined' || value === '') {
|
||||||
return {}
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
const result = []
|
||||||
throw createAppError(400, 'prod_list_parameters 必须是对象')
|
const indexByName = {}
|
||||||
|
|
||||||
|
function upsert(nameValue, rawValue) {
|
||||||
|
const name = normalizeText(nameValue)
|
||||||
|
if (!name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValue = rawValue === null || typeof rawValue === 'undefined' ? '' : String(rawValue)
|
||||||
|
const existingIndex = indexByName[name]
|
||||||
|
if (typeof existingIndex === 'number') {
|
||||||
|
result[existingIndex].value = normalizedValue
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
indexByName[name] = result.length
|
||||||
|
result.push({
|
||||||
|
name: name,
|
||||||
|
value: normalizedValue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (let i = 0; i < value.length; i += 1) {
|
||||||
|
const item = value[i] && typeof value[i] === 'object' ? value[i] : null
|
||||||
|
if (!item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
upsert(item.name || item.key, item.value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'object') {
|
||||||
|
throw createAppError(400, 'prod_list_parameters 必须是数组或对象')
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = {}
|
|
||||||
const keys = Object.keys(value)
|
const keys = Object.keys(value)
|
||||||
for (let i = 0; i < keys.length; i += 1) {
|
for (let i = 0; i < keys.length; i += 1) {
|
||||||
const key = normalizeText(keys[i])
|
upsert(keys[i], value[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
|
return result
|
||||||
@@ -141,7 +163,7 @@ function normalizeParameters(value) {
|
|||||||
|
|
||||||
function normalizeParametersForOutput(value) {
|
function normalizeParametersForOutput(value) {
|
||||||
if (value === null || typeof value === 'undefined' || value === '') {
|
if (value === null || typeof value === 'undefined' || value === '') {
|
||||||
return {}
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
let source = value
|
let source = value
|
||||||
@@ -153,9 +175,10 @@ function normalizeParametersForOutput(value) {
|
|||||||
if (raw.indexOf('map[') === 0 && raw.endsWith(']')) {
|
if (raw.indexOf('map[') === 0 && raw.endsWith(']')) {
|
||||||
const body = raw.slice(4, -1).trim()
|
const body = raw.slice(4, -1).trim()
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return {}
|
return []
|
||||||
}
|
}
|
||||||
const result = {}
|
const result = []
|
||||||
|
const indexByName = {}
|
||||||
const pairs = body.split(/\s+/)
|
const pairs = body.split(/\s+/)
|
||||||
for (let i = 0; i < pairs.length; i += 1) {
|
for (let i = 0; i < pairs.length; i += 1) {
|
||||||
const pair = pairs[i]
|
const pair = pairs[i]
|
||||||
@@ -168,32 +191,47 @@ function normalizeParametersForOutput(value) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const val = pair.slice(separatorIndex + 1)
|
const val = pair.slice(separatorIndex + 1)
|
||||||
result[key] = val === null || typeof val === 'undefined' ? '' : String(val)
|
const normalizedValue = val === null || typeof val === 'undefined' ? '' : String(val)
|
||||||
|
const existingIndex = indexByName[key]
|
||||||
|
if (typeof existingIndex === 'number') {
|
||||||
|
result[existingIndex].value = normalizedValue
|
||||||
|
} else {
|
||||||
|
indexByName[key] = result.length
|
||||||
|
result.push({ name: key, value: normalizedValue })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
return {}
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(source)) {
|
if (Array.isArray(source)) {
|
||||||
const mapped = {}
|
const mapped = []
|
||||||
|
const indexByName = {}
|
||||||
for (let i = 0; i < source.length; i += 1) {
|
for (let i = 0; i < source.length; i += 1) {
|
||||||
const item = source[i] && typeof source[i] === 'object' ? source[i] : null
|
const item = source[i] && typeof source[i] === 'object' ? source[i] : null
|
||||||
if (!item) {
|
if (!item) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const key = normalizeText(item.key)
|
const name = normalizeText(item.name || item.key)
|
||||||
if (!key) {
|
if (!name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mapped[key] = item.value === null || typeof item.value === 'undefined' ? '' : String(item.value)
|
const normalizedValue = item.value === null || typeof item.value === 'undefined' ? '' : String(item.value)
|
||||||
|
const existingIndex = indexByName[name]
|
||||||
|
if (typeof existingIndex === 'number') {
|
||||||
|
mapped[existingIndex].value = normalizedValue
|
||||||
|
} else {
|
||||||
|
indexByName[name] = mapped.length
|
||||||
|
mapped.push({ name: name, value: normalizedValue })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return mapped
|
return mapped
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof source !== 'object') {
|
if (typeof source !== 'object') {
|
||||||
return {}
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some PocketBase/Goja map-like values are not directly enumerable; roundtrip to plain object.
|
// Some PocketBase/Goja map-like values are not directly enumerable; roundtrip to plain object.
|
||||||
@@ -201,15 +239,23 @@ function normalizeParametersForOutput(value) {
|
|||||||
source = JSON.parse(JSON.stringify(source))
|
source = JSON.parse(JSON.stringify(source))
|
||||||
} catch (_error) {}
|
} catch (_error) {}
|
||||||
|
|
||||||
const result = {}
|
const result = []
|
||||||
|
const indexByName = {}
|
||||||
const keys = Object.keys(source)
|
const keys = Object.keys(source)
|
||||||
for (let i = 0; i < keys.length; i += 1) {
|
for (let i = 0; i < keys.length; i += 1) {
|
||||||
const key = normalizeText(keys[i])
|
const name = normalizeText(keys[i])
|
||||||
if (!key) {
|
if (!name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const current = source[keys[i]]
|
const current = source[keys[i]]
|
||||||
result[key] = current === null || typeof current === 'undefined' ? '' : String(current)
|
const normalizedValue = current === null || typeof current === 'undefined' ? '' : String(current)
|
||||||
|
const existingIndex = indexByName[name]
|
||||||
|
if (typeof existingIndex === 'number') {
|
||||||
|
result[existingIndex].value = normalizedValue
|
||||||
|
} else {
|
||||||
|
indexByName[name] = result.length
|
||||||
|
result.push({ name: name, value: normalizedValue })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -252,7 +298,7 @@ function exportProductRecord(record, extra) {
|
|||||||
const parametersFromText = parametersText ? normalizeParametersForOutput(parametersText) : {}
|
const parametersFromText = parametersText ? normalizeParametersForOutput(parametersText) : {}
|
||||||
const parametersRaw = record.get('prod_list_parameters')
|
const parametersRaw = record.get('prod_list_parameters')
|
||||||
const parametersFromRaw = normalizeParametersForOutput(parametersRaw)
|
const parametersFromRaw = normalizeParametersForOutput(parametersRaw)
|
||||||
const parameters = Object.keys(parametersFromText).length ? parametersFromText : parametersFromRaw
|
const parameters = parametersFromText.length ? parametersFromText : parametersFromRaw
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pb_id: record.id,
|
pb_id: record.id,
|
||||||
|
|||||||
@@ -778,7 +778,7 @@ routerAdd('GET', '/manage/product-manage', function (e) {
|
|||||||
|
|
||||||
paramsBodyEl.innerHTML = state.parameterRows.map(function (row, index) {
|
paramsBodyEl.innerHTML = state.parameterRows.map(function (row, index) {
|
||||||
return '<tr>'
|
return '<tr>'
|
||||||
+ '<td data-label="属性名"><input data-param-key="' + index + '" value="' + escapeHtml(row.key) + '" placeholder="属性名" /></td>'
|
+ '<td data-label="属性名"><input data-param-name="' + index + '" value="' + escapeHtml(row.name) + '" placeholder="属性名" /></td>'
|
||||||
+ '<td data-label="属性值"><input data-param-value="' + index + '" value="' + escapeHtml(row.value) + '" placeholder="属性值" /></td>'
|
+ '<td data-label="属性值"><input data-param-value="' + index + '" value="' + escapeHtml(row.value) + '" placeholder="属性值" /></td>'
|
||||||
+ '<td data-label="操作"><button class="btn btn-light" type="button" data-param-remove="' + index + '">删除</button></td>'
|
+ '<td data-label="操作"><button class="btn btn-light" type="button" data-param-remove="' + index + '">删除</button></td>'
|
||||||
+ '</tr>'
|
+ '</tr>'
|
||||||
@@ -808,13 +808,13 @@ routerAdd('GET', '/manage/product-manage', function (e) {
|
|||||||
const incomingRows = []
|
const incomingRows = []
|
||||||
const keys = Object.keys(parsed)
|
const keys = Object.keys(parsed)
|
||||||
for (let i = 0; i < keys.length; i += 1) {
|
for (let i = 0; i < keys.length; i += 1) {
|
||||||
const key = normalizeText(keys[i])
|
const name = normalizeText(keys[i])
|
||||||
if (!key) {
|
if (!name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const value = parsed[keys[i]]
|
const value = parsed[keys[i]]
|
||||||
incomingRows.push({
|
incomingRows.push({
|
||||||
key: key,
|
name: name,
|
||||||
value: value === null || typeof value === 'undefined' ? '' : String(value),
|
value: value === null || typeof value === 'undefined' ? '' : String(value),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -826,12 +826,12 @@ routerAdd('GET', '/manage/product-manage', function (e) {
|
|||||||
|
|
||||||
const existingIndexMap = {}
|
const existingIndexMap = {}
|
||||||
for (let i = 0; i < state.parameterRows.length; i += 1) {
|
for (let i = 0; i < state.parameterRows.length; i += 1) {
|
||||||
const key = normalizeText(state.parameterRows[i].key)
|
const name = normalizeText(state.parameterRows[i].name)
|
||||||
if (!key) {
|
if (!name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!Object.prototype.hasOwnProperty.call(existingIndexMap, key)) {
|
if (!Object.prototype.hasOwnProperty.call(existingIndexMap, name)) {
|
||||||
existingIndexMap[key] = i
|
existingIndexMap[name] = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -839,13 +839,13 @@ routerAdd('GET', '/manage/product-manage', function (e) {
|
|||||||
let updatedCount = 0
|
let updatedCount = 0
|
||||||
for (let i = 0; i < incomingRows.length; i += 1) {
|
for (let i = 0; i < incomingRows.length; i += 1) {
|
||||||
const row = incomingRows[i]
|
const row = incomingRows[i]
|
||||||
const idx = existingIndexMap[row.key]
|
const idx = existingIndexMap[row.name]
|
||||||
if (typeof idx === 'number') {
|
if (typeof idx === 'number') {
|
||||||
state.parameterRows[idx].value = row.value
|
state.parameterRows[idx].value = row.value
|
||||||
updatedCount += 1
|
updatedCount += 1
|
||||||
} else {
|
} else {
|
||||||
state.parameterRows.push({ key: row.key, value: row.value })
|
state.parameterRows.push({ name: row.name, value: row.value })
|
||||||
existingIndexMap[row.key] = state.parameterRows.length - 1
|
existingIndexMap[row.name] = state.parameterRows.length - 1
|
||||||
addedCount += 1
|
addedCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -854,22 +854,33 @@ routerAdd('GET', '/manage/product-manage', function (e) {
|
|||||||
setStatus('参数增量导入完成:新增 ' + addedCount + ' 项,更新 ' + updatedCount + ' 项,当前共 ' + state.parameterRows.length + ' 项。', 'success')
|
setStatus('参数增量导入完成:新增 ' + addedCount + ' 项,更新 ' + updatedCount + ' 项,当前共 ' + state.parameterRows.length + ' 项。', 'success')
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectParameterObject() {
|
function collectParameterArray() {
|
||||||
const result = {}
|
const result = []
|
||||||
|
const indexByName = {}
|
||||||
for (let i = 0; i < state.parameterRows.length; i += 1) {
|
for (let i = 0; i < state.parameterRows.length; i += 1) {
|
||||||
const key = normalizeText(state.parameterRows[i].key)
|
const name = normalizeText(state.parameterRows[i].name)
|
||||||
const value = normalizeText(state.parameterRows[i].value)
|
const value = normalizeText(state.parameterRows[i].value)
|
||||||
if (!key) {
|
if (!name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result[key] = value
|
|
||||||
|
const existingIndex = indexByName[name]
|
||||||
|
if (typeof existingIndex === 'number') {
|
||||||
|
result[existingIndex].value = value
|
||||||
|
} else {
|
||||||
|
indexByName[name] = result.length
|
||||||
|
result.push({
|
||||||
|
name: name,
|
||||||
|
value: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeParameterObject(value) {
|
function normalizeParameterRows(value) {
|
||||||
if (value === null || typeof value === 'undefined' || value === '') {
|
if (value === null || typeof value === 'undefined' || value === '') {
|
||||||
return {}
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
let source = value
|
let source = value
|
||||||
@@ -877,28 +888,41 @@ routerAdd('GET', '/manage/product-manage', function (e) {
|
|||||||
try {
|
try {
|
||||||
source = JSON.parse(source)
|
source = JSON.parse(source)
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return {}
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = []
|
||||||
|
const indexByName = {}
|
||||||
|
|
||||||
|
function upsert(nameValue, rawValue) {
|
||||||
|
const name = normalizeText(nameValue)
|
||||||
|
if (!name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const normalizedValue = rawValue === null || typeof rawValue === 'undefined' ? '' : String(rawValue)
|
||||||
|
const existingIndex = indexByName[name]
|
||||||
|
if (typeof existingIndex === 'number') {
|
||||||
|
rows[existingIndex].value = normalizedValue
|
||||||
|
} else {
|
||||||
|
indexByName[name] = rows.length
|
||||||
|
rows.push({ name: name, value: normalizedValue })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(source)) {
|
if (Array.isArray(source)) {
|
||||||
const mapped = {}
|
|
||||||
for (let i = 0; i < source.length; i += 1) {
|
for (let i = 0; i < source.length; i += 1) {
|
||||||
const item = source[i] && typeof source[i] === 'object' ? source[i] : null
|
const item = source[i] && typeof source[i] === 'object' ? source[i] : null
|
||||||
if (!item) {
|
if (!item) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const key = normalizeText(item.key)
|
upsert(item.name || item.key, item.value)
|
||||||
if (!key) {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
mapped[key] = item.value === null || typeof item.value === 'undefined' ? '' : String(item.value)
|
return rows
|
||||||
}
|
|
||||||
return mapped
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof source !== 'object') {
|
if (typeof source !== 'object') {
|
||||||
return {}
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some PocketBase/Goja payloads are object-like values; roundtrip makes keys enumerable.
|
// Some PocketBase/Goja payloads are object-like values; roundtrip makes keys enumerable.
|
||||||
@@ -906,18 +930,12 @@ routerAdd('GET', '/manage/product-manage', function (e) {
|
|||||||
source = JSON.parse(JSON.stringify(source))
|
source = JSON.parse(JSON.stringify(source))
|
||||||
} catch (_error) {}
|
} catch (_error) {}
|
||||||
|
|
||||||
const result = {}
|
|
||||||
const keys = Object.keys(source)
|
const keys = Object.keys(source)
|
||||||
for (let i = 0; i < keys.length; i += 1) {
|
for (let i = 0; i < keys.length; i += 1) {
|
||||||
const key = normalizeText(keys[i])
|
upsert(keys[i], source[keys[i]])
|
||||||
if (!key) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const current = source[keys[i]]
|
|
||||||
result[key] = current === null || typeof current === 'undefined' ? '' : String(current)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEditorMode() {
|
function updateEditorMode() {
|
||||||
@@ -1002,11 +1020,7 @@ routerAdd('GET', '/manage/product-manage', function (e) {
|
|||||||
state.selections.series = splitPipe(item.prod_list_series)
|
state.selections.series = splitPipe(item.prod_list_series)
|
||||||
state.selections.powerSupply = splitPipe(item.prod_list_power_supply)
|
state.selections.powerSupply = splitPipe(item.prod_list_power_supply)
|
||||||
state.selections.tags = splitPipe(item.prod_list_tags)
|
state.selections.tags = splitPipe(item.prod_list_tags)
|
||||||
const params = normalizeParameterObject(item.prod_list_parameters)
|
state.parameterRows = normalizeParameterRows(item.prod_list_parameters)
|
||||||
state.parameterRows = Object.keys(params).map(function (key) {
|
|
||||||
const current = params[key]
|
|
||||||
return { key: key, value: current === null || typeof current === 'undefined' ? '' : String(current) }
|
|
||||||
})
|
|
||||||
state.currentIconAttachment = item.prod_list_icon_attachment || null
|
state.currentIconAttachment = item.prod_list_icon_attachment || null
|
||||||
clearPendingIconPreview()
|
clearPendingIconPreview()
|
||||||
state.removedIconId = ''
|
state.removedIconId = ''
|
||||||
@@ -1142,7 +1156,7 @@ routerAdd('GET', '/manage/product-manage', function (e) {
|
|||||||
prod_list_icon: finalIconId,
|
prod_list_icon: finalIconId,
|
||||||
prod_list_description: normalizeText(fields.description.value),
|
prod_list_description: normalizeText(fields.description.value),
|
||||||
prod_list_feature: normalizeText(fields.feature.value),
|
prod_list_feature: normalizeText(fields.feature.value),
|
||||||
prod_list_parameters: collectParameterObject(),
|
prod_list_parameters: collectParameterArray(),
|
||||||
prod_list_plantype: joinPipe(state.selections.plantype),
|
prod_list_plantype: joinPipe(state.selections.plantype),
|
||||||
prod_list_category: joinPipe(state.selections.category),
|
prod_list_category: joinPipe(state.selections.category),
|
||||||
prod_list_comm_type: joinPipe(state.selections.commType),
|
prod_list_comm_type: joinPipe(state.selections.commType),
|
||||||
@@ -1281,10 +1295,10 @@ routerAdd('GET', '/manage/product-manage', function (e) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.matches('input[data-param-key]')) {
|
if (target.matches('input[data-param-name]')) {
|
||||||
const index = Number(target.getAttribute('data-param-key'))
|
const index = Number(target.getAttribute('data-param-name'))
|
||||||
if (Number.isInteger(index) && state.parameterRows[index]) {
|
if (Number.isInteger(index) && state.parameterRows[index]) {
|
||||||
state.parameterRows[index].key = target.value
|
state.parameterRows[index].name = target.value
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1345,7 +1359,7 @@ routerAdd('GET', '/manage/product-manage', function (e) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
document.getElementById('addParamBtn').addEventListener('click', function () {
|
document.getElementById('addParamBtn').addEventListener('click', function () {
|
||||||
state.parameterRows.push({ key: '', value: '' })
|
state.parameterRows.push({ name: '', value: '' })
|
||||||
renderParameterRows()
|
renderParameterRows()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -731,7 +731,9 @@ paths:
|
|||||||
prod_list_icon: <产品图标附件ID>|<string>
|
prod_list_icon: <产品图标附件ID>|<string>
|
||||||
prod_list_description: <产品说明>|<string>
|
prod_list_description: <产品说明>|<string>
|
||||||
prod_list_feature: <产品特色>|<string>
|
prod_list_feature: <产品特色>|<string>
|
||||||
prod_list_parameters: <产品参数JSON>|<object>
|
prod_list_parameters:
|
||||||
|
- name: <属性名>|<string>
|
||||||
|
value: <属性值>|<string>
|
||||||
prod_list_plantype: <产品方案>|<string>
|
prod_list_plantype: <产品方案>|<string>
|
||||||
prod_list_category: <产品分类>|<string>
|
prod_list_category: <产品分类>|<string>
|
||||||
prod_list_sort: <排序值>|<number>
|
prod_list_sort: <排序值>|<number>
|
||||||
@@ -1628,11 +1630,20 @@ components:
|
|||||||
description: 产品特色
|
description: 产品特色
|
||||||
example: <产品特色>|<string>
|
example: <产品特色>|<string>
|
||||||
prod_list_parameters:
|
prod_list_parameters:
|
||||||
|
type: array
|
||||||
|
description: 产品参数数组,每项包含 name/value
|
||||||
|
items:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
properties:
|
||||||
description: 产品参数(JSON 对象/数组)
|
name:
|
||||||
|
type: string
|
||||||
|
example: <属性名>|<string>
|
||||||
|
value:
|
||||||
|
type: string
|
||||||
|
example: <属性值>|<string>
|
||||||
example:
|
example:
|
||||||
key: <参数值>|<string>
|
- name: <属性名>|<string>
|
||||||
|
value: <属性值>|<string>
|
||||||
prod_list_plantype:
|
prod_list_plantype:
|
||||||
type: string
|
type: string
|
||||||
description: 产品方案
|
description: 产品方案
|
||||||
@@ -1694,7 +1705,8 @@ components:
|
|||||||
prod_list_description: <产品说明>|<string>
|
prod_list_description: <产品说明>|<string>
|
||||||
prod_list_feature: <产品特色>|<string>
|
prod_list_feature: <产品特色>|<string>
|
||||||
prod_list_parameters:
|
prod_list_parameters:
|
||||||
key: <参数值>|<string>
|
- name: <属性名>|<string>
|
||||||
|
value: <属性值>|<string>
|
||||||
prod_list_plantype: <产品方案>|<string>
|
prod_list_plantype: <产品方案>|<string>
|
||||||
prod_list_category: <产品分类>|<string>
|
prod_list_category: <产品分类>|<string>
|
||||||
prod_list_sort: <排序值>|<number>
|
prod_list_sort: <排序值>|<number>
|
||||||
@@ -1756,7 +1768,8 @@ components:
|
|||||||
prod_list_description: <产品说明>|<string>
|
prod_list_description: <产品说明>|<string>
|
||||||
prod_list_feature: <产品特色>|<string>
|
prod_list_feature: <产品特色>|<string>
|
||||||
prod_list_parameters:
|
prod_list_parameters:
|
||||||
key: <参数值>|<string>
|
- name: <属性名>|<string>
|
||||||
|
value: <属性值>|<string>
|
||||||
prod_list_plantype: <产品方案>|<string>
|
prod_list_plantype: <产品方案>|<string>
|
||||||
prod_list_category: <产品分类>|<string>
|
prod_list_category: <产品分类>|<string>
|
||||||
prod_list_sort: <排序值>|<number>
|
prod_list_sort: <排序值>|<number>
|
||||||
|
|||||||
282
script/migrate-product-parameters-to-array.js
Normal file
282
script/migrate-product-parameters-to-array.js
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { createRequire } from 'module';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
let runtimeConfig = {};
|
||||||
|
try {
|
||||||
|
runtimeConfig = require('../pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.js');
|
||||||
|
} catch (_error) {
|
||||||
|
runtimeConfig = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnvFile(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) return {};
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
for (const rawLine of content.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) continue;
|
||||||
|
const index = line.indexOf('=');
|
||||||
|
if (index === -1) continue;
|
||||||
|
const key = line.slice(0, index).trim();
|
||||||
|
const value = line.slice(index + 1).trim();
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendEnv = readEnvFile(path.resolve(__dirname, '..', 'back-end', '.env'));
|
||||||
|
|
||||||
|
const PB_URL = (process.env.PB_URL || backendEnv.POCKETBASE_API_URL || 'https://bai-api.blv-oa.com/pb').replace(/\/+$/, '');
|
||||||
|
const AUTH_TOKEN = process.env.POCKETBASE_AUTH_TOKEN || runtimeConfig.POCKETBASE_AUTH_TOKEN || '';
|
||||||
|
const DRY_RUN = String(process.env.DRY_RUN || '').trim() === '1';
|
||||||
|
|
||||||
|
if (!AUTH_TOKEN) {
|
||||||
|
console.error('❌ 缺少认证信息,请提供 POCKETBASE_AUTH_TOKEN。');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUrlCandidates(url) {
|
||||||
|
const trimmed = String(url || '').replace(/\/+$/, '');
|
||||||
|
const candidates = [];
|
||||||
|
if (trimmed) {
|
||||||
|
candidates.push(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const withoutPb = trimmed.replace(/\/pb$/i, '');
|
||||||
|
if (withoutPb && candidates.indexOf(withoutPb) === -1) {
|
||||||
|
candidates.push(withoutPb);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toParameterArray(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 mapped = {};
|
||||||
|
const body = raw.slice(4, -1).trim();
|
||||||
|
const pairs = body ? 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;
|
||||||
|
mapped[key] = pair.slice(separatorIndex + 1);
|
||||||
|
}
|
||||||
|
source = mapped;
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
const indexByName = {};
|
||||||
|
|
||||||
|
function upsert(nameValue, rawValue) {
|
||||||
|
const name = normalizeText(nameValue);
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const valueText = rawValue === null || typeof rawValue === 'undefined' ? '' : String(rawValue);
|
||||||
|
const existingIndex = indexByName[name];
|
||||||
|
if (typeof existingIndex === 'number') {
|
||||||
|
rows[existingIndex].value = valueText;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
indexByName[name] = rows.length;
|
||||||
|
rows.push({ name, value: valueText });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(source)) {
|
||||||
|
for (let i = 0; i < source.length; i += 1) {
|
||||||
|
const item = source[i] && typeof source[i] === 'object' ? source[i] : null;
|
||||||
|
if (!item) continue;
|
||||||
|
upsert(item.name || item.key, item.value);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof source !== 'object') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
source = JSON.parse(JSON.stringify(source));
|
||||||
|
} catch (_error) {}
|
||||||
|
|
||||||
|
const keys = Object.keys(source || {});
|
||||||
|
for (let i = 0; i < keys.length; i += 1) {
|
||||||
|
upsert(keys[i], source[keys[i]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameArray(left, right) {
|
||||||
|
return JSON.stringify(left || []) === JSON.stringify(right || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestPbJson(baseUrl, method, apiPath, token, body) {
|
||||||
|
const res = await fetch(baseUrl + apiPath, {
|
||||||
|
method: method,
|
||||||
|
headers: Object.assign(
|
||||||
|
{
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body ? { 'Content-Type': 'application/json' } : {},
|
||||||
|
),
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
let json = {};
|
||||||
|
try {
|
||||||
|
json = text ? JSON.parse(text) : {};
|
||||||
|
} catch (_error) {
|
||||||
|
json = { raw: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error(json.message || `HTTP ${res.status}`);
|
||||||
|
err.status = res.status;
|
||||||
|
err.response = json;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
try {
|
||||||
|
const urlCandidates = getUrlCandidates(PB_URL);
|
||||||
|
let activeUrl = '';
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < urlCandidates.length; i += 1) {
|
||||||
|
const candidate = urlCandidates[i];
|
||||||
|
try {
|
||||||
|
await requestPbJson(
|
||||||
|
candidate,
|
||||||
|
'GET',
|
||||||
|
'/api/collections/tbl_product_list/records?page=1&perPage=1',
|
||||||
|
AUTH_TOKEN,
|
||||||
|
);
|
||||||
|
activeUrl = candidate;
|
||||||
|
console.log(`✅ 已加载 POCKETBASE_AUTH_TOKEN(${candidate})。`);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
console.log(`⚠️ 候选地址探测失败(${candidate}):`, {
|
||||||
|
status: error && error.status,
|
||||||
|
message: error && error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeUrl) {
|
||||||
|
throw lastError || new Error('无法连接 PocketBase 或认证失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔄 开始迁移 tbl_product_list.prod_list_parameters,PB: ${activeUrl}`);
|
||||||
|
if (DRY_RUN) {
|
||||||
|
console.log('🧪 当前为 DRY_RUN=1,仅预览不会写入数据库。');
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = 1;
|
||||||
|
const perPage = 200;
|
||||||
|
let total = 0;
|
||||||
|
let changed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const list = await requestPbJson(
|
||||||
|
activeUrl,
|
||||||
|
'GET',
|
||||||
|
`/api/collections/tbl_product_list/records?page=${page}&perPage=${perPage}`,
|
||||||
|
AUTH_TOKEN,
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = Array.isArray(list.items) ? list.items : [];
|
||||||
|
if (!items.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
|
const item = items[i];
|
||||||
|
total += 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const current = item.prod_list_parameters;
|
||||||
|
const normalized = toParameterArray(current);
|
||||||
|
|
||||||
|
if (isSameArray(current, normalized)) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DRY_RUN) {
|
||||||
|
await requestPbJson(
|
||||||
|
activeUrl,
|
||||||
|
'PATCH',
|
||||||
|
`/api/collections/tbl_product_list/records/${item.id}`,
|
||||||
|
AUTH_TOKEN,
|
||||||
|
{ prod_list_parameters: normalized },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
changed += 1;
|
||||||
|
console.log(`✅ 已迁移: ${item.prod_list_id || item.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
console.error(`❌ 迁移失败: ${item.prod_list_id || item.id}`, error.message || error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Number(list.totalPages || 1);
|
||||||
|
if (page >= totalPages) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎯 迁移完成');
|
||||||
|
console.log(`- 总记录: ${total}`);
|
||||||
|
console.log(`- 已变更: ${changed}`);
|
||||||
|
console.log(`- 跳过不变: ${skipped}`);
|
||||||
|
console.log(`- 失败: ${failed}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 迁移任务执行失败:', {
|
||||||
|
status: error && error.status,
|
||||||
|
message: error && error.message,
|
||||||
|
response: error && error.response,
|
||||||
|
});
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate();
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"init:product-list": "node pocketbase.product-list.js",
|
"init:product-list": "node pocketbase.product-list.js",
|
||||||
"init:dictionary": "node pocketbase.dictionary.js",
|
"init:dictionary": "node pocketbase.dictionary.js",
|
||||||
"migrate:file-fields": "node pocketbase.file-fields-to-attachments.js",
|
"migrate:file-fields": "node pocketbase.file-fields-to-attachments.js",
|
||||||
|
"migrate:product-params-array": "node migrate-product-parameters-to-array.js",
|
||||||
"test:company-native-api": "node test-tbl-company-native-api.js",
|
"test:company-native-api": "node test-tbl-company-native-api.js",
|
||||||
"test:company-owner-sync": "node test-company-owner-sync.js"
|
"test:company-owner-sync": "node test-company-owner-sync.js"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user