Merge branch 'feat/xuanzhi-service' of http://10.1.0.1:3000/xuanzhi/xuanzhi-service into feat/xuanzhi-service
Some checks failed
CI / init (pull_request) Has been cancelled
CI / Frontend node 18.16.0 (pull_request) Has been cancelled
CI / Backend go (1.22) (pull_request) Has been cancelled
CI / release-pr (pull_request) Has been cancelled
CI / devops-test (1.22, 18.16.0) (pull_request) Has been cancelled
CI / release-please (pull_request) Has been cancelled
CI / devops-prod (1.22, 18.x) (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled

This commit is contained in:
2026-04-27 10:12:23 +08:00
27 changed files with 2018 additions and 342 deletions

View File

@@ -1,317 +0,0 @@
<div align=center>
<img src="http://qmplusimg.henrongyi.top/gvalogo.jpg" width="300" height="300" />
</div>
<div align=center>
<img src="https://img.shields.io/badge/golang-1.18-blue"/>
<img src="https://img.shields.io/badge/gin-1.9.1-lightBlue"/>
<img src="https://img.shields.io/badge/vue-3.3.4-brightgreen"/>
<img src="https://img.shields.io/badge/element--plus-2.3.8-green"/>
<img src="https://img.shields.io/badge/gorm-1.25.2-red"/>
</div>
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: <a href="https://github.com/LLemonGreen">LLemonGreen</a> And <a href="https://github.com/fkk0509">Fann</a>)
## 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
usernameadmin
password123456
### 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`)
- <font color=red>Make sure PRs are created to `develop` branch instead of `master` branch.</font>
- 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 toolsopen server catalogueYou 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 prohibitedwe 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 commandserver directory will appear in the docs folder `docs.go`, `swagger.json`, `swagger.yaml` Three file updatesAfter 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
![Architecture diagram](http://qmplusimg.henrongyi.top/gva/gin-vue-admin.png)
### 4.2 Front-end Detailed Design Diagram (Contributor: <a href="https://github.com/baobeisuper">baobeisuper</a>)
![Front-end Detailed Design Diagram](http://qmplusimg.henrongyi.top/naotu.png)
### 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
├─componentscomponents
├─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 EncapsulationThe 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 chunkProvides examples of file upload and large file upload by chunk.
- Form BuilderWith 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_1994your comments is welcomed。
### 6.2 Video courses
(1) Development environment course
> Bilibilihttps://www.bilibili.com/video/BV1Fg4y187Bw/
(2) Template course
> Bilibilihttps://www.bilibili.com/video/BV16K4y1r7BD/
(3) 2.0 version introduction and development experience
> Bilibilihttps://www.bilibili.com/video/BV1aV411d7Gm#reply2831798461
(4) Golang basic course
> https://space.bilibili.com/322210472/channel/detail?cid=108884
(5) gin frame basic teaching
> bilibilihttps://space.bilibili.com/322210472/channel/detail?cid=126418&ctype=0
(6) gin-vue-admin version update introduction video
> bilibilihttps://space.bilibili.com/322210472/channel/detail?cid=126418&ctype=0
## 7.Contacts
### 7.1 Groups
#### QQ group: 622360840
| QQ group |d
| :---: |
| <img src="http://qmplusimg.henrongyi.top/qq.jpg" width="180"/> |
#### Wechat group: comment "加入gin-vue-admin交流群"
| Wechat |
| :---: |
| <img width="150" src="http://qmplusimg.henrongyi.top/qrjjz.png">
#### [About Us](https://www.gin-vue-admin.com/about/join.html)
## 8. Contributors
Thank you for considering your contribution to gin-vue-admin!
<a href="https://openomy.app/github/flipped-aurora/gin-vue-admin" target="_blank" style="display: block; width: 100%;" align="center">
<img src="https://openomy.app/svg?repo=flipped-aurora/gin-vue-admin&chart=bubble&latestMonth=3" target="_blank" alt="Contribution Leaderboard" style="display: block; width: 100%;" />
</a>
<a href="https://github.com/flipped-aurora/gin-vue-admin/graphs/contributors">
<img src="https://contrib.rocks/image?repo=flipped-aurora/gin-vue-admin" />
</a>
## 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.

View File

@@ -0,0 +1,71 @@
# common-page-create-spec
## 适用范围
- 涉及新增通用页面、列表页、详情页、表单页、页面占位页时必读。
- 涉及页面目录落点、接口接入、本地路由/远程路由选择、菜单配置、`curl` 联调时必读。
## 创建主链路
- 先判定页面是否需要登录后访问、菜单展示、角色授权、默认首页、keep-alive再决定走本地路由还是远程路由。
- 页面源码默认放在 `src/view/<module>`;页面私有子组件放在 `src/view/<module>/components`
- 页面请求统一放在 `src/api/<module>.js`;插件页面请求统一放在 `src/plugin/<plugin>/api/<module>.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/<module>/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: <token>" \
--header "x-user-id: <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: <token>" \
--header "x-user-id: <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` 和未登录跳转。

View File

@@ -0,0 +1,6 @@
.ai-specs\coding-specs
api 调用规范
字典调用规范
路由`新增/修改`规范 并且指出 本地路由和远程路由
通用页面创建规范

View File

@@ -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] || ''
}
})()

View File

@@ -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] || ''
}
})()

View File

@@ -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`,由后端统一处理事务、错误聚合和缓存刷新。

View File

@@ -66,10 +66,38 @@
- 新增全局共享状态时,统一放在 `src/pinia/modules`
- 新增插件功能时,优先在 `src/plugin/<plugin>` 目录内闭环实现。
### 架构关系
## 链路关系
- **修改/新增对应落点时必须严格遵循以下规范**
#### 链路总线
- 关系总线:`sys-specs / coding-specs → Router / Permission → View → Components / Hooks / API → Request`,其中 `Pinia` 贯穿登录态、动态路由和跨页面共享状态;`Plugin` 沿用主应用同一套请求、路由、状态和样式基础设施
#### 新增链路规范
- 新增业务页面必须先判定:是否登录后访问、是否进入菜单、是否需要角色授权、是否需要按钮权限、是否需要 keep-alive、是否可能成为默认首页。
- 新增业务页面源码默认落在 `src/view/<module>/index.vue`;单页私有组件落在 `src/view/<module>/components`;跨页面复用组件才允许上提到 `src/components`
- 新增页面接口默认新增或复用 `src/api/<module>.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/<module>.js`;单页面查询条件、弹窗状态、表单状态默认留在页面内。
- 新增页面若存在跨页面复用行为,才允许新增 `src/hooks/useXxx.js`;只被当前页面使用的组合逻辑优先留在页面目录内。
- 新增页面若涉及插件业务,优先在 `src/plugin/<plugin>/view``src/plugin/<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 存放前端系统级文档
| 路径 | 用途 | 说明 |
|:---|:---|:---|

83
web/src/api/book.js Normal file
View File

@@ -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

View File

@@ -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",

View File

@@ -0,0 +1,12 @@
<template>
<BookAdminCrud :config="config" />
</template>
<script setup>
import BookAdminCrud from '../components/BookAdminCrud.vue'
import { getBookAdminPageConfig } from '../config/bookAdminConfig'
defineOptions({ name: 'BookAuthorManage' })
const config = getBookAdminPageConfig('author')
</script>

View File

@@ -0,0 +1,12 @@
<template>
<BookAdminCrud :config="config" />
</template>
<script setup>
import BookAdminCrud from '../components/BookAdminCrud.vue'
import { getBookAdminPageConfig } from '../config/bookAdminConfig'
defineOptions({ name: 'BookManage' })
const config = getBookAdminPageConfig('book')
</script>

View File

@@ -0,0 +1,12 @@
<template>
<BookAdminCrud :config="config" />
</template>
<script setup>
import BookAdminCrud from '../components/BookAdminCrud.vue'
import { getBookAdminPageConfig } from '../config/bookAdminConfig'
defineOptions({ name: 'BookChapterManage' })
const config = getBookAdminPageConfig('chapter')
</script>

View File

@@ -0,0 +1,12 @@
<template>
<BookAdminCrud :config="config" />
</template>
<script setup>
import BookAdminCrud from '../components/BookAdminCrud.vue'
import { getBookAdminPageConfig } from '../config/bookAdminConfig'
defineOptions({ name: 'BookCommentManage' })
const config = getBookAdminPageConfig('comment')
</script>

View File

@@ -0,0 +1,12 @@
<template>
<BookAdminCrud :config="config" />
</template>
<script setup>
import BookAdminCrud from '../components/BookAdminCrud.vue'
import { getBookAdminPageConfig } from '../config/bookAdminConfig'
defineOptions({ name: 'BookCommentLikeRecordManage' })
const config = getBookAdminPageConfig('commentLikeRecord')
</script>

View File

@@ -0,0 +1,715 @@
<template>
<div>
<div class="gva-search-box">
<el-form ref="searchFormRef" :inline="true" :model="searchInfo" @keyup.enter="onSubmit">
<el-form-item v-for="field in config.searchFields" :key="field.prop" :label="field.label">
<component
:is="getFieldComponent(field)"
v-model="searchInfo[field.prop]"
v-bind="getFieldProps(field, true)"
@focus="onRelationFocus(field)"
>
<el-option
v-for="option in getFieldOptions(field)"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</component>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="search" @click="onSubmit">查询</el-button>
<el-button icon="refresh" @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="gva-table-box">
<div class="gva-btn-list">
<el-button v-if="!config.readonlyCreate" type="primary" icon="plus" @click="openDialog">新增</el-button>
<el-button icon="delete" :disabled="!multipleSelection.length" @click="onDelete">删除</el-button>
</div>
<el-table
ref="multipleTable"
:data="tableData"
row-key="id"
style="width: 100%"
tooltip-effect="dark"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column
v-for="column in config.tableColumns"
:key="column.prop"
:align="column.align || 'left'"
:label="column.label"
:min-width="column.minWidth"
:prop="column.prop"
:show-overflow-tooltip="!isImageTableColumn(column)"
:width="column.width"
>
<template #default="scope">
<el-image
v-if="isImageTableColumn(column) && scope.row[column.prop]"
class="book-admin-table-image"
:src="getTableImageUrl(scope.row, column)"
:preview-src-list="getTableImagePreviewList(scope.row, column)"
fit="cover"
preview-teleported
/>
<span v-else>{{ formatCell(scope.row, column) }}</span>
</template>
</el-table-column>
<el-table-column align="left" fixed="right" label="操作" min-width="260">
<template #default="scope">
<el-button type="primary" link class="table-button" @click="getDetails(scope.row)">
<el-icon style="margin-right: 5px"><InfoFilled /></el-icon>查看详情
</el-button>
<el-button
v-if="!config.readonlyCreate"
type="primary"
link
icon="edit"
class="table-button"
@click="updateRow(scope.row)"
>变更</el-button>
<el-dropdown v-if="config.statusFields?.length" class="book-admin-status" @command="(command) => updateStatus(scope.row, command)">
<el-button type="primary" link>状态</el-button>
<template #dropdown>
<el-dropdown-menu>
<template v-for="status in config.statusFields" :key="status.prop">
<el-dropdown-item
v-for="option in getStatusOptions(status)"
:key="`${status.prop}-${option.value}`"
:command="{ status, value: option.value }"
>
{{ status.label }}{{ option.label }}
</el-dropdown-item>
</template>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
v-if="config.authorBinding"
type="primary"
link
class="table-button"
@click="openAuthorBinding(scope.row)"
>作者绑定</el-button>
<el-button type="primary" link icon="delete" @click="deleteRow(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="gva-pagination">
<el-pagination
layout="total, sizes, prev, pager, next, jumper"
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:total="total"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<el-drawer
v-model="dialogFormVisible"
destroy-on-close
size="800"
:show-close="false"
:before-close="closeDialog"
>
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg">{{ dialogType === 'create' ? `添加${config.title}` : `修改${config.title}` }}</span>
<div>
<el-button type="primary" @click="enterDialog"> </el-button>
<el-button @click="closeDialog"> </el-button>
</div>
</div>
</template>
<el-form ref="formRef" :model="formData" :rules="formRules" label-position="top">
<el-form-item v-for="field in config.fields" :key="field.prop" :label="`${field.label}:`" :prop="field.prop">
<SelectImage
v-if="field.type === 'image'"
v-model="formData[field.prop]"
file-type="image"
/>
<div v-else-if="field.type === 'file'" class="book-admin-file-field">
<el-upload
:action="`${getBaseUrl()}/fileUploadAndDownload/upload`"
:accept="field.accept || ''"
:headers="{ 'x-token': token }"
:limit="1"
:show-file-list="false"
:on-success="(res) => onFileUploadSuccess(res, field)"
:on-error="onFileUploadError"
>
<el-button type="primary" icon="upload">上传文件</el-button>
</el-upload>
<el-link
v-if="formData[field.prop]"
class="book-admin-file-field__link"
type="primary"
:href="getFileUrl(formData[field.prop])"
target="_blank"
>
{{ formData[field.prop] }}
</el-link>
<el-button v-if="formData[field.prop]" link type="primary" @click="formData[field.prop] = ''">清除</el-button>
</div>
<component
v-else
:is="getFieldComponent(field)"
v-model="formData[field.prop]"
v-bind="getFieldProps(field)"
@focus="onRelationFocus(field)"
>
<el-option
v-for="option in getFieldOptions(field)"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</component>
</el-form-item>
</el-form>
</el-drawer>
<el-drawer v-model="detailShow" destroy-on-close size="900" :show-close="true" :before-close="closeDetailShow">
<template #header>
<span class="text-lg">{{ config.title }}详情</span>
</template>
<el-descriptions :column="1" border>
<el-descriptions-item v-for="field in config.fields" :key="field.prop" :label="field.label">
{{ formatCell(detailForm, field) }}
</el-descriptions-item>
</el-descriptions>
<template v-if="relatedBlocks.length">
<div v-for="block in relatedBlocks" :key="block.title" class="book-admin-related">
<div class="book-admin-related__title">{{ block.title }}</div>
<el-table :data="block.rows" size="small" border>
<el-table-column v-for="column in block.columns" :key="column" :label="column" :prop="column" show-overflow-tooltip>
<template #default="scope">{{ formatLooseCell(scope.row[column]) }}</template>
</el-table-column>
</el-table>
</div>
</template>
</el-drawer>
<el-drawer
v-model="authorBindingVisible"
destroy-on-close
size="760"
:show-close="true"
:before-close="closeAuthorBinding"
>
<template #header>
<span class="text-lg">作者绑定与排序</span>
</template>
<el-form :inline="true" :model="authorRelationForm">
<el-form-item label="作者">
<el-select
v-model="authorRelationForm.authorId"
clearable
filterable
remote
reserve-keyword
placeholder="选择作者"
:remote-method="loadAuthorOptions"
@focus="loadAuthorOptions('')"
>
<el-option v-for="item in authorOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="authorRelationForm.authorSort" :min="1" :step="1" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="createAuthorRelation">绑定</el-button>
</el-form-item>
</el-form>
<el-table :data="authorRelations" border>
<el-table-column label="作者 ID" prop="authorId" width="120" />
<el-table-column label="排序" prop="authorSort" width="180">
<template #default="scope">
<el-input-number v-model="scope.row.authorSort" :min="1" :step="1" @change="updateAuthorSort(scope.row)" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="120">
<template #default="scope">
<el-button type="primary" link icon="delete" @click="deleteAuthorRelation(scope.row)">移除</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
</div>
</template>
<script setup>
import * as bookApi from '@/api/book'
import SelectImage from '@/components/selectImage/selectImage.vue'
import { getDict } from '@/utils/dictionary'
import { getUrl } from '@/utils/image'
import { filterDict, formatBoolean, formatDate, getBaseUrl } from '@/utils/format'
import { useUserStore } from '@/pinia'
import { isBookAdminImageColumn, resolveBookAdminColumnType } from '../config/bookAdminDisplay'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, reactive, ref } from 'vue'
defineOptions({
name: 'BookAdminCrud'
})
const props = defineProps({
config: {
type: Object,
required: true
}
})
const config = computed(() => props.config)
const api = computed(() => resolveApi(config.value.api))
const userStore = useUserStore()
const token = userStore.token
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const tableData = ref([])
const multipleSelection = ref([])
const searchInfo = ref({})
const formData = ref({})
const detailForm = ref({})
const dialogType = ref('')
const dialogFormVisible = ref(false)
const detailShow = ref(false)
const relatedBlocks = ref([])
const dictOptions = reactive({})
const relationOptions = reactive({})
const formRef = ref()
const searchFormRef = ref()
const authorBindingVisible = ref(false)
const authorBindingBook = ref({})
const authorRelations = ref([])
const authorOptions = ref([])
const authorRelationForm = ref({ authorId: undefined, authorSort: 1 })
const formRules = computed(() => {
const rules = {}
config.value.fields.forEach((field) => {
if (field.required) {
rules[field.prop] = [{ required: true, message: `请输入${field.label}`, trigger: ['blur', 'change'] }]
}
})
return rules
})
const resolveApi = (apiConfig) => {
return Object.fromEntries(Object.entries(apiConfig).map(([key, apiName]) => [key, bookApi[apiName]]))
}
const getFieldComponent = (field) => {
const componentMap = {
textarea: 'el-input',
number: 'el-input-number',
switch: 'el-switch',
image: 'div',
file: 'div',
dict: 'el-select',
relation: 'el-select',
date: 'el-date-picker',
datetime: 'el-date-picker'
}
return componentMap[field.type] || 'el-input'
}
const getFieldProps = (field, isSearch = false) => {
const base = { clearable: true, placeholder: isSearch ? '搜索条件' : `请输入${field.label}` }
if (field.type === 'textarea') return { ...base, type: 'textarea', rows: 4 }
if (field.type === 'number') return { min: field.min ?? 0, precision: field.precision, step: field.step ?? 1 }
if (field.type === 'switch') return {}
if (field.type === 'dict') return { ...base, filterable: true }
if (field.type === 'relation') {
return {
...base,
filterable: true,
remote: true,
reserveKeyword: true,
remoteMethod: (query) => loadRelationOptions(field, query)
}
}
if (field.type === 'date') return { ...base, type: 'date', valueFormat: 'YYYY-MM-DD' }
if (field.type === 'datetime') return { ...base, type: 'datetime', valueFormat: 'YYYY-MM-DD HH:mm:ss' }
return base
}
const getFieldOptions = (field) => {
if (field.type === 'dict') return dictOptions[field.dict] || []
if (field.type === 'relation') return relationOptions[field.prop] || []
return []
}
const getStatusOptions = (status) => {
if (status.type === 'boolean') {
return [
{ label: '是', value: true },
{ label: '否', value: false }
]
}
return dictOptions[status.dict] || []
}
const formatLooseCell = (value) => {
if (typeof value === 'boolean') return formatBoolean(value)
return value ?? ''
}
const formatCell = (row, column) => {
const value = row?.[column.prop]
const columnType = resolveBookAdminColumnType(config.value, column)
if (columnType === 'datetime') return formatDate(value)
if (columnType === 'date') return value ? String(value).slice(0, 10) : ''
if (columnType === 'image' || columnType === 'file') return value || ''
if (columnType === 'boolean' || columnType === 'switch') return formatBoolean(value)
if (columnType === 'dict') return filterDict(value, dictOptions[column.dict]) || value || ''
return value ?? ''
}
const isImageTableColumn = (column) => {
return isBookAdminImageColumn(config.value, column)
}
const getTableImageUrl = (row, column) => {
return getFileUrl(row?.[column.prop])
}
const getTableImagePreviewList = (row, column) => {
const url = getTableImageUrl(row, column)
return url ? [url] : []
}
const normalizeDetail = (res) => {
if (!res?.data) return {}
return res.data[config.value.detailKey] || res.data
}
const buildDefaultForm = () => {
const data = {}
config.value.fields.forEach((field) => {
if (Object.prototype.hasOwnProperty.call(field, 'defaultValue')) {
data[field.prop] = field.defaultValue
} else if (field.type === 'switch') {
data[field.prop] = false
} else {
data[field.prop] = undefined
}
})
return data
}
const loadDictOptions = async () => {
const dictCodes = new Set()
;[...config.value.searchFields, ...config.value.fields, ...config.value.tableColumns].forEach((field) => {
if (field.dict) dictCodes.add(field.dict)
})
config.value.statusFields?.forEach((status) => {
if (status.dict) dictCodes.add(status.dict)
})
for (const dict of dictCodes) {
dictOptions[dict] = await getDict(dict)
}
}
const loadRelationOptions = async (field, query = '') => {
if (field.type !== 'relation') return
const relation = field.relation
const listApi = bookApi[relation.listApi]
if (!listApi) return
const params = { page: 1, pageSize: 20 }
if (query && relation.searchProp) params[relation.searchProp] = query
const res = await listApi(params)
if (res.code === 0) {
relationOptions[field.prop] = (res.data.list || []).map((item) => ({
label: item[relation.labelProp],
value: item[relation.valueProp]
}))
}
}
const onRelationFocus = (field) => {
if (field.type === 'relation') loadRelationOptions(field, '')
}
const getFileUrl = (url) => {
return getUrl(url)
}
const onFileUploadSuccess = (res, field) => {
if (res.code !== 0 || !res.data?.file?.url) {
ElMessage({ type: 'error', message: res.msg || '上传失败' })
return
}
formData.value[field.prop] = res.data.file.url
ElMessage({ type: 'success', message: '上传成功' })
}
const onFileUploadError = () => {
ElMessage({ type: 'error', message: '上传失败' })
}
const getTableData = async () => {
const res = await api.value.list({
page: page.value,
pageSize: pageSize.value,
...searchInfo.value
})
if (res.code === 0) {
tableData.value = res.data.list || []
total.value = res.data.total
page.value = res.data.page
pageSize.value = res.data.pageSize
}
}
const onSubmit = () => {
page.value = 1
getTableData()
}
const onReset = () => {
searchInfo.value = {}
getTableData()
}
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
const handleSelectionChange = (val) => {
multipleSelection.value = val
}
const openDialog = () => {
dialogType.value = 'create'
formData.value = buildDefaultForm()
dialogFormVisible.value = true
}
const closeDialog = () => {
dialogFormVisible.value = false
formData.value = {}
}
const updateRow = async (row) => {
const res = await api.value.find({ id: row.id })
if (res.code === 0) {
dialogType.value = 'update'
formData.value = normalizeDetail(res)
dialogFormVisible.value = true
}
}
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)
if (res.code === 0) {
ElMessage({ type: 'success', message: '创建/更改成功' })
closeDialog()
getTableData()
}
})
}
const deleteRow = (row) => {
ElMessageBox.confirm('确定要删除吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const res = await api.value.delete({ id: row.id })
if (res.code === 0) {
ElMessage({ type: 'success', message: '删除成功' })
if (tableData.value.length === 1 && page.value > 1) page.value--
getTableData()
}
})
}
const onDelete = () => {
ElMessageBox.confirm('确定要删除吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const ids = multipleSelection.value.map((item) => item.id)
if (!ids.length) {
ElMessage({ type: 'warning', message: '请选择要删除的数据' })
return
}
const res = await api.value.deleteByIds({ ids })
if (res.code === 0) {
ElMessage({ type: 'success', message: '删除成功' })
if (tableData.value.length === ids.length && page.value > 1) page.value--
getTableData()
}
})
}
const updateStatus = (row, command) => {
const { status, value } = command
ElMessageBox.confirm(`确定调整${status.label}吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const res = await api.value.update({ ...row, [status.prop]: value })
if (res.code === 0) {
ElMessage({ type: 'success', message: '状态已调整' })
getTableData()
}
})
}
const loadRelatedBlocks = async (row) => {
relatedBlocks.value = []
if (!config.value.related?.length) return
const blocks = []
for (const relation of config.value.related) {
const listApi = bookApi[relation.api]
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 || [] })
}
}
relatedBlocks.value = blocks
}
const getDetails = async (row) => {
const res = await api.value.find({ id: row.id })
if (res.code === 0) {
detailForm.value = normalizeDetail(res)
await loadRelatedBlocks(row)
detailShow.value = true
}
}
const closeDetailShow = () => {
detailShow.value = false
detailForm.value = {}
relatedBlocks.value = []
}
const loadAuthorRelations = async () => {
const res = await bookApi.getBookAuthorRelationList({ page: 1, pageSize: 100, bookId: authorBindingBook.value.id })
if (res.code === 0) authorRelations.value = res.data.list || []
}
const loadAuthorOptions = async (query = '') => {
const params = { page: 1, pageSize: 20 }
if (query) params.name = query
const res = await bookApi.getBookAuthorList(params)
if (res.code === 0) {
authorOptions.value = (res.data.list || []).map((item) => ({ label: item.name, value: item.id }))
}
}
const openAuthorBinding = async (row) => {
authorBindingBook.value = row
authorRelationForm.value = { authorId: undefined, authorSort: 1 }
await Promise.all([loadAuthorRelations(), loadAuthorOptions('')])
authorBindingVisible.value = true
}
const closeAuthorBinding = () => {
authorBindingVisible.value = false
authorBindingBook.value = {}
authorRelations.value = []
}
const createAuthorRelation = async () => {
if (!authorRelationForm.value.authorId) {
ElMessage({ type: 'warning', message: '请选择作者' })
return
}
const res = await bookApi.createBookAuthorRelation({
bookId: authorBindingBook.value.id,
authorId: authorRelationForm.value.authorId,
authorSort: authorRelationForm.value.authorSort
})
if (res.code === 0) {
ElMessage({ type: 'success', message: '绑定成功' })
authorRelationForm.value = { authorId: undefined, authorSort: 1 }
loadAuthorRelations()
}
}
const updateAuthorSort = async (row) => {
const res = await bookApi.updateBookAuthorRelation(row)
if (res.code === 0) ElMessage({ type: 'success', message: '排序已更新' })
}
const deleteAuthorRelation = (row) => {
ElMessageBox.confirm('确定移除该作者绑定吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const res = await bookApi.deleteBookAuthorRelation({ id: row.id })
if (res.code === 0) {
ElMessage({ type: 'success', message: '移除成功' })
loadAuthorRelations()
}
})
}
loadDictOptions()
getTableData()
</script>
<style scoped>
.book-admin-status {
margin: 0 8px;
}
.book-admin-related {
margin-top: 20px;
}
.book-admin-related__title {
margin-bottom: 10px;
font-weight: 600;
}
.book-admin-file-field {
display: flex;
align-items: center;
gap: 10px;
max-width: 100%;
}
.book-admin-file-field__link {
max-width: 520px;
}
.book-admin-table-image {
width: 56px;
height: 56px;
display: block;
border-radius: 4px;
}
</style>

View File

@@ -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)

View File

@@ -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'
}

View File

@@ -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)

View File

@@ -0,0 +1,12 @@
<template>
<BookAdminCrud :config="config" />
</template>
<script setup>
import BookAdminCrud from '../components/BookAdminCrud.vue'
import { getBookAdminPageConfig } from '../config/bookAdminConfig'
defineOptions({ name: 'BookFavoriteRecordManage' })
const config = getBookAdminPageConfig('favoriteRecord')
</script>

View File

@@ -0,0 +1,21 @@
<template>
<div>
<router-view v-slot="{ Component }">
<transition mode="out-in" name="el-fade-in-linear">
<keep-alive :include="routerStore.keepAliveRouters">
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
</div>
</template>
<script setup>
import { useRouterStore } from '@/pinia/modules/router'
defineOptions({
name: 'Book'
})
const routerStore = useRouterStore()
</script>

View File

@@ -0,0 +1,12 @@
<template>
<BookAdminCrud :config="config" />
</template>
<script setup>
import BookAdminCrud from '../components/BookAdminCrud.vue'
import { getBookAdminPageConfig } from '../config/bookAdminConfig'
defineOptions({ name: 'BookReadRecordManage' })
const config = getBookAdminPageConfig('readRecord')
</script>

View File

@@ -0,0 +1,12 @@
<template>
<BookAdminCrud :config="config" />
</template>
<script setup>
import BookAdminCrud from '../components/BookAdminCrud.vue'
import { getBookAdminPageConfig } from '../config/bookAdminConfig'
defineOptions({ name: 'BookSeriesManage' })
const config = getBookAdminPageConfig('series')
</script>

View File

@@ -45,6 +45,7 @@
:style="{ left: left + 'px', top: top + 'px' }"
class="contextmenu"
>
<li @click="refreshTab">刷新</li>
<li @click="closeAll">关闭所有</li>
<li @click="closeLeft">关闭左侧</li>
<li @click="closeRight">关闭右侧</li>
@@ -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) => {

View File

@@ -48,6 +48,9 @@
<el-button icon="delete" :disabled="!apis.length" @click="onDelete">
删除
</el-button>
<el-button icon="user" :disabled="!apis.length" @click="openBatchAssignRoleDrawer">
批量分配角色
</el-button>
<el-button icon="Refresh" @click="onFresh"> 刷新缓存 </el-button>
<el-button icon="Compass" @click="onSync"> 同步API </el-button>
<ExportTemplate template-id="api" />
@@ -160,9 +163,17 @@
<el-button :loading="apiCompletionLoading" @click="closeSyncDialog">
</el-button>
<el-button
:loading="oneKeySyncing"
:disabled="syncing || apiCompletionLoading"
@click="oneKeySyncApi"
>
一键同步
</el-button>
<el-button
type="primary"
:loading="syncing || apiCompletionLoading"
:disabled="oneKeySyncing"
@click="enterSyncDialog"
>
@@ -413,14 +424,14 @@
>
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg">分配角色 - {{ assignApiRow.description }}</span>
<span class="text-lg">{{ assignRoleTitle }}</span>
<div>
<el-button @click="assignRoleDrawerVisible = false"> </el-button>
<el-button type="primary" :loading="assignRoleSubmitting" @click="confirmAssignRole"> </el-button>
</div>
</div>
</template>
<warning-bar title="保存时将全量覆盖该API的角色关联关系并自动刷新Casbin缓存" />
<warning-bar :title="assignRoleWarning" />
<el-tree
ref="roleTreeRef"
v-loading="assignRoleLoading"
@@ -452,9 +463,10 @@
setApiRoles
} from '@/api/api'
import { getAuthorityList } from '@/api/authority'
import { buildApiRoleAssignRequests } from './assignRole'
import { toSQLLine } from '@/utils/stringFun'
import WarningBar from '@/components/warningBar/warningBar.vue'
import { ref, nextTick } from 'vue'
import { ref, nextTick, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import ExportExcel from '@/components/exportExcel/exportExcel.vue'
import ExportTemplate from '@/components/exportExcel/exportTemplate.vue'
@@ -587,6 +599,7 @@
}
const syncing = ref(false)
const oneKeySyncing = ref(false)
const enterSyncDialog = async () => {
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
}
</script>

View File

@@ -0,0 +1,7 @@
export const buildApiRoleAssignRequests = (apiRows, authorityIds) => {
return apiRows.map((item) => ({
path: item.path,
method: item.method,
authorityIds
}))
}

View File

@@ -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), [])

View File

@@ -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')
})