diff --git a/README-en.md b/README-en.md
index 0ba961c..e69de29 100644
--- a/README-en.md
+++ b/README-en.md
@@ -1,317 +0,0 @@
-
-
-

-
-
-
-English | [简体中文](./README.md)
-
-[gitee](https://gitee.com/pixelmax/gin-vue-admin): https://gitee.com/pixelmax/gin-vue-admin
-
-[github](https://github.com/flipped-aurora/gin-vue-admin): https://github.com/flipped-aurora/gin-vue-admin
-
-# Project Guidelines
-[Online Documentation](https://www.gin-vue-admin.com/) : https://www.gin-vue-admin.com/
-
-[From the environment to the deployment of teaching videos](https://www.bilibili.com/video/BV1fV411y7dT)
-
-[Development Steps](https://www.gin-vue-admin.com/guide/start-quickly/env.html) (Contributor: LLemonGreen And Fann)
-
-## 1. Basic Introduction
-
-### 1.1 Project Introduction
-
-> Gin-vue-admin is a backstage management system based on [vue](https://vuejs.org) and [gin](https://gin-gonic.com), which separates the front and rear of the full stack. It integrates jwt authentication, dynamic routing, dynamic menu, casbin authentication, form generator, code generator and other functions. It provides a variety of sample files, allowing you to focus more time on business development.
-
-[Online Demo](http://demo.gin-vue-admin.com): http://demo.gin-vue-admin.com
-
-username:admin
-
-password:123456
-
-### 1.2 Contributing Guide
-
-Hi! Thank you for choosing gin-vue-admin.
-
-Gin-vue-admin is a full-stack (frontend and backend separation) framework for developers, designers and product managers.
-
-We are excited that you are interested in contributing to gin-vue-admin. Before submitting your contribution though, please make sure to take a moment and read through the following guidelines.
-
-#### 1.2.1 Issue Guidelines
-
-- Issues are exclusively for bug reports, feature requests and design-related topics. Other questions may be closed directly. If any questions come up when you are using Element, please hit [Gitter](https://gitter.im/element-en/Lobby) for help.
-
-- Before submitting an issue, please check if similar problems have already been issued.
-
-#### 1.2.2 Pull Request Guidelines
-
-- Fork this repository to your own account. Do not create branches here.
-
-- Commit info should be formatted as `[File Name]: Info about commit.` (e.g. `README.md: Fix xxx bug`)
-
-- Make sure PRs are created to `develop` branch instead of `master` branch.
-
-- If your PR fixes a bug, please provide a description about the related bug.
-
-- Merging a PR takes two maintainers: one approves the changes after reviewing, and then the other reviews and merges.
-
-### 1.3 Version list
-
-- master: 2.0 code, for prod
-- develop: 2.0 dev code, for test
-- [gin-vue-admin_v2_dev](https://github.com/flipped-aurora/gin-vue-admin/tree/gin-vue-admin_v2_dev) (v2.0 [GormV1](https://v1.gorm.io) Stable branch)
-- [gva_gormv2_dev](https://github.com/flipped-aurora/gin-vue-admin/tree/gva_gormv2_dev) (v2.0 [GormV2](https://v2.gorm.io) Development branch)
-
-## 2. Getting started
-
-```
-- node version > v8.6.0
-- golang version >= v1.14
-- IDE recommendation: Goland
-- initialization project: different versions of the database are not initialized. See synonyms at initialization https://www.gin-vue-admin.com/docs/first
-- Replace the Qiniuyun public key, private key, warehouse name and default url address in the project to avoid data confusion in the test file.
-```
-
-### 2.1 server project
-
-use `Goland` And other editing tools,open server catalogue,You can't open it. `gin-vue-admin` root directory
-
-```bash
-# clone the project
-git clone https://github.com/flipped-aurora/gin-vue-admin.git
-
-# open server catalogue
-cd server
-
-# use go mod And install the go dependency package
-go generate
-
-# Compile
-go build -o server main.go (windows the compile command is go build -o server.exe main.go )
-
-# Run binary
-./server (windows The run command is server.exe)
-```
-
-### 2.1 web project
-
-```bash
-# enter the project directory
-cd web
-
-# install dependency
-npm install
-
-# develop
-npm run serve
-```
-
-### 2.2 Server
-
-```bash
-# using go.mod
-
-# install go modules
-go list (go mod tidy)
-
-# build the server
-go build
-```
-
-### 2.3 API docs auto-generation using swagger
-
-#### 2.3.1 install swagger
-
-##### (1) Using VPN or outside mainland China
-````
-go get -u github.com/swaggo/swag/cmd/swag
-````
-
-##### (2) In mainland China
-
-In mainland China, access to go.org/x is prohibited,we recommend [goproxy.io](https://goproxy.io/zh/) or [goproxy.cn](https://goproxy.cn)
-
-````bash
-# If you are using a version of Go 1.13 - 1.15 Need to set up manually GO111MODULE=on, The opening mode is as follows, If your Go version is 1.16 ~ Latest edition You can ignore the following step one
-# Step one、Enable Go Modules Function
-go env -w GO111MODULE=on
-# Step two、Configuration GOPROXY Environment variable
-go env -w GOPROXY=https://goproxy.cn,https://goproxy.io,direct
-
-# If you dislike trouble,You can use the go generate Automatically execute code before compilation, But this can't be used command line terminal of `Goland` or `Vscode`
-cd server
-go generate -run "go env -w .*?"
-
-# Use the following command to download swag
-go get -u github.com/swaggo/swag/cmd/swag
-````
-
-#### 2.3.2 API docs generation
-
-````
-cd server
-swag init
-````
-
-> After executing the above command,server directory will appear in the docs folder `docs.go`, `swagger.json`, `swagger.yaml` Three file updates,After starting the go service, type in the browser [http://localhost:8888/swagger/index.html](http://localhost:8888/swagger/index.html) You can view swagger document
-
-
-## 3. Technical selection
-
-- Frontend: using [Element](https://github.com/ElemeFE/element) based on [Vue](https://vuejs.org),to code the page.
-- Backend: using [Gin](https://gin-gonic.com/) to quickly build basic RESTful API. [Gin](https://gin-gonic.com/)is a web framework written in Go (Golang).
-- DB: `MySql`(5.6.44),using [gorm](http://gorm.io)` to implement data manipulation, added support for SQLite databases.
-- Cache: using `Redis` to implement the recording of the JWT token of the currently active user and implement the multi-login restriction.
-- API: using Swagger to auto generate APIs docs。
-- Config: using [fsnotify](https://github.com/fsnotify/fsnotify) and [viper](https://github.com/spf13/viper) to implement `yaml` config file。
-- Log: using [zap](https://github.com/uber-go/zap) record logs。
-
-## 4. Project Architecture
-
-### 4.1 Architecture Diagram
-
-
-
-### 4.2 Front-end Detailed Design Diagram (Contributor: baobeisuper)
-
-
-
-### 4.3 Project Layout
-
-```
- ├── server
- ├── api (api entrance)
- │ └── v1 (v1 version interface)
- ├── config (configuration package)
- ├── core (core document)
- ├── docs (swagger document directory)
- ├── global (global object)
- ├── initialize (initialization)
- │ └── internal (initialize internal function)
- ├── middleware (middleware layer)
- ├── model (model layer)
- │ ├── request (input parameter structure)
- │ └── response (out-of-parameter structure)
- ├── packfile (static file packaging)
- ├── resource (static resource folder)
- │ ├── excel (excel import and export default path)
- │ ├── page (form generator)
- │ └── template (template)
- ├── router (routing layer)
- ├── service (service layer)
- ├── source (source layer)
- └── utils (tool kit)
- ├── timer (timer interface encapsulation)
- └── upload (oss interface encapsulation)
-
- └─web (frontend)
- ├─public (deploy templates)
- └─src (source code)
- ├─api (frontend APIs)
- ├─assets (static files)
- ├─components(components)
- ├─router (frontend routers)
- ├─store (vuex state management)
- ├─style (common styles)
- ├─utils (frontend common utilitie)
- └─view (pages)
-
-```
-
-## 5. Features
-
-- Authority management: Authority management based on `jwt` and `casbin`.
-- File upload and download: implement file upload operations based on `Qiniuyun', `Aliyun 'and `Tencent Cloud` (please develop your own application for each platform corresponding to `token` or `key` ).
-- Pagination Encapsulation:The frontend uses `mixins` to encapsulate paging, and the paging method can call `mixins` .
-- User management: The system administrator assigns user roles and role permissions.
-- Role management: Create the main object of permission control, and then assign different API permissions and menu permissions to the role.
-- Menu management: User dynamic menu configuration implementation, assigning different menus to different roles.
-- API management: Different users can call different API permissions.
-- Configuration management: the configuration file can be modified in the foreground (this feature is not available in the online experience site).
-- Conditional search: Add an example of conditional search.
-- Restful example: You can see sample APIs in user management module.
- - Front-end file reference: [web/src/view/superAdmin/api/api.vue](https://github.com/flipped-aurora/gin-vue-admin/blob/master/web/src/view/superAdmin/api/api.vue).
- - Stage reference: [server/router/sys_api.go](https://github.com/flipped-aurora/gin-vue-admin/blob/master/server/router/sys_api.go).
-- Multi-login restriction: Change `user-multipoint` to true in `system` in `config.yaml` (You need to configure redis and redis parameters yourself. During the test period, please report in time if there is a bug).
-- Upload file by chunk:Provides examples of file upload and large file upload by chunk.
-- Form Builder:With the help of [@form-generator](https://github.com/JakHuang/form-generator).
-- Code generator: Providing backend with basic logic and simple curd code generator.
-
-## 6. Knowledge base
-
-### 6.1 Team blog
-
-> https://www.yuque.com/flipped-aurora
->
->There are video courses about frontend framework in our blo. If you think the project is helpful to you, you can add my personal WeChat:shouzi_1994,your comments is welcomed。
-
-### 6.2 Video courses
-
-(1) Development environment course
-
-> Bilibili:https://www.bilibili.com/video/BV1Fg4y187Bw/
-
-(2) Template course
-
-> Bilibili:https://www.bilibili.com/video/BV16K4y1r7BD/
-
-(3) 2.0 version introduction and development experience
-
-> Bilibili:https://www.bilibili.com/video/BV1aV411d7Gm#reply2831798461
-
-(4) Golang basic course
-
-> https://space.bilibili.com/322210472/channel/detail?cid=108884
-
-(5) gin frame basic teaching
-
-> bilibili:https://space.bilibili.com/322210472/channel/detail?cid=126418&ctype=0
-
-(6) gin-vue-admin version update introduction video
-> bilibili:https://space.bilibili.com/322210472/channel/detail?cid=126418&ctype=0
-
-## 7.Contacts
-
-### 7.1 Groups
-
-#### QQ group: 622360840
-
-| QQ group |d
-| :---: |
-|
|
-
-
-#### Wechat group: comment "加入gin-vue-admin交流群"
-
-| Wechat |
-| :---: |
-|
-
-#### [About Us](https://www.gin-vue-admin.com/about/join.html)
-
-## 8. Contributors
-
-Thank you for considering your contribution to gin-vue-admin!
-
-
-
-
-
-
-
-
-
-## 9. Donate
-
-If you find this project useful, you can buy author a glass of juice :tropical_drink: [here](https://www.gin-vue-admin.com/coffee/index.html)
-
-## 10. Commercial considerations
-
-If you use this project for commercial purposes, please comply with the Apache2.0 agreement and retain the author's technical support statement.
-
diff --git a/web/.ai-specs/coding-specs/common-page-create-spec.md b/web/.ai-specs/coding-specs/common-page-create-spec.md
new file mode 100644
index 0000000..1708be1
--- /dev/null
+++ b/web/.ai-specs/coding-specs/common-page-create-spec.md
@@ -0,0 +1,71 @@
+# common-page-create-spec
+
+## 适用范围
+
+- 涉及新增通用页面、列表页、详情页、表单页、页面占位页时必读。
+- 涉及页面目录落点、接口接入、本地路由/远程路由选择、菜单配置、`curl` 联调时必读。
+
+## 创建主链路
+
+- 先判定页面是否需要登录后访问、菜单展示、角色授权、默认首页、keep-alive;再决定走本地路由还是远程路由。
+- 页面源码默认放在 `src/view/`;页面私有子组件放在 `src/view//components`。
+- 页面请求统一放在 `src/api/.js`;插件页面请求统一放在 `src/plugin//api/.js`。
+- 页面只负责编排、生命周期和页面局部状态;不要在页面内直写 axios,也不要把单页临时状态默认上提到 `pinia`。
+
+## 路由接入规则
+
+```mermaid
+flowchart LR
+ A["新增页面"] --> B{"是否需要登录后菜单/权限/默认首页?"}
+ B -- "否" --> C["本地路由: src/router/index.js"]
+ B -- "是" --> D["远程路由: 后台 menu + 前端动态注入"]
+ C --> E["同步检查 src/permission.js 白名单与跳转"]
+ D --> F["同步检查菜单权限、keep-alive、defaultRouter"]
+```
+
+- 本地路由:仅用于登录页、初始化页、公开页、扫码页、明确不依赖后台菜单的工具页。入口固定 `src/router/index.js`,并同步检查 `src/permission.js` 白名单和未登录跳转。
+- 远程路由:用于登录后业务页、需要左侧菜单、角色授权、默认首页、按钮权限、keep-alive 的页面。路由来源是 `/menu/getMenu`,前端在 `src/pinia/modules/router.js` 中拉取后动态注入。
+- 远程路由的 `component` 只允许写 `view/...` 或 `plugin/...` 字符串;对应文件必须真实存在,且能被 `src/utils/asyncRouter.js` 命中。
+- 远程路由的 `component` 不要写成 `/src/view/...`、Windows 反斜杠路径或别名导入字符串;当前动态映射只认相对 `src` 的正斜杠路径。
+- 页面需要承载子路由时,父页面必须是 `router-view` 占位页;不要把普通内容页直接拿来做父级容器。
+- `meta.defaultMenu` 只用于明确需要脱离常规 `layout` 承载的基础页面;普通业务页默认保持 `false`。
+
+## 菜单与页面最小约定
+
+- 页面目录命名跟随现有业务语义,页面入口优先 `src/view//index.vue`。
+- 远程路由最小字段必须保证:`name` 唯一、`path` 稳定、`component` 可映射、`meta.title` 可展示。
+- `name` 使用唯一英文标识;改名时必须同步检查菜单高亮、keep-alive、`defaultRouter` 和页面标题。
+- `path` 默认与 `name` 同步;只有明确需要参数化路径时才额外拼接,不要把查询条件硬塞进路由 path。
+- 需要缓存页签时设置 `meta.keepAlive`;需要进入后自动关闭 tab 时设置 `meta.closeTab`。
+- 页面进入菜单体系后,新增菜单不等于可访问;还必须补角色授权,否则页面可能存在但用户不可见。
+
+## curl 联调案例
+
+- 联调地址默认取 `VITE_BASE_API`;认证头遵循 `src/utils/request.js` 当前约定:`Content-Type`、`x-token`、`x-user-id`。
+- 页面业务接口先用 `curl` 跑通,再落 `src/api/*`;不要先在页面里硬编码请求排查接口。
+
+```bash
+curl --location --request POST "$BASE_URL/example/list" \
+ --header "Content-Type: application/json" \
+ --header "x-token: " \
+ --header "x-user-id: " \
+ --data-raw "{\"page\":1,\"pageSize\":10}"
+```
+
+```bash
+curl --location --request POST "$BASE_URL/menu/addBaseMenu" \
+ --header "Content-Type: application/json" \
+ --header "x-token: " \
+ --header "x-user-id: " \
+ --data-raw "{\"path\":\"demoPage\",\"name\":\"DemoPage\",\"component\":\"view/demoPage/index.vue\",\"parentId\":0,\"hidden\":false,\"sort\":10,\"meta\":{\"title\":\"Demo 页面\",\"icon\":\"House\",\"keepAlive\":false,\"closeTab\":false,\"defaultMenu\":false},\"parameters\":[],\"menuBtn\":[]}"
+```
+
+- 新增远程路由后,至少回查一次 `/menu/getMenu` 返回中是否已包含目标菜单,再排查前端注入问题。
+
+## 常见错误
+
+- 只创建了 `src/view` 页面文件,没有补本地路由或远程菜单配置。
+- 该走远程路由的业务页被塞进本地路由,导致菜单、角色权限、默认首页链路失效。
+- 远程路由 `component` 写错路径格式,导致 `asyncRouter.js` 找不到页面组件。
+- 页面里直接写 axios 请求或直接拼 token,绕过 `src/api` 和 `src/utils/request.js`。
+- 改了路由 `name/path`,没有同步检查 `keepAlive`、菜单高亮、`defaultRouter` 和未登录跳转。
diff --git a/web/.ai-specs/sys-specs/init.md b/web/.ai-specs/sys-specs/init.md
new file mode 100644
index 0000000..ddeb0ac
--- /dev/null
+++ b/web/.ai-specs/sys-specs/init.md
@@ -0,0 +1,6 @@
+.ai-specs\coding-specs
+
+api 调用规范
+字典调用规范
+路由`新增/修改`规范 并且指出 本地路由和远程路由
+通用页面创建规范
diff --git a/web/.ai-transition/book-admin-menu-scripts/01-create-book-admin-menus.browser-console.js b/web/.ai-transition/book-admin-menu-scripts/01-create-book-admin-menus.browser-console.js
new file mode 100644
index 0000000..8876c0e
--- /dev/null
+++ b/web/.ai-transition/book-admin-menu-scripts/01-create-book-admin-menus.browser-console.js
@@ -0,0 +1,241 @@
+/**
+ * 书籍后台管理菜单创建脚本
+ *
+ * 使用方式:
+ * 1. 登录后台管理前端。
+ * 2. 打开浏览器控制台。
+ * 3. 如前端接口代理不是 /api,先执行:
+ * window.__XUANZHI_BASE_API__ = 'http://127.0.0.1:8888'
+ * 4. 粘贴本文件全部内容并执行。
+ *
+ * 说明:
+ * - 脚本会先读取现有菜单树,已存在的菜单不会重复创建。
+ * - /menu/addBaseMenu 不返回新菜单 ID,所以每次创建后会重新读取菜单树定位 ID。
+ */
+(async () => {
+ const BASE_API = window.__XUANZHI_BASE_API__ || '/api'
+ const TOKEN = window.__XUANZHI_TOKEN__ || localStorage.getItem('token') || getCookie('x-token')
+ let userId = window.__XUANZHI_USER_ID__ || localStorage.getItem('userId') || ''
+
+ const menus = [
+ {
+ key: 'bookRoot',
+ parentKey: null,
+ path: 'book',
+ name: 'book',
+ component: 'view/book/index.vue',
+ sort: 60,
+ hidden: false,
+ meta: {
+ title: '书籍管理',
+ icon: 'Reading',
+ keepAlive: false,
+ closeTab: false,
+ defaultMenu: false
+ }
+ },
+ {
+ key: 'bookManage',
+ parentKey: 'bookRoot',
+ path: 'bookManage',
+ name: 'bookManage',
+ component: 'view/book/book/index.vue',
+ sort: 10,
+ hidden: false,
+ meta: { title: '书籍管理', icon: 'Notebook', keepAlive: false, closeTab: false, defaultMenu: false }
+ },
+ {
+ key: 'bookChapterManage',
+ parentKey: 'bookRoot',
+ path: 'bookChapterManage',
+ name: 'bookChapterManage',
+ component: 'view/book/chapter/index.vue',
+ sort: 20,
+ hidden: false,
+ meta: { title: '章节管理', icon: 'Tickets', keepAlive: false, closeTab: false, defaultMenu: false }
+ },
+ {
+ key: 'bookAuthorManage',
+ parentKey: 'bookRoot',
+ path: 'bookAuthorManage',
+ name: 'bookAuthorManage',
+ component: 'view/book/author/index.vue',
+ sort: 30,
+ hidden: false,
+ meta: { title: '作者管理', icon: 'User', keepAlive: false, closeTab: false, defaultMenu: false }
+ },
+ {
+ key: 'bookSeriesManage',
+ parentKey: 'bookRoot',
+ path: 'bookSeriesManage',
+ name: 'bookSeriesManage',
+ component: 'view/book/series/index.vue',
+ sort: 40,
+ hidden: false,
+ meta: { title: '系列管理', icon: 'Collection', keepAlive: false, closeTab: false, defaultMenu: false }
+ },
+ {
+ key: 'bookCommentManage',
+ parentKey: 'bookRoot',
+ path: 'bookCommentManage',
+ name: 'bookCommentManage',
+ component: 'view/book/comment/index.vue',
+ sort: 50,
+ hidden: false,
+ meta: { title: '评论管理', icon: 'ChatLineSquare', keepAlive: false, closeTab: false, defaultMenu: false }
+ },
+ {
+ key: 'bookReadRecordManage',
+ parentKey: 'bookRoot',
+ path: 'bookReadRecordManage',
+ name: 'bookReadRecordManage',
+ component: 'view/book/readRecord/index.vue',
+ sort: 60,
+ hidden: false,
+ meta: { title: '阅读记录管理', icon: 'View', keepAlive: false, closeTab: false, defaultMenu: false }
+ },
+ {
+ key: 'bookFavoriteRecordManage',
+ parentKey: 'bookRoot',
+ path: 'bookFavoriteRecordManage',
+ name: 'bookFavoriteRecordManage',
+ component: 'view/book/favoriteRecord/index.vue',
+ sort: 70,
+ hidden: false,
+ meta: { title: '收藏记录管理', icon: 'Star', keepAlive: false, closeTab: false, defaultMenu: false }
+ },
+ {
+ key: 'bookCommentLikeRecordManage',
+ parentKey: 'bookRoot',
+ path: 'bookCommentLikeRecordManage',
+ name: 'bookCommentLikeRecordManage',
+ component: 'view/book/commentLikeRecord/index.vue',
+ sort: 80,
+ hidden: false,
+ meta: { title: '评论点赞记录管理', icon: 'Pointer', keepAlive: false, closeTab: false, defaultMenu: false }
+ }
+ ]
+
+ if (!TOKEN) {
+ throw new Error('未找到 token,请先登录后台,或手动设置 window.__XUANZHI_TOKEN__')
+ }
+
+ userId = userId || await getCurrentUserId()
+
+ const createdOrFound = new Map()
+
+ for (const item of menus) {
+ const parentId = item.parentKey ? getMenuId(createdOrFound.get(item.parentKey)) : 0
+ const menu = await ensureMenu({ ...item, parentId })
+ createdOrFound.set(item.key, menu)
+ console.log(`[menu] ${item.meta.title} -> ID=${getMenuId(menu)} (${menu.component || item.component})`)
+ }
+
+ const result = Object.fromEntries(
+ [...createdOrFound.entries()].map(([key, menu]) => [key, getMenuId(menu)])
+ )
+ localStorage.setItem('xuanzhi-book-menu-ids', JSON.stringify(result))
+ console.log('书籍后台菜单创建/确认完成,菜单 ID 已写入 localStorage.xuanzhi-book-menu-ids:', result)
+ console.log('下一步:执行 02-assign-book-admin-menus-to-roles.browser-console.js 给角色分配菜单。')
+
+ async function ensureMenu(menu) {
+ const existed = await findMenu(menu)
+ if (existed) return existed
+
+ const payload = {
+ path: menu.path,
+ name: menu.name,
+ hidden: menu.hidden,
+ parentId: menu.parentId,
+ component: menu.component,
+ sort: menu.sort,
+ meta: menu.meta,
+ parameters: [],
+ menuBtn: []
+ }
+
+ await apiPost('/menu/addBaseMenu', payload)
+
+ const created = await findMenu(menu)
+ if (!created) {
+ throw new Error(`菜单已提交但未在菜单树中找到:${menu.name} / ${menu.component}`)
+ }
+ return created
+ }
+
+ async function findMenu(menu) {
+ const tree = await getBaseMenuTree()
+ const flat = flattenMenus(tree)
+ return flat.find((item) =>
+ item.name === menu.name ||
+ item.path === menu.path ||
+ item.component === menu.component
+ )
+ }
+
+ async function getBaseMenuTree() {
+ const res = await apiPost('/menu/getBaseMenuTree', {})
+ return res.data?.menus || []
+ }
+
+ async function getCurrentUserId() {
+ try {
+ const res = await apiGet('/user/getUserInfo')
+ return res.data?.userInfo?.ID || res.data?.userInfo?.id || ''
+ } catch (error) {
+ console.warn('读取当前用户 ID 失败,将不带 x-user-id 调用菜单接口。必要时可手动设置 window.__XUANZHI_USER_ID__。', error)
+ return ''
+ }
+ }
+
+ async function apiGet(path) {
+ return apiRequest(path, { method: 'GET' })
+ }
+
+ async function apiPost(path, data) {
+ return apiRequest(path, {
+ method: 'POST',
+ body: JSON.stringify(data)
+ })
+ }
+
+ async function apiRequest(path, options = {}) {
+ const res = await fetch(`${BASE_API}${path}`, {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-token': TOKEN,
+ 'x-user-id': String(userId || ''),
+ ...(options.headers || {})
+ }
+ })
+ const json = await res.json().catch(() => ({}))
+ if (!res.ok || json.code !== 0) {
+ throw new Error(`${path} 调用失败:${json.msg || res.statusText}`)
+ }
+ return json
+ }
+
+ function flattenMenus(list) {
+ const result = []
+ const walk = (items) => {
+ ;(items || []).forEach((item) => {
+ result.push(item)
+ walk(item.children)
+ })
+ }
+ walk(list)
+ return result
+ }
+
+ function getMenuId(menu) {
+ return menu?.ID || menu?.id || menu?.menuId
+ }
+
+ function getCookie(name) {
+ return document.cookie
+ .split('; ')
+ .find((row) => row.startsWith(`${name}=`))
+ ?.split('=')[1] || ''
+ }
+})()
diff --git a/web/.ai-transition/book-admin-menu-scripts/02-assign-book-admin-menus-to-roles.browser-console.js b/web/.ai-transition/book-admin-menu-scripts/02-assign-book-admin-menus-to-roles.browser-console.js
new file mode 100644
index 0000000..2bf9991
--- /dev/null
+++ b/web/.ai-transition/book-admin-menu-scripts/02-assign-book-admin-menus-to-roles.browser-console.js
@@ -0,0 +1,157 @@
+/**
+ * 书籍后台管理菜单角色授权脚本
+ *
+ * 使用方式:
+ * 1. 先执行 01-create-book-admin-menus.browser-console.js。
+ * 2. 登录后台管理前端,打开浏览器控制台。
+ * 3. 如需给多个角色授权,先设置角色 ID:
+ * window.__XUANZHI_BOOK_AUTHORITY_IDS__ = [888, 8881]
+ * 4. 如前端接口代理不是 /api,先执行:
+ * window.__XUANZHI_BASE_API__ = 'http://127.0.0.1:8888'
+ * 5. 粘贴本文件全部内容并执行。
+ *
+ * 说明:
+ * - /menu/addMenuAuthority 是全量覆盖角色菜单。
+ * - 本脚本会先读取角色已有菜单,再合并书籍菜单,避免覆盖原有权限。
+ */
+(async () => {
+ const BASE_API = window.__XUANZHI_BASE_API__ || '/api'
+ const TOKEN = window.__XUANZHI_TOKEN__ || localStorage.getItem('token') || getCookie('x-token')
+ const AUTHORITY_IDS = window.__XUANZHI_BOOK_AUTHORITY_IDS__ || [888]
+ const TARGET_MENU_NAMES = [
+ 'book',
+ 'bookManage',
+ 'bookChapterManage',
+ 'bookAuthorManage',
+ 'bookSeriesManage',
+ 'bookCommentManage',
+ 'bookReadRecordManage',
+ 'bookFavoriteRecordManage',
+ 'bookCommentLikeRecordManage'
+ ]
+ let userId = window.__XUANZHI_USER_ID__ || localStorage.getItem('userId') || ''
+
+ if (!TOKEN) {
+ throw new Error('未找到 token,请先登录后台,或手动设置 window.__XUANZHI_TOKEN__')
+ }
+ if (!Array.isArray(AUTHORITY_IDS) || AUTHORITY_IDS.length === 0) {
+ throw new Error('请设置 window.__XUANZHI_BOOK_AUTHORITY_IDS__,例如:[888, 8881]')
+ }
+
+ userId = userId || await getCurrentUserId()
+
+ const allMenus = flattenMenus(await getBaseMenuTree())
+ const targetMenus = TARGET_MENU_NAMES.map((name) => {
+ const menu = allMenus.find((item) => item.name === name)
+ if (!menu) throw new Error(`未找到菜单 ${name},请先执行创建菜单脚本。`)
+ return menu
+ })
+
+ for (const authorityId of AUTHORITY_IDS) {
+ const currentMenus = await getMenuAuthority(authorityId)
+ const merged = mergeMenusById(currentMenus, targetMenus)
+ await apiPost('/menu/addMenuAuthority', {
+ authorityId,
+ menus: merged
+ })
+ console.log(`[authority] ${authorityId} 已授权书籍后台菜单,合并后菜单数量:${merged.length}`)
+ }
+
+ console.log('书籍后台菜单角色授权完成。刷新页面或重新登录后可查看菜单。')
+
+ async function getBaseMenuTree() {
+ const res = await apiPost('/menu/getBaseMenuTree', {})
+ return res.data?.menus || []
+ }
+
+ async function getMenuAuthority(authorityId) {
+ const res = await apiPost('/menu/getMenuAuthority', { authorityId })
+ return res.data?.menus || []
+ }
+
+ function mergeMenusById(currentMenus, targetMenus) {
+ const map = new Map()
+ ;[...(currentMenus || []), ...(targetMenus || [])].forEach((item) => {
+ const id = getMenuId(item)
+ if (!id) return
+ map.set(Number(id), normalizeMenu(item))
+ })
+ return [...map.values()]
+ }
+
+ function normalizeMenu(item) {
+ return {
+ ID: getMenuId(item),
+ path: item.path,
+ name: item.name,
+ hidden: item.hidden,
+ parentId: item.parentId,
+ component: item.component,
+ sort: item.sort,
+ meta: item.meta,
+ parameters: item.parameters || [],
+ menuBtn: item.menuBtn || []
+ }
+ }
+
+ async function getCurrentUserId() {
+ try {
+ const res = await apiGet('/user/getUserInfo')
+ return res.data?.userInfo?.ID || res.data?.userInfo?.id || ''
+ } catch (error) {
+ console.warn('读取当前用户 ID 失败,将不带 x-user-id 调用菜单接口。必要时可手动设置 window.__XUANZHI_USER_ID__。', error)
+ return ''
+ }
+ }
+
+ async function apiGet(path) {
+ return apiRequest(path, { method: 'GET' })
+ }
+
+ async function apiPost(path, data) {
+ return apiRequest(path, {
+ method: 'POST',
+ body: JSON.stringify(data)
+ })
+ }
+
+ async function apiRequest(path, options = {}) {
+ const res = await fetch(`${BASE_API}${path}`, {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-token': TOKEN,
+ 'x-user-id': String(userId || ''),
+ ...(options.headers || {})
+ }
+ })
+ const json = await res.json().catch(() => ({}))
+ if (!res.ok || json.code !== 0) {
+ throw new Error(`${path} 调用失败:${json.msg || res.statusText}`)
+ }
+ return json
+ }
+
+ function flattenMenus(list) {
+ const result = []
+ const walk = (items) => {
+ ;(items || []).forEach((item) => {
+ result.push(item)
+ walk(item.children)
+ })
+ }
+ walk(list)
+ return result
+ }
+
+ function getMenuId(menu) {
+ return menu?.ID || menu?.id || menu?.menuId
+ }
+
+ function getCookie(name) {
+ return document.cookie
+ .split('; ')
+ .find((row) => row.startsWith(`${name}=`))
+ ?.split('=')[1] || ''
+ }
+})()
diff --git a/web/.ai-transition/sys-change/2026-04-25-api-batch-assign-role.md b/web/.ai-transition/sys-change/2026-04-25-api-batch-assign-role.md
new file mode 100644
index 0000000..5228a3a
--- /dev/null
+++ b/web/.ai-transition/sys-change/2026-04-25-api-batch-assign-role.md
@@ -0,0 +1,44 @@
+# API 管理批量分配角色方案归档
+
+## 背景
+
+- 页面:`/layout/admin/api`
+- 源码:`src/view/superAdmin/api/api.vue`
+- 需求:在 API 管理列表中勾选多个 API 后,支持一次性分配角色。
+
+## 方案
+
+- 在 API 表格顶部按钮区新增 `批量分配角色` 按钮。
+- 按钮复用表格已有 `selection-change` 选中数据,未勾选 API 时禁用。
+- 批量分配复用原单条 `分配角色` 抽屉,不新增独立页面或公共组件。
+- 批量模式下抽屉标题显示 `批量分配角色 - 已选 N 个API`。
+- 批量模式下角色树默认不预勾选角色,避免多个 API 当前角色不一致时产生误导。
+- 点击确定后,对所选 API 逐个调用现有 `setApiRoles` 接口。
+- 保存语义与单条分配保持一致:对每个 API 都是全量覆盖角色关联关系,并由后端接口刷新 Casbin 缓存。
+
+## 取舍
+
+- 当前采用前端批量调用现有接口,不新增后端批量接口。
+- 优点:改动边界小,复用既有 `getAuthorityList`、`setApiRoles` 和页面抽屉逻辑。
+- 限制:多个 API 会产生多次请求;如果后续需要事务一致性或大量 API 批量处理,应新增后端批量接口。
+
+## 涉及文件
+
+- `src/view/superAdmin/api/api.vue`
+ - 新增批量分配入口。
+ - 新增批量模式状态、标题和提示文案。
+ - 单条与批量共用提交函数。
+- `src/view/superAdmin/api/assignRole.js`
+ - 新增 `buildApiRoleAssignRequests`,统一构造角色分配请求参数。
+- `src/view/superAdmin/api/assignRole.test.mjs`
+ - 覆盖批量 API 到 `setApiRoles` payload 的转换逻辑。
+
+## 验证
+
+- `node .\src\view\superAdmin\api\assignRole.test.mjs`:通过。
+- `npm run build`:通过。
+- 构建存在 Vite 大 chunk warning,属于现有构建体积提示,不影响本次功能编译。
+
+## 后续建议
+
+- 若批量选择数量可能很大,建议后端补充批量接口,例如一次提交 `apis + authorityIds`,由后端统一处理事务、错误聚合和缓存刷新。
diff --git a/web/AGENTS.md b/web/AGENTS.md
index 37a94fe..d483c40 100644
--- a/web/AGENTS.md
+++ b/web/AGENTS.md
@@ -66,10 +66,38 @@
- 新增全局共享状态时,统一放在 `src/pinia/modules`。
- 新增插件功能时,优先在 `src/plugin/` 目录内闭环实现。
-### 架构关系
+## 链路关系
+- **修改/新增对应落点时必须严格遵循以下规范**
+#### 链路总线
- 关系总线:`sys-specs / coding-specs → Router / Permission → View → Components / Hooks / API → Request`,其中 `Pinia` 贯穿登录态、动态路由和跨页面共享状态;`Plugin` 沿用主应用同一套请求、路由、状态和样式基础设施
+#### 新增链路规范
+
+- 新增业务页面必须先判定:是否登录后访问、是否进入菜单、是否需要角色授权、是否需要按钮权限、是否需要 keep-alive、是否可能成为默认首页。
+- 新增业务页面源码默认落在 `src/view//index.vue`;单页私有组件落在 `src/view//components`;跨页面复用组件才允许上提到 `src/components`。
+- 新增页面接口默认新增或复用 `src/api/.js`;接口函数只组织参数并调用 `@/utils/request`,页面不得直接写 axios、token、401、loading 等请求基础逻辑。
+- 新增页面路由必须二选一:公开页、登录页、初始化页、独立工具页走 `src/router/index.js` 本地路由;登录后业务页、菜单页、权限页、缓存页、默认首页候选页走后台菜单远程路由。
+- 新增远程菜单必须同步确认 `path`、`name`、`component`、`meta.title`、`meta.keepAlive`、`meta.closeTab`、`meta.defaultMenu`、角色授权和按钮权限;`component` 只写 `view/...` 或 `plugin/...` 正斜杠路径。
+- 新增页面若依赖跨页面共享状态,才允许新增或修改 `src/pinia/modules/.js`;单页面查询条件、弹窗状态、表单状态默认留在页面内。
+- 新增页面若存在跨页面复用行为,才允许新增 `src/hooks/useXxx.js`;只被当前页面使用的组合逻辑优先留在页面目录内。
+- 新增页面若涉及插件业务,优先在 `src/plugin//view` 与 `src/plugin//api` 内闭环实现,不得复制主应用请求层、路由层、store 基础设施。
+- 新增页面若需要静态资源,构建期资源放 `src/assets`,公开直出资源放 `public`;页面局部样式留在页面内,只有全局覆盖或主题调整才修改 `src/style`。
+- 新增页面完成后必须回查链路:`页面文件存在 → API 可调用 → 路由/菜单可进入 → 权限可见 → keep-alive/defaultRouter 符合预期 → 刷新和未登录跳转正常`。
+
+#### 代码联动
+- 改 `view`:必须同步检查对应 `api`、`pinia`、路由入口、页面标题、keep-alive、权限显示是否仍一致
+- 改 `components`:必须同步检查调用页面、props、events、slots 和样式兼容性
+- 改 `hooks`:必须同步检查调用方是否仍满足生命周期和响应式前提
+- 改 `api`:必须同步检查 `src/utils/request.js` 契约、调用页面、store、错误提示和返回值结构
+- 改 `src/utils/request.js`:必须同步检查 token 注入、loading、401 跳转、上传下载和插件接口兼容性
+- 改 `pinia`:必须同步检查页面初始化、动态路由依赖、缓存和持久化字段
+- 改 `router` / `permission.js`:必须同步检查白名单、动态路由注入、默认首页、菜单跳转和未登录跳转
+- 改 `style`:必须同步检查全局覆盖范围,避免误伤登录页、主布局页和插件页
+- 改 `plugin/*`:必须同步检查插件内部 `api / view / form` 是否自洽,以及是否错误侵入主应用公共层
+
+#### 关系图
+
```mermaid
flowchart LR
SPEC["sys-specs / coding-specs"] --> Router
@@ -85,34 +113,32 @@ flowchart LR
Request --> Backend["Backend"]
```
-- 改 `view`:必须同步检查对应 `api`、`pinia`、路由入口、页面标题、keep-alive、权限显示是否仍一致
-- 改 `components`:必须同步检查调用页面、props、events、slots 和样式兼容性
-- 改 `hooks`:必须同步检查调用方是否仍满足生命周期和响应式前提
-- 改 `api`:必须同步检查 `src/utils/request.js` 契约、调用页面、store、错误提示和返回值结构
-- 改 `src/utils/request.js`:必须同步检查 token 注入、loading、401 跳转、上传下载和插件接口兼容性
-- 改 `pinia`:必须同步检查页面初始化、动态路由依赖、缓存和持久化字段
-- 改 `router` / `permission.js`:必须同步检查白名单、动态路由注入、默认首页、菜单跳转和未登录跳转
-- 改 `style`:必须同步检查全局覆盖范围,避免误伤登录页、主布局页和插件页
-- 改 `plugin/*`:必须同步检查插件内部 `api / view / form` 是否自洽,以及是否错误侵入主应用公共层
-
## 项目文档
-- **根目录**:`.ai-specs`
+### 外部文档路径
+| 路径 | 用途 | 说明 |
+|:---|:---|:---|
+| `D:\Code3\wdp\xuanzhi-service\.worktrees\feat-xuanzhi-service\server\.ai-specs\doc-api\admin` | 后台API文档路径 | 涉及 API / 参数 时必读,不允许凭空捏造API参数|
+| `D:\Code3\wdp\xuanzhi-service\.worktrees\feat-xuanzhi-service\server\.ai-specs\doc-dict` | 后台字典文档路径 | 涉及 字典 时必读,不允许凭空捏造字典数据 ,不允许使用硬编码字典数据 |
+
+### .ai-specs
+
- **要求**:开始写代码前,根据任务类型先定位目录,再读取对应文档;有对应文档必须先读
- **兜底**:索引表无匹配时,按本文件通用规则和现有同层代码风格实现
-### coding-specs 存放对前端功能代码的说明/限制/要求
+#### coding-specs 存放对前端功能代码的说明/限制/要求
| 路径 | 用途 | 说明 |
|:---|:---|:---|
| `.ai-specs\coding-specs\page-view-component-split.md` | 规定 `view / components / hooks` 的边界与默认落点 | 涉及新增页面、抽公共组件、抽 hooks 时必读 |
+| `.ai-specs\coding-specs\common-page-create-spec.md` | 规定通用页面创建链路、本地路由/远程路由选择和联调要求 | 涉及新增通用页面、菜单接入、页面联调时必读 |
| `.ai-specs\coding-specs\api-request-flow.md` | 规定 `api` 与 `request.js` 的分层、错误处理和请求链路 | 涉及新增接口、修改请求封装、上传下载、统一错误处理时必读 |
| `.ai-specs\coding-specs\router-permission-route.md` | 规定静态路由、动态路由、白名单和权限链路 | 涉及新增页面路由、登录跳转、菜单进入、默认首页时必读 |
| `.ai-specs\coding-specs\pinia-store-boundary.md` | 规定共享状态和页面状态的分层边界 | 涉及新增 store、共享状态、状态持久化时必读 |
| `.ai-specs\coding-specs\plugin-module-structure.md` | 规定 `plugin/*` 的目录结构和与主应用公共层的关系 | 涉及新增插件、修改插件目录、决定代码是否上提时必读 |
| `.ai-specs\coding-specs\style-asset-spec.md` | 规定全局样式、静态资源和主题覆盖的修改边界 | 涉及全局样式、Element Plus 覆盖、资源路径时必读 |
-### sys-specs 存放前端系统级文档
+#### sys-specs 存放前端系统级文档
| 路径 | 用途 | 说明 |
|:---|:---|:---|
diff --git a/web/src/api/book.js b/web/src/api/book.js
new file mode 100644
index 0000000..bbdec33
--- /dev/null
+++ b/web/src/api/book.js
@@ -0,0 +1,83 @@
+import service from '@/utils/request'
+
+const createCrudApi = (name) => ({
+ create: (data) => service({ url: `/book/create${name}`, method: 'post', data }),
+ delete: (params) => service({ url: `/book/delete${name}`, method: 'delete', params }),
+ deleteByIds: (params) => service({ url: `/book/delete${name}ByIds`, method: 'delete', params }),
+ update: (data) => service({ url: `/book/update${name}`, method: 'put', data }),
+ find: (params) => service({ url: `/book/find${name}`, method: 'get', params }),
+ list: (params) => service({ url: `/book/get${name}List`, method: 'get', params })
+})
+
+const bookApi = createCrudApi('Book')
+const bookAuthorApi = createCrudApi('BookAuthor')
+const bookAuthorRelationApi = createCrudApi('BookAuthorRelation')
+const bookChapterApi = createCrudApi('BookChapter')
+const bookCommentApi = createCrudApi('BookComment')
+const bookCommentLikeRecordApi = createCrudApi('BookCommentLikeRecord')
+const bookFavoriteRecordApi = createCrudApi('BookFavoriteRecord')
+const bookReadRecordApi = createCrudApi('BookReadRecord')
+const bookSeriesApi = createCrudApi('BookSeries')
+
+export const createBook = bookApi.create
+export const deleteBook = bookApi.delete
+export const deleteBookByIds = bookApi.deleteByIds
+export const updateBook = bookApi.update
+export const findBook = bookApi.find
+export const getBookList = bookApi.list
+
+export const createBookAuthor = bookAuthorApi.create
+export const deleteBookAuthor = bookAuthorApi.delete
+export const deleteBookAuthorByIds = bookAuthorApi.deleteByIds
+export const updateBookAuthor = bookAuthorApi.update
+export const findBookAuthor = bookAuthorApi.find
+export const getBookAuthorList = bookAuthorApi.list
+
+export const createBookAuthorRelation = bookAuthorRelationApi.create
+export const deleteBookAuthorRelation = bookAuthorRelationApi.delete
+export const deleteBookAuthorRelationByIds = bookAuthorRelationApi.deleteByIds
+export const updateBookAuthorRelation = bookAuthorRelationApi.update
+export const findBookAuthorRelation = bookAuthorRelationApi.find
+export const getBookAuthorRelationList = bookAuthorRelationApi.list
+
+export const createBookChapter = bookChapterApi.create
+export const deleteBookChapter = bookChapterApi.delete
+export const deleteBookChapterByIds = bookChapterApi.deleteByIds
+export const updateBookChapter = bookChapterApi.update
+export const findBookChapter = bookChapterApi.find
+export const getBookChapterList = bookChapterApi.list
+
+export const createBookComment = bookCommentApi.create
+export const deleteBookComment = bookCommentApi.delete
+export const deleteBookCommentByIds = bookCommentApi.deleteByIds
+export const updateBookComment = bookCommentApi.update
+export const findBookComment = bookCommentApi.find
+export const getBookCommentList = bookCommentApi.list
+
+export const createBookCommentLikeRecord = bookCommentLikeRecordApi.create
+export const deleteBookCommentLikeRecord = bookCommentLikeRecordApi.delete
+export const deleteBookCommentLikeRecordByIds = bookCommentLikeRecordApi.deleteByIds
+export const updateBookCommentLikeRecord = bookCommentLikeRecordApi.update
+export const findBookCommentLikeRecord = bookCommentLikeRecordApi.find
+export const getBookCommentLikeRecordList = bookCommentLikeRecordApi.list
+
+export const createBookFavoriteRecord = bookFavoriteRecordApi.create
+export const deleteBookFavoriteRecord = bookFavoriteRecordApi.delete
+export const deleteBookFavoriteRecordByIds = bookFavoriteRecordApi.deleteByIds
+export const updateBookFavoriteRecord = bookFavoriteRecordApi.update
+export const findBookFavoriteRecord = bookFavoriteRecordApi.find
+export const getBookFavoriteRecordList = bookFavoriteRecordApi.list
+
+export const createBookReadRecord = bookReadRecordApi.create
+export const deleteBookReadRecord = bookReadRecordApi.delete
+export const deleteBookReadRecordByIds = bookReadRecordApi.deleteByIds
+export const updateBookReadRecord = bookReadRecordApi.update
+export const findBookReadRecord = bookReadRecordApi.find
+export const getBookReadRecordList = bookReadRecordApi.list
+
+export const createBookSeries = bookSeriesApi.create
+export const deleteBookSeries = bookSeriesApi.delete
+export const deleteBookSeriesByIds = bookSeriesApi.deleteByIds
+export const updateBookSeries = bookSeriesApi.update
+export const findBookSeries = bookSeriesApi.find
+export const getBookSeriesList = bookSeriesApi.list
diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json
index 04a9333..fbd2d4e 100644
--- a/web/src/pathInfo.json
+++ b/web/src/pathInfo.json
@@ -1,5 +1,15 @@
{
"/src/view/about/index.vue": "About",
+ "/src/view/book/author/index.vue": "BookAuthorManage",
+ "/src/view/book/book/index.vue": "BookManage",
+ "/src/view/book/chapter/index.vue": "BookChapterManage",
+ "/src/view/book/comment/index.vue": "BookCommentManage",
+ "/src/view/book/commentLikeRecord/index.vue": "BookCommentLikeRecordManage",
+ "/src/view/book/components/BookAdminCrud.vue": "BookAdminCrud",
+ "/src/view/book/favoriteRecord/index.vue": "BookFavoriteRecordManage",
+ "/src/view/book/index.vue": "Book",
+ "/src/view/book/readRecord/index.vue": "BookReadRecordManage",
+ "/src/view/book/series/index.vue": "BookSeriesManage",
"/src/view/dashboard/components/banner.vue": "Banner",
"/src/view/dashboard/components/card.vue": "Card",
"/src/view/dashboard/components/charts-content-numbers.vue": "ChartsContentNumbers",
diff --git a/web/src/view/book/author/index.vue b/web/src/view/book/author/index.vue
new file mode 100644
index 0000000..bac1e7a
--- /dev/null
+++ b/web/src/view/book/author/index.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/web/src/view/book/book/index.vue b/web/src/view/book/book/index.vue
new file mode 100644
index 0000000..d7d8dff
--- /dev/null
+++ b/web/src/view/book/book/index.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/web/src/view/book/chapter/index.vue b/web/src/view/book/chapter/index.vue
new file mode 100644
index 0000000..c0ecacd
--- /dev/null
+++ b/web/src/view/book/chapter/index.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/web/src/view/book/comment/index.vue b/web/src/view/book/comment/index.vue
new file mode 100644
index 0000000..5fefe2a
--- /dev/null
+++ b/web/src/view/book/comment/index.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/web/src/view/book/commentLikeRecord/index.vue b/web/src/view/book/commentLikeRecord/index.vue
new file mode 100644
index 0000000..9f42259
--- /dev/null
+++ b/web/src/view/book/commentLikeRecord/index.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/web/src/view/book/components/BookAdminCrud.vue b/web/src/view/book/components/BookAdminCrud.vue
new file mode 100644
index 0000000..aceebc3
--- /dev/null
+++ b/web/src/view/book/components/BookAdminCrud.vue
@@ -0,0 +1,715 @@
+
+
+
+
+
+
+
+
+
+
+ 查询
+ 重置
+
+
+
+
+
+
+ 新增
+ 删除
+
+
+
+
+
+
+
+ {{ formatCell(scope.row, column) }}
+
+
+
+
+
+ 查看详情
+
+ 变更
+ updateStatus(scope.row, command)">
+ 状态
+
+
+
+
+ {{ status.label }}:{{ option.label }}
+
+
+
+
+
+ 作者绑定
+ 删除
+
+
+
+
+
+
+
+
+
+
+
{{ dialogType === 'create' ? `添加${config.title}` : `修改${config.title}` }}
+
+ 确 定
+ 取 消
+
+
+
+
+
+
+
+
+
+ 上传文件
+
+
+ {{ formData[field.prop] }}
+
+ 清除
+
+
+
+
+
+
+
+
+
+
+ {{ config.title }}详情
+
+
+
+ {{ formatCell(detailForm, field) }}
+
+
+
+
+
+
+
+
+
+
+ 作者绑定与排序
+
+
+
+
+
+
+
+
+
+
+
+ 绑定
+
+
+
+
+
+
+
+
+
+
+
+ 移除
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/view/book/config/bookAdminConfig.js b/web/src/view/book/config/bookAdminConfig.js
new file mode 100644
index 0000000..427f115
--- /dev/null
+++ b/web/src/view/book/config/bookAdminConfig.js
@@ -0,0 +1,334 @@
+export const bookAdminRouteComponents = [
+ 'view/book/index.vue',
+ 'view/book/book/index.vue',
+ 'view/book/chapter/index.vue',
+ 'view/book/author/index.vue',
+ 'view/book/series/index.vue',
+ 'view/book/comment/index.vue',
+ 'view/book/readRecord/index.vue',
+ 'view/book/favoriteRecord/index.vue',
+ 'view/book/commentLikeRecord/index.vue'
+]
+
+const dateColumn = { label: '创建时间', prop: 'createdAt', type: 'datetime', width: 180 }
+const idColumn = { label: 'ID', prop: 'id', width: 90 }
+
+const textField = (label, prop, extra = {}) => ({ label, prop, type: 'text', ...extra })
+const numberField = (label, prop, extra = {}) => ({ label, prop, type: 'number', ...extra })
+const textareaField = (label, prop, extra = {}) => ({ label, prop, type: 'textarea', ...extra })
+const switchField = (label, prop, extra = {}) => ({ label, prop, type: 'switch', ...extra })
+const dateField = (label, prop, extra = {}) => ({ label, prop, type: 'date', ...extra })
+const datetimeField = (label, prop, extra = {}) => ({ label, prop, type: 'datetime', ...extra })
+const imageField = (label, prop, extra = {}) => ({ label, prop, type: 'image', ...extra })
+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 })
+
+export const bookAdminPageConfigs = [
+ {
+ key: 'book',
+ title: '书籍管理',
+ detailKey: 'book',
+ api: {
+ create: 'createBook',
+ delete: 'deleteBook',
+ deleteByIds: 'deleteBookByIds',
+ update: 'updateBook',
+ find: 'findBook',
+ list: 'getBookList'
+ },
+ searchFields: [
+ textField('书名', 'title'),
+ dictField('书籍类型', 'bookType', 'book_type'),
+ dictField('时代标签', 'eraTag', 'book_era_tag'),
+ dictField('完结状态', 'completionStatus', 'book_completion_status'),
+ dictField('上下架状态', 'publishStatus', 'book_publish_status')
+ ],
+ tableColumns: [
+ idColumn,
+ { label: '书名', prop: 'title', minWidth: 180 },
+ { label: '类型', prop: 'bookType', type: 'dict', dict: 'book_type', width: 120 },
+ { label: '时代', prop: 'eraTag', type: 'dict', dict: 'book_era_tag', width: 110 },
+ { label: '评分', prop: 'rating', width: 90 },
+ { label: '点评数', prop: 'commentCount', width: 100 },
+ { label: '完结状态', prop: 'completionStatus', type: 'dict', dict: 'book_completion_status', width: 120 },
+ { label: '上下架状态', prop: 'publishStatus', type: 'dict', dict: 'book_publish_status', width: 120 },
+ dateColumn
+ ],
+ fields: [
+ textField('书名主标题', 'title', { required: true }),
+ textField('书籍副标题', 'subtitle'),
+ dictField('书籍类型', 'bookType', 'book_type', { required: true }),
+ dictField('时代标签', 'eraTag', 'book_era_tag', { required: true, defaultValue: 'unknown' }),
+ imageField('封面图片', 'coverUrl'),
+ textField('出版社名称', 'publisher'),
+ dateField('出版日期', 'publishedAt'),
+ textareaField('书籍简介', 'intro'),
+ numberField('热度聚合值', 'hotScore', { defaultValue: 0 }),
+ numberField('书籍评分', 'rating', { defaultValue: 0, precision: 1, step: 0.1 }),
+ numberField('点评数聚合值', 'commentCount', { defaultValue: 0 }),
+ numberField('书籍总字数', 'wordCount', { defaultValue: 0 }),
+ dictField('完结状态', 'completionStatus', 'book_completion_status', { required: true, defaultValue: 'serializing' }),
+ dictField('上下架状态', 'publishStatus', 'book_publish_status', { required: true, defaultValue: 'draft' }),
+ relationField('所属系列', 'seriesId', { listApi: 'getBookSeriesList', labelProp: 'name', valueProp: 'id', searchProp: 'name' }),
+ numberField('同系列排序', 'seriesSort', { defaultValue: 0 }),
+ fileField('原始 txt 文件', 'rawTxtUrl', { accept: '.txt,text/plain' })
+ ],
+ statusFields: [
+ { prop: 'publishStatus', label: '上下架状态', dict: 'book_publish_status' },
+ { prop: 'completionStatus', label: '完结状态', dict: 'book_completion_status' }
+ ],
+ related: [
+ { title: '关联作者', api: 'getBookAuthorRelationList', filterProp: 'bookId', columns: ['authorId', 'authorSort'] },
+ { title: '关联章节', api: 'getBookChapterList', filterProp: 'bookId', columns: ['id', 'title', 'chapterNo', 'isReadable', 'isEnabled'] },
+ { title: '关联评论', api: 'getBookCommentList', filterProp: 'bookId', columns: ['id', 'memberUserId', 'chapterId', 'lineIndex', 'commentStatus'] }
+ ],
+ authorBinding: true
+ },
+ {
+ key: 'chapter',
+ title: '章节管理',
+ detailKey: 'bookChapter',
+ api: {
+ create: 'createBookChapter',
+ delete: 'deleteBookChapter',
+ deleteByIds: 'deleteBookChapterByIds',
+ update: 'updateBookChapter',
+ find: 'findBookChapter',
+ list: 'getBookChapterList'
+ },
+ searchFields: [
+ relationField('所属书籍', 'bookId', { listApi: 'getBookList', labelProp: 'title', valueProp: 'id', searchProp: 'title' }),
+ switchField('开放阅读', 'isReadable'),
+ switchField('启用状态', 'isEnabled')
+ ],
+ tableColumns: [
+ idColumn,
+ { label: '章节标题', prop: 'title', minWidth: 180 },
+ { label: '书籍 ID', prop: 'bookId', width: 100 },
+ { label: '章节序号', prop: 'chapterNo', width: 110 },
+ { label: '开放阅读', prop: 'isReadable', type: 'boolean', width: 100 },
+ { label: '启用状态', prop: 'isEnabled', type: 'boolean', width: 100 },
+ { label: '正文总行数', prop: 'totalLines', width: 120 },
+ dateColumn
+ ],
+ fields: [
+ relationField('所属书籍', 'bookId', { listApi: 'getBookList', labelProp: 'title', valueProp: 'id', searchProp: 'title' }, { required: true }),
+ textField('章节标题', 'title', { required: true }),
+ numberField('章节序号', 'chapterNo', { required: true, defaultValue: 1 }),
+ switchField('是否开放阅读', 'isReadable', { defaultValue: false }),
+ fileField('章节正文文件', 'contentFileUrl', { required: true, accept: '.txt,text/plain' }),
+ numberField('正文总行数', 'totalLines', { defaultValue: 0 }),
+ switchField('章节是否启用', 'isEnabled', { defaultValue: true })
+ ],
+ statusFields: [
+ { prop: 'isReadable', label: '开放阅读', type: 'boolean' },
+ { prop: 'isEnabled', label: '启用状态', type: 'boolean' }
+ ]
+ },
+ {
+ key: 'author',
+ title: '作者管理',
+ detailKey: 'bookAuthor',
+ api: {
+ create: 'createBookAuthor',
+ delete: 'deleteBookAuthor',
+ deleteByIds: 'deleteBookAuthorByIds',
+ update: 'updateBookAuthor',
+ find: 'findBookAuthor',
+ list: 'getBookAuthorList'
+ },
+ searchFields: [
+ textField('作者名称', 'name'),
+ dictField('作者状态', 'authorStatus', 'book_author_status')
+ ],
+ tableColumns: [
+ idColumn,
+ { label: '作者名称', prop: 'name', minWidth: 160 },
+ { label: '作者状态', prop: 'authorStatus', type: 'dict', dict: 'book_author_status', width: 120 },
+ { label: '头像/封面 URL', prop: 'coverUrl', minWidth: 180 },
+ dateColumn
+ ],
+ fields: [
+ textField('作者名称', 'name', { required: true }),
+ dictField('作者状态', 'authorStatus', 'book_author_status', { required: true, defaultValue: 'enabled' }),
+ textareaField('作者简介', 'intro'),
+ imageField('作者头像或封面', 'coverUrl')
+ ],
+ statusFields: [
+ { prop: 'authorStatus', label: '作者状态', dict: 'book_author_status' }
+ ],
+ related: [
+ { title: '关联书籍', api: 'getBookAuthorRelationList', filterProp: 'authorId', columns: ['bookId', 'authorSort'] }
+ ]
+ },
+ {
+ key: 'series',
+ title: '系列管理',
+ detailKey: 'bookSeries',
+ api: {
+ create: 'createBookSeries',
+ delete: 'deleteBookSeries',
+ deleteByIds: 'deleteBookSeriesByIds',
+ update: 'updateBookSeries',
+ find: 'findBookSeries',
+ list: 'getBookSeriesList'
+ },
+ searchFields: [
+ textField('系列名称', 'name'),
+ switchField('启用状态', 'isEnabled')
+ ],
+ tableColumns: [
+ idColumn,
+ { label: '系列名称', prop: 'name', minWidth: 160 },
+ { label: '启用状态', prop: 'isEnabled', type: 'boolean', width: 100 },
+ { label: '系列封面 URL', prop: 'coverUrl', minWidth: 180 },
+ dateColumn
+ ],
+ fields: [
+ textField('系列名称', 'name', { required: true }),
+ imageField('系列封面图片', 'coverUrl'),
+ textareaField('系列简介', 'intro'),
+ switchField('系列是否启用', 'isEnabled', { defaultValue: true })
+ ],
+ statusFields: [
+ { prop: 'isEnabled', label: '启用状态', type: 'boolean' }
+ ],
+ related: [
+ { title: '系列下书籍', api: 'getBookList', filterProp: 'seriesId', columns: ['id', 'title', 'publishStatus', 'completionStatus', 'seriesSort'] }
+ ]
+ },
+ {
+ key: 'comment',
+ title: '评论管理',
+ detailKey: 'bookComment',
+ api: {
+ create: 'createBookComment',
+ delete: 'deleteBookComment',
+ deleteByIds: 'deleteBookCommentByIds',
+ update: 'updateBookComment',
+ find: 'findBookComment',
+ list: 'getBookCommentList'
+ },
+ searchFields: [
+ numberField('会员用户 ID', 'memberUserId'),
+ numberField('书籍 ID', 'bookId'),
+ numberField('章节 ID', 'chapterId'),
+ dictField('评论状态', 'commentStatus', 'book_comment_status')
+ ],
+ tableColumns: [
+ idColumn,
+ { label: '会员用户 ID', prop: 'memberUserId', width: 120 },
+ { label: '书籍 ID', prop: 'bookId', width: 100 },
+ { label: '章节 ID', prop: 'chapterId', width: 100 },
+ { label: '行序号', prop: 'lineIndex', width: 90 },
+ { label: '评论内容', prop: 'content', minWidth: 220 },
+ { label: '点赞数', prop: 'likeCount', width: 90 },
+ { label: '评论状态', prop: 'commentStatus', type: 'dict', dict: 'book_comment_status', width: 120 },
+ dateColumn
+ ],
+ fields: [
+ numberField('会员用户 ID', 'memberUserId', { required: true }),
+ relationField('关联书籍', 'bookId', { listApi: 'getBookList', labelProp: 'title', valueProp: 'id', searchProp: 'title' }, { required: true }),
+ numberField('章节 ID', 'chapterId', { defaultValue: 0 }),
+ numberField('文本行序号', 'lineIndex', { defaultValue: 0 }),
+ textareaField('评论内容', 'content', { required: true }),
+ numberField('点赞聚合数', 'likeCount', { defaultValue: 0 }),
+ dictField('评论状态', 'commentStatus', 'book_comment_status', { required: true, defaultValue: 'normal' })
+ ],
+ statusFields: [
+ { prop: 'commentStatus', label: '评论状态', dict: 'book_comment_status' }
+ ],
+ readonlyCreate: true
+ },
+ {
+ key: 'readRecord',
+ title: '阅读记录管理',
+ detailKey: 'bookReadRecord',
+ api: {
+ create: 'createBookReadRecord',
+ delete: 'deleteBookReadRecord',
+ deleteByIds: 'deleteBookReadRecordByIds',
+ update: 'updateBookReadRecord',
+ find: 'findBookReadRecord',
+ list: 'getBookReadRecordList'
+ },
+ searchFields: [numberField('会员用户 ID', 'memberUserId'), numberField('书籍 ID', 'bookId')],
+ tableColumns: [
+ idColumn,
+ { label: '会员用户 ID', prop: 'memberUserId', width: 120 },
+ { label: '书籍 ID', prop: 'bookId', width: 100 },
+ { label: '书名快照', prop: 'bookTitleSnapshot', minWidth: 180 },
+ { label: '阅读进度', prop: 'readProgress', width: 100 },
+ { label: '续读章节 ID', prop: 'chapterId', width: 120 },
+ { label: '续读行序号', prop: 'lineIndex', width: 120 },
+ { label: '最后阅读时间', prop: 'lastReadAt', type: 'datetime', width: 180 }
+ ],
+ fields: [
+ numberField('会员用户 ID', 'memberUserId', { required: true }),
+ relationField('书籍信息', 'bookId', { listApi: 'getBookList', labelProp: 'title', valueProp: 'id', searchProp: 'title' }, { required: true }),
+ textField('书名快照', 'bookTitleSnapshot', { required: true }),
+ numberField('阅读进度百分比', 'readProgress', { defaultValue: 0, precision: 2, step: 0.01 }),
+ numberField('续读章节 ID', 'chapterId', { required: true }),
+ numberField('续读文本行序号', 'lineIndex', { required: true }),
+ datetimeField('最后阅读时间', 'lastReadAt')
+ ],
+ readonlyCreate: true
+ },
+ {
+ key: 'favoriteRecord',
+ title: '收藏记录管理',
+ detailKey: 'bookFavoriteRecord',
+ api: {
+ create: 'createBookFavoriteRecord',
+ delete: 'deleteBookFavoriteRecord',
+ deleteByIds: 'deleteBookFavoriteRecordByIds',
+ update: 'updateBookFavoriteRecord',
+ find: 'findBookFavoriteRecord',
+ list: 'getBookFavoriteRecordList'
+ },
+ searchFields: [numberField('会员用户 ID', 'memberUserId'), numberField('书籍 ID', 'bookId')],
+ tableColumns: [
+ idColumn,
+ { label: '会员用户 ID', prop: 'memberUserId', width: 120 },
+ { label: '书籍 ID', prop: 'bookId', width: 100 },
+ { label: '收藏时间', prop: 'favoritedAt', type: 'datetime', width: 180 },
+ dateColumn
+ ],
+ fields: [
+ numberField('会员用户 ID', 'memberUserId', { required: true }),
+ relationField('书籍信息', 'bookId', { listApi: 'getBookList', labelProp: 'title', valueProp: 'id', searchProp: 'title' }, { required: true }),
+ datetimeField('收藏时间', 'favoritedAt')
+ ],
+ readonlyCreate: true
+ },
+ {
+ key: 'commentLikeRecord',
+ title: '评论点赞记录管理',
+ detailKey: 'bookCommentLikeRecord',
+ api: {
+ create: 'createBookCommentLikeRecord',
+ delete: 'deleteBookCommentLikeRecord',
+ deleteByIds: 'deleteBookCommentLikeRecordByIds',
+ update: 'updateBookCommentLikeRecord',
+ find: 'findBookCommentLikeRecord',
+ list: 'getBookCommentLikeRecordList'
+ },
+ searchFields: [numberField('评论 ID', 'commentId'), numberField('会员用户 ID', 'memberUserId')],
+ tableColumns: [
+ idColumn,
+ { label: '评论 ID', prop: 'commentId', width: 100 },
+ { label: '会员用户 ID', prop: 'memberUserId', width: 120 },
+ { label: '点赞时间', prop: 'likedAt', type: 'datetime', width: 180 },
+ dateColumn
+ ],
+ fields: [
+ numberField('评论 ID', 'commentId', { required: true }),
+ numberField('会员用户 ID', 'memberUserId', { required: true }),
+ datetimeField('点赞时间', 'likedAt')
+ ],
+ readonlyCreate: true
+ }
+]
+
+export const getBookAdminPageConfig = (key) => bookAdminPageConfigs.find((item) => item.key === key)
diff --git a/web/src/view/book/config/bookAdminDisplay.js b/web/src/view/book/config/bookAdminDisplay.js
new file mode 100644
index 0000000..97a578d
--- /dev/null
+++ b/web/src/view/book/config/bookAdminDisplay.js
@@ -0,0 +1,9 @@
+export const resolveBookAdminColumnType = (config, column) => {
+ if (column?.type) return column.type
+ const field = config?.fields?.find((item) => item.prop === column?.prop)
+ return field?.type || 'text'
+}
+
+export const isBookAdminImageColumn = (config, column) => {
+ return resolveBookAdminColumnType(config, column) === 'image'
+}
diff --git a/web/src/view/book/config/bookAdminDisplay.test.mjs b/web/src/view/book/config/bookAdminDisplay.test.mjs
new file mode 100644
index 0000000..f726889
--- /dev/null
+++ b/web/src/view/book/config/bookAdminDisplay.test.mjs
@@ -0,0 +1,15 @@
+import assert from 'node:assert/strict'
+import { bookAdminPageConfigs } 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 authorConfig = getConfig('author')
+const seriesConfig = getConfig('series')
+
+assert.equal(resolveBookAdminColumnType(authorConfig, getColumn(authorConfig, 'coverUrl')), 'image')
+assert.equal(resolveBookAdminColumnType(seriesConfig, getColumn(seriesConfig, 'coverUrl')), 'image')
+
+assert.equal(isBookAdminImageColumn(authorConfig, getColumn(authorConfig, 'coverUrl')), true)
+assert.equal(isBookAdminImageColumn(authorConfig, getColumn(authorConfig, 'name')), false)
diff --git a/web/src/view/book/favoriteRecord/index.vue b/web/src/view/book/favoriteRecord/index.vue
new file mode 100644
index 0000000..0786c47
--- /dev/null
+++ b/web/src/view/book/favoriteRecord/index.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/web/src/view/book/index.vue b/web/src/view/book/index.vue
new file mode 100644
index 0000000..baa33ac
--- /dev/null
+++ b/web/src/view/book/index.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/view/book/readRecord/index.vue b/web/src/view/book/readRecord/index.vue
new file mode 100644
index 0000000..ea6f712
--- /dev/null
+++ b/web/src/view/book/readRecord/index.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/web/src/view/book/series/index.vue b/web/src/view/book/series/index.vue
new file mode 100644
index 0000000..d35daf7
--- /dev/null
+++ b/web/src/view/book/series/index.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/web/src/view/layout/tabs/index.vue b/web/src/view/layout/tabs/index.vue
index 31f4531..c76aba7 100644
--- a/web/src/view/layout/tabs/index.vue
+++ b/web/src/view/layout/tabs/index.vue
@@ -45,6 +45,7 @@
:style="{ left: left + 'px', top: top + 'px' }"
class="contextmenu"
>
+ 刷新
关闭所有
关闭左侧
关闭右侧
@@ -118,6 +119,23 @@
contextMenuVisible.value = false
sessionStorage.setItem('historys', JSON.stringify(historys.value))
}
+ const refreshTab = async () => {
+ const tab = historyMap.value[rightActive.value]
+ if (!tab) {
+ contextMenuVisible.value = false
+ return
+ }
+
+ if (getFmtString(route) !== rightActive.value) {
+ await router.push({
+ name: tab.name,
+ query: tab.query,
+ params: tab.params
+ })
+ }
+ emitter.emit('reload')
+ contextMenuVisible.value = false
+ }
const closeLeft = () => {
let right
const rightIndex = historys.value.findIndex((item) => {
diff --git a/web/src/view/superAdmin/api/api.vue b/web/src/view/superAdmin/api/api.vue
index a4d78d9..281d680 100644
--- a/web/src/view/superAdmin/api/api.vue
+++ b/web/src/view/superAdmin/api/api.vue
@@ -48,6 +48,9 @@
删除
+
+ 批量分配角色
+
刷新缓存
同步API
@@ -160,9 +163,17 @@
取 消
+
+ 一键同步
+
确 定
@@ -413,14 +424,14 @@
>
-
分配角色 - {{ assignApiRow.description }}
+
{{ assignRoleTitle }}
取 消
确 定
-
+
{
if (
@@ -614,6 +627,24 @@
}
}
+ const oneKeySyncApi = async () => {
+ oneKeySyncing.value = true
+ try {
+ const res = await enterSyncApi({})
+ if (res.code === 0) {
+ ElMessage({
+ type: 'success',
+ message: res.msg
+ })
+ syncApiFlag.value = false
+ getTableData()
+ getGroup()
+ }
+ } finally {
+ oneKeySyncing.value = false
+ }
+ }
+
const onReset = () => {
searchInfo.value = {}
getTableData()
@@ -867,13 +898,27 @@
// 分配给角色
const assignRoleDrawerVisible = ref(false)
const assignApiRow = ref({})
+ const isBatchAssignRole = ref(false)
const authorityTreeData = ref([])
const assignRoleLoading = ref(false)
const assignRoleSubmitting = ref(false)
const roleTreeRef = ref(null)
+ const assignRoleTitle = computed(() => {
+ if (isBatchAssignRole.value) {
+ return `批量分配角色 - 已选 ${apis.value.length} 个API`
+ }
+ return `分配角色 - ${assignApiRow.value.description || assignApiRow.value.path || ''}`
+ })
+ const assignRoleWarning = computed(() => {
+ if (isBatchAssignRole.value) {
+ return '注:保存时将全量覆盖所选API的角色关联关系,并自动刷新Casbin缓存'
+ }
+ return '注:保存时将全量覆盖该API的角色关联关系,并自动刷新Casbin缓存'
+ })
const openAssignRoleDrawer = async (row) => {
assignApiRow.value = row
+ isBatchAssignRole.value = false
assignRoleDrawerVisible.value = true
assignRoleLoading.value = true
const [authRes, rolesRes] = await Promise.all([
@@ -891,23 +936,47 @@
assignRoleLoading.value = false
}
+ const openBatchAssignRoleDrawer = async () => {
+ if (!apis.value.length) {
+ ElMessage({ type: 'warning', message: '请先选择API' })
+ return
+ }
+ assignApiRow.value = {}
+ isBatchAssignRole.value = true
+ assignRoleDrawerVisible.value = true
+ assignRoleLoading.value = true
+ const authRes = await getAuthorityList()
+ if (authRes.code === 0) {
+ authorityTreeData.value = authRes.data
+ nextTick(() => {
+ roleTreeRef.value?.setCheckedKeys([])
+ })
+ }
+ assignRoleLoading.value = false
+ }
+
const confirmAssignRole = async () => {
assignRoleSubmitting.value = true
try {
const checkedKeys = roleTreeRef.value?.getCheckedKeys(false) || []
- const res = await setApiRoles({
- path: assignApiRow.value.path,
- method: assignApiRow.value.method,
- authorityIds: checkedKeys
- })
- if (res.code === 0) {
- ElMessage({ type: 'success', message: '分配成功!' })
+ const targetApis = isBatchAssignRole.value ? apis.value : [assignApiRow.value]
+ const requests = buildApiRoleAssignRequests(targetApis, checkedKeys)
+ const results = await Promise.all(requests.map((item) => setApiRoles(item)))
+ const isAllSuccess = results.every((item) => item.code === 0)
+ if (isAllSuccess) {
+ ElMessage({
+ type: 'success',
+ message: isBatchAssignRole.value ? `批量分配成功,共 ${requests.length} 个API` : '分配成功!'
+ })
assignRoleDrawerVisible.value = false
+ } else {
+ ElMessage({ type: 'error', message: '部分API分配失败,请重试' })
}
} catch {
ElMessage({ type: 'error', message: '分配失败,请重试' })
+ } finally {
+ assignRoleSubmitting.value = false
}
- assignRoleSubmitting.value = false
}
diff --git a/web/src/view/superAdmin/api/assignRole.js b/web/src/view/superAdmin/api/assignRole.js
new file mode 100644
index 0000000..f74cd41
--- /dev/null
+++ b/web/src/view/superAdmin/api/assignRole.js
@@ -0,0 +1,7 @@
+export const buildApiRoleAssignRequests = (apiRows, authorityIds) => {
+ return apiRows.map((item) => ({
+ path: item.path,
+ method: item.method,
+ authorityIds
+ }))
+}
diff --git a/web/src/view/superAdmin/api/assignRole.test.mjs b/web/src/view/superAdmin/api/assignRole.test.mjs
new file mode 100644
index 0000000..b8d8e9b
--- /dev/null
+++ b/web/src/view/superAdmin/api/assignRole.test.mjs
@@ -0,0 +1,15 @@
+import assert from 'node:assert/strict'
+import { buildApiRoleAssignRequests } from './assignRole.js'
+
+const selectedApis = [
+ { path: '/user/list', method: 'POST' },
+ { path: '/user/delete', method: 'DELETE' }
+]
+const authorityIds = [888, 999]
+
+assert.deepEqual(buildApiRoleAssignRequests(selectedApis, authorityIds), [
+ { path: '/user/list', method: 'POST', authorityIds },
+ { path: '/user/delete', method: 'DELETE', authorityIds }
+])
+
+assert.deepEqual(buildApiRoleAssignRequests([], authorityIds), [])
diff --git a/web/tests/bookAdminConfig.test.mjs b/web/tests/bookAdminConfig.test.mjs
new file mode 100644
index 0000000..195e0f3
--- /dev/null
+++ b/web/tests/bookAdminConfig.test.mjs
@@ -0,0 +1,56 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import {
+ bookAdminRouteComponents,
+ bookAdminPageConfigs
+} from '../src/view/book/config/bookAdminConfig.js'
+
+test('book admin exposes eight remote route component paths', () => {
+ assert.deepEqual(bookAdminRouteComponents, [
+ 'view/book/index.vue',
+ 'view/book/book/index.vue',
+ 'view/book/chapter/index.vue',
+ 'view/book/author/index.vue',
+ 'view/book/series/index.vue',
+ 'view/book/comment/index.vue',
+ 'view/book/readRecord/index.vue',
+ 'view/book/favoriteRecord/index.vue',
+ 'view/book/commentLikeRecord/index.vue'
+ ])
+})
+
+test('each book admin page config has crud handlers and stable identity', () => {
+ assert.equal(bookAdminPageConfigs.length, 8)
+
+ for (const config of bookAdminPageConfigs) {
+ assert.ok(config.key)
+ assert.ok(config.title)
+ assert.ok(config.api.list)
+ assert.ok(config.api.find)
+ assert.ok(config.api.create)
+ assert.ok(config.api.update)
+ assert.ok(config.api.delete)
+ assert.ok(config.api.deleteByIds)
+ assert.ok(Array.isArray(config.fields))
+ assert.ok(config.fields.length > 0)
+ assert.ok(Array.isArray(config.tableColumns))
+ assert.ok(config.tableColumns.length > 0)
+ }
+})
+
+test('url backed book fields use upload field types', () => {
+ const fieldTypesByProp = new Map()
+
+ for (const config of bookAdminPageConfigs) {
+ for (const field of config.fields) {
+ fieldTypesByProp.set(`${config.key}.${field.prop}`, field.type)
+ }
+ }
+
+ assert.equal(fieldTypesByProp.get('book.coverUrl'), 'image')
+ assert.equal(fieldTypesByProp.get('book.rawTxtUrl'), 'file')
+ assert.equal(fieldTypesByProp.get('chapter.contentFileUrl'), 'file')
+ assert.equal(fieldTypesByProp.get('author.coverUrl'), 'image')
+ assert.equal(fieldTypesByProp.get('series.coverUrl'), 'image')
+})