Merge pull request 'feat/xuanzhi-web' (#1) from feat/xuanzhi-web into main
Some checks failed
CI / init (push) Has been cancelled
CI / Frontend node 18.16.0 (push) Has been cancelled
CI / Backend go (1.22) (push) Has been cancelled
CI / release-pr (push) Has been cancelled
CI / devops-test (1.22, 18.16.0) (push) Has been cancelled
CI / release-please (push) Has been cancelled
CI / devops-prod (1.22, 18.x) (push) Has been cancelled
CI / docker (push) Has been cancelled
Some checks failed
CI / init (push) Has been cancelled
CI / Frontend node 18.16.0 (push) Has been cancelled
CI / Backend go (1.22) (push) Has been cancelled
CI / release-pr (push) Has been cancelled
CI / devops-test (1.22, 18.16.0) (push) Has been cancelled
CI / release-please (push) Has been cancelled
CI / devops-prod (1.22, 18.x) (push) Has been cancelled
CI / docker (push) Has been cancelled
Reviewed-on: #1
This commit is contained in:
317
README-en.md
317
README-en.md
@@ -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
|
|
||||||
|
|
||||||
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`)
|
|
||||||
|
|
||||||
- <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 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: <a href="https://github.com/baobeisuper">baobeisuper</a>)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 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
|
|
||||||
| :---: |
|
|
||||||
| <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.
|
|
||||||
|
|
||||||
|
|||||||
71
web/.ai-specs/coding-specs/common-page-create-spec.md
Normal file
71
web/.ai-specs/coding-specs/common-page-create-spec.md
Normal 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` 和未登录跳转。
|
||||||
6
web/.ai-specs/sys-specs/init.md
Normal file
6
web/.ai-specs/sys-specs/init.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.ai-specs\coding-specs
|
||||||
|
|
||||||
|
api 调用规范
|
||||||
|
字典调用规范
|
||||||
|
路由`新增/修改`规范 并且指出 本地路由和远程路由
|
||||||
|
通用页面创建规范
|
||||||
@@ -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] || ''
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -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] || ''
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -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`,由后端统一处理事务、错误聚合和缓存刷新。
|
||||||
@@ -66,10 +66,38 @@
|
|||||||
- 新增全局共享状态时,统一放在 `src/pinia/modules`。
|
- 新增全局共享状态时,统一放在 `src/pinia/modules`。
|
||||||
- 新增插件功能时,优先在 `src/plugin/<plugin>` 目录内闭环实现。
|
- 新增插件功能时,优先在 `src/plugin/<plugin>` 目录内闭环实现。
|
||||||
|
|
||||||
### 架构关系
|
## 链路关系
|
||||||
|
- **修改/新增对应落点时必须严格遵循以下规范**
|
||||||
|
|
||||||
|
#### 链路总线
|
||||||
- 关系总线:`sys-specs / coding-specs → Router / Permission → View → Components / Hooks / API → Request`,其中 `Pinia` 贯穿登录态、动态路由和跨页面共享状态;`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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
SPEC["sys-specs / coding-specs"] --> Router
|
SPEC["sys-specs / coding-specs"] --> Router
|
||||||
@@ -85,34 +113,32 @@ flowchart LR
|
|||||||
Request --> Backend["Backend"]
|
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\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\api-request-flow.md` | 规定 `api` 与 `request.js` 的分层、错误处理和请求链路 | 涉及新增接口、修改请求封装、上传下载、统一错误处理时必读 |
|
||||||
| `.ai-specs\coding-specs\router-permission-route.md` | 规定静态路由、动态路由、白名单和权限链路 | 涉及新增页面路由、登录跳转、菜单进入、默认首页时必读 |
|
| `.ai-specs\coding-specs\router-permission-route.md` | 规定静态路由、动态路由、白名单和权限链路 | 涉及新增页面路由、登录跳转、菜单进入、默认首页时必读 |
|
||||||
| `.ai-specs\coding-specs\pinia-store-boundary.md` | 规定共享状态和页面状态的分层边界 | 涉及新增 store、共享状态、状态持久化时必读 |
|
| `.ai-specs\coding-specs\pinia-store-boundary.md` | 规定共享状态和页面状态的分层边界 | 涉及新增 store、共享状态、状态持久化时必读 |
|
||||||
| `.ai-specs\coding-specs\plugin-module-structure.md` | 规定 `plugin/*` 的目录结构和与主应用公共层的关系 | 涉及新增插件、修改插件目录、决定代码是否上提时必读 |
|
| `.ai-specs\coding-specs\plugin-module-structure.md` | 规定 `plugin/*` 的目录结构和与主应用公共层的关系 | 涉及新增插件、修改插件目录、决定代码是否上提时必读 |
|
||||||
| `.ai-specs\coding-specs\style-asset-spec.md` | 规定全局样式、静态资源和主题覆盖的修改边界 | 涉及全局样式、Element Plus 覆盖、资源路径时必读 |
|
| `.ai-specs\coding-specs\style-asset-spec.md` | 规定全局样式、静态资源和主题覆盖的修改边界 | 涉及全局样式、Element Plus 覆盖、资源路径时必读 |
|
||||||
|
|
||||||
### sys-specs 存放前端系统级文档
|
#### sys-specs 存放前端系统级文档
|
||||||
|
|
||||||
| 路径 | 用途 | 说明 |
|
| 路径 | 用途 | 说明 |
|
||||||
|:---|:---|:---|
|
|:---|:---|:---|
|
||||||
|
|||||||
83
web/src/api/book.js
Normal file
83
web/src/api/book.js
Normal 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
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
{
|
{
|
||||||
"/src/view/about/index.vue": "About",
|
"/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/banner.vue": "Banner",
|
||||||
"/src/view/dashboard/components/card.vue": "Card",
|
"/src/view/dashboard/components/card.vue": "Card",
|
||||||
"/src/view/dashboard/components/charts-content-numbers.vue": "ChartsContentNumbers",
|
"/src/view/dashboard/components/charts-content-numbers.vue": "ChartsContentNumbers",
|
||||||
|
|||||||
12
web/src/view/book/author/index.vue
Normal file
12
web/src/view/book/author/index.vue
Normal 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>
|
||||||
12
web/src/view/book/book/index.vue
Normal file
12
web/src/view/book/book/index.vue
Normal 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>
|
||||||
12
web/src/view/book/chapter/index.vue
Normal file
12
web/src/view/book/chapter/index.vue
Normal 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>
|
||||||
12
web/src/view/book/comment/index.vue
Normal file
12
web/src/view/book/comment/index.vue
Normal 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>
|
||||||
12
web/src/view/book/commentLikeRecord/index.vue
Normal file
12
web/src/view/book/commentLikeRecord/index.vue
Normal 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>
|
||||||
715
web/src/view/book/components/BookAdminCrud.vue
Normal file
715
web/src/view/book/components/BookAdminCrud.vue
Normal 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>
|
||||||
334
web/src/view/book/config/bookAdminConfig.js
Normal file
334
web/src/view/book/config/bookAdminConfig.js
Normal 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)
|
||||||
9
web/src/view/book/config/bookAdminDisplay.js
Normal file
9
web/src/view/book/config/bookAdminDisplay.js
Normal 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'
|
||||||
|
}
|
||||||
15
web/src/view/book/config/bookAdminDisplay.test.mjs
Normal file
15
web/src/view/book/config/bookAdminDisplay.test.mjs
Normal 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)
|
||||||
12
web/src/view/book/favoriteRecord/index.vue
Normal file
12
web/src/view/book/favoriteRecord/index.vue
Normal 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>
|
||||||
21
web/src/view/book/index.vue
Normal file
21
web/src/view/book/index.vue
Normal 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>
|
||||||
12
web/src/view/book/readRecord/index.vue
Normal file
12
web/src/view/book/readRecord/index.vue
Normal 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>
|
||||||
12
web/src/view/book/series/index.vue
Normal file
12
web/src/view/book/series/index.vue
Normal 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>
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
:style="{ left: left + 'px', top: top + 'px' }"
|
:style="{ left: left + 'px', top: top + 'px' }"
|
||||||
class="contextmenu"
|
class="contextmenu"
|
||||||
>
|
>
|
||||||
|
<li @click="refreshTab">刷新</li>
|
||||||
<li @click="closeAll">关闭所有</li>
|
<li @click="closeAll">关闭所有</li>
|
||||||
<li @click="closeLeft">关闭左侧</li>
|
<li @click="closeLeft">关闭左侧</li>
|
||||||
<li @click="closeRight">关闭右侧</li>
|
<li @click="closeRight">关闭右侧</li>
|
||||||
@@ -118,6 +119,23 @@
|
|||||||
contextMenuVisible.value = false
|
contextMenuVisible.value = false
|
||||||
sessionStorage.setItem('historys', JSON.stringify(historys.value))
|
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 = () => {
|
const closeLeft = () => {
|
||||||
let right
|
let right
|
||||||
const rightIndex = historys.value.findIndex((item) => {
|
const rightIndex = historys.value.findIndex((item) => {
|
||||||
|
|||||||
@@ -48,6 +48,9 @@
|
|||||||
<el-button icon="delete" :disabled="!apis.length" @click="onDelete">
|
<el-button icon="delete" :disabled="!apis.length" @click="onDelete">
|
||||||
删除
|
删除
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button icon="user" :disabled="!apis.length" @click="openBatchAssignRoleDrawer">
|
||||||
|
批量分配角色
|
||||||
|
</el-button>
|
||||||
<el-button icon="Refresh" @click="onFresh"> 刷新缓存 </el-button>
|
<el-button icon="Refresh" @click="onFresh"> 刷新缓存 </el-button>
|
||||||
<el-button icon="Compass" @click="onSync"> 同步API </el-button>
|
<el-button icon="Compass" @click="onSync"> 同步API </el-button>
|
||||||
<ExportTemplate template-id="api" />
|
<ExportTemplate template-id="api" />
|
||||||
@@ -160,9 +163,17 @@
|
|||||||
<el-button :loading="apiCompletionLoading" @click="closeSyncDialog">
|
<el-button :loading="apiCompletionLoading" @click="closeSyncDialog">
|
||||||
取 消
|
取 消
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
:loading="oneKeySyncing"
|
||||||
|
:disabled="syncing || apiCompletionLoading"
|
||||||
|
@click="oneKeySyncApi"
|
||||||
|
>
|
||||||
|
一键同步
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="syncing || apiCompletionLoading"
|
:loading="syncing || apiCompletionLoading"
|
||||||
|
:disabled="oneKeySyncing"
|
||||||
@click="enterSyncDialog"
|
@click="enterSyncDialog"
|
||||||
>
|
>
|
||||||
确 定
|
确 定
|
||||||
@@ -413,14 +424,14 @@
|
|||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-lg">分配角色 - {{ assignApiRow.description }}</span>
|
<span class="text-lg">{{ assignRoleTitle }}</span>
|
||||||
<div>
|
<div>
|
||||||
<el-button @click="assignRoleDrawerVisible = false">取 消</el-button>
|
<el-button @click="assignRoleDrawerVisible = false">取 消</el-button>
|
||||||
<el-button type="primary" :loading="assignRoleSubmitting" @click="confirmAssignRole">确 定</el-button>
|
<el-button type="primary" :loading="assignRoleSubmitting" @click="confirmAssignRole">确 定</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<warning-bar title="注:保存时将全量覆盖该API的角色关联关系,并自动刷新Casbin缓存" />
|
<warning-bar :title="assignRoleWarning" />
|
||||||
<el-tree
|
<el-tree
|
||||||
ref="roleTreeRef"
|
ref="roleTreeRef"
|
||||||
v-loading="assignRoleLoading"
|
v-loading="assignRoleLoading"
|
||||||
@@ -452,9 +463,10 @@
|
|||||||
setApiRoles
|
setApiRoles
|
||||||
} from '@/api/api'
|
} from '@/api/api'
|
||||||
import { getAuthorityList } from '@/api/authority'
|
import { getAuthorityList } from '@/api/authority'
|
||||||
|
import { buildApiRoleAssignRequests } from './assignRole'
|
||||||
import { toSQLLine } from '@/utils/stringFun'
|
import { toSQLLine } from '@/utils/stringFun'
|
||||||
import WarningBar from '@/components/warningBar/warningBar.vue'
|
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 { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import ExportExcel from '@/components/exportExcel/exportExcel.vue'
|
import ExportExcel from '@/components/exportExcel/exportExcel.vue'
|
||||||
import ExportTemplate from '@/components/exportExcel/exportTemplate.vue'
|
import ExportTemplate from '@/components/exportExcel/exportTemplate.vue'
|
||||||
@@ -587,6 +599,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const syncing = ref(false)
|
const syncing = ref(false)
|
||||||
|
const oneKeySyncing = ref(false)
|
||||||
|
|
||||||
const enterSyncDialog = async () => {
|
const enterSyncDialog = async () => {
|
||||||
if (
|
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 = () => {
|
const onReset = () => {
|
||||||
searchInfo.value = {}
|
searchInfo.value = {}
|
||||||
getTableData()
|
getTableData()
|
||||||
@@ -867,13 +898,27 @@
|
|||||||
// 分配给角色
|
// 分配给角色
|
||||||
const assignRoleDrawerVisible = ref(false)
|
const assignRoleDrawerVisible = ref(false)
|
||||||
const assignApiRow = ref({})
|
const assignApiRow = ref({})
|
||||||
|
const isBatchAssignRole = ref(false)
|
||||||
const authorityTreeData = ref([])
|
const authorityTreeData = ref([])
|
||||||
const assignRoleLoading = ref(false)
|
const assignRoleLoading = ref(false)
|
||||||
const assignRoleSubmitting = ref(false)
|
const assignRoleSubmitting = ref(false)
|
||||||
const roleTreeRef = ref(null)
|
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) => {
|
const openAssignRoleDrawer = async (row) => {
|
||||||
assignApiRow.value = row
|
assignApiRow.value = row
|
||||||
|
isBatchAssignRole.value = false
|
||||||
assignRoleDrawerVisible.value = true
|
assignRoleDrawerVisible.value = true
|
||||||
assignRoleLoading.value = true
|
assignRoleLoading.value = true
|
||||||
const [authRes, rolesRes] = await Promise.all([
|
const [authRes, rolesRes] = await Promise.all([
|
||||||
@@ -891,23 +936,47 @@
|
|||||||
assignRoleLoading.value = false
|
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 () => {
|
const confirmAssignRole = async () => {
|
||||||
assignRoleSubmitting.value = true
|
assignRoleSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const checkedKeys = roleTreeRef.value?.getCheckedKeys(false) || []
|
const checkedKeys = roleTreeRef.value?.getCheckedKeys(false) || []
|
||||||
const res = await setApiRoles({
|
const targetApis = isBatchAssignRole.value ? apis.value : [assignApiRow.value]
|
||||||
path: assignApiRow.value.path,
|
const requests = buildApiRoleAssignRequests(targetApis, checkedKeys)
|
||||||
method: assignApiRow.value.method,
|
const results = await Promise.all(requests.map((item) => setApiRoles(item)))
|
||||||
authorityIds: checkedKeys
|
const isAllSuccess = results.every((item) => item.code === 0)
|
||||||
})
|
if (isAllSuccess) {
|
||||||
if (res.code === 0) {
|
ElMessage({
|
||||||
ElMessage({ type: 'success', message: '分配成功!' })
|
type: 'success',
|
||||||
|
message: isBatchAssignRole.value ? `批量分配成功,共 ${requests.length} 个API` : '分配成功!'
|
||||||
|
})
|
||||||
assignRoleDrawerVisible.value = false
|
assignRoleDrawerVisible.value = false
|
||||||
|
} else {
|
||||||
|
ElMessage({ type: 'error', message: '部分API分配失败,请重试' })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage({ type: 'error', message: '分配失败,请重试' })
|
ElMessage({ type: 'error', message: '分配失败,请重试' })
|
||||||
|
} finally {
|
||||||
|
assignRoleSubmitting.value = false
|
||||||
}
|
}
|
||||||
assignRoleSubmitting.value = false
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
7
web/src/view/superAdmin/api/assignRole.js
Normal file
7
web/src/view/superAdmin/api/assignRole.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const buildApiRoleAssignRequests = (apiRows, authorityIds) => {
|
||||||
|
return apiRows.map((item) => ({
|
||||||
|
path: item.path,
|
||||||
|
method: item.method,
|
||||||
|
authorityIds
|
||||||
|
}))
|
||||||
|
}
|
||||||
15
web/src/view/superAdmin/api/assignRole.test.mjs
Normal file
15
web/src/view/superAdmin/api/assignRole.test.mjs
Normal 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), [])
|
||||||
56
web/tests/bookAdminConfig.test.mjs
Normal file
56
web/tests/bookAdminConfig.test.mjs
Normal 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')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user