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