From c31466aeb5a07f93793974227a5c5c04b425def7 Mon Sep 17 00:00:00 2001
From: wdh-home <243823965@qq.com>
Date: Mon, 27 Apr 2026 13:55:21 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E4=B9=A6=E7=B1=8D?=
=?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=AD=97=E6=AE=B5=E5=B1=95=E7=A4=BA=E4=B8=8E?=
=?UTF-8?q?=E6=8F=90=E4=BA=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../coding-specs/common-page-create-spec.md | 7 +
web/package.json | 4 +-
.../view/book/components/BookAdminCrud.vue | 52 ++-
web/src/view/book/config/bookAdminConfig.js | 87 ++++-
.../book/config/bookAdminDisplay.test.mjs | 33 +-
web/src/view/book/config/bookAdminPayload.js | 31 ++
.../book/config/bookAdminPayload.test.mjs | 303 ++++++++++++++++++
7 files changed, 499 insertions(+), 18 deletions(-)
create mode 100644 web/src/view/book/config/bookAdminPayload.js
create mode 100644 web/src/view/book/config/bookAdminPayload.test.mjs
diff --git a/web/.ai-specs/coding-specs/common-page-create-spec.md b/web/.ai-specs/coding-specs/common-page-create-spec.md
index 1708be1..c848e94 100644
--- a/web/.ai-specs/coding-specs/common-page-create-spec.md
+++ b/web/.ai-specs/coding-specs/common-page-create-spec.md
@@ -38,6 +38,10 @@ flowchart LR
- `path` 默认与 `name` 同步;只有明确需要参数化路径时才额外拼接,不要把查询条件硬塞进路由 path。
- 需要缓存页签时设置 `meta.keepAlive`;需要进入后自动关闭 tab 时设置 `meta.closeTab`。
- 页面进入菜单体系后,新增菜单不等于可访问;还必须补角色授权,否则页面可能存在但用户不可见。
+- 列表页的 list item 遇到单图片字段时,必须使用图片预览组件展示;禁止把图片 URL、图片路径或单图片附件地址作为普通文本直接显示。
+- 新增/编辑功能遇到图片或文件属性时,必须使用上传组件并支持已有值回显;禁止使用 `el-input`、`textarea` 或普通文本输入组件让用户手填 URL/路径。
+- 列表页、详情页、关联表、related list item 遇到字典字段时,必须声明对应 `dict` 配置并使用字典格式化展示 `Label`;禁止把字典 `Value/key` 作为普通文本直接显示。
+- 字典字段在新增/编辑/筛选中提交和绑定使用 `Value`,页面展示统一使用字典 `Label`;涉及字典编码和值域时必须先读取后台 `doc-dict` 文档。
## curl 联调案例
@@ -69,3 +73,6 @@ curl --location --request POST "$BASE_URL/menu/addBaseMenu" \
- 远程路由 `component` 写错路径格式,导致 `asyncRouter.js` 找不到页面组件。
- 页面里直接写 axios 请求或直接拼 token,绕过 `src/api` 和 `src/utils/request.js`。
- 改了路由 `name/path`,没有同步检查 `keepAlive`、菜单高亮、`defaultRouter` 和未登录跳转。
+- list item 的单图片字段直接显示 URL 文本,没有使用图片预览。
+- 新增/编辑表单把图片或文件字段做成文本输入框,要求用户手动填写 URL 或路径。
+- 详情页或关联表字段直接显示字典 key,例如 `draft`、`on_shelf`、`completed`,没有通过字典格式化显示名称。
diff --git a/web/package.json b/web/package.json
index 350a38d..40365ab 100644
--- a/web/package.json
+++ b/web/package.json
@@ -3,8 +3,8 @@
"version": "2.9.1",
"private": true,
"scripts": {
- "dev": "node openDocument.js && vite --host --mode development",
- "serve": "node openDocument.js && vite --host --mode development",
+ "dev": "vite --host --mode development",
+ "serve": "vite --host --mode development",
"build": "vite build --mode production",
"limit-build": "npm install increase-memory-limit-fixbug cross-env -g && npm run fix-memory-limit && node ./limit && npm run build",
"preview": "vite preview",
diff --git a/web/src/view/book/components/BookAdminCrud.vue b/web/src/view/book/components/BookAdminCrud.vue
index aceebc3..c1ff9bc 100644
--- a/web/src/view/book/components/BookAdminCrud.vue
+++ b/web/src/view/book/components/BookAdminCrud.vue
@@ -194,8 +194,14 @@
@@ -235,7 +241,17 @@
-
+
+ {{ formatLooseCell(scope.row[column.prop]) }}
+
@@ -258,7 +274,9 @@
import { getUrl } from '@/utils/image'
import { filterDict, formatBoolean, formatDate, getBaseUrl } from '@/utils/format'
import { useUserStore } from '@/pinia'
+ import { bookAuthorRelationDisplayColumns } from '../config/bookAdminConfig'
import { isBookAdminImageColumn, resolveBookAdminColumnType } from '../config/bookAdminDisplay'
+ import { buildBookAdminSubmitPayload, buildBookAuthorRelationSubmitPayload } from '../config/bookAdminPayload'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, reactive, ref } from 'vue'
@@ -371,6 +389,19 @@
return value ?? ''
}
+ const normalizeRelatedColumn = (column) => {
+ return typeof column === 'string' ? { label: column, prop: column } : column
+ }
+
+ const formatRelatedCell = (row, column) => {
+ const value = row?.[column.prop]
+ if (column.type === 'datetime') return formatDate(value)
+ if (column.type === 'date') return value ? String(value).slice(0, 10) : ''
+ if (column.type === 'boolean' || column.type === 'switch') return formatBoolean(value)
+ if (column.type === 'dict') return filterDict(value, dictOptions[column.dict]) || value || ''
+ return formatLooseCell(value)
+ }
+
const formatCell = (row, column) => {
const value = row?.[column.prop]
const columnType = resolveBookAdminColumnType(config.value, column)
@@ -419,6 +450,11 @@
;[...config.value.searchFields, ...config.value.fields, ...config.value.tableColumns].forEach((field) => {
if (field.dict) dictCodes.add(field.dict)
})
+ config.value.related?.forEach((relation) => {
+ relation.columns?.map(normalizeRelatedColumn).forEach((column) => {
+ if (column.dict) dictCodes.add(column.dict)
+ })
+ })
config.value.statusFields?.forEach((status) => {
if (status.dict) dictCodes.add(status.dict)
})
@@ -525,7 +561,8 @@
const enterDialog = () => {
formRef.value?.validate(async (valid) => {
if (!valid) return
- const res = dialogType.value === 'update' ? await api.value.update(formData.value) : await api.value.create(formData.value)
+ const payload = buildBookAdminSubmitPayload(config.value, formData.value, dialogType.value)
+ const res = dialogType.value === 'update' ? await api.value.update(payload) : await api.value.create(payload)
if (res.code === 0) {
ElMessage({ type: 'success', message: '创建/更改成功' })
closeDialog()
@@ -576,7 +613,8 @@
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
- const res = await api.value.update({ ...row, [status.prop]: value })
+ const payload = buildBookAdminSubmitPayload(config.value, { ...row, [status.prop]: value }, 'update')
+ const res = await api.value.update(payload)
if (res.code === 0) {
ElMessage({ type: 'success', message: '状态已调整' })
getTableData()
@@ -593,7 +631,7 @@
if (!listApi) continue
const res = await listApi({ page: 1, pageSize: 10, [relation.filterProp]: row.id })
if (res.code === 0) {
- blocks.push({ title: relation.title, columns: relation.columns, rows: res.data.list || [] })
+ blocks.push({ title: relation.title, columns: relation.columns.map(normalizeRelatedColumn), rows: res.data.list || [] })
}
}
relatedBlocks.value = blocks
@@ -659,7 +697,7 @@
}
const updateAuthorSort = async (row) => {
- const res = await bookApi.updateBookAuthorRelation(row)
+ const res = await bookApi.updateBookAuthorRelation(buildBookAuthorRelationSubmitPayload(row, 'update'))
if (res.code === 0) ElMessage({ type: 'success', message: '排序已更新' })
}
diff --git a/web/src/view/book/config/bookAdminConfig.js b/web/src/view/book/config/bookAdminConfig.js
index 427f115..a973040 100644
--- a/web/src/view/book/config/bookAdminConfig.js
+++ b/web/src/view/book/config/bookAdminConfig.js
@@ -23,6 +23,51 @@ const imageField = (label, prop, extra = {}) => ({ label, prop, type: 'image', .
const fileField = (label, prop, extra = {}) => ({ label, prop, type: 'file', ...extra })
const dictField = (label, prop, dict, extra = {}) => ({ label, prop, type: 'dict', dict, ...extra })
const relationField = (label, prop, relation, extra = {}) => ({ label, prop, type: 'relation', relation, ...extra })
+const relatedColumn = (label, prop, extra = {}) => ({ label, prop, ...extra })
+
+export const bookAuthorRelationDisplayColumns = [
+ { label: '作者名称', prop: 'authorName', minWidth: 160 },
+ { label: '作者 ID', prop: 'authorId', width: 120 }
+]
+
+const bookSubmitFields = [
+ 'id',
+ 'title',
+ 'subtitle',
+ 'bookType',
+ 'eraTag',
+ 'coverUrl',
+ 'publisher',
+ 'publishedAt',
+ 'intro',
+ 'hotScore',
+ 'rating',
+ 'commentCount',
+ 'wordCount',
+ 'completionStatus',
+ 'publishStatus',
+ 'seriesId',
+ 'seriesSort',
+ 'rawTxtUrl'
+]
+
+const chapterSubmitFields = [
+ 'id',
+ 'bookId',
+ 'title',
+ 'chapterNo',
+ 'isReadable',
+ 'contentFileUrl',
+ 'totalLines',
+ 'isEnabled'
+]
+
+const authorSubmitFields = ['id', 'name', 'isEnabled', 'intro', 'coverUrl']
+const seriesSubmitFields = ['id', 'name', 'coverUrl', 'intro', 'isEnabled']
+const commentSubmitFields = ['id', 'memberUserId', 'bookId', 'chapterId', 'lineIndex', 'content', 'likeCount', 'commentStatus']
+const readRecordSubmitFields = ['id', 'memberUserId', 'bookId', 'bookTitleSnapshot', 'readProgress', 'chapterId', 'lineIndex', 'lastReadAt']
+const favoriteRecordSubmitFields = ['id', 'memberUserId', 'bookId', 'favoritedAt']
+const commentLikeRecordSubmitFields = ['id', 'commentId', 'memberUserId', 'likedAt']
export const bookAdminPageConfigs = [
{
@@ -37,6 +82,7 @@ export const bookAdminPageConfigs = [
find: 'findBook',
list: 'getBookList'
},
+ submitFields: bookSubmitFields,
searchFields: [
textField('书名', 'title'),
dictField('书籍类型', 'bookType', 'book_type'),
@@ -79,9 +125,9 @@ export const bookAdminPageConfigs = [
{ prop: 'completionStatus', label: '完结状态', dict: 'book_completion_status' }
],
related: [
- { title: '关联作者', api: 'getBookAuthorRelationList', filterProp: 'bookId', columns: ['authorId', 'authorSort'] },
+ { title: '关联作者', api: 'getBookAuthorRelationList', filterProp: 'bookId', columns: ['authorName', 'authorId', 'authorSort'] },
{ title: '关联章节', api: 'getBookChapterList', filterProp: 'bookId', columns: ['id', 'title', 'chapterNo', 'isReadable', 'isEnabled'] },
- { title: '关联评论', api: 'getBookCommentList', filterProp: 'bookId', columns: ['id', 'memberUserId', 'chapterId', 'lineIndex', 'commentStatus'] }
+ { title: '关联评论', api: 'getBookCommentList', filterProp: 'bookId', columns: ['id', 'memberUserId', 'chapterTitle', 'chapterId', 'lineIndex', 'commentStatus'] }
],
authorBinding: true
},
@@ -97,6 +143,7 @@ export const bookAdminPageConfigs = [
find: 'findBookChapter',
list: 'getBookChapterList'
},
+ submitFields: chapterSubmitFields,
searchFields: [
relationField('所属书籍', 'bookId', { listApi: 'getBookList', labelProp: 'title', valueProp: 'id', searchProp: 'title' }),
switchField('开放阅读', 'isReadable'),
@@ -105,6 +152,7 @@ export const bookAdminPageConfigs = [
tableColumns: [
idColumn,
{ label: '章节标题', prop: 'title', minWidth: 180 },
+ { label: '书名', prop: 'bookTitle', minWidth: 180 },
{ label: '书籍 ID', prop: 'bookId', width: 100 },
{ label: '章节序号', prop: 'chapterNo', width: 110 },
{ label: '开放阅读', prop: 'isReadable', type: 'boolean', width: 100 },
@@ -138,28 +186,29 @@ export const bookAdminPageConfigs = [
find: 'findBookAuthor',
list: 'getBookAuthorList'
},
+ submitFields: authorSubmitFields,
searchFields: [
textField('作者名称', 'name'),
- dictField('作者状态', 'authorStatus', 'book_author_status')
+ switchField('启用状态', 'isEnabled')
],
tableColumns: [
idColumn,
{ label: '作者名称', prop: 'name', minWidth: 160 },
- { label: '作者状态', prop: 'authorStatus', type: 'dict', dict: 'book_author_status', width: 120 },
+ { label: '启用状态', prop: 'isEnabled', type: 'boolean', width: 100 },
{ label: '头像/封面 URL', prop: 'coverUrl', minWidth: 180 },
dateColumn
],
fields: [
textField('作者名称', 'name', { required: true }),
- dictField('作者状态', 'authorStatus', 'book_author_status', { required: true, defaultValue: 'enabled' }),
+ switchField('作者是否启用', 'isEnabled', { defaultValue: true }),
textareaField('作者简介', 'intro'),
imageField('作者头像或封面', 'coverUrl')
],
statusFields: [
- { prop: 'authorStatus', label: '作者状态', dict: 'book_author_status' }
+ { prop: 'isEnabled', label: '启用状态', type: 'boolean' }
],
related: [
- { title: '关联书籍', api: 'getBookAuthorRelationList', filterProp: 'authorId', columns: ['bookId', 'authorSort'] }
+ { title: '关联书籍', api: 'getBookAuthorRelationList', filterProp: 'authorId', columns: ['bookTitle', 'bookId', 'authorSort'] }
]
},
{
@@ -174,6 +223,7 @@ export const bookAdminPageConfigs = [
find: 'findBookSeries',
list: 'getBookSeriesList'
},
+ submitFields: seriesSubmitFields,
searchFields: [
textField('系列名称', 'name'),
switchField('启用状态', 'isEnabled')
@@ -195,7 +245,18 @@ export const bookAdminPageConfigs = [
{ prop: 'isEnabled', label: '启用状态', type: 'boolean' }
],
related: [
- { title: '系列下书籍', api: 'getBookList', filterProp: 'seriesId', columns: ['id', 'title', 'publishStatus', 'completionStatus', 'seriesSort'] }
+ {
+ title: '系列下书籍',
+ api: 'getBookList',
+ filterProp: 'seriesId',
+ columns: [
+ relatedColumn('ID', 'id'),
+ relatedColumn('书名', 'title'),
+ relatedColumn('上下架状态', 'publishStatus', { type: 'dict', dict: 'book_publish_status' }),
+ relatedColumn('完结状态', 'completionStatus', { type: 'dict', dict: 'book_completion_status' }),
+ relatedColumn('同系列排序', 'seriesSort')
+ ]
+ }
]
},
{
@@ -210,6 +271,7 @@ export const bookAdminPageConfigs = [
find: 'findBookComment',
list: 'getBookCommentList'
},
+ submitFields: commentSubmitFields,
searchFields: [
numberField('会员用户 ID', 'memberUserId'),
numberField('书籍 ID', 'bookId'),
@@ -219,7 +281,9 @@ export const bookAdminPageConfigs = [
tableColumns: [
idColumn,
{ label: '会员用户 ID', prop: 'memberUserId', width: 120 },
+ { label: '书名', prop: 'bookTitle', minWidth: 180 },
{ label: '书籍 ID', prop: 'bookId', width: 100 },
+ { label: '章节标题', prop: 'chapterTitle', minWidth: 180 },
{ label: '章节 ID', prop: 'chapterId', width: 100 },
{ label: '行序号', prop: 'lineIndex', width: 90 },
{ label: '评论内容', prop: 'content', minWidth: 220 },
@@ -253,6 +317,7 @@ export const bookAdminPageConfigs = [
find: 'findBookReadRecord',
list: 'getBookReadRecordList'
},
+ submitFields: readRecordSubmitFields,
searchFields: [numberField('会员用户 ID', 'memberUserId'), numberField('书籍 ID', 'bookId')],
tableColumns: [
idColumn,
@@ -260,6 +325,7 @@ export const bookAdminPageConfigs = [
{ label: '书籍 ID', prop: 'bookId', width: 100 },
{ label: '书名快照', prop: 'bookTitleSnapshot', minWidth: 180 },
{ label: '阅读进度', prop: 'readProgress', width: 100 },
+ { label: '章节标题', prop: 'chapterTitle', minWidth: 180 },
{ label: '续读章节 ID', prop: 'chapterId', width: 120 },
{ label: '续读行序号', prop: 'lineIndex', width: 120 },
{ label: '最后阅读时间', prop: 'lastReadAt', type: 'datetime', width: 180 }
@@ -287,10 +353,12 @@ export const bookAdminPageConfigs = [
find: 'findBookFavoriteRecord',
list: 'getBookFavoriteRecordList'
},
+ submitFields: favoriteRecordSubmitFields,
searchFields: [numberField('会员用户 ID', 'memberUserId'), numberField('书籍 ID', 'bookId')],
tableColumns: [
idColumn,
{ label: '会员用户 ID', prop: 'memberUserId', width: 120 },
+ { label: '书名', prop: 'bookTitle', minWidth: 180 },
{ label: '书籍 ID', prop: 'bookId', width: 100 },
{ label: '收藏时间', prop: 'favoritedAt', type: 'datetime', width: 180 },
dateColumn
@@ -314,9 +382,12 @@ export const bookAdminPageConfigs = [
find: 'findBookCommentLikeRecord',
list: 'getBookCommentLikeRecordList'
},
+ submitFields: commentLikeRecordSubmitFields,
searchFields: [numberField('评论 ID', 'commentId'), numberField('会员用户 ID', 'memberUserId')],
tableColumns: [
idColumn,
+ { label: '书名', prop: 'bookTitle', minWidth: 180 },
+ { label: '评论内容', prop: 'commentContent', minWidth: 220 },
{ label: '评论 ID', prop: 'commentId', width: 100 },
{ label: '会员用户 ID', prop: 'memberUserId', width: 120 },
{ label: '点赞时间', prop: 'likedAt', type: 'datetime', width: 180 },
diff --git a/web/src/view/book/config/bookAdminDisplay.test.mjs b/web/src/view/book/config/bookAdminDisplay.test.mjs
index f726889..dc04808 100644
--- a/web/src/view/book/config/bookAdminDisplay.test.mjs
+++ b/web/src/view/book/config/bookAdminDisplay.test.mjs
@@ -1,13 +1,44 @@
import assert from 'node:assert/strict'
-import { bookAdminPageConfigs } from './bookAdminConfig.js'
+import { bookAdminPageConfigs, bookAuthorRelationDisplayColumns } from './bookAdminConfig.js'
import { isBookAdminImageColumn, resolveBookAdminColumnType } from './bookAdminDisplay.js'
const getConfig = (key) => bookAdminPageConfigs.find((item) => item.key === key)
const getColumn = (config, prop) => config.tableColumns.find((item) => item.prop === prop)
+const getRelated = (config, title) => config.related.find((item) => item.title === title)
+const getRelatedColumn = (related, prop) => related.columns.find((item) => (typeof item === 'string' ? item : item.prop) === prop)
const authorConfig = getConfig('author')
+const bookConfig = getConfig('book')
+const chapterConfig = getConfig('chapter')
+const commentConfig = getConfig('comment')
+const commentLikeRecordConfig = getConfig('commentLikeRecord')
+const favoriteRecordConfig = getConfig('favoriteRecord')
+const readRecordConfig = getConfig('readRecord')
const seriesConfig = getConfig('series')
+assert.equal(getColumn(authorConfig, 'isEnabled').type, 'boolean')
+assert.equal(authorConfig.searchFields.find((item) => item.prop === 'isEnabled').type, 'switch')
+assert.equal(authorConfig.fields.find((item) => item.prop === 'isEnabled').type, 'switch')
+assert.equal(authorConfig.fields.find((item) => item.prop === 'isEnabled').defaultValue, true)
+assert.equal(authorConfig.statusFields.find((item) => item.prop === 'isEnabled').type, 'boolean')
+assert.equal(authorConfig.searchFields.some((item) => item.prop === 'authorStatus'), false)
+assert.equal(authorConfig.fields.some((item) => item.prop === 'authorStatus'), false)
+assert.deepEqual(bookAuthorRelationDisplayColumns.map((item) => item.prop), ['authorName', 'authorId'])
+assert.deepEqual(getRelated(bookConfig, '关联作者').columns, ['authorName', 'authorId', 'authorSort'])
+assert.deepEqual(getRelated(authorConfig, '关联书籍').columns, ['bookTitle', 'bookId', 'authorSort'])
+assert.deepEqual(getRelated(bookConfig, '关联评论').columns, ['id', 'memberUserId', 'chapterTitle', 'chapterId', 'lineIndex', 'commentStatus'])
+assert.equal(getColumn(chapterConfig, 'bookTitle')?.label, '书名')
+assert.equal(getColumn(commentConfig, 'bookTitle')?.label, '书名')
+assert.equal(getColumn(commentConfig, 'chapterTitle')?.label, '章节标题')
+assert.equal(getColumn(commentLikeRecordConfig, 'bookTitle')?.label, '书名')
+assert.equal(getColumn(commentLikeRecordConfig, 'commentContent')?.label, '评论内容')
+assert.equal(getColumn(favoriteRecordConfig, 'bookTitle')?.label, '书名')
+assert.equal(getColumn(readRecordConfig, 'chapterTitle')?.label, '章节标题')
+assert.equal(getRelatedColumn(getRelated(seriesConfig, '系列下书籍'), 'publishStatus').type, 'dict')
+assert.equal(getRelatedColumn(getRelated(seriesConfig, '系列下书籍'), 'publishStatus').dict, 'book_publish_status')
+assert.equal(getRelatedColumn(getRelated(seriesConfig, '系列下书籍'), 'completionStatus').type, 'dict')
+assert.equal(getRelatedColumn(getRelated(seriesConfig, '系列下书籍'), 'completionStatus').dict, 'book_completion_status')
+
assert.equal(resolveBookAdminColumnType(authorConfig, getColumn(authorConfig, 'coverUrl')), 'image')
assert.equal(resolveBookAdminColumnType(seriesConfig, getColumn(seriesConfig, 'coverUrl')), 'image')
diff --git a/web/src/view/book/config/bookAdminPayload.js b/web/src/view/book/config/bookAdminPayload.js
new file mode 100644
index 0000000..f088257
--- /dev/null
+++ b/web/src/view/book/config/bookAdminPayload.js
@@ -0,0 +1,31 @@
+export const buildBookAdminSubmitPayload = (config, formData, dialogType) => {
+ if (!config?.submitFields?.length) return formData
+
+ const source = formData || {}
+ const fields = dialogType === 'update'
+ ? config.submitFields
+ : config.submitFields.filter((prop) => prop !== 'id')
+
+ return fields.reduce((payload, prop) => {
+ if (Object.prototype.hasOwnProperty.call(source, prop)) {
+ payload[prop] = source[prop]
+ }
+ return payload
+ }, {})
+}
+
+const bookAuthorRelationSubmitFields = ['id', 'bookId', 'authorId', 'authorSort']
+
+export const buildBookAuthorRelationSubmitPayload = (formData, dialogType) => {
+ const source = formData || {}
+ const fields = dialogType === 'update'
+ ? bookAuthorRelationSubmitFields
+ : bookAuthorRelationSubmitFields.filter((prop) => prop !== 'id')
+
+ return fields.reduce((payload, prop) => {
+ if (Object.prototype.hasOwnProperty.call(source, prop)) {
+ payload[prop] = source[prop]
+ }
+ return payload
+ }, {})
+}
diff --git a/web/src/view/book/config/bookAdminPayload.test.mjs b/web/src/view/book/config/bookAdminPayload.test.mjs
new file mode 100644
index 0000000..9eeadc8
--- /dev/null
+++ b/web/src/view/book/config/bookAdminPayload.test.mjs
@@ -0,0 +1,303 @@
+import assert from 'node:assert/strict'
+import { bookAdminPageConfigs } from './bookAdminConfig.js'
+import { buildBookAdminSubmitPayload, buildBookAuthorRelationSubmitPayload } from './bookAdminPayload.js'
+
+const getConfig = (key) => bookAdminPageConfigs.find((item) => item.key === key)
+
+const authorConfig = getConfig('author')
+const bookConfig = getConfig('book')
+const chapterConfig = getConfig('chapter')
+const commentConfig = getConfig('comment')
+const commentLikeRecordConfig = getConfig('commentLikeRecord')
+const favoriteRecordConfig = getConfig('favoriteRecord')
+const readRecordConfig = getConfig('readRecord')
+const seriesConfig = getConfig('series')
+
+assert.equal(bookAdminPageConfigs.every((config) => Array.isArray(config.submitFields) && config.submitFields.length > 0), true)
+
+assert.deepEqual(
+ buildBookAdminSubmitPayload(
+ authorConfig,
+ {
+ id: 12,
+ name: '鲁迅',
+ isEnabled: false,
+ intro: '作者简介',
+ coverUrl: '/uploads/author.png',
+ authorName: '鲁迅',
+ createdAt: '2026-04-01 10:00:00',
+ updatedAt: '2026-04-02 10:00:00'
+ },
+ 'update'
+ ),
+ {
+ id: 12,
+ name: '鲁迅',
+ isEnabled: false,
+ intro: '作者简介',
+ coverUrl: '/uploads/author.png'
+ }
+)
+
+assert.deepEqual(
+ buildBookAdminSubmitPayload(
+ authorConfig,
+ {
+ id: 12,
+ name: '鲁迅',
+ isEnabled: true,
+ intro: '',
+ coverUrl: '',
+ createdAt: '2026-04-01 10:00:00'
+ },
+ 'create'
+ ),
+ {
+ name: '鲁迅',
+ isEnabled: true,
+ intro: '',
+ coverUrl: ''
+ }
+)
+
+assert.deepEqual(
+ buildBookAdminSubmitPayload(
+ authorConfig,
+ {
+ id: 13,
+ name: '禁用作者',
+ isEnabled: false,
+ intro: '',
+ coverUrl: '',
+ createdAt: '2026-04-01 10:00:00'
+ },
+ 'create'
+ ),
+ {
+ name: '禁用作者',
+ isEnabled: false,
+ intro: '',
+ coverUrl: ''
+ }
+)
+
+assert.deepEqual(
+ buildBookAdminSubmitPayload(
+ bookConfig,
+ {
+ id: 1,
+ title: '测试书籍',
+ subtitle: '副标题',
+ bookType: 'novel',
+ eraTag: 'unknown',
+ coverUrl: '/uploads/book.png',
+ publisher: '测试出版社',
+ publishedAt: '2026-04-01',
+ intro: '简介',
+ hotScore: 0,
+ rating: 8.5,
+ commentCount: 0,
+ wordCount: 1000,
+ completionStatus: 'serializing',
+ publishStatus: 'draft',
+ seriesId: 3,
+ seriesSort: 0,
+ rawTxtUrl: '/uploads/book.txt',
+ createdAt: '2026-04-01 10:00:00',
+ updatedAt: '2026-04-02 10:00:00'
+ },
+ 'update'
+ ),
+ {
+ id: 1,
+ title: '测试书籍',
+ subtitle: '副标题',
+ bookType: 'novel',
+ eraTag: 'unknown',
+ coverUrl: '/uploads/book.png',
+ publisher: '测试出版社',
+ publishedAt: '2026-04-01',
+ intro: '简介',
+ hotScore: 0,
+ rating: 8.5,
+ commentCount: 0,
+ wordCount: 1000,
+ completionStatus: 'serializing',
+ publishStatus: 'draft',
+ seriesId: 3,
+ seriesSort: 0,
+ rawTxtUrl: '/uploads/book.txt'
+ }
+)
+
+assert.deepEqual(
+ buildBookAdminSubmitPayload(chapterConfig, {
+ id: 9,
+ bookId: 1,
+ title: '第一章',
+ chapterNo: 1,
+ isReadable: false,
+ contentFileUrl: '/uploads/chapter.txt',
+ totalLines: 0,
+ isEnabled: false,
+ createdAt: '2026-04-01 10:00:00'
+ }, 'update'),
+ {
+ id: 9,
+ bookId: 1,
+ title: '第一章',
+ chapterNo: 1,
+ isReadable: false,
+ contentFileUrl: '/uploads/chapter.txt',
+ totalLines: 0,
+ isEnabled: false
+ }
+)
+
+assert.deepEqual(
+ buildBookAdminSubmitPayload(chapterConfig, {
+ id: 10,
+ bookId: 1,
+ title: '第二章',
+ chapterNo: 2,
+ isReadable: false,
+ contentFileUrl: '/uploads/chapter-2.txt',
+ totalLines: 0,
+ isEnabled: false,
+ createdAt: '2026-04-01 10:00:00'
+ }, 'create'),
+ {
+ bookId: 1,
+ title: '第二章',
+ chapterNo: 2,
+ isReadable: false,
+ contentFileUrl: '/uploads/chapter-2.txt',
+ totalLines: 0,
+ isEnabled: false
+ }
+)
+
+assert.deepEqual(
+ buildBookAdminSubmitPayload(seriesConfig, {
+ id: 6,
+ name: '系列',
+ coverUrl: '',
+ intro: '',
+ isEnabled: false,
+ updatedAt: '2026-04-02 10:00:00'
+ }, 'create'),
+ {
+ name: '系列',
+ coverUrl: '',
+ intro: '',
+ isEnabled: false
+ }
+)
+
+assert.deepEqual(
+ buildBookAdminSubmitPayload(commentConfig, {
+ id: 31,
+ memberUserId: 8,
+ bookId: 1,
+ bookTitle: '边城',
+ chapterId: 4,
+ chapterTitle: '第一章 茶峒',
+ lineIndex: 6,
+ content: '这段很有画面感',
+ likeCount: 2,
+ commentStatus: 'normal',
+ createdAt: '2026-04-01 10:00:00'
+ }, 'update'),
+ {
+ id: 31,
+ memberUserId: 8,
+ bookId: 1,
+ chapterId: 4,
+ lineIndex: 6,
+ content: '这段很有画面感',
+ likeCount: 2,
+ commentStatus: 'normal'
+ }
+)
+
+assert.deepEqual(
+ buildBookAdminSubmitPayload(readRecordConfig, {
+ id: 41,
+ memberUserId: 8,
+ bookId: 1,
+ bookTitleSnapshot: '边城',
+ readProgress: 33.5,
+ chapterId: 4,
+ chapterTitle: '第一章 茶峒',
+ lineIndex: 10,
+ lastReadAt: '2026-04-02 10:00:00',
+ updatedAt: '2026-04-02 10:00:00'
+ }, 'update'),
+ {
+ id: 41,
+ memberUserId: 8,
+ bookId: 1,
+ bookTitleSnapshot: '边城',
+ readProgress: 33.5,
+ chapterId: 4,
+ lineIndex: 10,
+ lastReadAt: '2026-04-02 10:00:00'
+ }
+)
+
+assert.deepEqual(
+ buildBookAdminSubmitPayload(favoriteRecordConfig, {
+ id: 51,
+ memberUserId: 8,
+ bookId: 1,
+ bookTitle: '边城',
+ favoritedAt: '2026-04-02 10:00:00',
+ createdAt: '2026-04-01 10:00:00'
+ }, 'update'),
+ {
+ id: 51,
+ memberUserId: 8,
+ bookId: 1,
+ favoritedAt: '2026-04-02 10:00:00'
+ }
+)
+
+assert.deepEqual(
+ buildBookAdminSubmitPayload(commentLikeRecordConfig, {
+ id: 61,
+ commentId: 31,
+ memberUserId: 8,
+ bookTitle: '边城',
+ commentContent: '这段很有画面感',
+ likedAt: '2026-04-02 10:00:00',
+ updatedAt: '2026-04-02 10:00:00'
+ }, 'update'),
+ {
+ id: 61,
+ commentId: 31,
+ memberUserId: 8,
+ likedAt: '2026-04-02 10:00:00'
+ }
+)
+
+assert.deepEqual(
+ buildBookAuthorRelationSubmitPayload(
+ {
+ id: 21,
+ bookId: 3,
+ authorId: 12,
+ authorSort: 2,
+ bookTitle: '呐喊',
+ authorName: '鲁迅',
+ createdAt: '2026-04-01 10:00:00',
+ updatedAt: '2026-04-02 10:00:00'
+ },
+ 'update'
+ ),
+ {
+ id: 21,
+ bookId: 3,
+ authorId: 12,
+ authorSort: 2
+ }
+)