Compare commits

..
68 Commits
Author SHA1 Message Date
sookie 018d1d0b2a 获取歌词时先判断lyricId存在 2026-06-22 19:52:27 +08:00
sookie 263f8ef8d3 歌曲库补全支持上传的文件类型 2026-05-28 23:00:56 +08:00
sookie 6ccbec1f82 补充tooltip 2026-05-18 01:08:34 +08:00
sookie 5b2d25fe78 照片墙重写改造 2026-05-17 23:48:14 +08:00
sookie 8fc5ef572c 升级vite版本 2026-05-17 22:01:15 +08:00
sookie 10b19a3311 粒子特效 2026-05-16 23:40:20 +08:00
sookie 4533a754c9 播放器资源销毁逻辑优化 2026-05-16 23:18:28 +08:00
sookie 6d2e522818 音乐播放器重构 2026-05-16 22:51:43 +08:00
sookie 25e80d73ae 验证码接口适配调整 2026-05-16 16:03:54 +08:00
sookie f9b0b05c7e 去掉bgUrl的设定 2026-05-16 14:18:33 +08:00
sookie 0aef7d0ad5 图片拼接类型验证码, 两块图片之间添加一个细线, 遮盖切分的像素误差 2026-05-16 14:07:35 +08:00
sookie e581ced7e8 验证码组件封装 2026-05-16 13:21:55 +08:00
sookie 78bed71168 验证码调试页面布局精简 2026-05-16 12:52:40 +08:00
sookie 02e12f6e3d 验证码控件部分样式调整 2026-05-16 12:42:42 +08:00
sookie 662b22576d refactor: simplify captcha selector naming
Convert captcha vendor templates and DOM selectors from id-based tianai naming to class-based captcha naming.

Update the shared captcha container, common tips/loading helpers, and slider/rotate/concat/disable/image-click implementations so template markup, DOM queries, and less selectors stay aligned.

Keep captcha.js as the single style entry for the captcha module and remove per-component less imports from the submodule JavaScript files.
2026-05-16 12:25:52 +08:00
sookie e0f6175fed 登录功能接入验证码 2026-05-16 12:11:22 +08:00
sookie 4c2d41b699 删除不支持的图形验证码类型 2026-05-15 22:52:26 +08:00
sookie ee88c8abe9 删除废弃代码 2026-05-15 15:53:59 +08:00
sookie 3e331f58ce 验证码调试页面 2026-05-15 15:43:59 +08:00
sookie a22f9e5ff4 上传成功message提示 2026-05-12 22:59:09 +08:00
sookie aac55b6a39 去掉一层app div 2026-05-03 21:15:11 +08:00
sookie 895a902d96 后端版本获取 2026-04-30 11:34:25 +08:00
sookieandCopilot ed7e8a7768 修复播放器拖拽进度图标显示跳变问题
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 23:37:25 +08:00
sookieandCopilot 83a814c386 处理vue子组件emit事件警告
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 22:32:04 +08:00
sookieandCopilot c55b75686e 播放器组件重构&歌词加载问题修复
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 22:15:57 +08:00
sookie 042c1e54ed 环境变量修改为VITE前缀 2026-04-28 16:49:13 +08:00
sookieandCopilot bcc09d7064 打包工具更换为vite
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 16:34:30 +08:00
sookieandCopilot 893cfe6a39 添加@types/node
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 14:30:14 +08:00
sookieandCopilot e729ac1af2 fix: 过滤 dev overlay 中误报的 ResizeObserver loop 错误
ResizeObserver loop completed with undelivered notifications 是浏览器
规范允许的良性通知,Element Plus 下拉框展开时会触发,不影响实际功能。
webpack-dev-server 错误地将其当成运行时错误弹出 overlay,通过
client.overlay.runtimeErrors 回调将其过滤掉。

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 01:32:55 +08:00
sookieandCopilot d4ec727437 feat: 美化文章 Markdown 预览样式,支持亮/暗双色模式
- Drawer 宽度调整为 50%,标题栏加背景/底部边框
- 为预览内容 div 添加 markdown-body 类
- 新增完整 markdown-body 样式:
  - h1-h6 标题层级(h1/h2 带下划线)
  - 段落、强调、链接(带 hover 过渡)
  - 内联代码:使用 Element Plus 填充色 + danger 颜色
  - 代码块:亮色模式 #f6f8fa 浅灰背景,暗色模式 #161b22 深背景
  - 引用块:主题色左边框 + 浅色背景
  - 有序/无序列表(支持嵌套)
  - 表格:条纹行 + hover 高亮
  - 图片:居中、圆角、阴影
  - 水平线
- 暗色模式通过 html.dark 选择器单独覆盖代码块和图片阴影

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 01:27:39 +08:00
sookieandCopilot 18224299aa fix: 修复 dev 模式下 extract-css 插件不存在导致的报错
dev 模式不加载 mini-css-extract-plugin,需先判断插件是否存在再调用 tap()

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 01:19:40 +08:00
sookieandCopilot d5d82d3488 fix: 修复 npm 依赖安全漏洞并消除 CSS 顺序警告
- 执行 npm audit fix 自动修复 41 个漏洞中的大部分(babel、webpack、axios 等)
- 在 package.json 中添加 overrides 强制升级无法自动修复的间接依赖:
  - postcss >= 8.4.31(修复 CVE in @vue/component-compiler-utils)
  - serialize-javascript >= 7.0.5(修复 XSS/RCE in copy-webpack-plugin)
  - webpack-dev-server >= 5.2.1(修复源码泄露漏洞)
- 漏洞数从 41 降至 0
- vue.config.js 中为 mini-css-extract-plugin 添加 ignoreOrder: true,
  消除 Element Plus 按需导入时不同路由 chunk CSS 顺序冲突的警告

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 01:10:58 +08:00
sookie c144830fd7 el-alert换成tooltip 2026-04-03 11:02:11 +08:00
sookie 55877c5e92 修改照片墙搜索框, 换用input-number 2026-04-03 10:53:50 +08:00
sookie df11fa47a5 所有大按钮都加上plain 2026-04-03 10:41:29 +08:00
sookie 11f52789eb 删除发布博客的功能 2026-04-03 10:27:50 +08:00
sookie ca4c17042d 首页菜单按权限过滤 2026-04-02 20:47:29 +08:00
sookie a4f7db415e 权限系统重构:
- 角色模型改为 permissions 字段,移除 methods/includeUri/excludeUri
- 角色管理页面支持权限树选择,保存时只传最上级权限
- /common/verifyToken 返回 permissions,存入 vuex
- 新增 v-permission 指令,所有操作按钮和菜单均按权限控制显示
- 菜单按 list 权限过滤
- 各业务页面按钮加权限指令
- 角色管理列表只显示名称/描述/时间,不显示权限列,权限必选
2026-04-02 20:38:14 +08:00
sookieandCopilot 86c7d75963 feat: 引入环境变量配置,统一管理接口路径
- 新增 .env 文件,配置 VUE_APP_PROXY_TARGET(开发模式代理目标)和 VUE_APP_API_BASE(接口根路径)
- vue.config.js 的 devServer proxy 目标改用 VUE_APP_PROXY_TARGET 环境变量
- src/utils/http.ts 的 axios 实例配置 baseURL 为 VUE_APP_API_BASE,所有请求自动加前缀
- 所有页面中 http 接口调用去掉 /api/v2 前缀,由 axios baseURL 自动补全
- 文件上传的 el-upload action 属性改用动态绑定,引用 VUE_APP_API_BASE 环境变量
- 涉及文件:Login.vue、Home.vue、Article.vue、Hitokoto.vue、Music.vue、PhotoWall.vue、SourceImage.vue、Statistics.vue、SystemUser.vue、SystemConfig.vue、SystemConfigAdd.vue、SystemRole.vue

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 15:21:39 +08:00
sookie 98d2852604 树添加图标 2026-04-01 14:44:13 +08:00
sookie 9aaacb9937 优化表格页面布局样式,统一表格容器与分页、操作栏结构,调整相关 .vue 页面表格结构以适配新样式。\n\n- 优化 src/static/common.less 的表格、分页、操作栏样式结构\n- 调整 src/views/api/Hitokoto.vue、Music.vue、PhotoWall.vue、SourceImage.vue、Article.vue、SystemConfig.vue、SystemRole.vue、SystemUser.vue 的表格结构,统一使用 .table-container 类\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> 2026-04-01 14:21:23 +08:00
sookie 58729a2960 feat(Music.vue): 新增“时长”列,显示格式为mm:ss,展示音乐时长(time字段)并格式化为两位数分钟和秒数。 2026-04-01 10:46:34 +08:00
sookie b0370df011 /lib/update 2026-04-01 10:25:14 +08:00
sookieandCopilot 3656f91370 style: 登录页和首页适配深色模式
- 将硬编码颜色值替换为Element Plus CSS变量
- 适配Login.vue和Welcome.vue的文字色、背景色、边框色

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 23:04:05 +08:00
sookie de58ca78be 换用el-image-viewer 2026-03-31 22:56:31 +08:00
sookieandCopilot 3ca39fc010 feat: 添加深色模式和照片预览优化
- 新增全局深色模式切换功能,支持手动切换和跟随系统主题
- 优化照片墙预览,使用ElImageViewer组件支持左右切换
- 引入Element Plus深色模式相关样式文件

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 22:51:51 +08:00
sookieandCopilot b68de2e9bf feat: 添加歌单管理功能
- 新增歌单管理界面,支持添加和删除歌单
- 在MusicLibModel中添加musicCount字段显示歌曲数量
- 优化歌词编辑界面,添加加载状态提示
- 优化UI细节和路由视图加载时机

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 22:23:24 +08:00
sookie 265ce0c6c7 /music/lib/list 2026-03-31 16:44:52 +08:00
sookie 0e9cafd6c4 修改删除接口参数 2026-03-31 15:29:12 +08:00
sookie 6ffb158bf7 退出登录调用 2026-03-28 00:56:59 +08:00
sookie 96f5b93053 更新caniuse-lite 2026-03-27 22:20:02 +08:00
sookie e9fa36a661 修改密码功能 2026-03-25 15:14:16 +08:00
sookieandClaude Opus 4.6 ad9c2b0086 feat: 添加紫色系书本风格 Logo
- 创建 public/logo.svg:紫色渐变书本图标,带金色书签装饰
- 更新 public/index.html:使用 SVG 作为 favicon
- 删除旧的 favicon.ico

Logo 特点:
- 圆形背景配紫色渐变(#7c3aed → #c084fc)
- 打开的书本造型,淡紫色书页
- 金色书签点缀,增加视觉层次
- 响应式矢量图形,任意尺寸清晰显示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:24:30 +08:00
sookie 9232901211 处理日期传参 2026-03-21 13:29:41 +08:00
sookieandClaude Opus 4.6 a22bc9dac6 refactor: 优化接口响应处理,移除冗余代码
- Login.vue: 删除多余的 else 分支(拦截器已处理错误)
- Hitokoto.vue/Music.vue/SystemConfig.vue/SystemRole.vue/SystemUser.vue:
  - ElMessage.success 使用固定中文提示替代 data.message
  - 删除未使用的 data 变量
- SourceImage.vue/Article.vue: response.status → response.code === 0

统一接口响应格式后的代码优化。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 12:01:51 +08:00
sookieandClaude Opus 4.6 4dd48489a5 refactor: 统一接口响应格式处理
- 修改 axios 响应拦截器,统一处理包装后的响应格式:
  - code === 0: 返回 data 中的实际数据
  - code === -1: ElMessage.error 提示 message 并 reject
  - 其他情况: 兼容原始响应格式

- 更新各页面数据取值方式:
  - 分页列表: data.data → data.list
  - SystemUser.vue, SystemRole.vue, Article.vue
  - Music.vue, Hitokoto.vue, PhotoWall.vue, SourceImage.vue
  - SystemConfigAdd.vue: data.data.exists → data.exists

- Home.vue: 简化 verifyToken 响应处理
- SystemConfig.vue: 简化 save 响应处理
- SystemRole.vue/SystemUser.vue: 简化 delete 响应处理

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:46:15 +08:00
sookie 4bad97a81d 修复报错 2026-03-21 02:38:04 +08:00
sookie a0e49ceda6 修改下划线分隔的字段 2026-03-21 01:26:06 +08:00
sookie 499951f9da v1改为v2 2026-03-21 01:06:54 +08:00
sookieandClaude Opus 4.6 9131148bc1 feat: 美化统计页面布局
- 新增顶部统计卡片:文章总数、分类数量、最早发布、高频词汇
- 图表卡片化设计,统一圆角、阴影、边框样式
- 优化 ECharts 配色,使用 Element Plus 主题色系
- 图表添加标题、副标题和图标
- 支持响应式布局(大屏/中屏/小屏适配)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 22:27:35 +08:00
sookieandClaude Opus 4.6 1e7748db64 feat: 表格区域独立滚动,表头固定
- 修改 common.less:
  - 新增 .page-wrapper 类,使用 flex 纵向布局占满父容器
  - 修改 .table-container 为 flex 布局,让表格自适应高度
  - .el-table 使用 flex:1 实现内容区域滚动、表头固定

- 7个列表页面添加 page-wrapper 类:
  - SystemUser.vue, SystemRole.vue
  - Hitokoto.vue, PhotoWall.vue, Music.vue, SourceImage.vue
  - Article.vue(特殊处理:左右分栏布局适配)

现在设置 100 条数据时,表格内容独立滚动,分页始终可见,不会触发整页滚动。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 22:16:49 +08:00
sookieandClaude Opus 4.6 5b9dc0ccb9 refactor: 使用 CSS 变量定制 Element Plus 主题,优化 Welcome 页样式
- 重构 common.less,使用 Element Plus CSS 变量覆盖组件样式:
  - Menu: --el-menu-bg-color, --el-menu-hover-bg-color 等
  - Table: --el-table-header-bg-color, --el-table-row-hover-bg-color 等
  - Dialog: --el-dialog-border-radius, --el-dialog-padding-primary
  - 保留无对应变量的直接样式,添加注释说明

- 优化 Welcome.vue:
  - 调整 max-width 从 900px 到 1000px
  - 添加 box-sizing: border-box

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 22:03:54 +08:00
sookie 83731771a1 fix: 移除 fork-ts-checker 插件,修复与 TypeScript 5 的兼容性错误 2026-03-20 00:37:47 +08:00
sookie 90a6153776 升级依赖 & 修复 TS 类型解析 & 适配 element-plus 样式变化
依赖升级:
- element-plus 2.2.28 -> 2.13.5
- vue 3.2.45 -> 3.5.30
- vue-router 4.0.11 -> 4.6.4
- axios 0.22 -> 1.13.6
- echarts 5.2.1 -> 5.6.0
- hls.js 1.3 -> 1.6.15
- typescript 4.5.5 -> 5.9.3

破坏性变更修复:
- axios 1.x: config.headers.token= 改为 config.headers.set()
- element-plus locale 路径: lib/ -> es/
- Article.vue: Node 改为具名导入 { Node }
- main.ts: 路由守卫参数补全显式类型声明
- SystemConfig.vue: 移除废弃的 clearValidate 包装函数

TypeScript 配置修复 (tsconfig.json):
- moduleResolution: node -> bundler (支持 exports 字段, 解决 vue-router .mts 类型文件)
- 新增 skipLibCheck: true (规避第三方库内部类型冲突)
- 新增 vuex paths 映射 (vuex exports 字段缺少 types 条件)

样式适配 (common.less):
- el-dialog 新增 padding: 0, 消除升级后产生的白边
- el-form--inline 内 el-select 补 min-width: 160px, 修复搜索栏下拉框过窄问题

清理:
- 删除 src/vuex.d.ts (已无用的 vuex store 类型扩充文件)
2026-03-20 00:33:09 +08:00
sookie 2c8789b8cb 导航tab美化 2026-03-19 23:30:31 +08:00
sookie 8a2ba4e2bf 样式优化 2026-03-19 23:24:30 +08:00
sookie cf34013c53 登录页/欢迎页美化 2026-03-19 23:11:03 +08:00
sookie 96b032d262 refactor: 全面迁移 vue-class-component 至 Vue 3 Composition API
- 将所有页面组件从 class 语法重写为 <script setup> 风格
  - App.vue / Login.vue / Home.vue / Welcome.vue
  - api: Hitokoto.vue / HitokotoAdd.vue / Music.vue / PhotoWall.vue / SourceImage.vue
  - system: Article.vue / Statistics.vue / SystemConfig.vue / SystemConfigAdd.vue / SystemRole.vue / SystemUser.vue

- 新增 src/utils/http.ts:独立 axios 实例,含请求/响应拦截器,替代 vue-axios 插件
- baselist.ts:abstract class BaseList<T> → useBaseList<T>() 组合式函数
- types.ts:VForm 类型改用 Element Plus 原生 FormInstance
- main.ts:移除 vue-axios 及内联 axios 配置,路由守卫直接引用 store

- 依赖清理:移除 vue-class-component、vue-axios
2026-03-19 23:05:59 +08:00
93 changed files with 10610 additions and 9249 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_APP_PROXY_TARGET=https://www.colorfulsweet.site
VITE_APP_API_BASE=/api/v2
+11 -1
View File
@@ -7,13 +7,19 @@ export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
CaptchaPanel: typeof import('./src/components/CaptchaPanel.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckboxButton: typeof import('element-plus/es')['ElCheckboxButton']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
@@ -22,7 +28,10 @@ declare module '@vue/runtime-core' {
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
@@ -37,6 +46,7 @@ declare module '@vue/runtime-core' {
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTransfer: typeof import('element-plus/es')['ElTransfer']
ElTree: typeof import('element-plus/es')['ElTree']
ElUpload: typeof import('element-plus/es')['ElUpload']
+2 -1
View File
@@ -2,11 +2,12 @@
<html>
<head>
<meta charset="UTF-8" />
<link rel="icon" href="./favicon.ico" />
<link rel="icon" type="image/svg+xml" href="./logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>博客管理后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2793
View File
File diff suppressed because it is too large Load Diff
+19 -23
View File
@@ -2,35 +2,31 @@
"name": "blog-admin-web",
"version": "0.1.0",
"scripts": {
"dev": "vue-cli-service serve",
"build": "vue-cli-service build"
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.0.10",
"axios": "^0.22.0",
"echarts": "^5.2.1",
"element-plus": "^2.2.28",
"hls.js": "^1.3.0",
"hyperdown": "^2.4.29",
"moment": "^2.29.1",
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.6",
"echarts": "^5.6.0",
"element-plus": "^2.13.5",
"hls.js": "^1.6.15",
"hyperdown": "^2.4.31",
"moment": "^2.30.1",
"pretty-bytes": "^5.6.0",
"vue": "^3.2.45",
"vue-axios": "^3.3.7",
"vue-class-component": "8.0.0-rc.1",
"vue-router": "^4.0.11",
"vuex": "^4.0.2"
"vue": "^3.5.30",
"vue-router": "^4.6.4",
"vuex": "^4.1.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-plugin-typescript": "~5.0.0",
"@vue/cli-plugin-vuex": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"less": "^4.0.0",
"less-loader": "^8.0.0",
"typescript": "~4.5.5",
"@types/node": "^25.6.0",
"@vitejs/plugin-vue": "^6.0.7",
"less": "^4.6.4",
"typescript": "^5.4.0",
"unplugin-auto-import": "^0.12.1",
"unplugin-vue-components": "^0.22.12"
"unplugin-vue-components": "^0.22.12",
"vite": "^8.0.13"
},
"browserslist": [
"> 1%",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

+70
View File
@@ -0,0 +1,70 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<!-- 主渐变:深紫到亮紫 -->
<linearGradient id="bookGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#7c3aed;stop-opacity:1" />
<stop offset="50%" style="stop-color:#a855f7;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c084fc;stop-opacity:1" />
</linearGradient>
<!-- 阴影渐变 -->
<linearGradient id="shadowGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#000000;stop-opacity:0.2" />
<stop offset="100%" style="stop-color:#000000;stop-opacity:0" />
</linearGradient>
<!-- 内页渐变 -->
<linearGradient id="pageGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#f3e8ff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e9d5ff;stop-opacity:1" />
</linearGradient>
</defs>
<!-- 背景圆形 -->
<circle cx="256" cy="256" r="240" fill="url(#bookGradient)"/>
<!-- 书本主体阴影 -->
<ellipse cx="256" cy="420" rx="140" ry="20" fill="url(#shadowGradient)"/>
<!-- 书本下半部分(封面) -->
<path d="M126 180 L256 220 L386 180 L386 380 Q256 410 126 380 Z"
fill="#6d28d9"/>
<!-- 书本左页 -->
<path d="M126 180 Q191 160 256 180 L256 380 Q191 360 126 380 Z"
fill="url(#pageGradient)"/>
<!-- 书本右页 -->
<path d="M256 180 Q321 160 386 180 L386 380 Q321 360 256 380 Z"
fill="url(#pageGradient)"/>
<!-- 书脊/中缝 -->
<path d="M246 175 L266 175 L266 385 L246 385 Z"
fill="#a855f7" opacity="0.8"/>
<!-- 书页折痕(左) -->
<path d="M136 200 Q191 185 246 200"
stroke="#c084fc" stroke-width="2" fill="none" opacity="0.6"/>
<path d="M136 240 Q191 225 246 240"
stroke="#c084fc" stroke-width="2" fill="none" opacity="0.4"/>
<path d="M136 280 Q191 265 246 280"
stroke="#c084fc" stroke-width="2" fill="none" opacity="0.3"/>
<!-- 书页折痕(右) -->
<path d="M266 200 Q321 185 376 200"
stroke="#c084fc" stroke-width="2" fill="none" opacity="0.6"/>
<path d="M266 240 Q321 225 376 240"
stroke="#c084fc" stroke-width="2" fill="none" opacity="0.4"/>
<path d="M266 280 Q321 265 376 280"
stroke="#c084fc" stroke-width="2" fill="none" opacity="0.3"/>
<!-- 书本高光 -->
<ellipse cx="200" cy="220" rx="30" ry="15"
fill="#ffffff" opacity="0.3" transform="rotate(-20 200 220)"/>
<ellipse cx="312" cy="220" rx="30" ry="15"
fill="#ffffff" opacity="0.3" transform="rotate(20 312 220)"/>
<!-- 装饰性书签 -->
<path d="M290 140 L290 220 L300 210 L310 220 L310 140 Z"
fill="#fbbf24" opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+6 -14
View File
@@ -1,21 +1,13 @@
<template>
<div id="app" >
<el-config-provider :locale="locale" >
<router-view ></router-view>
</el-config-provider>
</div>
<el-config-provider :locale="locale" >
<router-view ></router-view>
</el-config-provider>
</template>
<script lang="ts">
import { Options, Vue } from 'vue-class-component'
import zhCn from 'element-plus/lib/locale/lang/zh-cn'
<script setup lang="ts">
import zhCn from 'element-plus/es/locale/lang/zh-cn'
@Options({
name: 'App'
})
export default class App extends Vue{
locale = zhCn
}
const locale = zhCn
</script>
<style lang="less">
@import url('./static/common.less');
+115
View File
@@ -0,0 +1,115 @@
<template>
<div ref="captchaBoxRef"></div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref } from 'vue'
import {
createTianAiCaptcha,
type TianAiCaptchaConfig,
type TianAiCaptchaInstance,
type TianAiCaptchaResult,
type TianAiCaptchaStyle
} from '@/vendor/tianai-captcha'
type CaptchaMode = 'data' | 'validate'
type CaptchaType = 'SLIDER' | 'ROTATE' | 'CONCAT' | 'WORD_IMAGE_CLICK'
const props = withDefaults(defineProps<{
mode?: CaptchaMode
types?: CaptchaType[]
styleConfig?: TianAiCaptchaStyle
}>(), {
mode: 'data',
types: () => []
})
const emit = defineEmits<{
(e: 'close'): void
(e: 'data-ready', result: TianAiCaptchaResult): void
(e: 'init-error', error: Error): void
(e: 'state-change', active: boolean): void
(e: 'valid-fail', res: any): void
(e: 'valid-success', res: any): void
}>()
const captchaBoxRef = ref<HTMLElement | null>(null)
const captchaInstance = ref<TianAiCaptchaInstance | null>(null)
const active = ref(false)
const requestCaptchaDataUrl = buildCaptchaApiUrl('/captcha/generate')
const validCaptchaUrl = buildCaptchaApiUrl('/captcha/check')
async function init() {
await nextTick()
if (!captchaBoxRef.value) {
emit('init-error', new Error('验证码容器初始化失败'))
return
}
destroy()
const config: TianAiCaptchaConfig = {
bindEl: captchaBoxRef.value,
requestCaptchaDataUrl,
types: props.types,
btnRefreshFun: (_el, tac) => {
tac.reloadCaptcha()
},
btnCloseFun: () => {
destroy()
emit('close')
}
}
switch (props.mode) {
case 'validate':
config.validCaptchaUrl = validCaptchaUrl
config.validSuccess = res => emit('valid-success', res)
config.validFail = res => emit('valid-fail', res)
break
case 'data':
config.onDataReady = result => emit('data-ready', result)
break
default:
emit('init-error', new Error(`未知的验证码模式: ${props.mode}`))
return
}
captchaInstance.value = createTianAiCaptcha(config, props.styleConfig)
captchaInstance.value.init()
setActive(true)
}
function reload() {
captchaInstance.value?.reloadCaptcha()
}
function destroy() {
if (captchaInstance.value) {
captchaInstance.value.destroyWindow()
captchaInstance.value = null
}
setActive(false)
}
function setActive(nextValue: boolean) {
if (active.value === nextValue) return
active.value = nextValue
emit('state-change', nextValue)
}
defineExpose({
destroy,
init,
reload,
})
function buildCaptchaApiUrl(path: string) {
const normalizedBase = (import.meta.env.VITE_APP_API_BASE || '').replace(/\/+$/, '')
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${normalizedBase}${normalizedPath}`
}
onBeforeUnmount(() => destroy())
</script>
+33 -10
View File
@@ -1,24 +1,47 @@
export default [
export interface MenuItem {
title: string
path: string
permission: string // 需要的权限标识(list类型)
}
export interface MenuGroup {
name: string
title: string
icon: string
child: MenuItem[]
}
const menus: MenuGroup[] = [
{
name: 'system',
title: '系统管理',
icon: 'Operation',
child: [
{ title: '系统配置', path: '/system/config' },
{ title: '用户管理', path: '/system/user' },
{ title: '角色管理', path: '/system/role' },
{ title: '博客文章', path: '/system/article' },
{ title: '分析统计', path: '/system/statistics' }
{ title: '系统配置', path: '/system/config', permission: 'config:list' },
{ title: '用户管理', path: '/system/user', permission: 'user:list' },
{ title: '角色管理', path: '/system/role', permission: 'role:list' },
{ title: '博客文章', path: '/system/article', permission: 'article:list' },
{ title: '分析统计', path: '/system/statistics', permission: 'article:list' }
]
},{
name: 'api',
title: 'API数据',
icon: 'Histogram',
child: [
{ title: '一言', path: '/api/hitokoto' },
{ title: '照片墙', path: '/api/photoWall' },
{ title: '图片资源库', path: '/api/sourceImage' },
{ title: '歌曲库', path: '/api/music' }
{ title: '一言', path: '/api/hitokoto', permission: 'hitokoto:list' },
{ title: '照片墙', path: '/api/photoWall', permission: 'photoWall:list' },
{ title: '图片资源库', path: '/api/sourceImage', permission: 'sourceImage:list' },
{ title: '歌曲库', path: '/api/music', permission: 'music:list' }
]
},
{
name: 'debug',
title: '调试工具',
icon: 'Tools',
child: [
{ title: '图形验证码', path: '/debug/captcha', permission: 'captcha:list' }
]
}
]
export default menus
+11 -47
View File
@@ -2,59 +2,23 @@ import { createApp } from 'vue'
import App from './App.vue'
import { router, filterExclude } from './router'
import store from './store'
import type { RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
import VueAxios from 'vue-axios'
import axios from 'axios'
// 配置默认axios参数
const service = axios.create({
timeout: 10000
})
import { ElMessage, ElLoading } from 'element-plus'
import { ElLoading } from 'element-plus'
import 'element-plus/theme-chalk/el-message.css'
import 'element-plus/theme-chalk/el-message-box.css'
import 'element-plus/theme-chalk/el-image-viewer.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
// 添加请求拦截器
service.interceptors.request.use(config => {
// 在发送请求之前添加token到请求头
const token = mountedApp.$store.state.loginInfo.token
if (token !== null && config.headers) {
config.headers.token = token
}
return config
}, err => {
// 请求错误的处理
ElMessage.error('请求超时,请稍后再试')
return Promise.reject(err)
})
service.interceptors.response.use(res=> {
return res.data
}, err => {
if (err.response.status >= 500) {
ElMessage.error('服务器内部错误')
} else if (err.response.status >= 400) {
if(typeof err.response.data.message === 'string') {
ElMessage.warning(err.response.data.message)
} else if (Array.isArray(err.response.data.message)) {
let message = err.response.data.message.join('<br/>')
ElMessage.warning(message)
}
if (err.response.status === 403) {
mountedApp.$router.push('/login')
}
}
return Promise.reject(err)
})
import { permissionDirective } from '@/utils/permission'
// 全局路由导航前置守卫
router.beforeEach(function (to, from, next) {
router.beforeEach(function (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) {
if (to.meta?.title) {
mountedApp.$store.commit('addTab', { title: to.meta.title, path: to.path, name: to.name })
store.commit('addTab', { title: to.meta.title, path: to.path, name: to.name })
}
mountedApp.$store.state.activeTab = to.path
if(filterExclude.indexOf(to.path) !== -1 || mountedApp.$store.state.loginInfo.token) {
store.state.activeTab = to.path
if(filterExclude.indexOf(to.path) !== -1 || store.state.loginInfo.token) {
next()
} else {
next('/login')
@@ -66,8 +30,8 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
const mountedApp = app.use(router)
app.use(router)
.use(store)
.use(VueAxios, service)
.directive('loading', ElLoading.directive)
.directive('permission', permissionDirective)
.mount('#app')
+1 -1
View File
@@ -4,6 +4,6 @@ export default interface HitokotoModel {
type: string
from: string
creator: string
created_at: Date
createdAt: Date
number: number
}
+5 -3
View File
@@ -7,8 +7,8 @@ export interface MusicModel {
title?: string // 标题
album?: string // 唱片集
artist?: string // 艺术家
lib_id: string // 歌单ID
lyric_id: string // 歌词ID
libId: string // 歌单ID
lyricId: string // 歌词ID
isEditing?: boolean // 是否正在编辑当前行
}
@@ -16,11 +16,12 @@ export interface MusicLibModel {
_id: string
name: string // 歌单名称
path: string // 歌单文件路径
musicCount: number // 歌曲数量
}
export interface MusicLyricModel {
_id?: string
cloud_id?: number
cloudId?: number
name?: string
lyric?: string
}
@@ -33,6 +34,7 @@ export interface MusicPlayerItem {
src: string
pic: string
lrc?: string
lyricId?: string
}
export interface StatType {
+1 -1
View File
@@ -5,5 +5,5 @@ export interface SourceImageModel {
size: number // 图片大小
label: string[] // 图片标签
img: ArrayBuffer // 图片二进制数据
created_at: Date
createdAt: Date
}
+33 -54
View File
@@ -1,65 +1,44 @@
import { Vue } from 'vue-class-component'
import { ref, reactive } from 'vue'
import { Page } from './common.dto'
import moment from 'moment'
export default abstract class BaseList<T extends Page> extends Vue {
/**
* 表格数据加载中
*/
loading: boolean = false
/**
* 表单提交中
*/
modalLoading: boolean = false
/**
* 数据总数
*/
total: number = 0
abstract search: T
/**
* 加载数据的实现
*/
abstract loadData(): Promise<void>
/**
* 加载数据
* @param resetPage 是否重置页码
*/
loadDataBase(resetPage: boolean = false): void {
if(resetPage) {
this.search.pageNum = 1
export function useBaseList<T extends Page>(searchInit: T) {
const loading = ref(false)
const modalLoading = ref(false)
const total = ref(0)
const search = reactive(searchInit) as T
let _loadData: (() => Promise<void>) | null = null
function setLoadData(fn: () => Promise<void>) {
_loadData = fn
}
function loadDataBase(resetPage: boolean = false): void {
if (resetPage) {
search.pageNum = 1
}
this.loadData()
}
/**
* 重置搜索项
*/
reset(): void {
this.search.reset()
this.loadData()
}
/**
* 页码变更
* @param pageNum 页码
*/
pageChange(pageNum: number): void {
this.search.pageNum = pageNum
this.loadData()
_loadData?.()
}
/**
* 每页数据条数变更
* @param pageSize 每页数据条数
*/
pageSizeChange(pageSize: number): void {
this.search.limit = pageSize
this.loadData()
function reset(): void {
search.reset()
_loadData?.()
}
/**
* 日期时间格式化
* @param dateStr 日期时间
*/
datetimeFormat(dateStr: string): string | null {
function pageChange(pageNum: number): void {
search.pageNum = pageNum
_loadData?.()
}
function pageSizeChange(pageSize: number): void {
search.limit = pageSize
_loadData?.()
}
function datetimeFormat(dateStr: string): string | null {
return dateStr ? moment(dateStr).format('YYYY-MM-DD HH:mm:ss') : null
}
return { loading, modalLoading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat }
}
+4 -4
View File
@@ -6,9 +6,9 @@ export interface ArticleModel {
categories: string[] //分类
tags: string[] // 标签
title: string // 标题
create_date: Date // 文章发布日期
content_len: number // 正文长度
is_splited: boolean // 是否分词
createDate: Date // 文章发布日期
contentLen: number // 正文长度
isSplited: boolean // 是否分词
}
export interface TreeNodeData {
@@ -22,5 +22,5 @@ export interface TreeNodeData {
export interface TreeNodeSource {
_id: string
cnt: number
article_id: string | null
articleId: string | null
}
+3 -3
View File
@@ -3,7 +3,7 @@ export interface SystemConfigModel {
name: string // 配置项名称
value: object | string // 配置项内容
description: string // 描述
is_public: boolean // 是否公开
created_at?: Date // 创建时间
updated_at?: Date // 更新时间
isPublic: boolean // 是否公开
createdAt?: Date // 创建时间
updatedAt?: Date // 更新时间
}
+11 -5
View File
@@ -2,9 +2,15 @@ export interface SystemRoleModel {
_id: string | null
name: string | null // 角色名称
description: string | null // 描述
methods: string[] // 允许的请求类型(优先级3)
include_uri: string[] // 包含的URI(优先级2)
exclude_uri: string[] // 排除的URI(优先级1)
created_at?: Date // 创建时间
updated_at?: Date // 更新时间
permissions: string[] // 权限标识列表
createdAt?: Date // 创建时间
updatedAt?: Date // 更新时间
}
export interface PermissionTreeNode {
code: string
name: string
description: string
leaf: boolean
children: PermissionTreeNode[]
}
+3 -3
View File
@@ -3,7 +3,7 @@ export interface SystemUserModel {
username: string | null
password: string | null
realname: string | null
role_ids: string[]
created_at?: Date
updated_at?: Date
roleIds: string[]
createdAt?: Date
updatedAt?: Date
}
+11 -9
View File
@@ -8,16 +8,18 @@ const routes: Array<RouteRecordRaw> = [
{ path: '/login', name: 'Login', component: Login },
{ path: '/', name: 'Home', component: Home, children: [
{ path: '/', name: 'Welcome', component: Welcome },
{ path: '/system/user', name: 'SystemUser', component: () => import( /* webpackChunkName: "system" */ '@/views/system/SystemUser.vue'), meta: { title: '用户管理' } },
{ path: '/system/role', name: 'SystemRole', component: () => import( /* webpackChunkName: "system" */ '@/views/system/SystemRole.vue'), meta: { title: '角色管理' } },
{ path: '/system/config', name: 'SystemConfig', component: () => import( /* webpackChunkName: "system" */ '@/views/system/SystemConfig.vue'), meta: { title: '系统配置' } },
{ path: '/system/article', name: 'Article', component: () => import( /* webpackChunkName: "system" */ '@/views/system/Article.vue'), meta: { title: '博客文章' } },
{ path: '/system/statistics', name: 'Statistics', component: () => import( /* webpackChunkName: "system" */ '@/views/system/Statistics.vue'), meta: { title: '分析统计' } },
{ path: '/system/user', name: 'SystemUser', component: () => import('@/views/system/SystemUser.vue'), meta: { title: '用户管理' } },
{ path: '/system/role', name: 'SystemRole', component: () => import('@/views/system/SystemRole.vue'), meta: { title: '角色管理' } },
{ path: '/system/config', name: 'SystemConfig', component: () => import('@/views/system/SystemConfig.vue'), meta: { title: '系统配置' } },
{ path: '/system/article', name: 'Article', component: () => import('@/views/system/Article.vue'), meta: { title: '博客文章' } },
{ path: '/system/statistics', name: 'Statistics', component: () => import('@/views/system/Statistics.vue'), meta: { title: '分析统计' } },
{ path: '/api/music', name: 'Music', component: () => import( /* webpackChunkName: "api" */ '@/views/api/Music.vue'), meta: { title: '歌曲库' } },
{ path: '/api/hitokoto', name: 'Hitokoto', component: () => import( /* webpackChunkName: "api" */ '@/views/api/Hitokoto.vue'), meta: { title: '一言' } },
{ path: '/api/photoWall', name: 'PhotoWall', component: () => import( /* webpackChunkName: "api" */ '@/views/api/PhotoWall.vue'), meta: { title: '照片墙' } },
{ path: '/api/sourceImage', name: 'SourceImage', component: () => import( /* webpackChunkName: "api" */ '@/views/api/SourceImage.vue'), meta: { title: '图片资源库' } },
{ path: '/api/music', name: 'Music', component: () => import('@/views/api/Music.vue'), meta: { title: '歌曲库' } },
{ path: '/api/hitokoto', name: 'Hitokoto', component: () => import('@/views/api/Hitokoto.vue'), meta: { title: '一言' } },
{ path: '/api/photoWall', name: 'PhotoWall', component: () => import('@/views/api/PhotoWall.vue'), meta: { title: '照片墙' } },
{ path: '/api/sourceImage', name: 'SourceImage', component: () => import('@/views/api/SourceImage.vue'), meta: { title: '图片资源库' } },
{ path: '/debug/captcha', name: 'CaptchaSandbox', component: () => import('@/views/debug/CaptchaSandbox.vue'), meta: { title: '图形验证码' } },
]}
]
+414 -34
View File
@@ -1,15 +1,23 @@
html,body,#app,.layout {
html, body, #app, .layout {
margin: 0;
padding: 0;
height: 100%;
}
.layout-header{
/* ===== 顶部导航栏 ===== */
.layout-header {
color: #fff;
background-color: #515a6e;
background: linear-gradient(90deg, #1d2b45 0%, #2c3e60 100%);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
display: flex;
align-items: center;
position: relative;
div.main-title {
display: inline-block;
font-size: 22px;
font-size: 20px;
font-weight: var(--el-font-weight-primary);
letter-spacing: 1px;
line-height: 60px;
}
.nav-btns-right {
@@ -20,45 +28,172 @@ html,body,#app,.layout {
transform: translateY(-50%);
}
}
.layout-main {
padding: 10px !important;
display: flex !important;
flex-direction: column;
> .layout-tabs{
margin: 15px 15px 0 15px;
/* ===== 侧边栏 ===== */
.el-aside {
background: var(--el-fill-color-light);
border-right: 1px solid var(--el-border-color-light);
.el-menu {
// CSS 变量覆盖
--el-menu-bg-color: transparent;
--el-menu-border-color: transparent;
--el-menu-hover-bg-color: var(--el-fill-color);
--el-menu-active-color: var(--el-color-primary);
}
> .layout-content{
margin: 0 15px 15px 15px;
.el-menu-item {
// 圆角/间距/宽度无对应 CSS 变量,保留直接样式
border-radius: var(--el-border-radius-base);
margin: 2px 8px;
width: calc(100% - 16px);
&.is-active {
// 激活背景色无对应 CSS 变量,保留直接样式
background-color: var(--el-color-primary-light-9);
font-weight: var(--el-font-weight-primary);
}
}
.el-sub-menu__title:hover {
// Element Plus 子菜单标题 hover 使用 --el-menu-bg-color 而非 --el-menu-hover-bg-color
// 因此无法通过变量覆盖,保留直接样式
background-color: var(--el-fill-color);
}
}
/* ===== 主内容区 ===== */
.el-main.layout-main {
--el-main-padding: 0;
display: flex;
flex-direction: column;
background: var(--el-fill-color);
> .layout-tabs {
margin: 12px 16px 0;
}
> .layout-content {
margin: 12px 16px;
overflow: auto;
background: #fff;
border: 1px solid #e1e1e1;
padding: 10px;
background: var(--el-bg-color);
border-radius: 10px;
border: 1px solid var(--el-border-color-lighter);
padding: 20px;
flex-grow: 1;
flex-basis: 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
> .layout-copy{
text-align: center;
padding: 10px 0;
color: #9ea7b4;
/* ===== 页面布局(用于带分页的表格页面)===== */
.page-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.table-container {
// CSS 变量覆盖:表头背景/文字色、行悬停色、边框色、正文色
--el-table-header-bg-color: var(--el-fill-color-light);
--el-table-header-text-color: var(--el-text-color-regular);
--el-table-row-hover-bg-color: var(--el-color-primary-light-9);
--el-table-border-color: var(--el-border-color-lighter);
--el-table-text-color: var(--el-text-color-primary);
// 固定高度,让表头固定,tbody滚动
flex: 1;
border-radius: var(--el-border-radius-base);
overflow: hidden;
border-left: 1px solid var(--el-border-color-lighter);
border-right: 1px solid var(--el-border-color-lighter);
border-top: 1px solid var(--el-border-color-lighter);
&::before {
display: none;
}
th.el-table__cell {
font-weight: var(--el-font-weight-primary);
font-size: var(--el-font-size-small);
}
td.el-table__cell {
// 行分隔线比表头边框更浅,与 --el-table-border-color 不同,需直接覆盖
border-bottom-color: var(--el-fill-color);
font-size: var(--el-font-size-small);
}
.el-button.is-link {
font-size: 16px;
padding: 4px 6px;
}
}
/* ===== 分页 ===== */
.page-container {
margin-top: 20px;
display: flex;
justify-content: center;
}
/* ===== 操作栏 ===== */
.btn-container {
margin-bottom: 14px;
display: flex;
align-items: center;
.search-btn {
margin-left: auto;
}
}
}
.table-container {
position: relative;
}
.btn-container {
margin-bottom: 10px;
.search-btn {
float: right;
/* ===== 表单弹窗 ===== */
body .el-dialog {
// CSS 变量覆盖:圆角、清除 element-plus 2.x 新增的根元素 padding
--el-dialog-border-radius: 12px;
--el-dialog-padding-primary: 0;
// overflow:hidden 无对应 CSS 变量,保留直接样式(使圆角裁剪生效)
overflow: hidden;
.el-dialog__header {
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
padding: 16px 20px;
margin: 0;
.el-dialog__title {
font-size: var(--el-font-size-base);
font-weight: var(--el-font-weight-primary);
color: var(--el-text-color-primary);
}
}
.el-dialog__body {
padding: 24px 24px 8px;
}
.el-dialog__footer {
border-top: 1px solid var(--el-border-color-lighter);
padding: 12px 20px;
background: var(--el-fill-color-lighter);
}
}
.page-container {
padding: 10px;
text-align: center;
/* ===== 搜索表单 ===== */
.el-form--inline {
margin-bottom: 14px;
.el-form-item {
margin-bottom: 0;
}
.el-select {
min-width: 160px;
}
}
/* ===== 其他工具类 ===== */
.carsouel-img {
position: relative;
left: 50%;transform:
translateX(-50%);
left: 50%;
transform: translateX(-50%);
height: 500px;
width: auto;
}
@@ -74,7 +209,252 @@ html,body,#app,.layout {
overflow: auto;
}
}
.el-tabs--card.nav-tabs > .el-tabs__header {
margin-bottom: 0;
border-bottom: none;
/* ===== Markdown 预览 ===== */
.article-preview-drawer {
> .el-drawer__header {
border-bottom: 1px solid var(--el-border-color-lighter);
padding: 14px 20px;
margin-bottom: 0;
background: var(--el-fill-color-light);
.el-drawer__title {
font-size: var(--el-font-size-base);
font-weight: var(--el-font-weight-primary);
color: var(--el-text-color-primary);
}
}
> .el-drawer__body {
padding: 24px 32px;
}
}
.markdown-body {
font-size: 15px;
line-height: 1.8;
color: var(--el-text-color-primary);
word-break: break-word;
// 首个元素去掉顶部外边距
> :first-child { margin-top: 0 !important; }
// ── 标题 ───────────────────────────────────
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.35;
margin: 1.4em 0 0.6em;
color: var(--el-text-color-primary);
}
h1 {
font-size: 1.8em;
padding-bottom: 0.4em;
border-bottom: 2px solid var(--el-border-color);
}
h2 {
font-size: 1.45em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--el-border-color-lighter);
}
h3 { font-size: 1.2em; }
h4 { font-size: 1.05em; }
h5 { font-size: 0.95em; }
h6 {
font-size: 0.9em;
color: var(--el-text-color-secondary);
}
// ── 段落 ───────────────────────────────────
p { margin: 0.85em 0; }
// ── 链接 ───────────────────────────────────
a {
color: var(--el-color-primary);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.2s, color 0.2s;
&:hover {
color: var(--el-color-primary-light-3);
border-bottom-color: var(--el-color-primary-light-3);
}
}
// ── 强调 ───────────────────────────────────
strong { font-weight: 600; }
em { font-style: italic; }
del { color: var(--el-text-color-secondary); text-decoration: line-through; }
// ── 内联代码 ───────────────────────────────
:not(pre) > code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.85em;
padding: 0.18em 0.45em;
border-radius: 4px;
background: var(--el-fill-color-dark);
color: var(--el-color-danger);
border: 1px solid var(--el-border-color-lighter);
}
// ── 代码块(亮色:浅灰背景;暗色:深背景) ───
pre {
background: #f6f8fa;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 1em 1.2em;
overflow-x: auto;
margin: 1em 0;
code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.875em;
background: none;
border: none;
padding: 0;
color: #24292f;
line-height: 1.65;
}
}
// ── 引用块 ─────────────────────────────────
blockquote {
margin: 1em 0;
padding: 0.6em 1.1em;
border-left: 4px solid var(--el-color-primary-light-5);
background: var(--el-color-primary-light-9);
border-radius: 0 6px 6px 0;
color: var(--el-text-color-secondary);
> :first-child { margin-top: 0; }
> :last-child { margin-bottom: 0; }
}
// ── 列表 ───────────────────────────────────
ul, ol {
margin: 0.7em 0;
padding-left: 1.8em;
}
li {
margin: 0.3em 0;
line-height: 1.75;
> ul, > ol { margin: 0.2em 0; }
}
ul > li { list-style-type: disc; }
ul > li > ul > li { list-style-type: circle; }
ol > li { list-style-type: decimal; }
// ── 水平线 ─────────────────────────────────
hr {
border: none;
border-top: 1px solid var(--el-border-color);
margin: 1.6em 0;
}
// ── 图片 ───────────────────────────────────
img {
max-width: 100%;
height: auto;
border-radius: 6px;
display: block;
margin: 1em auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
// ── 表格 ───────────────────────────────────
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 0.9em;
border: 1px solid var(--el-border-color);
border-radius: 6px;
overflow: hidden;
th, td {
padding: 0.6em 1em;
border: 1px solid var(--el-border-color-lighter);
text-align: left;
vertical-align: top;
}
th {
background: var(--el-fill-color-light);
font-weight: 600;
color: var(--el-text-color-regular);
}
tr:nth-child(even) td { background: var(--el-fill-color-lighter); }
tr:hover td { background: var(--el-color-primary-light-9); }
}
}
// 暗色模式:代码块使用深色背景
html.dark .markdown-body {
pre {
background: #161b22;
border-color: rgba(255, 255, 255, 0.08);
code { color: #e6edf3; }
}
img { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4); }
}
/* ========================================== */
.el-tabs--card.nav-tabs {
> .el-tabs__header {
margin-bottom: 0;
border-bottom: none;
background: var(--el-bg-color);
padding: 0 4px;
border-radius: 8px 8px 0 0;
.el-tabs__nav-wrap {
margin-bottom: 0;
}
.el-tabs__nav {
border: none;
}
.el-tabs__item {
height: 36px;
line-height: 36px;
padding: 0 16px;
font-size: var(--el-font-size-small);
color: var(--el-text-color-secondary);
border: none;
border-radius: 6px 6px 0 0;
margin-right: 2px;
transition: color 0.2s, background 0.2s;
position: relative;
&:hover {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
&.is-active {
color: var(--el-color-primary);
font-weight: var(--el-font-weight-primary);
background: var(--el-fill-color);
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: var(--el-color-primary);
border-radius: 2px 2px 0 0;
}
}
.is-icon-close {
font-size: 11px;
margin-left: 4px;
border-radius: 50%;
transition: background 0.2s;
&:hover {
background: var(--el-color-danger-light-7);
color: var(--el-color-danger);
}
}
}
}
}
+12 -4
View File
@@ -8,7 +8,8 @@ class Store {
state: StateType = {
loginInfo: {
userInfo: null,
token: localStorage.getItem('login_token')
token: localStorage.getItem('login_token'),
permissions: []
},
tabs: [],
activeTab: null,
@@ -21,18 +22,25 @@ class Store {
* @param {Object} state
* @param {UserInfo} data 登录数据
*/
login(state: StateType, data: {token: string, userInfo: UserInfo}): void {
localStorage.setItem('login_token', data.token)
state.loginInfo.token = data.token
login(state: StateType, data: {token?: string, userInfo: UserInfo, permissions?: string[]}): void {
if (data.token) {
localStorage.setItem('login_token', data.token)
state.loginInfo.token = data.token
}
state.loginInfo.userInfo = data.userInfo
if (data.permissions) {
state.loginInfo.permissions = data.permissions
}
},
/**
* 注销
* @param {Object} state
*/
logout(state: StateType): void {
localStorage.removeItem('login_token')
state.loginInfo.token = null
state.loginInfo.userInfo = null
state.loginInfo.permissions = []
},
/**
* 添加导航页签
+2 -1
View File
@@ -2,7 +2,7 @@ export interface UserInfo {
_id: string
username: string // 用户名
realname: string // 昵称
role_ids: string[] // 角色ID
roleIds: string[] // 角色ID
}
export interface TabItem {
@@ -15,6 +15,7 @@ export interface StateType {
loginInfo: { // 登录信息
userInfo: null | UserInfo
token: null | string
permissions: string[] // 用户权限列表
}
tabs: TabItem[] // 导航页签
activeTab: string | null // 当前激活的页签
+2 -7
View File
@@ -1,8 +1,3 @@
import { Vue } from 'vue-class-component'
import type { FormInstance } from 'element-plus'
export type VForm = Vue & {
validate: (callback: (valid: boolean) => void) => void
resetValidation: () => boolean
reset: () => void
clearValidate: () => void
}
export type VForm = FormInstance
+54
View File
@@ -0,0 +1,54 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import store from '@/store'
import { router } from '@/router'
const http = axios.create({
baseURL: import.meta.env.VITE_APP_API_BASE,
timeout: 10000,
paramsSerializer: {
indexes: null
}
})
// 添加请求拦截器
http.interceptors.request.use(config => {
const token = store.state.loginInfo.token
if (token !== null) {
config.headers.set('token', token)
}
return config
}, err => {
ElMessage.error('请求超时,请稍后再试')
return Promise.reject(err)
})
http.interceptors.response.use(res => {
const responseBody = res.data
// 统一响应格式处理
switch (responseBody.code) {
case 0:
// 成功,直接返回数据
return responseBody.data
case -1:
// 失败,显示错误信息
ElMessage.error(responseBody.message || '请求失败')
return Promise.reject(new Error(responseBody.message || '请求失败'))
default:
// 其他情况,兼容没有包装格式的响应
return res.data
}
}, err => {
if (err.response?.status >= 500) {
ElMessage.error('服务器内部错误')
} else if (err.response?.status >= 400) {
const message = err.response.data?.message
message && ElMessage.error(message)
if (err.response.status === 403) {
router.push('/login')
}
}
return Promise.reject(err)
})
export default http
+37
View File
@@ -0,0 +1,37 @@
import type { Directive } from 'vue'
import store from '@/store'
/**
* 检查用户是否具备指定权限
* 支持上下级关系:如果用户拥有 'user' 权限,则 'user:list' 也视为拥有
*/
export function hasPermission(permission: string): boolean {
const permissions: string[] = store.state.loginInfo.permissions
if (!permissions || !permissions.length) return false
// 直接匹配
if (permissions.includes(permission)) return true
// 上级权限匹配:如果权限是 'user:list',检查用户是否拥有 'user'
const colonIndex = permission.indexOf(':')
if (colonIndex !== -1) {
const parent = permission.substring(0, colonIndex)
if (permissions.includes(parent)) return true
}
return false
}
/**
* v-permission 自定义指令
* 用法:v-permission="'user:save'" 或 v-permission="['user:save', 'user:delete']"
* 当传入数组时,满足其中任一权限即可显示
*/
export const permissionDirective: Directive<HTMLElement, string | string[]> = {
mounted(el, binding) {
const value = binding.value
if (!value) return
const permissions = Array.isArray(value) ? value : [value]
const hasPerm = permissions.some(p => hasPermission(p))
if (!hasPerm) {
el.parentNode?.removeChild(el)
}
}
}
+174
View File
@@ -0,0 +1,174 @@
import "./captcha.less";
import Slider from "./slider/slider";
import Rotate from "./rotate/rotate";
import Concat from "./concat/concat";
import Disable from "./disable/disable";
import ImageClick from "./image_click/image_click";
import WordImageClick from "./word_image_click/word_image_click";
import { CaptchaConfig, wrapConfig, wrapStyle } from "./config/config";
import { clearAllPreventDefault } from "./common/common";
const template = `
<div class="__captcha-parent">
<div class="captcha-box">
<div class="captcha-loading loading"></div>
</div>
<!-- 底部 -->
<div class="slider-bottom">
<div class="refresh-btn"></div>
<div class="close-btn"></div>
</div>
</div>
`;
function createCaptchaByType(type, tac) {
const box = tac.config.domBindEl.find(".captcha-box");
const styleConfig = tac.style;
switch (type) {
case "SLIDER":
return new Slider(box, styleConfig);
case "ROTATE":
return new Rotate(box, styleConfig);
case "CONCAT":
return new Concat(box, styleConfig);
case "IMAGE_CLICK":
return new ImageClick(box, styleConfig);
case "WORD_IMAGE_CLICK":
return new WordImageClick(box, styleConfig);
case "DISABLED":
return new Disable(box, styleConfig);
default:
return null;
}
}
class TianAiCaptcha {
constructor(config, style) {
this.config = wrapConfig(config);
if (this.config.btnRefreshFun) {
this.btnRefreshFun = this.config.btnRefreshFun;
}
if (this.config.btnCloseFun) {
this.btnCloseFun = this.config.btnCloseFun;
}
this.style = wrapStyle(style);
}
init() {
this.destroyWindow();
this.config.domBindEl.append(template);
this.domTemplate = this.config.domBindEl.find(".__captcha-parent");
clearAllPreventDefault(this.domTemplate);
// 绑定按钮事件
this.config.domBindEl
.find(".refresh-btn")
.click((el) => {
this.btnRefreshFun(el, this);
});
this.config.domBindEl
.find(".close-btn")
.click((el) => {
this.btnCloseFun(el, this);
});
// 加载验证码
this.reloadCaptcha();
return this;
}
btnRefreshFun(el, tac) {
tac.reloadCaptcha();
}
btnCloseFun(el, tac) {
tac.destroyWindow();
}
reloadCaptcha() {
this.showLoading();
this.destroyCaptcha(() => {
this.createCaptcha();
});
}
showLoading() {
this.config.domBindEl
.find(".captcha-loading")
.css("display", "block");
}
closeLoading() {
this.config.domBindEl
.find(".captcha-loading")
.css("display", "none");
}
destroyWindow() {
if (this.C) {
this.C.destroy();
this.C = undefined;
}
if (this.domTemplate) {
this.domTemplate.remove();
}
}
openCaptcha() {
setTimeout(() => {
this.C.el.css("transform", "translateX(0)");
}, 10);
}
createCaptcha() {
this.config.requestCaptchaData().then((data) => {
this.closeLoading();
if (!data.code) {
throw new Error("[TAC] 后台验证码接口数据错误!!!");
}
let captchaType = data.code === 200 ? data.data?.type : "DISABLED";
const captcha = createCaptchaByType(captchaType, this);
if (captcha == null) {
throw new Error("[TAC] 未知的验证码类型[" + captchaType + "]");
}
captcha.init(data, (d, c) => {
// 验证
const currentCaptchaData = c.currentCaptchaData;
const data = {
bgImageWidth: currentCaptchaData.bgImageWidth,
bgImageHeight: currentCaptchaData.bgImageHeight,
templateImageWidth: currentCaptchaData.templateImageWidth,
templateImageHeight: currentCaptchaData.templateImageHeight,
startTime: currentCaptchaData.startTime.getTime(),
stopTime: currentCaptchaData.stopTime.getTime(),
trackList: currentCaptchaData.trackList,
};
if (c.type === "ROTATE_DEGREE" || c.type === "ROTATE") {
data.bgImageWidth = c.currentCaptchaData.end;
}
if (currentCaptchaData.data) {
data.data = currentCaptchaData.data;
}
// 清空
const id = c.currentCaptchaData.currentCaptchaId;
c.currentCaptchaData = undefined;
if (this.config.onDataReady) {
Promise.resolve(this.config.onDataReady({ id, data }, c, this)).catch((error) => {
console.error("[TAC] onDataReady callback failed", error);
});
return;
}
// 调用验证接口
this.config.validCaptcha(id, data, c, this);
});
this.C = captcha;
this.openCaptcha();
});
}
destroyCaptcha(callback) {
if (this.C) {
this.C.el.css("transform", "translateX(300px)");
setTimeout(() => {
this.C.destroy();
callback && callback();
}, 500);
} else {
callback();
}
}
}
export { TianAiCaptcha, CaptchaConfig };
+98
View File
@@ -0,0 +1,98 @@
.__captcha-parent {
box-shadow: 0 0 11px 0 #999999;
width: 316px;
overflow: hidden;
position: relative;
z-index: 997;
box-sizing: border-box;
border-radius: 5px;
padding: 8px;
.captcha-box {
height: 260px;
width: 100%;
position: relative;
overflow: hidden;
.loading {
width: 120px;
height: 20px;
mask: linear-gradient(90deg, #000 70%, #0000 0) 0/20%;
background: linear-gradient(#f7b645 0 0) 0 / 0% no-repeat #dddddd6b;
animation: cartoon 1s infinite steps(6);
margin: 120px auto;
@keyframes cartoon {
100% {
background-size: 120%;
}
}
}
.captcha {
transform-style: preserve-3d;
will-change: transform;
transition-duration: 0.45s;
transform: translateX(-100%);
}
}
.slider-bottom {
text-align: right;
margin-top: 8px;
display: flex;
justify-content: flex-end;
gap: 10px;
.close-btn, .refresh-btn {
width: 20px;
height: 20px;
background-repeat: no-repeat;
cursor: pointer;
}
.close-btn {
background-image: url("../images/icon.png");
background-position: 0 -14px;
}
.refresh-btn {
background-image: url("../images/icon.png");
background-position: 0 -167px;
}
}
.slider-move-shadow {
animation: move-shadow 2s infinite;
height: 100%;
width: 5px;
background-color: #fff;
position: absolute;
top: 0;
left: 0;
filter: opacity(0.5);
box-shadow: 1px 1px 1px #fff;
border-radius: 50%;
}
.captcha-slider-move-track-mask {
border-width: 1px;
border-style: solid;
border-color: #00f4ab;
width: 0;
height: 32px;
background-color: #a9ffe5;
opacity: 0.5;
position: absolute;
top: -1px;
left: -1px;
border-radius: 5px;
}
@keyframes move-shadow {
from {
left: 0;
}
to {
left: 289px;
}
}
@import "./common/common.less";
@import "./slider/slider.less";
@import "./rotate/rotate.less";
@import "./concat/concat.less";
@import "./disable/disable.less";
@import "./image_click/image_click.less";
}
+462
View File
@@ -0,0 +1,462 @@
/** 是否打印日志 */
var isPrintLog = false;
function printLog(params) {
if (isPrintLog) {
console.log(JSON.stringify(params));
}
}
/**
* 清除默认事件
* @param event event
*/
function clearPreventDefault(event) {
if (event.preventDefault) {
event.preventDefault();
}
}
/**
* 阻止某div默认事件
* @param dom
*/
function clearAllPreventDefault(dom) {
Dom(dom).each((el) => {
// 手机端
el.addEventListener("touchmove", clearPreventDefault, { passive: false });
// pc端
el.addEventListener("mousemove", clearPreventDefault, { passive: false });
});
}
/**
* 获取当前坐标
* @param event 事件
* @returns {{x: number, y: number}}
*/
function getCurrentCoordinate(event) {
if (event.pageX !== null && event.pageX !== undefined) {
return {
x: Math.round(event.pageX),
y: Math.round(event.pageY),
};
}
let targetTouches;
if (event.changedTouches) {
// 抬起事件
targetTouches = event.changedTouches;
} else if (event.targetTouches) {
// pc 按下事件
targetTouches = event.targetTouches;
} else if (event.originalEvent && event.originalEvent.targetTouches) {
// 鼠标触摸事件
targetTouches = event.originalEvent.targetTouches;
}
if (targetTouches[0].pageX !== null && targetTouches[0].pageX !== undefined) {
return {
x: Math.round(targetTouches[0].pageX),
y: Math.round(targetTouches[0].pageY),
};
}
return {
x: Math.round(targetTouches[0].clientX),
y: Math.round(targetTouches[0].clientY),
};
}
function down(currentCaptcha, event) {
// debugger
const coordinate = getCurrentCoordinate(event);
let startX = coordinate.x;
let startY = coordinate.y;
currentCaptcha.currentCaptchaData.startX = startX;
currentCaptcha.currentCaptchaData.startY = startY;
const trackList = currentCaptcha.currentCaptchaData.trackList;
currentCaptcha.currentCaptchaData.startTime = new Date();
const startTime = currentCaptcha.currentCaptchaData.startTime;
trackList.push({
x: coordinate.x,
y: coordinate.y,
type: "down",
t: new Date().getTime() - startTime.getTime(),
});
printLog(["start", startX, startY]);
currentCaptcha.__m__ = move.bind(null, currentCaptcha);
currentCaptcha.__u__ = up.bind(null, currentCaptcha);
// pc
window.addEventListener("mousemove", currentCaptcha.__m__);
window.addEventListener("mouseup", currentCaptcha.__u__);
// 手机端
window.addEventListener("touchmove", currentCaptcha.__m__, false);
window.addEventListener("touchend", currentCaptcha.__u__, false);
if (currentCaptcha && currentCaptcha.doDown) {
currentCaptcha.doDown(event, currentCaptcha);
}
}
function move(currentCaptcha, event) {
if (event.touches && event.touches.length > 0) {
event = event.touches[0];
}
// debugger
const coordinate = getCurrentCoordinate(event);
let pageX = coordinate.x;
let pageY = coordinate.y;
const startX = currentCaptcha.currentCaptchaData.startX;
const startY = currentCaptcha.currentCaptchaData.startY;
const startTime = currentCaptcha.currentCaptchaData.startTime;
const end = currentCaptcha.currentCaptchaData.end;
const bgImageWidth = currentCaptcha.currentCaptchaData.bgImageWidth;
const trackList = currentCaptcha.currentCaptchaData.trackList;
let moveX = pageX - startX;
let moveY = pageY - startY;
const track = {
x: coordinate.x,
y: coordinate.y,
type: "move",
t: new Date().getTime() - startTime.getTime(),
};
trackList.push(track);
if (moveX < 0) {
moveX = 0;
} else if (moveX > end) {
moveX = end;
}
currentCaptcha.currentCaptchaData.moveX = moveX;
currentCaptcha.currentCaptchaData.moveY = moveY;
if (currentCaptcha.doMove) {
currentCaptcha.doMove(event, currentCaptcha);
}
printLog(["move", track]);
}
function destroyEvent(currentCaptcha) {
if (currentCaptcha) {
if (currentCaptcha.__m__) {
window.removeEventListener("mousemove", currentCaptcha.__m__);
window.removeEventListener("touchmove", currentCaptcha.__m__);
}
if (currentCaptcha.__u__) {
window.removeEventListener("mouseup", currentCaptcha.__u__);
window.removeEventListener("touchend", currentCaptcha.__u__);
}
}
}
function up(currentCaptcha, event) {
destroyEvent(currentCaptcha);
const coordinate = getCurrentCoordinate(event);
currentCaptcha.currentCaptchaData.stopTime = new Date();
const startTime = currentCaptcha.currentCaptchaData.startTime;
const trackList = currentCaptcha.currentCaptchaData.trackList;
const track = {
x: coordinate.x,
y: coordinate.y,
type: "up",
t: new Date().getTime() - startTime.getTime(),
};
trackList.push(track);
printLog(["up", track]);
printLog(["tracks", trackList]);
if (currentCaptcha.doUp) {
currentCaptcha.doUp(event, currentCaptcha);
}
currentCaptcha.endCallback(currentCaptcha.currentCaptchaData, currentCaptcha);
}
function initConfig(
bgImageWidth,
bgImageHeight,
templateImageWidth,
templateImageHeight,
end,
) {
// bugfix 图片宽高可能会有小数情况,强转一下整数
const currentCaptchaConfig = {
startTime: new Date(),
trackList: [],
movePercent: 0,
clickCount: 0,
bgImageWidth: Math.round(bgImageWidth),
bgImageHeight: Math.round(bgImageHeight),
templateImageWidth: Math.round(templateImageWidth),
templateImageHeight: Math.round(templateImageHeight),
end: end,
};
printLog(["init", currentCaptchaConfig]);
return currentCaptchaConfig;
}
function closeTips(el, callback) {
const tipEl = Dom(el).find(".captcha-tips");
tipEl.removeClass("captcha-tips-on");
// tipEl.removeClass("captcha-tips-success")
// tipEl.removeClass("captcha-tips-error")
// 延时
if (callback) {
setTimeout(callback, 0.35);
}
}
function showTips(el, msg, type, callback) {
const tipEl = Dom(el).find(".captcha-tips");
tipEl.text(msg);
if (type === 1) {
// 成功
tipEl.removeClass("captcha-tips-error");
tipEl.addClass("captcha-tips-success");
} else {
// 失败
tipEl.removeClass("captcha-tips-success");
tipEl.addClass("captcha-tips-error");
}
tipEl.addClass("captcha-tips-on");
// 延时
setTimeout(callback, 1000);
}
class CommonCaptcha {
showTips(msg, type, callback) {
showTips(this.el, msg, type, callback);
}
closeTips(msg, callback) {
closeTips(this.el, msg, callback);
}
}
function Dom(domStr, dom) {
return new DomEl(domStr, dom);
}
class DomEl {
constructor(domStr, dom) {
if (dom instanceof HTMLElement) {
this.dom = dom;
this.domStr = domStr;
return;
}
if (domStr instanceof DomEl) {
this.dom = domStr.dom;
this.domStr = domStr.domStr;
} else if (typeof domStr === "string") {
this.dom = document.querySelector(domStr);
this.domStr = domStr;
} else if (domStr instanceof HTMLElement) {
this.dom = domStr;
this.domStr = domStr.nodeName;
} else {
throw new Error("不支持的类型");
}
}
each(callback) {
this.getTarget().querySelectorAll("*").forEach(callback);
}
removeClass(className) {
let element = this.getTarget();
if (element.classList) {
// 使用 classList API 移除类
element.classList.remove(className);
} else {
// 兼容旧版本浏览器
const currentClass = element.className;
const regex = new RegExp("(?:^|\\s)" + className + "(?!\\S)", "g");
element.className = currentClass.replace(regex, "");
}
return this;
}
addClass(className) {
const element = this.getTarget();
if (element.classList) {
// 使用 classList API 添加类
element.classList.add(className);
} else {
// 兼容旧版本浏览器
let currentClass = element.className;
if (currentClass.indexOf(className) === -1) {
element.className = currentClass + " " + className;
}
}
return this;
}
find(str) {
const el = this.getTarget().querySelector(str);
if (el) {
return new DomEl(str, el);
}
return null;
}
children(selector) {
const childNodes = this.getTarget().childNodes;
for (let i = 0; i < childNodes.length; i++) {
if (childNodes[i].nodeType === 1 && childNodes[i].matches(selector)) {
return new DomEl(selector, childNodes[i]);
}
}
return null;
}
remove() {
this.getTarget().remove();
return null;
}
css(property, value) {
if (typeof property === "string" && typeof value === "string") {
// 设置单个属性
this.getTarget().style[property] = value;
} else if (typeof property === "object") {
// 设置多个属性
for (var prop in property) {
if (property.hasOwnProperty(prop)) {
this.getTarget().style[prop] = property[prop];
}
}
} else if (typeof property === "string" && typeof value === "undefined") {
// 获取单个属性
return window.getComputedStyle(element)[property];
}
}
attr(attributeName, value) {
if (value === undefined) {
// 如果未提供值,则返回属性的当前值
return this.getTarget().getAttribute(attributeName);
} else {
// 如果提供了值,则设置属性的值
this.getTarget().setAttribute(attributeName, value);
}
return this;
}
text(str) {
this.getTarget().innerText = str;
return this;
}
html(str) {
this.getTarget().innerHtml = str;
return this;
}
is(dom) {
if (dom && typeof dom === "object" && typeof dom.nodeType !== "undefined") {
return this.dom === dom;
}
if (dom instanceof DomEl) {
return this.dom === dom.dom;
}
}
append(content) {
if (typeof content === "string") {
this.getTarget().insertAdjacentHTML("beforeend", content);
} else if (content instanceof HTMLElement) {
this.getTarget().appendChild(content);
} else {
throw new Error("Invalid content type");
}
return this;
}
click(fun) {
this.on("click", fun);
return this;
}
mousedown(fun) {
this.on("mousedown", fun);
return this;
}
touchstart(fun) {
this.on("touchstart", fun);
return this;
}
on(eventType, fun) {
this.getTarget().addEventListener(eventType, fun, { passive: true });
return this;
}
width() {
return this.getTarget().offsetWidth;
}
height() {
return this.getTarget().offsetHeight;
}
getTarget() {
if (this.dom) {
return this.dom;
}
throw new Error("dom不存在: [" + this.domStr + "]");
}
}
function http(options) {
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open(options.method || "GET", options.url);
// 设置请求头
if (options.headers) {
for (const header in options.headers) {
if (options.headers.hasOwnProperty(header)) {
xhr.setRequestHeader(header, options.headers[header]);
}
}
}
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status >= 200 && xhr.status <= 500) {
const contentType = xhr.getResponseHeader("Content-Type");
if (contentType && contentType.indexOf("application/json") !== -1) {
resolve(JSON.parse(xhr.responseText));
} else {
resolve(xhr.responseText);
}
} else {
reject(new Error("Request failed with status: " + xhr.status));
}
}
};
xhr.onerror = function () {
reject(new Error("Network Error"));
};
xhr.send(options.data);
});
}
function isEmptyObject(obj) {
for (var key in obj) {
if (obj.hasOwnProperty(key)) {
return false; // 对象不为空
}
}
return true; // 对象为空
}
export {
isEmptyObject,
http,
Dom,
DomEl,
CommonCaptcha,
clearAllPreventDefault,
down,
move,
up,
initConfig,
showTips,
closeTips,
destroyEvent,
};
+73
View File
@@ -0,0 +1,73 @@
.captcha {
text-align: left;
box-sizing: content-box;
width: 100%;
height: 260px;
z-index: 999;
.slider-bottom .logo {
height: 30px;
}
.slider-bottom {
height: 19px;
width: 100%;
}
.content {
.captcha-tips {
height: 25px;
width: 100%;
position: absolute;
bottom: -25px;
left: 0;
z-index: 999;
font-size: 15px;
line-height: 25px;
color: #fff;
text-align: center;
transition: bottom 0.3s ease-in-out;
}
.captcha-tips.captcha-tips-error {
background-color: #ff5d39;
}
.captcha-tips.captcha-tips-success {
background-color: #39c522;
}
.captcha-tips.captcha-tips-on {
bottom: 0;
}
.captcha-loading {
z-index: 9999;
background-color: #f5f5f5;
text-align: center;
height: 100%;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
align-items: center;
img {
display: block;
width: 45px;
height: 45px;
}
}
}
.captcha-slider-bg-canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border-radius: 5px;
}
.captcha-slider-bg-div {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
.captcha-slider-bg-div-slice {
position: absolute;
}
}
}
+137
View File
@@ -0,0 +1,137 @@
import {
Dom,
CommonCaptcha,
clearAllPreventDefault,
down,
initConfig,
destroyEvent,
} from "../common/common.js";
const TYPE = "CONCAT";
function getTemplate() {
return `
<div class="captcha captcha-slider captcha-concat">
<div class="slider-tip">
<span class="captcha-slider-move-track-font" >拖动滑块完成拼图</span>
</div>
<div class="content">
<div class="captcha-slider-concat-img-div"></div>
<div class="captcha-slider-concat-bg-img"></div>
<div class="captcha-tips"></div>
</div>
<div class="slider-move">
<div class="slider-move-track">
<div class="captcha-slider-move-track-mask"></div>
<div class="slider-move-shadow"></div>
</div>
<div class="slider-move-btn captcha-slider-move-btn">
</div>
</div>
</div>
`;
}
class Concat extends CommonCaptcha {
constructor(divId, styleConfig) {
super();
this.boxEl = Dom(divId);
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate());
this.el = this.boxEl.find(".captcha");
this.loadStyle();
// 按钮绑定事件
this.el
.find(".captcha-slider-move-btn")
.mousedown(down.bind(null, this));
this.el
.find(".captcha-slider-move-btn")
.touchstart(down.bind(null, this));
clearAllPreventDefault(this.el);
// 绑定全局
window.currentCaptcha = this;
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy() {
destroyEvent();
const existsCaptchaEl = this.boxEl.children(".captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
}
doMove() {
const moveX = this.currentCaptchaData.moveX;
this.el
.find(".captcha-slider-move-btn")
.css("transform", "translate(" + moveX + "px, 0px)");
this.el
.find(".captcha-slider-concat-img-div")
.css("background-position-x", moveX + "px");
this.el
.find(".captcha-slider-move-track-mask")
.css("width", moveX + "px");
}
loadStyle() {
let sliderImg = "";
let moveTrackMaskBorderColor = "#00f4ab";
let moveTrackMaskBgColor = "#a9ffe5";
const styleConfig = this.styleConfig;
if (styleConfig) {
sliderImg = styleConfig.btnUrl;
moveTrackMaskBgColor = styleConfig.moveTrackMaskBgColor;
moveTrackMaskBorderColor = styleConfig.moveTrackMaskBorderColor;
}
this.el
.find(".slider-move .slider-move-btn")
.css("background-image", "url(" + sliderImg + ")");
// this.el.find(".captcha-slider-move-track-font").text(title);
this.el
.find(".captcha-slider-move-track-mask")
.css("border-color", moveTrackMaskBorderColor);
this.el
.find(".captcha-slider-move-track-mask")
.css("background-color", moveTrackMaskBgColor);
}
loadCaptchaForData(that, data) {
const bgImg = that.el.find(".captcha-slider-concat-bg-img");
const sliderImg = that.el.find(".captcha-slider-concat-img-div");
bgImg.css("background-image", "url(" + data.data.backgroundImage + ")");
sliderImg.css("background-image", "url(" + data.data.backgroundImage + ")");
sliderImg.css("background-position", "0px 0px");
var backgroundImageHeight = data.data.backgroundImageHeight;
var height =
((backgroundImageHeight - data.data.data.randomY) /
backgroundImageHeight) *
180;
sliderImg.css("height", height + "px");
that.currentCaptchaData = initConfig(
bgImg.width(),
bgImg.height(),
sliderImg.width(),
sliderImg.height(),
300 - 63 + 5,
);
that.currentCaptchaData.currentCaptchaId = data.data.id;
}
}
export default Concat;
+29
View File
@@ -0,0 +1,29 @@
.captcha.captcha-concat {
.captcha-slider-concat-img-div {
background-size: 100% 180px;
position: absolute;
transform: translate(0px, 0px);
z-index: 1;
width: 100%;
&::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: rgba(255, 255, 255, 0.92);
box-shadow:
0 0 6px 3px rgb(255 255 255 / 60%),
0 1px 4px rgb(0 0 0 / 90%);
pointer-events: none;
}
}
.captcha-slider-concat-bg-img {
width: 100%;
height: 100%;
position: absolute;
transform: translate(0px, 0px);
background-size: 100% 180px;
}
}
+206
View File
@@ -0,0 +1,206 @@
import StyleConfig from "./styleConfig";
import { Dom, http } from "../common/common";
class CaptchaConfig {
constructor(args) {
if (!args.bindEl) {
throw new Error("[TAC] 必须配置 [bindEl]用于将验证码绑定到该元素上");
}
if (!args.requestCaptchaDataUrl) {
throw new Error("[TAC] 必须配置 [requestCaptchaDataUrl]请求验证码接口");
}
if (!args.validCaptchaUrl && !args.onDataReady) {
throw new Error("[TAC] 必须配置 [validCaptchaUrl]验证验证码接口");
}
this.bindEl = args.bindEl;
this.domBindEl = Dom(args.bindEl);
this.requestCaptchaDataUrl = args.requestCaptchaDataUrl;
this.types = Array.isArray(args.types) ? args.types.filter(Boolean) : [];
this.validCaptchaUrl = args.validCaptchaUrl;
if (args.validSuccess) {
this.validSuccess = args.validSuccess;
}
if (args.validFail) {
this.validFail = args.validFail;
}
if (args.onDataReady) {
this.onDataReady = args.onDataReady;
}
if (args.requestHeaders) {
this.requestHeaders = args.requestHeaders;
} else {
this.requestHeaders = {};
}
if (args.btnCloseFun) {
this.btnCloseFun = args.btnCloseFun;
}
if (args.btnRefreshFun) {
this.btnRefreshFun = args.btnRefreshFun;
}
this.requestChain = [];
// 时间戳转换
this.timeToTimestamp = args.timeToTimestamp || true;
this.insertRequestChain(0, {
preRequest(type, param, c, tac) {
if (this.timeToTimestamp && param.data) {
for (let key in param.data) {
// 将date全部转换为时间戳
if (param.data[key] instanceof Date) {
param.data[key] = param.data[key].getTime();
}
}
}
return true;
},
});
}
addRequestChain(fun) {
this.requestChain.push(fun);
}
insertRequestChain(index, chain) {
this.requestChain.splice(index, 0, chain);
}
removeRequestChain(index) {
this.requestChain.splice(index, 1);
}
requestCaptchaData() {
const requestParam = {};
requestParam.headers = this.requestHeaders || {};
requestParam.data = { types: this.types };
// 设置默认值
requestParam.headers["Content-Type"] = "application/json;charset=UTF-8";
requestParam.method = "POST";
requestParam.url = this.requestCaptchaDataUrl;
// 请求前装载参数
this._preRequest("requestCaptchaData", requestParam);
// 发送请求
const request = this.doSendRequest(requestParam);
// 返回结果
return request.then((res) => {
// 装返回结果
this._postRequest("requestCaptchaData", requestParam, res);
// 返回结果
return res;
});
}
doSendRequest(requestParam) {
// 如果content-type是json,那么data就是json字符串, 这里直接匹配所有header是否包含application/json
if (requestParam.headers) {
for (const key in requestParam.headers) {
if (requestParam.headers[key].indexOf("application/json") > -1) {
if (typeof requestParam.data !== "string") {
requestParam.data = JSON.stringify(requestParam.data);
}
break;
}
}
}
return http(requestParam).then((res) => {
try {
return JSON.parse(res);
} catch (e) {
return res;
}
});
}
_preRequest(type, requestParam, c, tac) {
for (let i = 0; i < this.requestChain.length; i++) {
const r = this.requestChain[i];
if (r.preRequest) {
if (!r.preRequest(type, requestParam, this, c, tac)) {
break;
}
}
}
}
_postRequest(type, requestParam, res, c, tac) {
for (let i = 0; i < this.requestChain.length; i++) {
const r = this.requestChain[i];
// 判断r是否存圩postRequest方法
if (r.postRequest) {
if (!r.postRequest(type, requestParam, res, this, c, tac)) {
break;
}
}
}
}
validCaptcha(currentCaptchaId, data, c, tac) {
const sendParam = {
id: currentCaptchaId,
data: data,
};
let requestParam = {};
requestParam.headers = this.requestHeaders || {};
requestParam.data = sendParam;
requestParam.headers["Content-Type"] = "application/json;charset=UTF-8";
requestParam.method = "POST";
requestParam.url = this.validCaptchaUrl;
this._preRequest("validCaptcha", requestParam, c, tac);
const request = this.doSendRequest(requestParam);
return request
.then((res) => {
this._postRequest("validCaptcha", requestParam, res, c, tac);
return res;
})
.then((res) => {
if (res.code == 200) {
const useTimes = (data.stopTime - data.startTime) / 1000;
c.showTips(`验证成功,耗时${useTimes}秒`, 1, () =>
this.validSuccess(res, c, tac),
);
} else {
let tipMsg = "验证失败,请重新尝试!";
if (res.code) {
if (res.code != 4001) {
tipMsg = "验证码被黑洞吸走了!";
}
}
c.showTips(tipMsg, 0, () => this.validFail(res, c, tac));
}
})
.catch((e) => {
let tipMsg = c.styleConfig.i18n.tips_error;
if (e.code && e.code != 200) {
if (e.code != 4001) {
tipMsg = c.styleConfig.i18n.tips_4001;
}
c.showTips(tipMsg, 0, () => this.validFail(e, c, tac));
return;
}
c.showTips(tipMsg, 0, () => this.validFail(e, c, tac));
});
}
validSuccess(res, c, tac) {
console.log(
"验证码校验成功, 请重写 [config.validSuccess] 方法, 用于自定义逻辑处理",
);
window.currentCaptchaRes = res;
tac.destroyWindow();
}
validFail(res, c, tac) {
tac.reloadCaptcha();
}
}
function wrapConfig(config) {
if (config instanceof CaptchaConfig) {
return config;
}
return new CaptchaConfig(config);
}
function wrapStyle(style) {
let margeStyle = { ...StyleConfig, ...style };
margeStyle.i18n = { ...StyleConfig.i18n, ...style?.i18n };
return margeStyle;
}
const captchaRequestChains = {};
export { CaptchaConfig, wrapConfig, wrapStyle };
+23
View File
@@ -0,0 +1,23 @@
export default {
// 按钮图片
btnUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIwAAABkCAYAAABU19jRAAAJcUlEQVR4nO2d63MT1xmHf9rV6mr5fgNMuSW+ENsY8N0EE2BMhinJNB8y/dD2Qz/0v+gMf0w/JHTKNJAhICwbsA02TpNAHEMgQIwNBSEb8F2rvXTeY1kjYyA+TmVJmfeZ8YiRWa9299E57/mdI63Dtm3E+RjAKTDMaj4F8AU9uyzMCQBn+EQxb+EjAF+RMH8AcJrPFLMGvCSMzWeKWSN/I2GiAFx8xpi1oPBZYiTQWRhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGCiefrtShGwZiup74+4qqwu12Z/W7lIVJEfN6FDfv3sPXfYOIRRfpm1UQKC7EkQ+PYFtRcdZKw8KkiLsPJ/CfgSFcH7yOxWhU7MSluYQoR44fxdaCoqyUhoVJEfZ8FN99c1N0Sx6PR+zEMAz0XAgBNtB14hi25OXDkWXHxUVvinA4ln6ScTqdsGwbvRd7EPwyiEcvXyDbvpyHhUkRaq4fe/c3wEWSWFZiJySNYZroCYYQPHsBY1OTWSWNevLkyb/TYwa8lt8UAb8ftluDW9UwPj4hDs0Rb3JUVRXd09j9nwELKKgoR4HXlw2Hb3INkyK8mob9NdUwLROq4sCVKwMrdqRpGkzTFN0TaWR2HcKu0rKMr2lYmBTi1jS01dUt7UBx4PKlfvHP5JaGuqseIY0DjmOHsKukNKOPiYVJMU5VRXt9PSwboO+fvHJ5QEiiKEvlIz3S86HuHiiqAhw9iJ0lpRnb0rAwG4CqKHh/Tz0UhwOWaWGg/5oofEkmJLU4wfPdQia765CQJhNHJCzMBkEtSVtdLRw2YNo2hgaGEDMMMWpahrwJBUMUCkM9djgjE2EWZgOhFqW5rlbMKdm2heHBYUT1mCiAEW9pKKfpPh8Sj5mYCLMwG4zLqWJfTZWQgL5S++uhYURjBrR4S0MtUSYnwixMGvBoGvZUV4quh0S4Pjgsaho1XtOIcM8wxJCb+qmu33dljDS/CWEeTb/E/Pw89EUdebkBVBQWrnnbWVjQoMAtsT9asGDQhf8VUbnX5UJ9VaVoZahVuXZ1cMXoiaSJxWIiEab/dPj4UXFczjRrk/VJ70/hp/jhuxF89o9TGP1+FH6fD9OxGHw5Pnicb34/PJ2dweitu7hwLojvb47A9rhQmJeXGLm8iQeP/4uRH27h88/+iZhhYs40UFZQsK7XrqkqigvyYbk18VrHH74+EX74YAzRqI66mupE15UmzKwW5kEkgtFvRxA8ex7hJ2HMzczgzu0f8fjxExRt2YzcgB9udfUJjuo6Tv/7HE6f+pe4GHd//AkwLRhuDeXFRW+U5v7EI4yMjKI3GMLt0Tt4cO8BAoEcWJoTZYXrl6asqBC6U0GOy42HY+MrZi1JmoWFRZQW5sNyuVBeUpxOabJ7aiASjiB4/iKmnj+H5loaacwvLOL2jRF4AjnY8dc/I/DKbTdoSHvr8SO8DD/DzPSMWHrg1JwYvHZdpK2NVZWU26/aF3VDTyLP0N/bh4mJR3C7XZiZnRVdht/nx7u7tsOzzg5qORFWHAocigO9vX2Jronwej24cXMEbq8XrfW169rH/4usnq02o1FEo9FEE47luN22sTAzC0OPrd7ItnHn9h0MDg3D6/WKbZdHJqYRg26ar92XDgvD39zA2Ng4VKdTbEf7mpmeRX/fAPRfeRch+luNNTXICeSu+h3ti7okUzdgp3luO6uFUTUN9lLmnniOCkdKVnML8uB0r76rD72Di4qL4NI0IUnydpZlw/WmGsY00bRvDzZvKhfFKLAU9VOG8v7BdijW+i8kLX649yyMz0+fwVQksur3NILyejzw5efCoaT3kmW1MN68AMq2bBIXXtd18WMZBt6r242DBzvgda3uWhQ4xNzOkeNdohZYXFjA4vwCfD4/Sio2i9bjdeSoGirKylFYXirykehiFHpUR2FJCbZu+x1yXlMrrQWSZWwygv6Ll3DxXBCX+66u6I7o2DRFRWtbM1o62xNdb7rI7lGSqqBs+zZMTj4XLYY/x49t7+zABx8eReWO7ciLL41ctZmqoqRiE/x+P6amp5FbkI9jx7tw+GgncqmbesPuPAEfduzcgenZOTg0FaWby/GXP/0RdZXvrOvlkyzjzyfR81UIoQs9IpRJniqglszt0tDc1oS9bc2o37lTLMhKI2bW35HtRXQRs3MLmH/xUrzzVb8HJQUFyHX/crJCQ+JwOALFqaKspGjNRWtkbg5zc7PQXC5szl/f6Ig6MFqiSavuqHCmumuFLIYBt+ZEY0sTGtua0VBTJQK/NKPzLfzSQEKWL4NiiG5a1gpZzPhMdnNrE/a3N2NPVaUI+jIAnacGNhiShdbx9pzrFgunSA4tqeUQRbuqoLW9BQ0tjSINzhBZBCzMBvPzVAS950KiG6KWJVkWGnXRELrjQBtqG/eioTqzZAELs3FQy3Iv/BR9wUtiUtGOr+tNhoptGt1V7atD4+4aEehlGizMBnH/WRj9wcuiG7LjI7Vllm8d3nnoAKoaakXq+0tzWumChUkxdlyWge4rYt0uzRMpSck01SzUDR3s7MC7e2pFRqSmOZx7GyxMCrESLcsldAd7oCgrEx6xrldRRM1SvbceHfV1K0K7TISFSREx28L41KRIcGmdruOVz82KBFd1oqWjBe/tb0ArLd3McFnAwqSOiclJ9JwP4fLFXtEtJXdDywluU2uTGDpTgZupNcur8GerU8R0eBJDV6+LRVbJLYdIcF2aSHD3tzaL9b20zjdbYGFShB0z4HY6V9QtFNLRXFATxf2U4FZXZkLcLwULkyJoaUXMNMV6HbyS4O6jicQMS3DXCguTInJKC9HU0YoPOg8k1uy0t7eivnmfSHB9WSgLwZOPKcKwLcT0GL69cxe3b46KoK6+ZS92V2zNyAR3jfBsdaox6LPSpiVyf/rEo/rq11JlFzxbnWoomEMW5CtrhWsYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgr6qGx6b4/BZBXUwnzCl4xZI5844g3MCQBn+Kwxb+EjAGcdST3SxwBO8RljXsOnAL4AgP8BXnVIgIvemwsAAAAASUVORK5CYII=",
// 移动边框背景颜色
moveTrackMaskBgColor: "#89d2ff",
// 移动边框颜色
moveTrackMaskBorderColor: "#0298f8",
// 文字提示
i18n: {
tips_success: "验证成功,耗时%s秒",
tips_error: "验证失败,请重新尝试!",
slider_title: "拖动滑块完成拼图",
concat_title: "拖动滑块完成拼图",
image_click_title: "请依次点击:",
rotate_title: "拖动滑块完成拼图",
// TITLE 大小
slider_title_size: "15px",
concat_title_size: "15px",
image_click_title_size: "20px",
rotate_title_size: "15px",
},
};
+57
View File
@@ -0,0 +1,57 @@
const TYPE = "DISABLE";
function getTemplate(styleConfig) {
return `
<div class="captcha captcha-disable">
<div class="slider-tip">
<span class="captcha-slider-move-track-font" style="font-size: ${styleConfig.i18n.disable_title_size}">${styleConfig.i18n.disable_title}</span>
</div>
<div class="content">
<div class="bg-img-div">
<!-- <svg width="100" height="100" viewBox="0 0 100 100">-->
<!-- <polygon points="50,10 90,90 10,90" fill="none" stroke="#FF9900" stroke-width="4"/>-->
<!-- <path d="M50 35V65 M50 75V75" stroke="#FF9900" stroke-width="4" stroke-linecap="round"/>-->
<!-- </svg>-->
<span class="content-span"></span>
</div>
</div>
</div>
`;
}
class Disable {
constructor(boxEl, styleConfig) {
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find(".captcha");
// 绑定全局
// window.currentCaptcha = this;
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy() {
const existsCaptchaEl = this.boxEl.find(".captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
}
loadCaptchaForData(that, data) {
const msg = data.msg || data.message || "接口异常";
that.el.find(".content-span").text(msg);
}
}
export default Disable;
+25
View File
@@ -0,0 +1,25 @@
.captcha.captcha-disable {
z-index: 999;
position: absolute;
left: 0;
top: 0;
.content {
width: 100%;
height: 180px;
position: relative;
overflow: hidden;
.bg-img-div {
background-image: url("../../images/dun.jpeg");
width: 100%;
height: 100%;
overflow: hidden;
.content-span {
color: #fff;
overflow: hidden;
margin-top: 132px;
display: block;
text-align: center;
}
}
}
}
@@ -0,0 +1,131 @@
import {
CommonCaptcha,
move,
initConfig,
destroyEvent,
} from "../common/common.js";
/**
* 滑动验证码
*/
const TYPE = "IMAGE_CLICK";
function getTemplate(styleConfig) {
return `
<div class="captcha captcha-slider captcha-word-click">
<div class="click-tip">
<span class="captcha-click-track-font" style="font-size: ${styleConfig.i18n.image_click_title_size}">${styleConfig.i18n.image_click_title}</span>
<img src="" class="captcha-tip-img tip-img">
</div>
<div class="content">
<div class="bg-img-div">
<img class="captcha-slider-bg-img" src="" alt/>
<canvas class="captcha-slider-bg-canvas"></canvas>
<div class="bg-img-click-mask"></div>
</div>
<div class="captcha-tips"></div>
</div>
<div class="click-confirm-btn">确定</div>
</div>
`;
}
class ImageClick extends CommonCaptcha {
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find(".captcha");
// 绑定全局
// window.currentCaptcha = this;
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
const moveFun = move.bind(null, this);
// 绑定事件
this.el.find(".bg-img-click-mask").click((event) => {
if (event.target.className === "click-span") {
return;
}
this.currentCaptchaData.clickCount++;
const trackList = this.currentCaptchaData.trackList;
if (this.currentCaptchaData.clickCount === 1) {
this.currentCaptchaData.startTime = new Date();
// move 轨迹
window.addEventListener("mousemove", moveFun);
this.currentCaptchaData.startX = event.offsetX;
this.currentCaptchaData.startY = event.offsetY;
}
const startTime = this.currentCaptchaData.startTime;
trackList.push({
x: Math.round(event.offsetX),
y: Math.round(event.offsetY),
type: "click",
t: new Date().getTime() - startTime.getTime(),
});
const left = event.offsetX - 10;
const top = event.offsetY - 10;
this.el
.find(".bg-img-click-mask")
.append(
"<span class='click-span' style='left:" +
left +
"px;top: " +
top +
"px'>" +
this.currentCaptchaData.clickCount +
"</span>",
);
// if (this.currentCaptchaData.clickCount === 4) {
// // 校验
// this.currentCaptchaData.stopTime = new Date();
// window.removeEventListener("mousemove", move);
// this.endCallback(this.currentCaptchaData,this);
// }
});
this.el.find(".click-confirm-btn").click(() => {
if (this.currentCaptchaData.clickCount > 0) {
// 校验
this.currentCaptchaData.stopTime = new Date();
window.removeEventListener("mousemove", moveFun);
this.endCallback(this.currentCaptchaData, this);
}
});
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy() {
const existsCaptchaEl = this.boxEl.children(".captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData(that, data) {
const bgImg = that.el.find(".captcha-slider-bg-img");
const tipImg = that.el.find(".captcha-tip-img");
bgImg.on("load", () => {
that.currentCaptchaData = initConfig(
bgImg.width(),
bgImg.height(),
tipImg.width(),
tipImg.height(),
);
that.currentCaptchaData.currentCaptchaId = data.data.id;
});
bgImg.attr("src", data.data.backgroundImage);
tipImg.attr("src", data.data.templateImage);
}
}
export default ImageClick;
@@ -0,0 +1,66 @@
.captcha.captcha-word-click {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
.tip-img {
height: 35px;
position: absolute;
right: 15px;
}
.captcha-click-track-font {
font-size: 18px;
display: inline-block;
height: 40px;
line-height: 40px;
position: absolute;
}
}
.slider-bottom {
position: relative;
top: 6px;
}
.content {
.bg-img-click-mask {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
.click-span {
position: absolute;
left: 0;
top: 0;
border-radius: 50px;
background-color: #409eff;
width: 20px;
height: 20px;
text-align: center;
line-height: 20px;
color: #fff;
border: 2px solid #fff;
box-sizing: content-box;
}
}
}
.click-confirm-btn {
width: 100%;
height: 35px;
border-radius: 4px;
background-image: linear-gradient(
173deg,
hsl(38.09deg 91% 57.89%) 0%,
hsl(38.09deg 89.38% 71.74%) 100%
);
font-size: 15px;
text-align: center;
box-sizing: border-box;
line-height: 35px;
color: #fff;
margin-top: 3px;
}
.click-confirm-btn:hover {
cursor: pointer;
}
}
+134
View File
@@ -0,0 +1,134 @@
import {
CommonCaptcha,
down,
initConfig,
destroyEvent,
} from "../common/common.js";
/**
* 滑动验证码
*/
const TYPE = "ROTATE";
function getTemplate(styleConfig) {
return `
<div class="captcha captcha-slider captcha-rotate">
<div class="slider-tip">
<span class="captcha-slider-move-track-font" style="font-size: ${styleConfig.i18n.rotate_title_size}">${styleConfig.i18n.rotate_title}</span>
</div>
<div class="content">
<div class="bg-img-div">
<img class="captcha-slider-bg-img" src="" alt/>
<canvas class="captcha-slider-bg-canvas"></canvas>
</div>
<div class="rotate-img-div captcha-slider-img-div">
<img class="captcha-slider-move-img" src="" alt/>
</div>
<div class="captcha-tips"></div>
</div>
<div class="slider-move">
<div class="slider-move-track">
<div class="captcha-slider-move-track-mask"></div>
<div class="slider-move-shadow"></div>
</div>
<div class="slider-move-btn captcha-slider-move-btn">
</div>
</div>
</div>
`;
}
class Rotate extends CommonCaptcha {
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find(".captcha");
this.loadStyle();
// 按钮绑定事件
this.el
.find(".captcha-slider-move-btn")
.mousedown(down.bind(null, this));
this.el
.find(".captcha-slider-move-btn")
.touchstart(down.bind(null, this));
// 绑定全局
// window.currentCaptcha = this;
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy() {
const existsCaptchaEl = this.boxEl.children(".captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
doMove() {
const moveX = this.currentCaptchaData.moveX;
this.el
.find(".captcha-slider-move-btn")
.css("transform", "translate(" + moveX + "px, 0px)");
this.el
.find(".captcha-slider-move-img")
.css(
"transform",
"rotate(" + moveX / (this.currentCaptchaData.end / 360) + "deg)",
);
this.el
.find(".captcha-slider-move-track-mask")
.css("width", moveX + "px");
}
loadStyle() {
let sliderImg = "";
let moveTrackMaskBorderColor = "#00f4ab";
let moveTrackMaskBgColor = "#a9ffe5";
const styleConfig = this.styleConfig;
if (styleConfig) {
sliderImg = styleConfig.btnUrl;
moveTrackMaskBgColor = styleConfig.moveTrackMaskBgColor;
moveTrackMaskBorderColor = styleConfig.moveTrackMaskBorderColor;
}
this.el
.find(".slider-move .slider-move-btn")
.css("background-image", "url(" + sliderImg + ")");
// this.el.find(".captcha-slider-move-track-font").text(title);
this.el
.find(".captcha-slider-move-track-mask")
.css("border-color", moveTrackMaskBorderColor);
this.el
.find(".captcha-slider-move-track-mask")
.css("background-color", moveTrackMaskBgColor);
}
loadCaptchaForData(that, data) {
const bgImg = that.el.find(".captcha-slider-bg-img");
const sliderImg = that.el.find(".captcha-slider-move-img");
bgImg.attr("src", data.data.backgroundImage);
sliderImg.attr("src", data.data.templateImage);
bgImg.on("load", () => {
that.currentCaptchaData = initConfig(
bgImg.width(),
bgImg.height(),
sliderImg.width(),
sliderImg.height(),
300 - 63 + 5,
);
that.currentCaptchaData.currentCaptchaId = data.data.id;
});
}
}
export default Rotate;
+11
View File
@@ -0,0 +1,11 @@
.captcha.captcha-rotate {
.rotate-img-div {
height: 100%;
text-align: center;
img {
height: 100%;
transform: rotate(0deg);
display: inline-block;
}
}
}
+142
View File
@@ -0,0 +1,142 @@
import {
CommonCaptcha,
closeTips,
down,
initConfig,
showTips,
destroyEvent,
} from "../common/common.js";
/**
* 滑动验证码
*/
const TYPE = "SLIDER";
function getTemplate(styleConfig) {
return `
<div class="captcha captcha-slider">
<div class="slider-tip">
<span class="captcha-slider-move-track-font" style="font-size: ${styleConfig.i18n.slider_title_size}">${styleConfig.i18n.slider_title}</span>
</div>
<div class="content">
<div class="bg-img-div">
<img class="captcha-slider-bg-img" src="" alt/>
<canvas class="captcha-slider-bg-canvas"></canvas>
<div class="captcha-slider-bg-div"></div>
</div>
<div class="slider-img-div captcha-slider-img-div">
<img class="captcha-slider-move-img" src="" alt/>
</div>
<div class="captcha-tips"></div>
</div>
<div class="slider-move">
<div class="slider-move-track">
<div class="captcha-slider-move-track-mask"></div>
<div class="slider-move-shadow"></div>
</div>
<div class="slider-move-btn captcha-slider-move-btn">
</div>
</div>
</div>
`;
}
class Slider extends CommonCaptcha {
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find(".captcha");
this.loadStyle();
// 按钮绑定事件
this.el
.find(".captcha-slider-move-btn")
.mousedown(down.bind(null, this));
this.el
.find(".captcha-slider-move-btn")
.touchstart(down.bind(null, this));
// 绑定全局
// window.currentCaptcha = this;
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
showTips(msg, type, callback) {
showTips(this.el, msg, type, callback);
}
closeTips(callback) {
closeTips(this.el, callback);
}
destroy() {
const existsCaptchaEl = this.boxEl.children(".captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
doMove() {
const moveX = this.currentCaptchaData.moveX;
this.el
.find(".captcha-slider-move-btn")
.css("transform", "translate(" + moveX + "px, 0px)");
this.el
.find(".captcha-slider-img-div")
.css("transform", "translate(" + moveX + "px, 0px)");
this.el
.find(".captcha-slider-move-track-mask")
.css("width", moveX + "px");
}
loadStyle() {
let sliderImg = "";
let moveTrackMaskBorderColor = "#00f4ab";
let moveTrackMaskBgColor = "#a9ffe5";
const styleConfig = this.styleConfig;
if (styleConfig) {
sliderImg = styleConfig.btnUrl;
moveTrackMaskBgColor = styleConfig.moveTrackMaskBgColor;
moveTrackMaskBorderColor = styleConfig.moveTrackMaskBorderColor;
}
this.el
.find(".slider-move .slider-move-btn")
.css("background-image", "url(" + sliderImg + ")");
// this.el.find(".captcha-slider-move-track-font").text(title);
this.el
.find(".captcha-slider-move-track-mask")
.css("border-color", moveTrackMaskBorderColor);
this.el
.find(".captcha-slider-move-track-mask")
.css("background-color", moveTrackMaskBgColor);
}
loadCaptchaForData(that, data) {
const bgImg = that.el.find(".captcha-slider-bg-img");
const sliderImg = that.el.find(".captcha-slider-move-img");
bgImg.attr("src", data.data.backgroundImage);
sliderImg.attr("src", data.data.templateImage);
bgImg.on("load", () => {
that.currentCaptchaData = initConfig(
bgImg.width(),
bgImg.height(),
sliderImg.width(),
sliderImg.height(),
300 - 63 + 5,
);
that.currentCaptchaData.currentCaptchaId = data.data.id;
});
}
}
export default Slider;
+87
View File
@@ -0,0 +1,87 @@
.captcha.captcha-slider {
z-index: 999;
position: absolute;
left: 0;
top: 0;
.content {
width: 100%;
height: 180px;
position: relative;
overflow: hidden;
}
.bg-img-div {
width: 100%;
height: 100%;
position: absolute;
transform: translate(0px, 0px);
img {
height: 100%;
width: 100%;
}
}
.slider-img-div {
height: 100%;
position: absolute;
left: 0;
transform: translate(0px, 0px);
.captcha-slider-move-img {
height: 100%;
}
}
.slider-move {
height: 34px;
width: 100%;
margin: 11px 0;
position: relative;
}
.slider-move-track {
position: relative;
height: 32px;
line-height: 32px;
text-align: center;
background: #f5f5f5;
color: #999;
transition: 0s;
font-size: 14px;
box-sizing: content-box;
border: 1px solid #f5f5f5;
border-radius: 4px;
}
.refresh-btn,
.close-btn {
display: inline-block;
}
.slider-move {
line-height: 38px;
font-size: 14px;
text-align: center;
white-space: nowrap;
color: #88949d;
-moz-user-select: none;
-webkit-user-select: none;
user-select: none;
filter: opacity(0.8);
}
.slider-move .slider-move-btn {
transform: translate(0px, 0px);
position: absolute;
top: -6px;
left: 0;
width: 63px;
height: 45px;
background-color: #fff;
background-repeat: no-repeat;
background-size: contain;
border-radius: 5px;
}
.slider-tip {
margin-bottom: 5px;
font-weight: bold;
font-size: 15px;
line-height: normal;
}
.slider-move-btn:hover {
cursor: pointer;
}
user-select: none;
}
@@ -0,0 +1,13 @@
import ImageClick from "../image_click/image_click";
/**
* 滑动验证码
*/
const TYPE = "WORD_IMAGE_CLICK";
class WordImageClick extends ImageClick {
constructor(divId, styleConfig) {
super(divId, styleConfig);
this.type = TYPE;
}
}
export default WordImageClick;
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+43
View File
@@ -0,0 +1,43 @@
import { CaptchaConfig, TianAiCaptcha } from "./captcha/captcha.js"
export type TianAiCaptchaBindTarget = string | HTMLElement
export interface TianAiCaptchaResult {
id: string
data: Record<string, any>
}
export interface TianAiCaptchaStyle {
btnUrl?: string
moveTrackMaskBgColor?: string
moveTrackMaskBorderColor?: string
i18n?: Record<string, string>
}
export interface TianAiCaptchaConfig {
bindEl: TianAiCaptchaBindTarget
requestCaptchaDataUrl: string
types?: string[]
validCaptchaUrl?: string
timeToTimestamp?: boolean
validSuccess?: (res: any, captcha: any, tac: TianAiCaptchaInstance) => void
validFail?: (res: any, captcha: any, tac: TianAiCaptchaInstance) => void
onDataReady?: (result: TianAiCaptchaResult, captcha: any, tac: TianAiCaptchaInstance) => void | Promise<void>
btnRefreshFun?: (el: Event, tac: TianAiCaptchaInstance) => void
btnCloseFun?: (el: Event, tac: TianAiCaptchaInstance) => void
}
export interface TianAiCaptchaInstance {
init: () => TianAiCaptchaInstance
reloadCaptcha: () => void
destroyWindow: () => void
}
export function createTianAiCaptcha(
config: TianAiCaptchaConfig,
style?: TianAiCaptchaStyle,
): TianAiCaptchaInstance {
return new TianAiCaptcha(config, style) as TianAiCaptchaInstance
}
export { CaptchaConfig, TianAiCaptcha }
+199 -66
View File
@@ -3,8 +3,12 @@
<el-header class="layout-header">
<div class="main-title">博客管理后台</div>
<div class="nav-btns-right">
<el-button link @click="toggleDarkMode" style="color: #fff; font-size: 18px; margin-right: 12px;" plain>
<Moon v-if="!isDark" style="width: 18px" />
<Sunny v-else style="width: 18px" />
</el-button>
<el-dropdown @command="dropdownMenuCommand">
<el-button link type="primary">
<el-button link type="primary" plain>
<UserFilled style="width: 18px" />
{{ realname }}
<ArrowDown style="width: 18px"/>
@@ -13,6 +17,7 @@
<el-dropdown-menu>
<el-dropdown-item command="home">返回首页</el-dropdown-item>
<el-dropdown-item command="changePassword">修改密码</el-dropdown-item>
<el-dropdown-item command="about">关于</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -35,90 +40,218 @@
</el-aside>
<el-main class="layout-main">
<div class="layout-tabs">
<el-tabs type="card" class="nav-tabs" v-model="$store.state.activeTab" @tab-change="openMenu" @tab-remove="removeTab">
<el-tabs type="card" class="nav-tabs" v-model="store.state.activeTab" @tab-change="openMenu" @tab-remove="removeTab">
<el-tab-pane label="首页" name="/"></el-tab-pane>
<el-tab-pane v-for="tab in $store.state.tabs" :key="tab.name" :label="tab.title" :name="tab.path" closable></el-tab-pane>
<el-tab-pane v-for="tab in store.state.tabs" :key="tab.name" :label="tab.title" :name="tab.path" closable></el-tab-pane>
</el-tabs>
</div>
<div class="layout-content">
<router-view class="main-view" v-slot="{ Component }">
<router-view class="main-view" v-slot="{ Component }" v-if="mainViewReady">
<keep-alive :include="keepViews">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
<div class="layout-copy">2016-{{currentYear}} &copy; colorfulsweet 上次更新:{{ version }}</div>
</el-main>
</el-container>
</el-container>
<el-dialog v-model="changePwdModal" title="修改密码" width="420px" @closed="resetPwdForm">
<el-form ref="changePwdForm" :model="changePwdData" :rules="changePwdRules" :label-width="100">
<el-form-item label="旧密码" prop="oldPassword">
<el-input v-model="changePwdData.oldPassword" type="password" show-password />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="changePwdData.newPassword" type="password" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="changePwdData.confirmPassword" type="password" show-password />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="changePwdModal = false" plain>取消</el-button>
<el-button type="primary" @click="submitChangePassword" :loading="changePwdLoading" plain>确定</el-button>
</span>
</template>
</el-dialog>
<el-dialog v-model="aboutModal" title="关于" width="420px">
<el-descriptions :column="1" border>
<el-descriptions-item label="前端版本">
{{ version }}
</el-descriptions-item>
<el-descriptions-item label="后端版本">
{{ backendVersion }}
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</template>
<script lang="ts">
import { Options, Vue } from 'vue-class-component'
import menus from '../config/menu'
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { useStore } from 'vuex'
import { useRouter, useRoute } from 'vue-router'
import allMenus from '../config/menu'
import type { MenuGroup } from '../config/menu'
import http from '@/utils/http'
import { ElMessage } from 'element-plus'
import { VForm } from '@/types'
import { hasPermission } from '@/utils/permission'
@Options({
name: 'Home'
const store = useStore()
const router = useRouter()
const route = useRoute()
const version = __APP_VERSION__
const defaultActiveMenuKey = ref<string | null>(null)
const openMenuNames = ref<string[]>([])
const mainViewReady = ref(false)
const isDark = ref(false)
const aboutModal = ref(false)
const backendVersion = ref('未知')
const menus = computed((): MenuGroup[] => {
return allMenus
.map(group => ({
...group,
child: group.child.filter(item => hasPermission(item.permission))
}))
.filter(group => group.child.length > 0)
})
export default class Home extends Vue{
public version?: string = process.env.VERSION
currentYear = new Date().getFullYear()
// 菜单项
menus = menus
defaultActiveMenuKey: string | null = null
openMenuNames: string[] = []
get realname(): null | string { // 当前用户的显示名称
return this.$store.state.loginInfo.userInfo
? this.$store.state.loginInfo.userInfo.realname : null
function applyDarkMode(dark: boolean) {
isDark.value = dark
document.documentElement.classList.toggle('dark', dark)
}
function toggleDarkMode() {
const next = !isDark.value
applyDarkMode(next)
localStorage.setItem('theme_mode', next ? 'dark' : 'light')
}
// 初始化主题
;(() => {
const saved = localStorage.getItem('theme_mode')
if (saved) {
applyDarkMode(saved === 'dark')
} else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
applyDarkMode(prefersDark)
}
get keepViews(): string[] {
return this.$store.state.tabs
.filter(item => item.name)
.map(item => item.name)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme_mode')) {
applyDarkMode(e.matches)
}
})
})()
const changePwdModal = ref(false)
const changePwdLoading = ref(false)
const changePwdForm = ref<VForm>()
const changePwdData = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const changePwdRules = {
oldPassword: [
{ required: true, message: '请输入旧密码', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 8, max: 16, message: '密码长度8~16位', trigger: 'blur' },
{ pattern: /^(?![\d]+$)(?![a-zA-Z]+$)(?![-=+_.,]+$)[\da-zA-Z-=+_.,]{8,16}$/, message: '密码由字母、数字、特殊字符中的任意两种组成', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{ validator: (_rule: object, value: string, callback: Function) => {
value !== changePwdData.newPassword ? callback(new Error('两次输入的密码不一致')) : callback()
}, trigger: 'blur'
}
]
}
const realname = computed((): null | string => {
return store.state.loginInfo.userInfo
? store.state.loginInfo.userInfo.realname : null
})
const keepViews = computed((): string[] => {
return store.state.tabs
.filter((item: any) => item.name)
.map((item: any) => item.name)
})
// created
defaultActiveMenuKey.value = route.path
if (defaultActiveMenuKey.value) {
let result = /^\/(.*)\//.exec(defaultActiveMenuKey.value)
if (result) {
openMenuNames.value.push(result[1])
}
async created(): Promise<void> {
this.defaultActiveMenuKey = this.$route.path
if(this.defaultActiveMenuKey) {
let result = /^\/(.*)\//.exec(this.defaultActiveMenuKey)
if(result) {
this.openMenuNames.push(result[1])
}
}
if(!this.$store.state.loginInfo.token) {
this.$router.push('/login')
return
}
const data = await this.$http.post<{token: string}, any>('/api/v1/common/verifyToken', {token: this.$store.state.loginInfo.token})
if(data.status) {
// 如果是已过期的token 服务端会签发新的token
this.$store.commit('login', {token: data.newToken || this.$store.state.loginInfo.token, userInfo: data.userInfo})
} else {
this.$router.push('/login')
}
}
if (!store.state.loginInfo.token) {
router.push('/login')
} else {
http.post<{token: string}, any>('/common/verifyToken', {token: store.state.loginInfo.token}).then(data => {
store.commit('login', {userInfo: data.userInfo, permissions: data.permissions})
mainViewReady.value = true
}).catch(() => {
router.push('/login')
})
}
function dropdownMenuCommand(command: string): void {
switch (command) {
case 'home':
router.push('/')
break
case 'about':
openAboutDialog()
break
case 'changePassword':
changePwdModal.value = true
break
case 'logout':
http.post('/common/logout').finally(() => {
store.commit('logout')
router.push('/login')
})
break
}
dropdownMenuCommand(command: string): void {
switch(command) {
case 'home': // 返回首页
this.$router.push('/')
break
case 'changePassword': // 修改密码
// TODO
break
case 'logout': //注销
this.$store.commit('logout')
this.$router.push('/login')
break
}
}
openMenu(path: string): void {
this.$router.push(path)
}
removeTab(path: string): void {
this.$store.commit('removeTab', path)
if (this.$store.state.activeTab === path) { // 关闭的是当前打开的页签
const { tabs } = this.$store.state
this.openMenu(tabs[tabs.length - 1]?.path || '/')
}
}
async function openAboutDialog(): Promise<void> {
aboutModal.value = true
backendVersion.value = '正在获取...'
backendVersion.value = await http.get<never, string>('/system/version')
}
function submitChangePassword(): void {
changePwdForm.value?.validate(async (valid: boolean) => {
if (!valid) return
changePwdLoading.value = true
await http.post('/system/user/changePassword', changePwdData).finally(() => {
changePwdLoading.value = false
})
changePwdModal.value = false
ElMessage.success('密码修改成功')
})
}
function resetPwdForm(): void {
changePwdData.oldPassword = ''
changePwdData.newPassword = ''
changePwdData.confirmPassword = ''
nextTick(() => changePwdForm.value?.clearValidate())
}
function openMenu(path: string): void {
router.push(path)
}
function removeTab(path: string): void {
store.commit('removeTab', path)
if (store.state.activeTab === path) {
const { tabs } = store.state
openMenu(tabs[tabs.length - 1]?.path || '/')
}
}
</script>
+351 -69
View File
@@ -1,80 +1,362 @@
<template>
<div class="login-wrapper">
<h2 class="title">博客管理后台</h2>
<el-form ref="loginForm" :model="userInfo" :rules="ruleValidate" :label-width="80">
<el-form-item label="用户名" prop="username">
<el-input v-model="userInfo.username" @on-enter="login"/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="userInfo.password" type="password" @on-enter="login" />
</el-form-item>
</el-form>
<div class="login-btn">
<el-button type="primary" @click="login">登录</el-button>
<div class="login-page">
<div class="login-card">
<div class="card-left">
<div class="brand">
<div class="brand-icon">✦</div>
<h1>Blog Admin</h1>
<p>简洁 · 高效 · 专注写作</p>
</div>
</div>
<div class="card-right">
<h2 class="form-title">欢迎回来</h2>
<p class="form-subtitle">请登录你的管理账号</p>
<el-form ref="loginForm" :model="userInfo" :rules="ruleValidate" label-position="top">
<el-form-item label="用户名" prop="username">
<el-input
v-model="userInfo.username"
placeholder="请输入用户名"
prefix-icon="User"
size="large"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="userInfo.password"
type="password"
placeholder="请输入密码"
prefix-icon="Lock"
size="large"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-button class="login-btn" type="primary" size="large" :loading="loading" @click="handleLogin" plain>
登 录
</el-button>
</el-form>
</div>
</div>
<el-dialog
v-model="captchaDialogVisible"
class="captcha-dialog"
width="460px"
align-center
destroy-on-close
append-to-body
title="安全验证"
:close-on-click-modal="!loading"
:close-on-press-escape="!loading"
:show-close="!loading"
@closed="handleCaptchaDialogClosed"
>
<p class="captcha-dialog-subtitle">请先完成验证码校验,再继续登录。</p>
<CaptchaPanel
ref="captchaRef"
class="captcha-box"
@close="closeCaptchaDialog"
@data-ready="handleCaptchaSolved"
@init-error="handleCaptchaInitError"
/>
</el-dialog>
</div>
</template>
<script lang="ts">
import { Options, Vue } from 'vue-class-component'
<script setup lang="ts">
import { nextTick, onBeforeUnmount, reactive, ref } from 'vue'
import { useStore } from 'vuex'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { VForm } from "../types"
import type { VForm } from '../types'
import CaptchaPanel from '@/components/CaptchaPanel.vue'
import http from '@/utils/http'
import type { TianAiCaptchaResult } from '@/vendor/tianai-captcha'
@Options({
name: 'Login'
})
export default class Login extends Vue {
userInfo: UserInfo = {
username: null,
password: null
}
ruleValidate = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' }
],
}
created(): void {
this.$store.commit('logout')
this.$store.commit('clearTabs')
localStorage.clear()
}
/**
* 登录
*/
async login() {
(this.$refs.loginForm as VForm).validate(async (valid: boolean) => {
if (!valid) return
const data = await this.$http.post<UserInfo, any>('/api/v1/common/login', this.userInfo)
if(data.token) {
this.$store.commit('login', data)
this.$router.push('/')
} else {
ElMessage.error(data.message)
}
})
}
}
type UserInfo = {
type LoginFormState = {
username: string | null,
password: string | null
};
</script>
<style lang="less" scoped>
.login-wrapper {
width: 400px;
vertical-align: middle;
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
.title {
text-align: center;
margin-bottom: 30px;
}
.login-btn {
text-align: center;
}
type LoginRequest = LoginFormState & {
captchaId?: string,
captchaData?: Record<string, any>
}
type LoginConfigResponse = {
captcha?: {
enabled?: boolean
}
}
type CaptchaPanelRef = {
destroy: () => void
init: () => Promise<void>
reload: () => void
}
const store = useStore()
const router = useRouter()
const loginForm = ref<VForm>()
const captchaRef = ref<CaptchaPanelRef | null>(null)
const captchaDialogVisible = ref(false)
const captchaErrorPattern = /验证码|captcha/i
const userInfo: LoginFormState = reactive({
username: null,
password: null
})
const ruleValidate = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' }
],
}
const loading = ref(false)
// created
store.commit('logout')
store.commit('clearTabs')
localStorage.clear()
async function openCaptchaDialog() {
captchaDialogVisible.value = true
await nextTick()
await captchaRef.value?.init()
}
async function handleLogin() {
loginForm.value?.validate(async (valid: boolean) => {
if (!valid || loading.value) return
loading.value = true
try {
const loginConfig = await http.get<any, LoginConfigResponse>('/common/loginConfig', {
params: {
username: userInfo.username,
}
})
if (loginConfig.captcha?.enabled) {
loading.value = false
await openCaptchaDialog()
return
}
await submitLogin(userInfo)
} catch {
loading.value = false
}
})
}
async function handleCaptchaSolved(result: TianAiCaptchaResult) {
if (loading.value) return
loading.value = true
try {
await submitLogin({
...userInfo,
captchaId: result.id,
captchaData: result.data,
})
} catch (error) {
if (isCaptchaError(error)) {
captchaRef.value?.reload()
} else {
closeCaptchaDialog()
}
} finally {
loading.value = false
}
}
async function submitLogin(payload: LoginRequest) {
const data = await http.post<LoginRequest, any>('/common/login', payload)
closeCaptchaDialog(false)
if (data.token) {
store.commit('login', data)
router.push('/')
}
}
function closeCaptchaDialog(resetLoading = true) {
captchaDialogVisible.value = false
captchaRef.value?.destroy()
if (resetLoading) {
loading.value = false
}
}
function handleCaptchaDialogClosed() {
captchaRef.value?.destroy()
}
function handleCaptchaInitError() {
captchaDialogVisible.value = false
ElMessage.error('验证码初始化失败')
}
function isCaptchaError(error: unknown) {
if (error instanceof Error) {
return captchaErrorPattern.test(error.message)
}
return false
}
onBeforeUnmount(() => captchaRef.value?.destroy())
</script>
<style lang="less" scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 40%, #0f3460 100%);
position: relative;
overflow: hidden;
&::before,
&::after {
content: '';
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.15;
}
&::before {
width: 500px;
height: 500px;
background: #409eff;
top: -100px;
left: -100px;
}
&::after {
width: 400px;
height: 400px;
background: #67c23a;
bottom: -100px;
right: -100px;
}
}
.login-card {
display: flex;
width: 820px;
min-height: 480px;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.5);
position: relative;
z-index: 1;
}
.card-left {
flex: 1;
background: linear-gradient(145deg, #409eff, #0068d4);
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
width: 300px;
height: 300px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
top: -80px;
right: -80px;
}
&::after {
content: '';
position: absolute;
width: 200px;
height: 200px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
bottom: -60px;
left: -60px;
}
.brand {
text-align: center;
color: #fff;
position: relative;
z-index: 1;
.brand-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.9;
}
h1 {
font-size: 28px;
font-weight: 700;
margin: 0 0 12px;
letter-spacing: 2px;
}
p {
font-size: 14px;
opacity: 0.75;
letter-spacing: 3px;
margin: 0;
}
}
}
.card-right {
flex: 1.1;
background: var(--el-bg-color);
padding: 52px 48px;
display: flex;
flex-direction: column;
justify-content: center;
.form-title {
font-size: 26px;
font-weight: 700;
color: var(--el-text-color-primary);
margin: 0 0 8px;
}
.form-subtitle {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0 0 36px;
}
:deep(.el-form-item__label) {
font-weight: 600;
color: var(--el-text-color-regular);
}
.login-btn {
width: 100%;
margin-top: 8px;
height: 46px;
font-size: 16px;
letter-spacing: 4px;
border-radius: 8px;
}
}
.captcha-dialog-subtitle {
margin: 0 0 16px;
color: var(--el-text-color-secondary);
font-size: 14px;
}
.captcha-box {
width: fit-content;
margin: 0 auto;
}
</style>
+118 -31
View File
@@ -1,45 +1,132 @@
<template>
<div>
<div class="nav-list">
<el-row v-for="menu in menus" :key="menu.name">
<el-col :span="3" class="nav-title">{{ menu.title }}</el-col>
<el-col :span="21">
<router-link :to="submenu.path" v-for="submenu in menu.child" :key="submenu.path" class="nav-item">
{{submenu.title}}
<div class="welcome-page">
<div class="welcome-header">
<h2>欢迎回来 👋</h2>
<p>选择下方快捷入口,开始管理你的博客</p>
</div>
<div class="menu-sections">
<div class="menu-section" v-for="menu in menus" :key="menu.name">
<div class="section-title">
<el-icon><component :is="menu.icon" /></el-icon>
<span>{{ menu.title }}</span>
</div>
<div class="section-items">
<router-link
:to="submenu.path"
v-for="submenu in menu.child"
:key="submenu.path"
class="menu-item"
>
<span class="item-label">{{ submenu.title }}</span>
<el-icon class="item-arrow"><ArrowRight /></el-icon>
</router-link>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Options, Vue } from 'vue-class-component'
import menus from '../config/menu'
<script setup lang="ts">
import { computed } from 'vue'
import allMenus from '../config/menu'
import type { MenuGroup } from '../config/menu'
import { hasPermission } from '@/utils/permission'
@Options({
name: 'Welcome'
const menus = computed((): MenuGroup[] => {
return allMenus
.map(group => ({
...group,
child: group.child.filter(item => hasPermission(item.permission))
}))
.filter(group => group.child.length > 0)
})
export default class Welcome extends Vue {
menus = menus
}
</script>
<style lang="less" scoped>
.nav-list {
.nav-title {
font-size: 16px;
line-height: 66px;
text-align: right;
padding-right: 20px;
.welcome-page {
padding: 32px;
max-width: 1000px;
box-sizing: border-box;
}
.welcome-header {
margin-bottom: 32px;
h2 {
font-size: 26px;
font-weight: 700;
color: var(--el-text-color-primary);
margin: 0 0 8px;
}
.nav-item {
display: inline-block;
width: 200px;
font-size: 16px;
margin: 10px;
border: 1px solid #ccc;
padding: 10px;
text-decoration: none;
p {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
}
.menu-sections {
display: flex;
flex-direction: column;
gap: 24px;
}
.menu-section {
background: var(--el-bg-color);
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
border: 1px solid var(--el-border-color-lighter);
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-regular);
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--el-border-color-light);
.el-icon {
font-size: 17px;
color: var(--el-color-primary);
}
}
.section-items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: 8px;
background: var(--el-fill-color-light);
text-decoration: none;
color: var(--el-text-color-primary);
font-size: 14px;
transition: all 0.2s;
border: 1px solid transparent;
.item-arrow {
font-size: 13px;
color: var(--el-text-color-placeholder);
transition: transform 0.2s, color 0.2s;
}
&:hover {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
color: var(--el-color-primary);
.item-arrow {
color: var(--el-color-primary);
transform: translateX(3px);
}
}
}
</style>
+102 -97
View File
@@ -1,11 +1,11 @@
<template>
<div>
<div class="page-wrapper">
<el-form inline :model="search">
<el-form-item label="内容">
<el-input v-model="search.content" />
<el-input v-model="search.hitokoto" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="search.type" multiple collapse-tags>
<el-select v-model="search.types" multiple collapse-tags>
<el-option v-for="item in typeList" :key="item.value" :value="item.value" :label="item.label" />
</el-select>
</el-form-item>
@@ -19,36 +19,34 @@
</el-form-item>
</el-form>
<div class="btn-container">
<el-button type="primary" @click="addModal = true">添加</el-button>
<el-button type="danger" @click="deleteAll">删除</el-button>
<el-button type="primary" @click="addModal = true" v-permission="'hitokoto:save'" plain>添加</el-button>
<el-button type="danger" @click="deleteAll" v-permission="'hitokoto:delete'" plain>删除</el-button>
<div class="search-btn">
<el-button type="primary" @click="loadDataBase(true)" icon="Search">搜索</el-button>
<el-button @click="reset" icon="RefreshLeft">重置</el-button>
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
</div>
</div>
<div class="table-container">
<el-table :data="hitokotoData" v-loading="loading" stripe @selection-change="dataSelect">
<el-table-column type="selection" width="55" />
<el-table-column prop="type" label="类型" width="180">
<template #default="scope">
{{ findTypeText(scope.row.type) }}
</template>
</el-table-column>
<el-table-column prop="hitokoto" label="内容" />
<el-table-column prop="from" label="来自" width="180"/>
<el-table-column prop="creator" label="作者" width="180"/>
<el-table-column prop="number" label="编号" width="70"/>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="scope">
{{ datetimeFormat(scope.row.created_at) }}
</template>
</el-table-column>
</el-table>
</div>
<el-table class="table-container" :data="hitokotoData" v-loading="loading" stripe @selection-change="dataSelect">
<el-table-column type="selection" width="55" />
<el-table-column prop="type" label="类型" width="180">
<template #default="scope">
{{ findTypeText(scope.row.type) }}
</template>
</el-table-column>
<el-table-column prop="hitokoto" label="内容" />
<el-table-column prop="from" label="来自" width="180"/>
<el-table-column prop="creator" label="作者" width="180"/>
<el-table-column prop="number" label="编号" width="70"/>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="scope">
{{ datetimeFormat(scope.row.createdAt) }}
</template>
</el-table-column>
</el-table>
<div class="page-container">
<el-pagination background
:page-sizes="$store.state.pageSizeOpts"
:layout="$store.state.pageLayout"
:page-sizes="store.state.pageSizeOpts"
:layout="store.state.pageLayout"
:current-page="search.pageNum"
:total="total"
@size-change="pageSizeChange"
@@ -59,90 +57,97 @@
<hitokoto-add ref="addForm" :typeList="typeList" :formData="formData" />
<template #footer>
<span class="dialog-footer">
<el-button @click="addModal = false">取消</el-button>
<el-button type="primary" @click="save" :loading="modalLoading">确定</el-button>
<el-button @click="addModal = false" plain>取消</el-button>
<el-button type="primary" @click="save" :loading="modalLoading" plain>确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useStore } from 'vuex'
import HitokotoAdd from './HitokotoAdd.vue'
import { Options, Vue } from 'vue-class-component'
import BaseList from '@/model/baselist'
import { useBaseList } from '@/model/baselist'
import { Page } from '@/model/common.dto'
import HitokotoModel from '@/model/api/hitokoto'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VForm } from '@/types'
let selectedData: string[] = []
@Options({
name: 'Hitokoto',
components: { HitokotoAdd }
})
export default class Hitokoto extends BaseList<HitokotoPage> {
search = new HitokotoPage()
typeList: {label: string, value: string}[] = []
hitokotoData: HitokotoModel[] = []
formData: {[propName:string]: string | null} = {}
addModal: boolean = false
async loadData() {
this.loading = true
const data = await this.$http.get<HitokotoPage, any>('/api/v1/hitokoto/list', {params:this.search})
selectedData = []
this.loading = false
this.total = data.total
this.hitokotoData = data.data
}
async save() {
((this.$refs.addForm as Vue).$refs.hitokotoForm as VForm).validate(async (valid: boolean) => {
if (!valid) return
this.modalLoading = true
const data = await this.$http.post<any, any>('/api/v1/hitokoto/save', this.formData)
this.modalLoading = false
this.addModal = false
ElMessage.success(data.message)
this.loadData()
// 清空表单
this.formData = {}
})
}
deleteAll() {
if(!selectedData.length) {
ElMessage.warning('请选择要删除的数据')
return
}
ElMessageBox.confirm(`是否确认删除选中的${selectedData.length}条数据?`, '确认删除', {type: 'warning'}).then(async () => {
const data = await this.$http.delete<any, any>('/api/v1/hitokoto/delete', {params:{_ids: selectedData}})
ElMessage.success(data.message)
this.loadData()
}).catch(() => {})
}
dataSelect(selection: HitokotoModel[]) {
selectedData = selection.map(item => item._id)
}
created() {
this.loadData()
this.$http.get<never, any>('/api/v1/common/config/hitokoto_type').then(data => {
this.typeList = data
})
}
findTypeText(value: string): string | null {
const type = this.typeList.find(item => item.value === value)
return type ? type.label : null
}
}
import http from '@/utils/http'
class HitokotoPage extends Page {
content?: string
type?: string
hitokoto?: string
types?: string[]
createdAt?: [Date, Date]
reset() {
super.reset()
this.content = undefined
this.type = undefined
this.hitokoto = undefined
this.types = []
this.createdAt = undefined
}
}
const store = useStore()
const { loading, modalLoading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new HitokotoPage())
const typeList = ref<{label: string, value: string}[]>([])
const hitokotoData = ref<HitokotoModel[]>([])
const formData = reactive<{[propName: string]: string | null}>({})
const addModal = ref(false)
const addForm = ref<InstanceType<typeof HitokotoAdd>>()
let selectedData: string[] = []
async function loadData() {
loading.value = true
// 处理日期范围:结束日期设置为 23:59:59
const params = { ...search }
if (params.createdAt && Array.isArray(params.createdAt) && params.createdAt[1]) {
const endDate = new Date(params.createdAt[1])
endDate.setHours(23, 59, 59, 999)
params.createdAt[1] = endDate
}
const data = await http.get<HitokotoPage, any>('/hitokoto/list', {params})
selectedData = []
loading.value = false
total.value = data.total
hitokotoData.value = data.list
}
setLoadData(loadData)
async function save() {
addForm.value?.hitokotoForm?.validate(async (valid: boolean) => {
if (!valid) return
modalLoading.value = true
const data = await http.post<any, any>('/hitokoto/save', formData)
modalLoading.value = false
addModal.value = false
ElMessage.success('保存成功')
loadData()
Object.keys(formData).forEach(key => delete formData[key])
})
}
function deleteAll() {
if (!selectedData.length) {
ElMessage.warning('请选择要删除的数据')
return
}
ElMessageBox.confirm(`是否确认删除选中的${selectedData.length}条数据?`, '确认删除', {type: 'warning'}).then(async () => {
await http.delete<any, any>('/hitokoto/delete', {params: {ids: selectedData}})
ElMessage.success('删除成功')
loadData()
}).catch(() => {})
}
function dataSelect(selection: HitokotoModel[]) {
selectedData = selection.map(item => item._id)
}
function findTypeText(value: string): string | null {
const type = typeList.value.find(item => item.value === value)
return type ? type.label : null
}
// created
loadData()
http.get<never, any>('/common/config/hitokoto_type').then(data => {
typeList.value = data
})
</script>
+19 -20
View File
@@ -18,26 +18,25 @@
</el-form>
</div>
</template>
<script lang="ts">
import { Options, Vue } from 'vue-class-component'
<script setup lang="ts">
import { ref } from 'vue'
import type { VForm } from '@/types'
@Options({
name: 'SystemConfigAdd',
props: {
typeList: Array,
formData: Object
}
})
export default class HitokotoAdd extends Vue {
typeList!: {label: string, value: string}[]
formData!: {[propName:string]: string | null}
ruleValidate = {
hitokoto: [
{ required: true, message: '请输入内容', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择类型', trigger: 'blur' }
],
}
defineProps<{
typeList: {label: string, value: string}[]
formData: {[propName: string]: string | null}
}>()
const hitokotoForm = ref<VForm>()
const ruleValidate = {
hitokoto: [
{ required: true, message: '请输入内容', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择类型', trigger: 'blur' }
],
}
defineExpose({ hitokotoForm })
</script>
+399 -277
View File
@@ -1,22 +1,19 @@
<template>
<div>
<div class="page-wrapper">
<el-form inline :model="search">
<el-form-item label="名称">
<el-input v-model="search.name" />
<el-form-item label="名称/标题">
<el-input v-model="search.title" />
</el-form-item>
<el-form-item label="所属歌单">
<el-select v-model="search.lib_id" multiple collapse-tags>
<el-select v-model="search.libIds" multiple collapse-tags clearable>
<el-option v-for="musicLib in musicLibs" :key="musicLib._id" :value="musicLib._id" :label="musicLib.name" />
</el-select>
</el-form-item>
<el-form-item label="文件类型">
<el-select v-model="search.ext" multiple >
<el-select v-model="search.exts" multiple collapse-tags clearable>
<el-option v-for="ext in exts" :key="ext" :value="ext" :label="ext" />
</el-select>
</el-form-item>
<el-form-item label="标题">
<el-input v-model="search.title" />
</el-form-item>
<el-form-item label="唱片集">
<el-input v-model="search.album" />
</el-form-item>
@@ -26,61 +23,65 @@
</el-form>
<div class="btn-container">
<el-button type="success" plain icon="VideoPlay" @click="playMusic">播放</el-button>
<el-button type="primary" icon="Upload" @click="openUploadModal">上传音乐</el-button>
<el-button type="primary" icon="Upload" @click="openUploadModal" v-permission="'music:save'" plain>上传音乐</el-button>
<el-button type="warning" plain icon="Notebook" @click="libDrawerVisible = true" v-permission="'music:save'">歌单管理</el-button>
<div class="search-btn">
<el-button type="primary" @click="loadDataBase(true)" icon="Search">搜索</el-button>
<el-button @click="reset" icon="RefreshLeft">重置</el-button>
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
</div>
</div>
<div class="table-container">
<el-table :data="musicData" v-loading="loading" stripe @selection-change="dataSelect">
<el-table-column type="selection" width="55" />
<el-table-column prop="name" label="名称" show-overflow-tooltip role=""/>
<el-table-column prop="ext" label="类型" width="100" />
<el-table-column prop="size" label="文件大小" width="150">
<template #default="scope">
{{ prettyBytes(scope.row.size) }}
<el-table class="table-container" :data="musicData" v-loading="loading" stripe @selection-change="dataSelect">
<el-table-column type="selection" width="55" />
<el-table-column prop="name" label="名称" show-overflow-tooltip role=""/>
<el-table-column prop="ext" label="类型" width="80" />
<el-table-column prop="size" label="文件大小" width="110">
<template #default="scope">
{{ prettyBytes(scope.row.size) }}
</template>
</el-table-column>
<el-table-column prop="time" label="时长" width="110">
<template #default="scope">
{{ formatDuration(scope.row.time) }}
</template>
</el-table-column>
<el-table-column prop="title" label="标题" show-overflow-tooltip />
<el-table-column prop="album" label="唱片集" />
<el-table-column prop="artist" label="艺术家" />
<el-table-column prop="libId" label="所属歌单" >
<template #default="scope">
<template v-if="scope.row.isEditing && currentRow">
<el-select v-model="currentRow.libId">
<el-option v-for="musicLib in musicLibs" :key="musicLib._id" :value="musicLib._id" :label="musicLib.name" />
</el-select>
<el-button link icon="Check" type="primary" @click="saveMusicLib(scope.row)"></el-button>
<el-button link icon="Close" type="primary" @click="scope.row.isEditing = false"></el-button>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" show-overflow-tooltip />
<el-table-column prop="album" label="唱片集" />
<el-table-column prop="artist" label="艺术家" />
<el-table-column prop="lib_id" label="所属歌单" >
<template #default="scope">
<template v-if="scope.row.isEditing && currentRow">
<el-select v-model="currentRow.lib_id">
<el-option v-for="musicLib in musicLibs" :key="musicLib._id" :value="musicLib._id" :label="musicLib.name" />
</el-select>
<el-button link icon="Check" type="primary" @click="saveMusicLib(scope.row)"></el-button>
<el-button link icon="Close" type="primary" @click="scope.row.isEditing = false"></el-button>
</template>
<template v-else>
{{ findMusicLib(scope.row.lib_id) }}
<el-button link icon="Edit" type="primary" @click="updateLib(scope.row)"></el-button>
</template>
<template v-else>
{{ findMusicLib(scope.row.libId) }}
<el-button link icon="Edit" type="primary" @click="updateLib(scope.row)"></el-button>
</template>
</el-table-column>
<el-table-column prop="lyric_id" label="歌词" width="120" >
<template #default="scope">
<div style="width: 18px">
<Check v-if="scope.row.lyric_id" />
<Close v-else/>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="230" >
<template #default="scope">
<el-button link icon="Document" @click="updateLyric(scope.row)" title="歌词"></el-button>
<el-button link icon="Download" @click="download(scope.row)" title="下载"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除"></el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="lyricId" label="歌词" width="80" >
<template #default="scope">
<div style="width: 18px">
<Check v-if="scope.row.lyricId" />
<Close v-else/>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="150" >
<template #default="scope">
<el-button link icon="Document" @click="updateLyric(scope.row)" title="歌词" v-permission="'music:save'"></el-button>
<el-button link icon="Download" @click="download(scope.row)" title="下载"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除" v-permission="'music:delete'"></el-button>
</template>
</el-table-column>
</el-table>
<div class="page-container">
<el-pagination background
:page-sizes="$store.state.pageSizeOpts"
:layout="$store.state.pageLayout"
:page-sizes="store.state.pageSizeOpts"
:layout="store.state.pageLayout"
:current-page="search.pageNum"
:total="total"
@size-change="pageSizeChange"
@@ -88,9 +89,9 @@
</el-pagination>
</div>
<el-dialog v-model="modifyLyricModal" title="编辑歌词" :width="600" >
<el-form ref="lyricForm" :model="lyricFormData" :rules="lyricRuleValidate" :label-width="120">
<el-form-item label="网易云ID" prop="cloud_id">
<el-input v-model="lyricFormData.cloud_id" />
<el-form v-loading="lyricLoading" ref="lyricForm" :model="lyricFormData" :rules="lyricRuleValidate" :label-width="120">
<el-form-item label="网易云ID" prop="cloudId">
<el-input v-model="lyricFormData.cloudId" />
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input v-model="lyricFormData.name" />
@@ -101,270 +102,391 @@
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="modifyLyricModal = false">取消</el-button>
<el-button type="primary" @click="saveLyric" :loading="modalLoading">确定</el-button>
<el-button @click="modifyLyricModal = false" plain>取消</el-button>
<el-button type="primary" @click="saveLyric" :loading="modalLoading" plain>确定</el-button>
</span>
</template>
</el-dialog>
<el-dialog v-model="uploadModal" title="上传音乐" :width="550" @closed="uploadModalClosed" >
<el-form :label-width="80">
<el-form-item label="歌单">
<el-select v-model="libIdSelected">
<el-form ref="uploadForm" :model="uploadFormData" :rules="uploadRules" :label-width="80">
<el-form-item label="歌单" prop="libId">
<el-select v-model="uploadFormData.libId">
<el-option v-for="musicLib in musicLibs" :key="musicLib._id" :value="musicLib._id" :label="musicLib.name" />
</el-select>
</el-form-item>
<el-upload
ref="musicUpload"
action="/api/v2/music/upload"
:action="`${apiBase}/music/upload`"
name="file"
accept=".mp3,.flac"
:headers="{token: $store.state.loginInfo.token}"
:accept="uploadAccept"
:headers="{token: store.state.loginInfo.token}"
:on-success="uploadSuccess"
:on-error="uploadError"
:auto-upload="false"
multiple
drag
:data="{libId: libIdSelected}">
:data="{libId: uploadFormData.libId}">
<div class="el-upload__text">
拖拽到此处或<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
可选择 mp3/flac 文件
<div class="el-upload__tip" style="display: flex; align-items: center; gap: 5px;">
<span>支持上传的文件类型</span>
<el-tag v-for="ext in uploadExtList" :key="ext" size="small">
{{ ext }}<span v-if="ext === 'ncm'"> (自动解码)</span>
</el-tag>
</div>
</template>
</el-upload>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="uploadModal = false">取消</el-button>
<el-button type="primary" @click="uploadMusic" >开始上传</el-button>
<el-button @click="uploadModal = false" plain>取消</el-button>
<el-button type="primary" @click="uploadMusic" plain>开始上传</el-button>
</span>
</template>
</el-dialog>
<el-drawer v-model="musicPlaying" :close-on-click-modal="false" size="40%" title="播放音乐">
<a-player v-if="musicPlaying" ref="player" autoplay showLrc :list="musicList" v-model:music="currentMusic" @play="musicPlay"/>
<el-drawer v-model="libDrawerVisible" title="歌单管理" size="900px">
<el-button type="primary" icon="Plus" @click="addLibRow" style="margin-bottom: 12px" plain>添加歌单</el-button>
<el-table :data="libTableData" stripe>
<el-table-column prop="name" label="名称">
<template #default="scope">
<el-input v-if="scope.row._isNew" v-model="scope.row.name" placeholder="歌单名称" />
<span v-else>{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="path" label="路径">
<template #default="scope">
<el-input v-if="scope.row._isNew" v-model="scope.row.path" placeholder="music/歌单名/" />
<span v-else>{{ scope.row.path }}</span>
</template>
</el-table-column>
<el-table-column prop="musicCount" label="歌曲数量" width="100" />
<el-table-column label="操作" width="160">
<template #default="scope">
<el-button v-if="scope.row._isNew" link type="primary" icon="Check" @click="saveLib(scope.row)" :loading="libSaving">保存</el-button>
<el-button v-if="scope.row._isNew" link icon="Close" @click="cancelAddLib">取消</el-button>
<el-button v-if="!scope.row._isNew" link type="danger" icon="Delete" @click="removeLib(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
<el-drawer v-model="musicPlaying" :close-on-click-modal="false" size="1200px" title="播放音乐" class="music-drawer">
<music-player-panel
v-if="musicPlaying && currentMusic"
ref="player"
autoplay
:music="currentMusic"
:list="musicList"
@update:music="currentMusic = $event"
@play="musicPlay"
@toggle="player?.toggle()"
@prev="switchPrev"
@next="switchNext"
@toggle-shuffle="player && (player.shuffle = !player.shuffle)"
@next-mode="cycleRepeatMode"
@toggle-mute="togglePlayerMute"
@select-song="onSelectSong"
/>
</el-drawer>
</div>
</template>
<script lang="ts">
import { Options } from 'vue-class-component'
import BaseList from '@/model/baselist'
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useStore } from 'vuex'
import { useBaseList } from '@/model/baselist'
import { MsgResult, Page } from '@/model/common.dto'
import { ElUpload, ElMessage, ElMessageBox } from 'element-plus'
import { UploadInstance, ElMessage, ElMessageBox } from 'element-plus'
import { MusicModel, MusicLibModel, MusicLyricModel, MusicPlayerItem } from '@/model/api/music'
import APlayer from './aplayer/vue-aplayer.vue'
import MusicPlayerPanel from './aplayer/music-player-panel.vue'
import prettyBytes from 'pretty-bytes'
import { VForm } from '@/types'
let selectedIds: string[] = []
@Options({
name: 'Music',
components: { ElUpload, APlayer }
})
export default class Music extends BaseList<MusicPage> {
search = new MusicPage()
currentRow: MusicModel | null = null
libIdSelected: string | null = null
exts: string[] = []
musicLibs: MusicLibModel[] = []
musicData: MusicModel[] = []
uploadModal: boolean = false
modifyLyricModal: boolean = false
lyricRuleValidate = {
cloud_id: [
{ required: true, message: '请输入网易云ID', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入名称', trigger: 'blur' }
],
lyric: [
{ required: true, message: '请输入歌词正文', trigger: 'blur' }
],
}
prettyBytes = prettyBytes
lyricFormData: MusicLyricModel = {}
// 是否正在播放音乐
musicPlaying: boolean = false
musicList: MusicPlayerItem[] = []
currentMusic?: MusicPlayerItem
created() {
this.$http.get<never, any>('/api/v1/music/listLibs').then(data => {
this.musicLibs = data
this.loadData()
})
this.$http.get<never, any>('/api/v1/music/listExts').then(data => {
this.exts = data
})
}
async loadData() {
this.loading = true
const data = await this.$http.get<MusicPage, any>('/api/v1/music/list', {params: this.search})
selectedIds = []
this.loading = false
this.total = data.total
this.musicData = data.data
}
dataSelect(selection: MusicModel[]) {
selectedIds = selection.map(item => item._id)
}
findMusicLib(value: string): string | null {
const musicLib = this.musicLibs.find(item => item._id === value)
return musicLib ? musicLib.name : null
}
// 根据当前搜索条件播放音乐
async playMusic() {
try {
const data = await this.$http.get<any, any>('/api/v1/music/list/all', {params: selectedIds.length ? {ids: selectedIds} : this.search})
this.musicList = data.map((item: MusicModel, index: number) => {
const musicItem: MusicPlayerItem = {
id: index,
title: item.title || item.name,
artist: item.artist,
album: item.album,
src: `/api/v2/common/music/load/${item._id}`,
pic: `/api/v2/common/music/album/${item._id}`,
}
if(item.lyric_id) {
musicItem.lrc = `${location.origin}/api/v2/common/music/lyric/${item.lyric_id}`
}
return musicItem
})
this.currentMusic = this.musicList[0]
this.musicPlaying = true
} catch (err) {
console.error(err)
ElMessage.error('获取播放列表失败')
}
}
updateLib(row: MusicModel) {
this.currentRow = { ...row }
row.isEditing = true
}
download(row: MusicModel) {
const link = document.createElement('a')
link.setAttribute('href', `/api/v2/common/music/load/${row._id}`)
link.setAttribute('download', row.name)
link.setAttribute('target', '_blank')
link.click()
}
remove(row: MusicModel) {
ElMessageBox.confirm(`是否确认删除 ${row.name} ?`, '确认删除', {type: 'warning'}).then(async () => {
const data = await this.$http.delete<{params: {id: string}}, any>('/api/v2/music/delete', {params: {id: row._id}})
ElMessage.success(data.message)
this.loadData()
}).catch(() => {})
}
async updateLyric(row: MusicModel) {
this.currentRow = { ...row }
this.modifyLyricModal = true
if (row.lyric_id) {
const data = (await this.$http.get<any, any>('/api/v1/music/lyric/get', {params: {lyricId: row.lyric_id}}))
data.cloud_id = data.cloud_id ? data.cloud_id.toString() : null
this.lyricFormData = data
} else {
this.lyricFormData = {}
}
}
async saveLyric() {
(this.$refs.lyricForm as VForm).validate(async (valid: boolean) => {
if (!valid) return
this.modalLoading = true
const data = await this.$http.post<MusicLyricModel, any>(`/api/v1/music/lyric/save?musicId=${this.currentRow ? this.currentRow._id : ''}`, this.lyricFormData)
this.modalLoading = false
this.modifyLyricModal = false
ElMessage.success(data.message)
this.loadData()
// 清空表单
this.lyricFormData = {}
})
}
async saveMusicLib(row: MusicModel) {
if (!this.currentRow) return
const data = await this.$http.post<{id: string, libId: string}, any>('/api/v2/music/updateLib', {id: this.currentRow._id, libId: this.currentRow.lib_id})
ElMessage.success(data.message)
row.lib_id = this.currentRow.lib_id
row.isEditing = false
}
openUploadModal() {
this.uploadModal = true
this.libIdSelected = null
}
async uploadMusic() {
if (!this.libIdSelected) {
ElMessage.warning('请选择歌单')
return
}
// 执行上传
(this.$refs.musicUpload as typeof ElUpload).submit()
}
uploadSuccess(response: MsgResult) {
if(response.code === 0) {
ElMessage.success(response.message)
this.loadData()
} else {
ElMessage.warning(response.message)
}
}
uploadError(error: Error) {
ElMessage.error(error.message)
}
uploadModalClosed() {
(this.$refs.musicUpload as typeof ElUpload).clearFiles()
}
/**
* 创建媒体信息
*/
musicPlay() {
if(!('mediaSession' in window.navigator) || !this.currentMusic) return;
const player = <any>this.$refs.player
const currentId = this.currentMusic.id
navigator.mediaSession.metadata = new MediaMetadata({
title: this.currentMusic.title,
artist: this.currentMusic.artist,
album: this.currentMusic.album,
artwork: [{src: location.origin + this.currentMusic.pic}]
})
navigator.mediaSession.setActionHandler('play', () => { // 播放
player.play()
})
navigator.mediaSession.setActionHandler('pause', () => { // 暂停
player.pause()
})
navigator.mediaSession.setActionHandler('previoustrack', () => { // 上一首
if (currentId === 0) { // 已经是第一首
player.switch(this.musicList.length - 1)
} else {
player.switch(currentId - 1)
}
})
navigator.mediaSession.setActionHandler('nexttrack', () => { // 下一首
if (currentId === this.musicList.length - 1) { // 已经是最后一首
player.switch(0)
} else {
player.switch(currentId + 1)
}
})
}
// 格式化秒为 mm:ss
function formatDuration(seconds?: number): string {
if (!seconds || isNaN(seconds)) return '00:00'
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
import type { VForm } from '@/types'
import http from '@/utils/http'
class MusicPage extends Page {
name?: string
ext: string[] = []
exts: string[] = []
title?: string
album?: string
artist?: string
lib_id?: string[] = []
libIds?: string[] = []
reset() {
super.reset()
this.name = undefined
this.ext = []
this.exts = []
this.title = undefined
this.album = undefined
this.artist = undefined
this.lib_id = []
this.libIds = []
}
}
const store = useStore()
const apiBase = import.meta.env.VITE_APP_API_BASE
const { loading, modalLoading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange } = useBaseList(new MusicPage())
const currentRow = ref<MusicModel | null>(null)
const uploadFormData = ref<{ libId: string | null }>({ libId: null })
const uploadForm = ref<VForm>()
const exts = ref<string[]>([])
const musicLibs = ref<MusicLibModel[]>([])
const musicData = ref<MusicModel[]>([])
const uploadModal = ref(false)
const modifyLyricModal = ref(false)
const lyricFormData = ref<MusicLyricModel>({})
const musicPlaying = ref(false)
const musicList = ref<MusicPlayerItem[]>([])
const currentMusic = ref<MusicPlayerItem>()
const lyricForm = ref<VForm>()
const musicUpload = ref<UploadInstance>()
const player = ref<InstanceType<typeof MusicPlayerPanel>>()
const lyricLoading = ref(false)
const libDrawerVisible = ref(false)
const libTableData = ref<(MusicLibModel & { _isNew?: boolean })[]>([])
const libSaving = ref(false)
const uploadRules = {
libId: [{ required: true, message: '请选择歌单', trigger: 'change' }]
}
const uploadExtList = computed(() => {
return Array.from(new Set([...exts.value, 'ncm']))
})
const uploadAccept = computed(() => {
return uploadExtList.value.map(ext => `.${ext}`).join(',')
})
const lyricRuleValidate = {
cloudId: [
{ required: true, message: '请输入网易云ID', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入名称', trigger: 'blur' }
],
lyric: [
{ required: true, message: '请输入歌词正文', trigger: 'blur' }
],
}
let selectedIds: string[] = []
watch(() => currentMusic.value, async (newMusic) => {
if (newMusic?.lyricId && !newMusic?.lrc) {
const { lyric } = await http.get<any, any>('/music/lyric/get', {params: {lyricId: newMusic.lyricId}})
newMusic.lrc = lyric
}
})
async function loadData() {
loading.value = true
const data = await http.get<MusicPage, any>('/music/list', {params: search})
selectedIds = []
loading.value = false
total.value = data.total
musicData.value = data.list
}
setLoadData(loadData)
function dataSelect(selection: MusicModel[]) {
selectedIds = selection.map(item => item._id)
}
function findMusicLib(value: string): string | null {
const musicLib = musicLibs.value.find(item => item._id === value)
return musicLib ? musicLib.name : null
}
async function playMusic() {
try {
const data = await http.get<any, any>('/music/list/all', {params: selectedIds.length ? {ids: selectedIds} : search})
musicList.value = data.map((item: MusicModel, index: number) => {
const musicItem: MusicPlayerItem = {
id: index,
title: item.title || item.name,
artist: item.artist,
album: item.album,
src: `${apiBase}/common/music/load/${item._id}`,
pic: `${apiBase}/common/music/album/${item._id}`,
lyricId: item.lyricId,
}
return musicItem
})
currentMusic.value = musicList.value[0]
musicPlaying.value = true
} catch (err) {
console.error(err)
ElMessage.error('获取播放列表失败')
}
}
function updateLib(row: MusicModel) {
currentRow.value = { ...row }
row.isEditing = true
}
function download(row: MusicModel) {
const link = document.createElement('a')
link.setAttribute('href', `${apiBase}/common/music/load/${row._id}`)
link.setAttribute('download', row.name)
link.setAttribute('target', '_blank')
link.click()
}
function remove(row: MusicModel) {
ElMessageBox.confirm(`是否确认删除 ${row.name} ?`, '确认删除', {type: 'warning'}).then(async () => {
await http.delete<{params: {id: string}}, any>('/music/delete', {params: {id: row._id}})
ElMessage.success("删除成功")
loadData()
}).catch(() => {})
}
async function updateLyric(row: MusicModel) {
currentRow.value = { ...row }
lyricFormData.value = {}
modifyLyricModal.value = true
if (row.lyricId) {
lyricLoading.value = true
try {
const data = await http.get<any, any>('/music/lyric/get', {params: {lyricId: row.lyricId}})
data.cloudId = data.cloudId ? data.cloudId.toString() : null
lyricFormData.value = data
} finally {
lyricLoading.value = false
}
}
}
async function saveLyric() {
lyricForm.value?.validate(async (valid: boolean) => {
if (!valid) return
modalLoading.value = true
await http.post<MusicLyricModel, any>(`/music/lyric/save?musicId=${currentRow.value ? currentRow.value._id : ''}`, lyricFormData.value)
modalLoading.value = false
modifyLyricModal.value = false
ElMessage.success("歌词保存成功")
loadData()
lyricFormData.value = {}
})
}
async function saveMusicLib(row: MusicModel) {
if (!currentRow.value) return
await http.post<{id: string, libId: string}, any>('/music/lib/update', {id: currentRow.value._id, libId: currentRow.value.libId})
ElMessage.success("歌单更新成功")
row.libId = currentRow.value.libId
row.isEditing = false
}
function openUploadModal() {
uploadModal.value = true
uploadFormData.value = { libId: null }
}
async function uploadMusic() {
uploadForm.value?.validate((valid: boolean) => {
if (!valid) return
musicUpload.value?.submit()
})
}
function uploadSuccess(response: MsgResult) {
if (response.code === 0) {
ElMessage.success('上传成功')
loadData()
} else {
ElMessage.warning(response.message || '上传失败')
}
}
function uploadError(error: Error) {
ElMessage.error(error.message)
}
function uploadModalClosed() {
musicUpload.value?.clearFiles()
}
function musicPlay() {
if (!('mediaSession' in window.navigator) || !currentMusic.value) return
const currentId = currentMusic.value.id
navigator.mediaSession.metadata = new MediaMetadata({
title: currentMusic.value.title,
artist: currentMusic.value.artist,
album: currentMusic.value.album,
artwork: [{src: location.origin + currentMusic.value.pic}]
})
navigator.mediaSession.setActionHandler('play', () => {
void player.value?.play()
})
navigator.mediaSession.setActionHandler('pause', () => {
player.value?.pause()
})
navigator.mediaSession.setActionHandler('previoustrack', () => {
switchPrev()
})
navigator.mediaSession.setActionHandler('nexttrack', () => {
switchNext()
})
}
function switchPrev() {
if (!currentMusic.value) return
const idx = musicList.value.indexOf(currentMusic.value)
const prevIdx = idx <= 0 ? musicList.value.length - 1 : idx - 1
player.value?.switch(prevIdx)
}
function switchNext() {
if (!currentMusic.value) return
const idx = musicList.value.indexOf(currentMusic.value)
const nextIdx = idx >= musicList.value.length - 1 ? 0 : idx + 1
player.value?.switch(nextIdx)
}
function cycleRepeatMode() {
if (!player.value) return
if (player.value.repeatMode === 'no_repeat') player.value.repeatMode = 'repeat_all'
else if (player.value.repeatMode === 'repeat_all') player.value.repeatMode = 'repeat_one'
else player.value.repeatMode = 'no_repeat'
}
function togglePlayerMute() {
if (!player.value?.audio) return
const audio = player.value.audio as unknown as HTMLAudioElement
audio.muted = !audio.muted
}
function onSelectSong(song: MusicPlayerItem) {
const idx = musicList.value.indexOf(song)
if (idx >= 0) player.value?.switch(idx)
}
function loadLibs() {
return http.get<never, any>('/music/lib/list').then(data => {
musicLibs.value = data
libTableData.value = data.map((item: MusicLibModel) => ({ ...item }))
})
}
function addLibRow() {
if (libTableData.value.some(item => (item as any)._isNew)) return
libTableData.value.unshift({ _id: '', name: '', path: '', musicCount: 0, _isNew: true })
}
function cancelAddLib() {
libTableData.value = libTableData.value.filter(item => !(item as any)._isNew)
}
async function saveLib(row: MusicLibModel & { _isNew?: boolean }) {
if (!row.name || !row.path) {
ElMessage.warning('请输入歌单名称和路径')
return
}
libSaving.value = true
try {
await http.post<any, any>('/music/lib/add', { name: row.name, path: row.path })
ElMessage.success('歌单创建成功')
await loadLibs()
} finally {
libSaving.value = false
}
}
function removeLib(row: MusicLibModel) {
ElMessageBox.confirm(`是否确认删除歌单「${row.name}」?`, '确认删除', { type: 'warning' }).then(async () => {
await http.delete<any, any>('/music/lib/delete', { params: { id: row._id } })
ElMessage.success('歌单删除成功')
await loadLibs()
}).catch(() => {})
}
// created
loadLibs().then(() => {
loadData()
})
http.get<never, any>('/music/listExts').then(data => {
exts.value = data
})
</script>
+416 -151
View File
@@ -1,171 +1,436 @@
<template>
<div>
<el-alert type="info" show-icon :closable="false" style="margin-bottom: 10px" >
<template #title>上传要求</template>
图片格式为{{allowUploadExt.join('、')}},文件大小不超过10MB。
</el-alert>
<el-form inline :model="search">
<el-form-item label="文件名">
<el-input v-model="search.name" />
</el-form-item>
<el-form-item label="宽度" >
<el-input v-model="search.widthMin" type="number" style="vertical-align: middle;" >
<template #prepend>≥</template>
</el-input>
</el-form-item>
<el-form-item>
<el-input v-model="search.widthMax" type="number" style="vertical-align: middle;" >
<template #prepend>≤</template>
</el-input>
</el-form-item>
<el-form-item label="高度" >
<el-input v-model="search.heightMin" type="number" style="vertical-align: middle;" >
<template #prepend>≥</template>
</el-input>
</el-form-item>
<el-form-item>
<el-input v-model="search.heightMax" type="number" style="vertical-align: middle;" >
<template #prepend>≤</template>
</el-input>
</el-form-item>
</el-form>
<div class="btn-container">
<el-upload
action="/api/v2/photoWall/upload"
accept="image/jpeg,image/png"
name="image"
:headers="{token: $store.state.loginInfo.token}"
:before-upload="beforeUpload"
:on-success="uploadSuccess"
:on-error="uploadError"
auto-upload
:show-file-list="false"
style="display: inline-block;margin-right: 10px;">
<el-button type="primary" icon="Upload" :loading="isUploading">上传图片</el-button>
</el-upload>
<el-button type="danger" @click="deleteAll" style="vertical-align: bottom">批量删除</el-button>
<div class="search-btn">
<el-button type="primary" @click="loadDataBase(true)" icon="Search">搜索</el-button>
<el-button @click="reset" icon="RefreshLeft">重置</el-button>
<div class="page-wrapper" ref="rootEl">
<!-- 操作栏 -->
<div class="btn-container">
<el-upload
:action="`${apiBase}/photoWall/upload`"
accept="image/jpeg,image/png"
name="image"
:headers="{ token: store.state.loginInfo.token }"
:before-upload="beforeUpload"
:on-success="uploadSuccess"
:on-error="uploadError"
auto-upload
:show-file-list="false"
style="margin-right: 10px;">
<el-tooltip :content="`图片格式为${allowUploadExt.join('、')},文件大小不超过10MB。`">
<el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'photoWall:save'" plain>上传图片</el-button>
</el-tooltip>
</el-upload>
<el-button
v-if="selected.size > 0"
type="danger"
icon="Delete"
plain
v-permission="'photoWall:delete'"
@click="deleteSelected">
删除选中 ({{ selected.size }})
</el-button>
<div class="search-btn">
<el-button link type="primary" :disabled="!photos.length" @click="selectAll" v-if="!isAllSelected">全选</el-button>
<el-button link type="primary" @click="clearSelect" v-else>取消全选</el-button>
<el-button link type="primary" :disabled="!photos.length" @click="invertSelect">反选</el-button>
</div>
</div>
</div>
<div class="table-container">
<el-table :data="photowallData" v-loading="loading" stripe @selection-change="dataSelect">
<el-table-column type="selection" width="55" />
<el-table-column prop="name" label="文件名" />
<el-table-column prop="md5" label="md5" width="300" />
<el-table-column prop="thumbnail" label="缩略图" />
<el-table-column prop="width" label="宽度" width="70" />
<el-table-column prop="height" label="高度" width="70" />
<el-table-column label="操作" width="100" >
<template #default="scope">
<el-button link icon="View" plain @click="preview(scope.row)" title="预览"></el-button>
</template>
</el-table-column>
</el-table>
<!-- 瀑布流(可滚动区域) -->
<div class="masonry-scroll">
<div class="masonry-scene">
<div
v-for="(col, ci) in columns"
:key="ci"
class="masonry-col">
<div
v-for="item in col"
:key="item._id"
class="card-wrap"
:class="{ 'is-selected': selected.has(item._id), 'is-entered': entered.has(item._id) }"
@click="toggleSelect(item)">
<div class="card-inner">
<!-- 图片 -->
<img
class="card-img"
:src="cdnBase ? `${cdnBase}/${item.thumbnail || item.name}` : ''"
:alt="item.name"
loading="lazy"
draggable="false" />
<!-- 选中角标 -->
<div class="check-badge">
<el-icon><Check /></el-icon>
</div>
<!-- Hover 浮层 -->
<div class="card-overlay">
<div class="overlay-info">
<p class="overlay-name" :title="item.name">{{ item.name }}</p>
<p class="overlay-size">{{ item.width }} × {{ item.height }}</p>
</div>
<el-button-group class="overlay-actions">
<el-button size="small" icon="View" plain @click.stop="preview(item)">预览</el-button>
<el-button
size="small"
icon="Delete"
type="danger"
plain
v-permission="'photoWall:delete'"
@click.stop="deleteSingle(item)">
删除
</el-button>
</el-button-group>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="page-container">
<el-pagination
background
:page-sizes="store.state.pageSizeOpts"
:layout="store.state.pageLayout"
:current-page="search.pageNum"
:page-size="search.limit"
:total="total"
@size-change="pageSizeChange"
@current-change="pageChange" />
</div>
<!-- 图片预览 -->
<el-image-viewer
v-if="previewVisible"
:url-list="previewUrls"
:initial-index="previewIndex"
@close="previewVisible = false" />
</div>
<div class="page-container">
<el-pagination background
:page-sizes="$store.state.pageSizeOpts"
:layout="$store.state.pageLayout"
:current-page="search.pageNum"
:total="total"
@size-change="pageSizeChange"
@current-change="pageChange">
</el-pagination>
</div>
</div>
</template>
<script lang="ts">
import { Options } from 'vue-class-component'
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useStore } from 'vuex'
import { ElMessage, ElMessageBox } from 'element-plus'
import { MsgResult, Page } from '@/model/common.dto'
import BaseList from '@/model/baselist'
import PhotoWallModel from '@/model/api/photowall'
import { h } from 'vue'
import type { MsgResult } from '@/model/common.dto'
import { Page } from '@/model/common.dto'
import { useBaseList } from '@/model/baselist'
import type PhotoWallModel from '@/model/api/photowall'
import http from '@/utils/http'
let selectedData: string[] = []
@Options({
name: 'PhotoWall'
// ─── 常量 ─────────────────────────────────────────────
const COLUMN_WIDTH = 220 // 每列目标宽度(px)
const COL_GAP = 14 // 列间距(px)
// ─── 基础状态 ─────────────────────────────────────────
const store = useStore()
const apiBase = import.meta.env.VITE_APP_API_BASE
const rootEl = ref<HTMLElement | null>(null)
const photos = ref<PhotoWallModel[]>([])
const isUploading = ref(false)
const allowUploadExt = ['jpg', 'jpeg', 'png']
class GalleryPage extends Page {
constructor() { super(); this.limit = 50 }
}
const { loading, total, search, setLoadData, pageChange, pageSizeChange } = useBaseList(new GalleryPage())
const cdnBase = ref('')
const selected = ref<Set<string>>(new Set())
const entered = ref<Set<string>>(new Set())
const previewVisible = ref(false)
const previewUrls = ref<string[]>([])
const previewIndex = ref(0)
// ─── 瀑布流分列 ───────────────────────────────────────
const colCount = ref(4)
function recalcCols() {
if (!rootEl.value) return
const w = rootEl.value.clientWidth - 32
colCount.value = Math.max(1, Math.floor((w + COL_GAP) / (COLUMN_WIDTH + COL_GAP)))
}
const columns = computed<PhotoWallModel[][]>(() => {
const cols: PhotoWallModel[][] = Array.from({ length: colCount.value }, () => [])
photos.value.forEach((item, i) => {
cols[i % colCount.value].push(item)
})
return cols
})
export default class PhotoWall extends BaseList<PhotoWallPage> {
search = new PhotoWallPage()
allowUploadExt = ['jpg','jpeg','png']
photowallData = []
isUploading: boolean = false
async loadData() {
this.loading = true
const data = await this.$http.get<PhotoWallPage, any>('/api/v1/photowall/list', {params:this.search})
selectedData = []
this.loading = false
this.total = data.total
this.photowallData = data.data
}
deleteAll() {
if(!selectedData || !selectedData.length) {
ElMessage.warning('请选择要删除的数据')
return
// ─── 数据加载 ─────────────────────────────────────────
async function loadData() {
loading.value = true
entered.value = new Set()
try {
if (!cdnBase.value) {
cdnBase.value = await http.get('/common/config/picture_cdn')
}
ElMessageBox.confirm(`是否确认删除选中的${selectedData.length}条数据?`, '确认删除', {type: 'warning'}).then(async () => {
await this.$http.delete('/api/v1/photowall/delete', {params:{_ids: selectedData}})
ElMessage.success('删除成功')
this.loadData()
}).catch(() => {})
const data = await http.get<any, any>('/photoWall/list', {
params: { pageNum: search.pageNum, limit: search.limit }
})
total.value = data.total
photos.value = data.list as PhotoWallModel[]
previewUrls.value = photos.value.map(item => `${cdnBase.value}/${item.name}`)
await nextTick()
entered.value = new Set(photos.value.map(p => p._id))
} finally {
loading.value = false
}
dataSelect(selection: PhotoWallModel[]) {
selectedData = selection.map(item => item._id)
}
setLoadData(loadData)
// ─── 选中 ─────────────────────────────────────────────
function toggleSelect(item: PhotoWallModel) {
const s = new Set(selected.value)
s.has(item._id) ? s.delete(item._id) : s.add(item._id)
selected.value = s
}
function selectAll() {
selected.value = new Set(photos.value.map(p => p._id))
}
function invertSelect() {
const s = new Set<string>()
photos.value.forEach(p => {
if (!selected.value.has(p._id)) s.add(p._id)
})
selected.value = s
}
function clearSelect() {
selected.value = new Set()
}
const isAllSelected = computed(() => photos.value.length && selected.value.size === photos.value.length)
// ─── 预览 ─────────────────────────────────────────────
function preview(item: PhotoWallModel) {
previewIndex.value = photos.value.findIndex(p => p._id === item._id) || 0
previewVisible.value = true
}
// ─── 删除 ─────────────────────────────────────────────
async function deleteSingle(item: PhotoWallModel) {
await ElMessageBox.confirm(`确认删除「${item.name}」?`, '确认删除', { type: 'warning' })
await http.delete('/photoWall/delete', { params: { ids: [item._id] } })
ElMessage.success('删除成功')
loadData()
}
async function deleteSelected() {
const ids = [...selected.value]
await ElMessageBox.confirm(`确认删除选中的 ${ids.length} 张图片?`, '确认删除', { type: 'warning' })
await http.delete('/photoWall/delete', { params: { ids } })
selected.value = new Set()
ElMessage.success('删除成功')
loadData()
}
// ─── 上传 ─────────────────────────────────────────────
function beforeUpload(file: File): boolean {
if (file.size > 10 << 20) {
ElMessage.warning('文件大小超过 10MB')
return false
}
beforeUpload(file: File): boolean {
if(file.size > 10 << 20) {
ElMessage.warning('文件大小超过10MB')
return false
isUploading.value = true
return true
}
function uploadSuccess(response: MsgResult) {
isUploading.value = false
if (response.code === 0) {
ElMessage.success('上传成功')
loadData()
} else {
ElMessage.warning(response.message || '上传失败')
}
}
function uploadError(error: Error) {
isUploading.value = false
ElMessage.error(error.message)
}
// ─── 响应式列数 ───────────────────────────────────────
let resizeObserver: ResizeObserver | null = null
onMounted(async () => {
recalcCols()
resizeObserver = new ResizeObserver(() => recalcCols())
if (rootEl.value) resizeObserver.observe(rootEl.value)
await loadData()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
</script>
<style scoped lang="less">
// ─── 可滚动瀑布流区域 ─────────────────────────────────
.masonry-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px;
}
.masonry-scene {
display: flex;
gap: 14px;
align-items: flex-start;
}
.masonry-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
// ─── 卡片外壳(选中描边) ─────────────────────────────
.card-wrap {
opacity: 0;
transform: translateY(20px);
border-radius: 10px;
outline: 2px solid transparent;
outline-offset: 2px;
transition:
opacity 0.35s ease,
transform 0.35s ease,
outline-color 0.2s ease;
cursor: pointer;
&.is-entered {
opacity: 1;
transform: translateY(0);
}
&.is-selected {
outline-color: var(--el-color-primary);
.check-badge {
opacity: 1;
transform: scale(1);
}
this.isUploading = true
return true
}
uploadSuccess(response: MsgResult) {
if(response.code === 0) {
ElMessage.success(response.message)
this.loadData()
} else {
ElMessage.warning(response.message)
}
this.isUploading = false
&:hover .card-overlay {
opacity: 1;
transform: translateY(0);
}
uploadError(error: Error) {
this.isUploading = false
ElMessage.error(error.message)
&:hover .overlay-info {
opacity: 1;
transform: translateY(0);
}
async preview(row: PhotoWallModel) {
const previewHeight = Math.floor(row.height * (500 / row.width))
const pictureCdn = await this.$http.get('/api/v1/common/config/picture_cdn')
ElMessageBox({
title: '图片预览',
message: h('img', { style: `width:500px;height:${previewHeight}px;`, src: `${pictureCdn}/${row.name}` }, ''),
showCancelButton: false,
confirmButtonText: '关闭',
customStyle: {width: '530px', maxWidth: 'unset'}
}).catch(() => {})
}
created() {
this.loadData()
&:hover .overlay-actions {
opacity: 1;
transform: translateY(0);
}
}
class PhotoWallPage extends Page {
name?: string
widthMin?: number = 0
widthMax?: number = 0
heightMin?: number = 0
heightMax?: number = 0
reset() {
super.reset()
this.name = undefined
this.widthMin = 0
this.widthMax = 0
this.heightMin = 0
this.heightMax = 0
// ─── 卡片内容 ─────────────────────────────────────────
.card-inner {
position: relative;
width: 100%;
min-height: 80px;
border-radius: 10px;
overflow: hidden;
background: var(--el-fill-color-light);
}
.card-img {
display: block;
width: 100%;
height: auto;
border-radius: 10px;
transition: transform 0.4s ease;
.card-wrap:hover & {
transform: scale(1.04);
}
}
</script>
// ─── 选中角标 ─────────────────────────────────────────
.check-badge {
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--el-color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
opacity: 0;
transform: scale(0.5);
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
// ─── Hover 浮层 ───────────────────────────────────────
.card-overlay {
position: absolute;
inset: 0;
border-radius: 10px;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.82) 0%,
rgba(0, 0, 0, 0.4) 55%,
rgba(0, 0, 0, 0.08) 100%
);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 12px;
box-sizing: border-box;
opacity: 0;
transform: translateY(6px);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.overlay-info {
opacity: 0;
transform: translateY(8px);
transition:
opacity 0.3s ease 0.04s,
transform 0.3s ease 0.04s;
.overlay-name {
margin: 0 0 3px;
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
word-break: break-all;
display: -webkit-box;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.overlay-size {
margin: 0 0 10px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.5px;
}
}
.overlay-actions {
display: flex;
justify-content: center;
opacity: 0;
transform: translateY(8px);
transition:
opacity 0.3s ease 0.08s,
transform 0.3s ease 0.08s;
}
</style>
+140 -135
View File
@@ -1,57 +1,53 @@
<template>
<div>
<el-alert type="info" show-icon :closable="false" >
<template #title>上传要求</template>
图片格式为{{allowUploadExt.join('、')}},文件大小不超过10MB。
</el-alert>
<div class="btn-container" style="margin-top:10px;">
<div class="page-wrapper">
<div class="btn-container">
<el-upload
action="/api/source-image/upload"
:action="`${apiBase}/source-image/upload`"
accept="image/jpeg,image/png,image/svg+xml,image/x-icon"
name="image"
:headers="{token: $store.state.loginInfo.token}"
:headers="{token: store.state.loginInfo.token}"
:before-upload="beforeUpload"
:on-success="uploadSuccess"
:on-error="uploadError"
auto-upload
:show-file-list="false"
style="display: inline-block;margin-right: 10px;">
<el-button type="primary" icon="Upload" :loading="isUploading">上传图片</el-button>
style="margin-right: 10px;">
<el-tooltip :content="`图片格式为${allowUploadExt.join('、')},文件大小不超过10MB。`">
<el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'sourceImage:save'" plain>上传图片</el-button>
</el-tooltip>
</el-upload>
<el-button type="danger" @click="deleteAll" style="vertical-align: bottom">批量删除</el-button>
</div>
<div class="table-container">
<el-table :data="sourceImageData" v-loading="loading" stripe @selection-change="dataSelect">
<el-table-column type="selection" width="55" />
<el-table-column prop="hash" label="md5" width="300" />
<el-table-column prop="size" label="文件大小" >
<template #default="scope">
{{ prettyBytes(scope.row.size) }}
</template>
</el-table-column>
<el-table-column prop="mime" label="MIME" />
<el-table-column prop="label" label="标签" >
<template #default="scope">
<el-tag v-for="label in scope.row.label" :key="label" effect="plain">{{label}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="上传时间" >
<template #default="scope">
{{ datetimeFormat(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" >
<template #default="scope">
<el-button link icon="EditPen" type="primary" plain @click="modifyTags(scope.row)" title="修改标签"></el-button>
<el-button link icon="View" plain @click="preview(scope.row)" title="预览"></el-button>
</template>
</el-table-column>
</el-table>
<el-button type="danger" @click="deleteAll" style="vertical-align: bottom" v-permission="'sourceImage:delete'" plain>批量删除</el-button>
</div>
<el-table class="table-container" :data="sourceImageData" v-loading="loading" stripe @selection-change="dataSelect">
<el-table-column type="selection" width="55" />
<el-table-column prop="hash" label="md5" width="300" />
<el-table-column prop="size" label="文件大小" >
<template #default="scope">
{{ prettyBytes(scope.row.size) }}
</template>
</el-table-column>
<el-table-column prop="mime" label="MIME" />
<el-table-column prop="label" label="标签" >
<template #default="scope">
<el-tag v-for="label in scope.row.label" :key="label" effect="plain">{{label}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="上传时间" >
<template #default="scope">
{{ datetimeFormat(scope.row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" >
<template #default="scope">
<el-button link icon="EditPen" type="primary" @click="modifyTags(scope.row)" title="修改标签" v-permission="'sourceImage:save'"></el-button>
<el-button link icon="View" @click="preview(scope.row)" title="预览"></el-button>
</template>
</el-table-column>
</el-table>
<div class="page-container">
<el-pagination background
:page-sizes="$store.state.pageSizeOpts"
:layout="$store.state.pageLayout"
:page-sizes="store.state.pageSizeOpts"
:layout="store.state.pageLayout"
:current-page="search.pageNum"
:total="total"
@size-change="pageSizeChange"
@@ -72,107 +68,116 @@
:data="labels"
></el-transfer>
</el-dialog>
<el-image-viewer
v-if="previewVisible"
:url-list="previewUrlList"
:initial-index="previewIndex"
@close="previewVisible = false"
/>
</div>
</template>
<script lang="ts">
import { Options } from 'vue-class-component'
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useStore } from 'vuex'
import { ElMessage, ElMessageBox } from 'element-plus'
import prettyBytes from 'pretty-bytes'
import { MsgResult, Page } from '@/model/common.dto'
import BaseList from '@/model/baselist'
import { useBaseList } from '@/model/baselist'
import { SourceImageModel } from '@/model/api/source-image'
import { h } from 'vue'
import http from '@/utils/http'
const store = useStore()
const apiBase = import.meta.env.VITE_APP_API_BASE
const { loading, total, search, setLoadData, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new Page())
const allowUploadExt = ['jpg', 'jpeg', 'png', 'svg', 'ico']
const sourceImageData = ref<SourceImageModel[]>([])
const curModifyLabels = ref<string[]>([])
const labelList = ref<string[]>([])
const curId = ref<string | null>(null)
const modifyModal = ref(false)
const isUploading = ref(false)
const previewVisible = ref(false)
const previewUrlList = ref<string[]>([])
const previewIndex = ref(0)
function renderFunc(h: Function, option: any) {
return h('span', null, option.label)
}
const labels = computed(() => {
return labelList.value.map(item => {
return { key: item, label: item }
})
})
let selectedData: string[] = []
@Options({
name: 'SourceImage',
})
export default class SourceImage extends BaseList<Page> {
prettyBytes = prettyBytes
search = new Page()
allowUploadExt = ['jpg','jpeg','png','svg','ico']
sourceImageData: SourceImageModel[] = []
curModifyLabels: string[] = []
labelList: string[] = []
curId: string | null = null
modifyModal: boolean = false
isUploading: boolean = false
renderFunc(h: Function, option: any) {
return h('span', null, option.label)
}
get labels() {
return this.labelList.map(item => {
return { key: item, label: item }
})
}
async loadData(): Promise<void> {
this.loading = true
const data = await this.$http.get<Page, any>('/api/v1/source-image/list', {params:this.search})
selectedData = []
this.loading = false
this.total = data.total
this.sourceImageData = data.data
}
deleteAll(): void {
if(!selectedData.length) {
ElMessage.warning('请选择要删除的数据')
return
}
ElMessageBox.confirm(`是否确认删除选中的${selectedData.length}条数据?`, '确认删除', {type: 'warning'}).then(async () => {
await this.$http.delete('/api/v1/source-image/delete', {params:{_ids: selectedData}})
ElMessage.success('删除成功')
this.loadData()
}).catch(() => {})
}
dataSelect(selection: SourceImageModel[]): void {
selectedData = selection.map(item => item._id)
}
beforeUpload(file: File): boolean {
if(file.size > 10 << 20) {
ElMessage.warning('文件大小超过10MB')
return false
}
this.isUploading = true
return true
}
uploadSuccess(response: MsgResult): void {
if(response.status) {
ElMessage.success(response.message)
this.loadData()
} else {
ElMessage.warning(response.message)
}
this.isUploading = false
}
uploadError(error: Error): void {
this.isUploading = false
ElMessage.error(error.message)
}
preview(row: SourceImageModel): void {
ElMessageBox({
title: '图片预览',
message: h('img', { style: `width:500px`, src: `/api/v1/common/randomBg?id=${row._id}` }, ''),
showCancelButton: false,
confirmButtonText: '关闭',
customStyle: {width: '530px', maxWidth: 'unset'}
}).catch(() => {})
}
modifyTags(item: SourceImageModel): void {
this.curModifyLabels.length = 0
if(item.label) {
this.curModifyLabels.push(...item.label)
}
this.curId = item._id
this.modifyModal = true
}
async tarnsferChange(newTargetKeys: string[], direction: 'right' | 'left', moveKeys: string[]) {
await this.$http.post('/api/v1/source-image/updateLabel', {id: this.curId, labels: newTargetKeys})
}
created() {
this.$http.get<never, any>('/api/v1/common/config/image_label').then(data => {
this.labelList.push(...data)
this.loadData()
})
}
async function loadData(): Promise<void> {
loading.value = true
const data = await http.get<Page, any>('/source-image/list', {params: search})
selectedData = []
loading.value = false
total.value = data.total
sourceImageData.value = data.list
}
setLoadData(loadData)
function deleteAll(): void {
if (!selectedData.length) {
ElMessage.warning('请选择要删除的数据')
return
}
ElMessageBox.confirm(`是否确认删除选中的${selectedData.length}条数据?`, '确认删除', {type: 'warning'}).then(async () => {
await http.delete('/source-image/delete', {params: {ids: selectedData}})
ElMessage.success('删除成功')
loadData()
}).catch(() => {})
}
function dataSelect(selection: SourceImageModel[]): void {
selectedData = selection.map(item => item._id)
}
function beforeUpload(file: File): boolean {
if (file.size > 10 << 20) {
ElMessage.warning('文件大小超过10MB')
return false
}
isUploading.value = true
return true
}
function uploadSuccess(response: MsgResult): void {
if (response.code === 0) {
ElMessage.success('上传成功')
loadData()
} else {
ElMessage.warning(response.message || '上传失败')
}
isUploading.value = false
}
function uploadError(error: Error): void {
isUploading.value = false
ElMessage.error(error.message)
}
function preview(row: SourceImageModel): void {
const index = sourceImageData.value.findIndex(item => item._id === row._id)
previewUrlList.value = sourceImageData.value.map(item => `${apiBase}/common/randomBg?id=${item._id}`)
previewIndex.value = index >= 0 ? index : 0
previewVisible.value = true
}
function modifyTags(item: SourceImageModel): void {
curModifyLabels.value.length = 0
if (item.label) {
curModifyLabels.value.push(...item.label)
}
curId.value = item._id
modifyModal.value = true
}
async function tarnsferChange(newTargetKeys: string[], direction: 'right' | 'left', moveKeys: string[]) {
await http.post('/source-image/updateLabel', {id: curId.value, labels: newTargetKeys})
}
// created
http.get<never, any>('/common/config/image_label').then(data => {
labelList.value.push(...data)
loadData()
})
</script>
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
<path fill="#FFF" d="M4 16c0-6.6 5.4-12 12-12s12 5.4 12 12c0 1.2-0.8 2-2 2s-2-0.8-2-2c0-4.4-3.6-8-8-8s-8 3.6-8 8 3.6 8 8 8c1.2 0 2 0.8 2 2s-0.8 2-2 2c-6.6 0-12-5.4-12-12z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 265 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
<path d="M26.667 5.333h-21.333c-0 0-0.001 0-0.001 0-1.472 0-2.666 1.194-2.666 2.666 0 0 0 0.001 0 0.001v-0 16c0 0 0 0.001 0 0.001 0 1.472 1.194 2.666 2.666 2.666 0 0 0.001 0 0.001 0h21.333c0 0 0.001 0 0.001 0 1.472 0 2.666-1.194 2.666-2.666 0-0 0-0.001 0-0.001v0-16c0-0 0-0.001 0-0.001 0-1.472-1.194-2.666-2.666-2.666-0 0-0.001 0-0.001 0h0zM5.333 16h5.333v2.667h-5.333v-2.667zM18.667 24h-13.333v-2.667h13.333v2.667zM26.667 24h-5.333v-2.667h5.333v2.667zM26.667 18.667h-13.333v-2.667h13.333v2.667z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 590 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="-5 0 32 32">
<path d="M20.8 14.4q0.704 0 1.152 0.48t0.448 1.12-0.48 1.12-1.12 0.48h-19.2q-0.64 0-1.12-0.48t-0.48-1.12 0.448-1.12 1.152-0.48h19.2zM1.6 11.2q-0.64 0-1.12-0.48t-0.48-1.12 0.448-1.12 1.152-0.48h19.2q0.704 0 1.152 0.48t0.448 1.12-0.48 1.12-1.12 0.48h-19.2zM20.8 20.8q0.704 0 1.152 0.48t0.448 1.12-0.48 1.12-1.12 0.48h-19.2q-0.64 0-1.12-0.48t-0.48-1.12 0.448-1.12 1.152-0.48h19.2z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 473 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
<path d="M2.667 7.027l1.707-1.693 22.293 22.293-1.693 1.707-4-4h-11.64v4l-5.333-5.333 5.333-5.333v4h8.973l-8.973-8.973v0.973h-2.667v-3.64l-4-4zM22.667 17.333h2.667v5.573l-2.667-2.667v-2.907zM22.667 6.667v-4l5.333 5.333-5.333 5.333v-4h-10.907l-2.667-2.667h13.573z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 357 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="-8 0 32 32">
<path stroke="#000" fill="#FFF" d="M14.080 4.8q2.88 0 2.88 2.048v18.24q0 2.112-2.88 2.112t-2.88-2.112v-18.24q0-2.048 2.88-2.048zM2.88 4.8q2.88 0 2.88 2.048v18.24q0 2.112-2.88 2.112t-2.88-2.112v-18.24q0-2.048 2.88-2.048z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 315 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="-8 0 32 32">
<path stroke="#000" fill="#FFF" d="M15.552 15.168q0.448 0.32 0.448 0.832 0 0.448-0.448 0.768l-13.696 8.512q-0.768 0.512-1.312 0.192t-0.544-1.28v-16.448q0-0.96 0.544-1.28t1.312 0.192z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 278 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
<path d="M9.333 9.333h13.333v4l5.333-5.333-5.333-5.333v4h-16v8h2.667v-5.333zM22.667 22.667h-13.333v-4l-5.333 5.333 5.333 5.333v-4h16v-8h-2.667v5.333z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 240 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
<path d="M9.333 9.333h13.333v4l5.333-5.333-5.333-5.333v4h-16v8h2.667v-5.333zM22.667 22.667h-13.333v-4l-5.333 5.333 5.333 5.333v-4h16v-8h-2.667v5.333zM17.333 20v-8h-1.333l-2.667 1.333v1.333h2v5.333h2z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 294 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
<path d="M22.667 4l7 6-7 6 7 6-7 6v-4h-3.653l-3.76-3.76 2.827-2.827 2.587 2.587h2v-8h-2l-12 12h-6v-4h4.347l12-12h3.653v-4zM2.667 8h6l3.76 3.76-2.827 2.827-2.587-2.587h-4.347v-4z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 272 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
<path d="M25.468 6.947c-0.326-0.172-0.724-0.151-1.030 0.057l-6.438 4.38v-3.553c0-0.371-0.205-0.71-0.532-0.884-0.326-0.172-0.724-0.151-1.030 0.057l-12 8.164c-0.274 0.186-0.438 0.496-0.438 0.827s0.164 0.641 0.438 0.827l12 8.168c0.169 0.115 0.365 0.174 0.562 0.174 0.16 0 0.321-0.038 0.468-0.116 0.327-0.173 0.532-0.514 0.532-0.884v-3.556l6.438 4.382c0.169 0.115 0.365 0.174 0.562 0.174 0.16 0 0.321-0.038 0.468-0.116 0.327-0.173 0.532-0.514 0.532-0.884v-16.333c0-0.371-0.205-0.71-0.532-0.884z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 585 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
<path d="M13.728 6.272v19.456q0 0.448-0.352 0.8t-0.8 0.32-0.8-0.32l-5.952-5.952h-4.672q-0.48 0-0.8-0.352t-0.352-0.8v-6.848q0-0.48 0.352-0.8t0.8-0.352h4.672l5.952-5.952q0.32-0.32 0.8-0.32t0.8 0.32 0.352 0.8zM20.576 16q0 1.344-0.768 2.528t-2.016 1.664q-0.16 0.096-0.448 0.096-0.448 0-0.8-0.32t-0.32-0.832q0-0.384 0.192-0.64t0.544-0.448 0.608-0.384 0.512-0.64 0.192-1.024-0.192-1.024-0.512-0.64-0.608-0.384-0.544-0.448-0.192-0.64q0-0.48 0.32-0.832t0.8-0.32q0.288 0 0.448 0.096 1.248 0.48 2.016 1.664t0.768 2.528z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 604 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
<path d="M13.728 6.272v19.456q0 0.448-0.352 0.8t-0.8 0.32-0.8-0.32l-5.952-5.952h-4.672q-0.48 0-0.8-0.352t-0.352-0.8v-6.848q0-0.48 0.352-0.8t0.8-0.352h4.672l5.952-5.952q0.32-0.32 0.8-0.32t0.8 0.32 0.352 0.8z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 301 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
<path d="M13.728 6.272v19.456q0 0.448-0.352 0.8t-0.8 0.32-0.8-0.32l-5.952-5.952h-4.672q-0.48 0-0.8-0.352t-0.352-0.8v-6.848q0-0.48 0.352-0.8t0.8-0.352h4.672l5.952-5.952q0.32-0.32 0.8-0.32t0.8 0.32 0.352 0.8zM20.576 16q0 1.344-0.768 2.528t-2.016 1.664q-0.16 0.096-0.448 0.096-0.448 0-0.8-0.32t-0.32-0.832q0-0.384 0.192-0.64t0.544-0.448 0.608-0.384 0.512-0.64 0.192-1.024-0.192-1.024-0.512-0.64-0.608-0.384-0.544-0.448-0.192-0.64q0-0.48 0.32-0.832t0.8-0.32q0.288 0 0.448 0.096 1.248 0.48 2.016 1.664t0.768 2.528zM25.152 16q0 2.72-1.536 5.056t-4 3.36q-0.256 0.096-0.448 0.096-0.48 0-0.832-0.352t-0.32-0.8q0-0.704 0.672-1.056 1.024-0.512 1.376-0.8 1.312-0.96 2.048-2.4t0.736-3.104-0.736-3.104-2.048-2.4q-0.352-0.288-1.376-0.8-0.672-0.352-0.672-1.056 0-0.448 0.32-0.8t0.8-0.352q0.224 0 0.48 0.096 2.496 1.056 4 3.36t1.536 5.056zM29.728 16q0 4.096-2.272 7.552t-6.048 5.056q-0.224 0.096-0.448 0.096-0.48 0-0.832-0.352t-0.32-0.8q0-0.64 0.704-1.056 0.128-0.064 0.384-0.192t0.416-0.192q0.8-0.448 1.44-0.896 2.208-1.632 3.456-4.064t1.216-5.152-1.216-5.152-3.456-4.064q-0.64-0.448-1.44-0.896-0.128-0.096-0.416-0.192t-0.384-0.192q-0.704-0.416-0.704-1.056 0-0.448 0.32-0.8t0.832-0.352q0.224 0 0.448 0.096 3.776 1.632 6.048 5.056t2.272 7.552z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,153 +0,0 @@
<template>
<div
class="aplayer-bar-wrap"
@mousedown="onThumbMouseDown"
ref="barWrap"
>
<div class="aplayer-bar">
<div
class="aplayer-loaded"
:style="{width: `${loadProgress * 100}%`}">
</div>
<div
class="aplayer-played"
:style="{width: `${playProgress * 100}%`, background: theme}"
>
<span
ref="thumb"
@mouseover="thumbHovered = true"
@mouseout="thumbHovered = false"
class="aplayer-thumb"
:style="{borderColor: theme, backgroundColor: thumbHovered ? theme : '#fff'}"
>
<span class="aplayer-loading-icon" :style="{backgroundColor: theme }">
<player-icon type="loading"/>
</span>
</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { getElementViewLeft } from '../utils'
import PlayerIcon from './aplayer-icon.vue'
defineProps<{
loadProgress: number
playProgress: number
theme?: string
}>()
const emit = defineEmits(['dragbegin', 'dragging', 'dragend'])
const thumbHovered = ref(false)
const barWrap = ref<unknown>(null)
const onThumbMouseDown = (e: MouseEvent) => {
const barWidth = (<HTMLElement>barWrap.value).clientWidth
let percentage = (e.clientX - getElementViewLeft(<HTMLElement>barWrap.value)) / barWidth
percentage = percentage > 0 ? percentage : 0
percentage = percentage < 1 ? percentage : 1
emit('dragbegin', percentage)
document.addEventListener('mousemove', onDocumentMouseMove)
document.addEventListener('mouseup', onDocumentMouseUp)
}
const onDocumentMouseMove = (e: MouseEvent) => {
const barWidth = (<HTMLElement>barWrap.value).clientWidth
let percentage = (e.clientX - getElementViewLeft(<HTMLElement>barWrap.value)) / barWidth
percentage = percentage > 0 ? percentage : 0
percentage = percentage < 1 ? percentage : 1
emit('dragging', percentage)
}
const onDocumentMouseUp = (e: MouseEvent) => {
document.removeEventListener('mouseup', onDocumentMouseUp)
document.removeEventListener('mousemove', onDocumentMouseMove)
const barWidth = (<HTMLElement>barWrap.value).clientWidth
let percentage = (e.clientX - getElementViewLeft(<HTMLElement>barWrap.value)) / barWidth
percentage = percentage > 0 ? percentage : 0
percentage = percentage < 1 ? percentage : 1
emit('dragend', percentage)
}
</script>
<style lang="less" scoped>
.aplayer-bar-wrap {
margin: 0 0 0 5px;
padding: 4px 0;
cursor: pointer;
flex: 1;
.aplayer-bar {
position: relative;
height: 2px;
width: 100%;
background: #cdcdcd;
.aplayer-loaded {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background: #aaa;
height: 2px;
transition: all 0.5s ease;
will-change: width;
}
.aplayer-played {
position: absolute;
left: 0;
top: 0;
bottom: 0;
height: 2px;
transition: background-color .3s;
will-change: width;
.aplayer-thumb {
position: absolute;
top: 0;
right: 5px;
margin-top: -5px;
margin-right: -10px;
width: 10px;
height: 10px;
border: 1px solid;
transform: scale(.8);
will-change: transform;
transition: transform 300ms, background-color .3s, border-color .3s;
border-radius: 50%;
background: #fff;
cursor: pointer;
&:hover {
transform: scale(1);
}
overflow: hidden;
.aplayer-loading-icon {
display: none;
width: 100%;
height: 100%;
.icon-svg {
position: absolute;
animation: spin 1s linear infinite;
}
}
}
}
}
}
.aplayer-loading {
.aplayer-bar-wrap .aplayer-bar .aplayer-thumb .aplayer-loading-icon {
display: block;
}
.aplayer-info .aplayer-controller .aplayer-bar-wrap .aplayer-bar .aplayer-played .aplayer-thumb {
transform: scale(1);
}
}
@keyframes spin {
0% {
transform: rotate(0)
}
100% {
transform: rotate(360deg)
}
}
</style>
@@ -1,117 +0,0 @@
<template>
<div class="aplayer-volume-wrap">
<icon-button
:class="`aplayer-icon-${volumeIcon}`"
:icon="volumeIcon"
@click="$emit('togglemute')"
/>
<div
class="aplayer-volume-bar-wrap"
@mousedown="onBarMouseDown"
>
<div class="aplayer-volume-bar" ref="bar">
<div
class="aplayer-volume"
:style="{
height: muted ? 0 : `${Math.trunc(volume * 100)}%`,
background: theme
}"
>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import IconButton from './aplayer-iconbutton.vue'
import {getElementViewTop} from '../utils'
const props = defineProps<{
volume: number
muted: boolean
theme: string
}>()
const emit = defineEmits(['setvolume'])
const barHeight = 40
const volumeIcon = computed(() => {
if (props.muted || props.volume <= 0) return 'volume_off'
if (props.volume >= 1) return 'volume_up'
return 'volume_down'
})
const bar = ref<unknown>(null)
const onBarMouseDown = () => {
document.addEventListener('mousemove', onDocumentMouseMove)
document.addEventListener('mouseup', onDocumentMouseUp)
}
const onDocumentMouseMove = (e: MouseEvent) => {
let percentage = (barHeight - e.clientY + getElementViewTop(<HTMLElement>bar.value)) / barHeight
percentage = percentage > 0 ? percentage : 0
percentage = percentage < 1 ? percentage : 1
emit('setvolume', percentage)
}
const onDocumentMouseUp = (e: MouseEvent) => {
document.removeEventListener('mouseup', onDocumentMouseUp)
document.removeEventListener('mousemove', onDocumentMouseMove)
let percentage = (barHeight - e.clientY + getElementViewTop(<HTMLElement>bar.value)) / barHeight
percentage = percentage > 0 ? percentage : 0
percentage = percentage < 1 ? percentage : 1
emit('setvolume', percentage)
}
</script>
<style lang="less" scoped>
.aplayer-volume-wrap {
position: relative;
cursor: pointer;
z-index: 0;
&:hover .aplayer-volume-bar-wrap {
display: block;
}
.aplayer-volume-bar-wrap {
display: none;
position: absolute;
bottom: 15px;
left: -4px;
right: -4px;
height: 40px;
z-index: -1;
transition: all .2s ease;
&::after {
content: '';
position: absolute;
bottom: -16px;
left: 0;
right: 0;
height: 62px;
background-color: #fff;
box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.07), 0 0 5px 0 rgba(0, 0, 0, 0.1);
}
.aplayer-volume-bar {
position: absolute;
bottom: 0;
left: 11px;
width: 5px;
height: 40px;
background: #aaa;
border-radius: 2.5px;
overflow: hidden;
z-index: 1;
.aplayer-volume {
position: absolute;
bottom: 0;
left: 0;
right: 0;
transition: height 0.1s ease, background-color .3s;
will-change: height;
}
}
}
}
</style>
@@ -1,129 +0,0 @@
<template>
<div class="aplayer-controller">
<v-progress
:loadProgress="loadProgress"
:playProgress="playProgress"
:theme="theme"
@dragbegin="val => $emit('dragbegin', val)"
@dragend="val => $emit('dragend', val)"
@dragging="val => $emit('dragging', val)"
/>
<div class="aplayer-time">
<div class="aplayer-time-inner">
- <span class="aplayer-ptime">{{secondToTime(stat.playedTime)}}</span> / <span
class="aplayer-dtime">{{secondToTime(stat.duration)}}</span>
</div>
<volume
v-if="!isMobile"
:volume="volume"
:theme="theme"
:muted="muted"
@togglemute="$emit('togglemute')"
@setvolume="(v: number) => $emit('setvolume', v)"
/>
<icon-button
class="aplayer-icon-mode"
icon="shuffle"
:class="{ 'inactive': !shuffle }"
@click="$emit('toggleshuffle')"
/>
<icon-button
class="aplayer-icon-mode"
:icon="repeat === 'repeat_one' ? 'repeat_one' : 'repeat_all'"
:class="{ 'inactive': repeat === 'no_repeat'}"
@click="$emit('nextmode')"
/>
<icon-button
class="aplayer-icon-menu"
icon="menu"
:class="{ 'inactive': !showList }"
@click="$emit('togglelist')"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import IconButton from './aplayer-iconbutton.vue'
import VProgress from './aplayer-controller-progress.vue'
import Volume from './aplayer-controller-volume.vue'
import { StatType } from '@/model/api/music'
const props = defineProps<{
shuffle: boolean
repeat: string
stat: StatType
theme: string
volume: number
muted: boolean
showList: boolean
isMobile: boolean
}>()
const loadProgress = computed(() => {
if (props.stat.duration === 0) return 0
return props.stat.loadedTime / props.stat.duration
})
const playProgress = computed(() => {
if (props.stat.duration === 0) return 0
return props.stat.playedTime / props.stat.duration
})
const secondToTime = (second: number) => {
if (isNaN(second)) {
return '00:00'
}
const pad0 = (num: number) => {
return num < 10 ? '0' + num : '' + num
}
const min = Math.trunc(second / 60)
const sec = Math.trunc(second - min * 60)
const hours = Math.trunc(min / 60)
const minAdjust = Math.trunc((second / 60) - (60 * Math.trunc((second / 60) / 60)))
return second >= 3600 ? pad0(hours) + ':' + pad0(minAdjust) + ':' + pad0(sec) : pad0(min) + ':' + pad0(sec)
}
</script>
<style lang="less" scoped>
.aplayer-controller {
display: flex;
align-items: center;
position: relative;
.aplayer-time {
display: flex;
align-items: center;
position: relative;
height: 17px;
color: #999;
font-size: 11px;
padding-left: 7px;
.aplayer-volume-wrap {
margin-left: 4px;
margin-right: 4px;
}
.aplayer-icon {
cursor: pointer;
transition: all 0.2s ease;
margin-left: 4px;
&.inactive {
opacity: .3;
}
&.aplayer-icon-menu {
display: none;
}
}
.aplayer-volume-wrap + .aplayer-icon {
margin-left: 0;
}
&.aplayer-time-narrow {
.aplayer-icon-mode {
display: none;
}
.aplayer-icon-menu {
display: none;
}
}
}
}
</style>
@@ -1,24 +0,0 @@
<template>
<img class="icon-svg" :src="svg" :style="style" />
</template>
<script lang="ts" setup>
import { computed } from 'vue'
const props = defineProps<{type: string}>()
const requireAssets = require.context('../assets', false, /\.svg$/)
const SVGs = requireAssets.keys().reduce((svgs: {[propName:string]: string}, path: string) => {
const svgFile = requireAssets(path)
const fileNameMatch = path.match(/^.*\/(.+?)\.svg$/)
if (fileNameMatch) {
svgs[fileNameMatch[1]] = svgFile
}
return svgs
}, {})
const svg = computed(() => SVGs[props.type])
const style = computed(() => {
if (props.type === 'next') {
return { transform: 'rotate(180deg)' }
}
})
</script>
@@ -1,33 +0,0 @@
<template>
<button
type="button"
class="aplayer-icon"
>
<player-icon :type="icon"/>
</button>
</template>
<script lang="ts" setup>
import PlayerIcon from './aplayer-icon.vue'
defineProps<{icon: string}>()
</script>
<style lang="less" scoped>
.aplayer-icon {
width: 15px;
height: 15px;
border: none;
background-color: transparent;
outline: none;
cursor: pointer;
opacity: .8;
vertical-align: middle;
padding: 0;
font-size: 12px;
margin: 0;
display: inline;
&:hover {
opacity: 1;
}
}
</style>
@@ -1,135 +0,0 @@
<template>
<transition name="slide-v">
<div
class="aplayer-list"
:style="listHeightStyle"
ref="list"
v-show="show"
>
<ol ref="ol" :style="listHeightStyle">
<li
v-for="(aMusic, index) of musicList"
:key="index"
:class="{'aplayer-list-light': aMusic === currentMusic}"
@click="$emit('selectsong', aMusic)"
>
<span class="aplayer-list-cur" :style="{background: theme}"></span>
<span class="aplayer-list-index">{{ index + 1}}</span>
<span class="aplayer-list-title">{{ aMusic.title || 'Untitled' }}</span>
<span class="aplayer-list-author">{{ aMusic.artist || 'Unknown' }}</span>
</li>
</ol>
</div>
</transition>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { MusicPlayerItem } from '@/model/api/music'
const props = defineProps<{
show: boolean
currentMusic: MusicPlayerItem
musicList: MusicPlayerItem[]
theme?: string
listMaxHeight?: string
}>()
const listHeightStyle = computed(() => {
return {
height: `${33 * props.musicList.length - 1}px`,
maxHeight: props.listMaxHeight || ''
}
})
</script>
<style lang="less" scoped>
.aplayer-list {
overflow: hidden;
&.slide-v-enter-active,
&.slide-v-leave-active {
transition: height 500ms ease;
will-change: height;
}
&.slide-v-enter,
&.slide-v-leave-to {
height: 0 !important;
}
ol {
list-style-type: none;
margin: 0;
padding: 0;
overflow-y: auto;
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-track {
background-color: #f9f9f9;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: #eee;
}
&::-webkit-scrollbar-thumb:hover {
background-color: #ccc;
}
&:hover {
li.aplayer-list-light:not(:hover) {
background-color: inherit;
transition: inherit;
}
}
&:not(:hover) {
li.aplayer-list-light {
transition: background-color .6s ease;
}
}
li {
position: relative;
height: 32px;
line-height: 32px;
padding: 0 15px;
font-size: 12px;
border-top: 1px solid #e9e9e9;
cursor: pointer;
transition: all 0.2s ease;
overflow: hidden;
margin: 0;
text-align: start;
display: flex;
&:first-child {
border-top: none;
}
&:hover {
background: #efefef;
}
&.aplayer-list-light {
background: #efefef;
.aplayer-list-cur {
display: inline-block;
}
}
.aplayer-list-cur {
display: none;
width: 3px;
height: 22px;
position: absolute;
left: 0;
top: 5px;
transition: background-color .3s;
}
.aplayer-list-index {
color: #666;
margin-right: 12px;
}
.aplayer-list-title {
flex-grow: 1;
}
.aplayer-list-author {
flex-shrink: 0;
color: #666;
float: right;
}
}
}
}
</style>
@@ -1,120 +0,0 @@
<template>
<div class="aplayer-lrc">
<div
class="aplayer-lrc-contents"
:style="transformStyle"
>
<p
v-for="(line, index) of lrcLines"
:key="index"
:class="{ 'aplayer-lrc-current': index === currentLineIndex }"
>
{{ line[1] }}
</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue'
import { parseLrc } from '../utils'
import { StatType } from '@/model/api/music'
const props = defineProps<{
lrcSource: string
playStat: StatType
}>()
const displayLrc = ref('')
const currentLineIndex = ref(0)
const lrcLines = computed(() => parseLrc(displayLrc.value))
const transformStyle = computed(() => {
return {
transform: `translateY(${-currentLineIndex.value * 16}px)`,
webkitTransform: `translateY(${-currentLineIndex.value * 16}px)`,
}
})
watch(() => props.lrcSource, (lrcSource: string) => {
currentLineIndex.value = 0
if (/^https?:\/\//.test(lrcSource)) {
fetch(lrcSource)
.then(response => response.text())
.then(lrc => displayLrc.value = lrc)
} else {
displayLrc.value = lrcSource
}
}, { immediate: true })
watch(() => props.playStat.playedTime, (playedTime: number) => {
for (let i = 0; i < lrcLines.value.length; i++) {
const line = lrcLines.value[i]
const nextLine = lrcLines.value[i + 1]
if (playedTime >= line[0] && (!nextLine || playedTime < nextLine[0])) {
currentLineIndex.value = i
}
}
})
</script>
<style lang="less" scoped>
@import "../less/variables";
.aplayer-lrc {
position: relative;
height: @lrc-height;
text-align: center;
overflow: hidden;
margin-bottom: 7px;
&:before {
position: absolute;
top: 0;
z-index: 1;
display: block;
overflow: hidden;
width: 100%;
height: 10%;
content: ' ';
background: -moz-linear-gradient(top, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
background: -webkit-linear-gradient(top, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
background: linear-gradient(to bottom, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#00ffffff', GradientType=0);
}
&:after {
position: absolute;
bottom: 0;
z-index: 1;
display: block;
overflow: hidden;
width: 100%;
height: 33%;
content: ' ';
background: -moz-linear-gradient(top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%);
background: -webkit-linear-gradient(top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%);
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ffffff', endColorstr='#ccffffff', GradientType=0);
}
p {
font-size: 12px;
color: #666;
line-height: 16px;
height: 16px;
padding: 0;
margin: 0;
transition: all 0.5s ease-out;
opacity: 0.4;
overflow: hidden;
&.aplayer-lrc-current {
opacity: 1;
overflow: visible;
height: initial;
}
}
.aplayer-lrc-contents {
width: 100%;
transition: all 0.5s ease-out;
user-select: text;
cursor: default;
}
}
</style>
@@ -1,134 +0,0 @@
<template>
<div
class="aplayer-pic"
:style="{backgroundColor: theme}"
@mousedown="onDragBegin"
@click="onClick"
>
<img v-if="pic" ref="thumbnailPic" :src="pic" @load="imgLoad" />
<div class="aplayer-button" :class="playing ? 'aplayer-pause' : 'aplayer-play'">
<icon-button
:icon="playing ? 'pause' : 'play'"
:class="playing ? 'aplayer-icon-pause' : 'aplayer-icon-play'"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import IconButton from './aplayer-iconbutton.vue'
import { adaptingThemeColor } from '../utils'
const props = withDefaults(defineProps<{
pic?: string
theme: string
playing: boolean
enableDrag: boolean
}>(), {
playing: false,
enableDrag: false
})
const emit = defineEmits(['dragbegin', 'dragging', 'dragend', 'toggleplay', 'adaptingTheme'])
const hasMovedSinceMouseDown = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const thumbnailPic = ref<unknown>(null)
const onDragBegin = (e: MouseEvent) => {
if (props.enableDrag) {
hasMovedSinceMouseDown.value = false
emit('dragbegin')
dragStartX.value = e.clientX
dragStartY.value = e.clientY
document.addEventListener('mousemove', onDocumentMouseMove)
document.addEventListener('mouseup', onDocumentMouseUp)
}
}
const onDocumentMouseMove = (e: MouseEvent) => {
hasMovedSinceMouseDown.value = true
emit('dragging', {offsetLeft: e.clientX - dragStartX.value, offsetTop: e.clientY - dragStartY.value})
}
const onDocumentMouseUp = () => {
document.removeEventListener('mouseup', onDocumentMouseUp)
document.removeEventListener('mousemove', onDocumentMouseMove)
emit('dragend')
}
const onClick = () => {
if (!hasMovedSinceMouseDown.value) {
emit('toggleplay')
}
}
const imgLoad = () => {
const color = adaptingThemeColor(<HTMLImageElement>thumbnailPic.value)
emit('adaptingTheme', `rgb(${color.r},${color.g},${color.b})`)
}
watch(() => props.pic, (pic?: string) => {
if (!pic) emit('adaptingTheme', null)
}, { immediate: true })
</script>
<style lang="less" scoped>
@import "../less/variables";
.aplayer-float {
.aplayer-pic:active {
cursor: move;
}
}
.aplayer-pic {
flex-shrink: 0;
position: relative;
height: @aplayer-height;
width: @aplayer-height;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
.aplayer-button {
opacity: 1;
}
}
.aplayer-button {
position: absolute;
border-radius: 50%;
opacity: 0.8;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
background: rgba(0, 0, 0, 0.2);
transition: all 0.1s ease;
}
.aplayer-play {
width: 26px;
height: 26px;
border: 2px solid #fff;
bottom: 50%;
right: 50%;
margin: 0 -15px -15px 0;
.aplayer-icon-play {
position: absolute;
top: 3px;
left: 4px;
height: 20px;
width: 20px;
}
}
.aplayer-pause {
width: 16px;
height: 16px;
border: 2px solid #fff;
bottom: 4px;
right: 4px;
.aplayer-icon-pause {
position: absolute;
top: 2px;
left: 2px;
height: 12px;
width: 12px;
}
}
img {
width: 100%;
height: 100%;
}
}
</style>
@@ -1,3 +0,0 @@
@aplayer-height: 66px;
@lrc-height: 30px;
@aplayer-height-lrc: @aplayer-height + @lrc-height - 6;
@@ -0,0 +1,999 @@
<template>
<div class="music-player-panel" :style="panelBgStyle">
<!-- 上半部分:唱片 + 歌曲信息 -->
<div class="player-main">
<!-- 左侧:唱片盘面 + 圆形频谱 -->
<div class="disc-section">
<div class="disc-container">
<!-- 唱片 -->
<div class="disc-outer" :class="{ playing: isPlaying }">
<canvas ref="visualizerCanvas" class="visualizer-canvas"></canvas>
<div class="vinyl-grooves"></div>
<div class="disc-cover-wrapper">
<img v-if="currentMusic?.pic" ref="coverImg" :src="currentMusic.pic" class="disc-cover" crossorigin="anonymous" @load="onCoverLoad" />
<div v-else class="disc-cover disc-cover-empty"></div>
</div>
<div class="disc-center-dot"></div>
</div>
<!-- 唱针 SVG -->
<svg class="needle" :class="{ playing: isPlaying }" viewBox="0 0 120 200" xmlns="http://www.w3.org/2000/svg">
<circle cx="60" cy="16" r="14" fill="#555" stroke="#777" stroke-width="2"/>
<circle cx="60" cy="16" r="6" fill="#888" stroke="#aaa" stroke-width="1"/>
<rect x="57" y="16" width="6" height="120" rx="3" fill="url(#armGradient)"/>
<rect x="55" y="130" width="10" height="30" rx="2" fill="#444" stroke="#666" stroke-width="1"/>
<rect x="58" y="158" width="4" height="14" rx="1" fill="#333"/>
<circle cx="60" cy="174" r="3" fill="#222" stroke="#555" stroke-width="1"/>
<defs>
<linearGradient id="armGradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#777"/>
<stop offset="50%" stop-color="#bbb"/>
<stop offset="100%" stop-color="#777"/>
</linearGradient>
</defs>
</svg>
</div>
</div>
<!-- 右侧:歌曲信息 + 歌词 -->
<div class="info-section">
<div class="song-info">
<h2 class="song-title">{{ currentMusic?.title || 'Untitled' }}</h2>
<div class="song-meta">
<span v-if="currentMusic?.album">专辑:{{ currentMusic.album }}</span>
<span v-if="currentMusic?.artist">歌手:{{ currentMusic.artist }}</span>
</div>
</div>
<div class="lyrics-area" ref="lyricsContainer">
<div class="lyrics-content" :style="lyricsTransformStyle">
<p
v-for="(line, index) in lrcLines"
:key="index"
:class="{ 'current-line': index === currentLineIndex }"
>{{ line[1] || '' }}</p>
</div>
<div v-if="!lrcLines.length" class="no-lyrics">暂无歌词</div>
</div>
</div>
</div>
<!-- 进度条 -->
<div class="progress-section">
<span class="time-label">{{ formatTime(playedTime) }}</span>
<div class="progress-bar" ref="progressBar" @mousedown="onProgressMouseDown">
<div class="progress-loaded" :style="{ width: loadProgress + '%' }"></div>
<div class="progress-played" :style="{ width: playProgress + '%' }">
<span class="progress-thumb"></span>
</div>
</div>
<span class="time-label">{{ formatTime(duration) }}</span>
</div>
<!-- 播放控制 -->
<div class="controls-section">
<button class="ctrl-btn" :class="{ active: shuffle }" @click="emit('toggleShuffle')" title="随机播放">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>
</button>
<button class="ctrl-btn" @click="emit('prev')" title="上一首">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button>
<button class="ctrl-btn ctrl-btn-play" @click="emit('toggle')" :title="isPlaying ? '暂停' : '播放'">
<svg v-if="isPlaying" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="ctrl-btn" @click="emit('next')" title="下一首">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
<button class="ctrl-btn" :class="{ active: repeatMode !== 'no_repeat' }" @click="emit('nextMode')" :title="repeatTitle">
<svg v-if="repeatMode === 'repeat_one'" viewBox="0 0 24 24" fill="currentColor"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4zm-4-2V9h-1l-2 1v1h1.5v4H13z"/></svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
</button>
<div class="volume-control">
<button class="ctrl-btn" @click="emit('toggleMute')" :title="muted ? '取消静音' : '静音'">
<svg v-if="muted" viewBox="0 0 24 24" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>
<svg v-else-if="volume > 0.5" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/></svg>
</button>
<input type="range" class="volume-slider" min="0" max="1" step="0.01" :value="volume" @input="onVolumeInput" />
</div>
</div>
<!-- 播放列表 -->
<div class="playlist-section" v-if="list.length > 1">
<div class="playlist-header" @click="playlistVisible = !playlistVisible">
<span>播放列表 ({{ list.length }})</span>
<span class="playlist-toggle">{{ playlistVisible ? '收起' : '展开' }}</span>
</div>
<transition name="slide">
<div class="playlist-body" v-show="playlistVisible">
<div
v-for="(item, index) in list"
:key="index"
class="playlist-item"
:class="{ active: item === currentMusic }"
@click="emit('selectSong', item)"
>
<span class="playlist-index">{{ index + 1 }}</span>
<span class="playlist-title">{{ item.title || 'Untitled' }}</span>
<span class="playlist-artist">{{ item.artist || '' }}</span>
</div>
</div>
</transition>
</div>
<!-- 隐藏的audio -->
<audio ref="audioEl"></audio>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, reactive, nextTick } from 'vue'
import { MusicPlayerItem, StatType } from '@/model/api/music'
import { parseLrc, adaptingThemeColor } from './utils'
import { Particle, spawnParticles, updateAndDrawParticles, drawCircularSpectrum, formatTime } from './visualizer'
import Hls from 'hls.js'
const props = defineProps<{
music: MusicPlayerItem
list: MusicPlayerItem[]
autoplay?: boolean
}>()
const emit = defineEmits(['update:music', 'toggle', 'prev', 'next', 'toggleShuffle', 'nextMode', 'toggleMute', 'selectSong', 'play'])
const audioEl = ref<HTMLAudioElement | null>(null)
const progressBar = ref<HTMLDivElement | null>(null)
const visualizerCanvas = ref<HTMLCanvasElement | null>(null)
const coverImg = ref<HTMLImageElement | null>(null)
const playlistVisible = ref(false)
const themeColor = ref<{ r: number; g: number; b: number } | null>(null)
const panelBgStyle = computed(() => {
if (!themeColor.value) return null
const { r, g, b } = themeColor.value
const dr = Math.floor(r * 0.25), dg = Math.floor(g * 0.25), db = Math.floor(b * 0.25)
return { backgroundColor: `rgb(${dr}, ${dg}, ${db})` }
})
function onCoverLoad() {
if (!coverImg.value) return
try {
const color = adaptingThemeColor(coverImg.value)
themeColor.value = color
} catch {
themeColor.value = null
}
}
const isPlaying = ref(false)
const shuffle = ref(false)
const repeatMode = ref<'no_repeat' | 'repeat_one' | 'repeat_all'>('no_repeat')
const muted = ref(false)
const volume = ref(0.5)
const currentLineIndex = ref(0)
const hls = ref<Hls | null>(null)
const playStat = reactive<StatType>({
duration: 0,
loadedTime: 0,
playedTime: 0,
})
const internalMusic = ref<MusicPlayerItem>(props.music)
const currentMusic = computed({
get: () => internalMusic.value,
set: (val) => {
internalMusic.value = val
emit('update:music', val)
},
})
const playedTime = computed(() => playStat.playedTime)
const duration = computed(() => playStat.duration)
const loadProgress = computed(() => playStat.duration ? (playStat.loadedTime / playStat.duration) * 100 : 0)
const playProgress = computed(() => playStat.duration ? (playStat.playedTime / playStat.duration) * 100 : 0)
const lrcLines = computed(() => {
if (!currentMusic.value?.lrc) return []
return parseLrc(currentMusic.value.lrc)
})
const lyricsContainer = ref<HTMLDivElement | null>(null)
const lyricsTransformStyle = computed(() => {
const lineHeight = 34
const containerHeight = lyricsContainer.value?.clientHeight || 300
const centerOffset = Math.floor(containerHeight / 2 / lineHeight)
const offset = Math.max(0, currentLineIndex.value - centerOffset)
return { transform: `translateY(-${offset * lineHeight}px)` }
})
const repeatTitle = computed(() => {
if (repeatMode.value === 'no_repeat') return '顺序播放'
if (repeatMode.value === 'repeat_one') return '单曲循环'
return '列表循环'
})
// Audio Visualizer
let audioCtx: AudioContext | null = null
let analyser: AnalyserNode | null = null
let source: MediaElementAudioSourceNode | null = null
let animationId: number | null = null
let dataArray: Uint8Array<ArrayBuffer> | null = null
let visualizerConnected = false
// 粒子系统
const particles: Particle[] = []
function initVisualizer(audio: HTMLAudioElement) {
if (visualizerConnected) return
audioCtx = new AudioContext()
analyser = audioCtx.createAnalyser()
analyser.fftSize = 256
source = audioCtx.createMediaElementSource(audio)
source.connect(analyser)
analyser.connect(audioCtx.destination)
dataArray = new Uint8Array(analyser.frequencyBinCount)
visualizerConnected = true
}
function drawVisualizer() {
if (!visualizerCanvas.value || !analyser || !dataArray) return
const canvas = visualizerCanvas.value
const ctx = canvas.getContext('2d')
if (!ctx) return
const width = canvas.width
const height = canvas.height
if (width === 0 || height === 0) return
analyser.getByteFrequencyData(dataArray)
ctx.clearRect(0, 0, width, height)
const { lowEnergy, highEnergy } = drawCircularSpectrum({ ctx, width, height, dataArray })
const centerX = width / 2
const centerY = height / 2
const discRadius = Math.min(width, height) * 0.30
spawnParticles(particles, centerX, centerY, discRadius, lowEnergy, highEnergy)
updateAndDrawParticles(ctx, particles)
animationId = requestAnimationFrame(drawVisualizer)
}
function startVisualizer() {
if (animationId) return
if (audioCtx?.state === 'suspended') audioCtx.resume()
drawVisualizer()
}
function stopVisualizer() {
if (animationId) {
cancelAnimationFrame(animationId)
animationId = null
}
particles.length = 0
}
function destroyVisualizer() {
stopVisualizer()
source?.disconnect()
analyser?.disconnect()
source = null
analyser = null
dataArray = null
visualizerConnected = false
if (audioCtx && audioCtx.state !== 'closed') {
void audioCtx.close()
}
audioCtx = null
}
function destroyHls() {
hls.value?.detachMedia()
hls.value?.destroy()
hls.value = null
}
// Audio controls
function play() {
const media = audioEl.value
if (!media) return
media.play()
}
function pause() {
audioEl.value?.pause()
}
function toggle() {
if (isPlaying.value) pause()
else play()
}
function switchTrack(index: number) {
const normalizedIndex = ((index % props.list.length) + props.list.length) % props.list.length
currentMusic.value = props.list[normalizedIndex]
nextTick(() => play())
}
function resolveProgressRatio(clientX: number) {
if (!progressBar.value) return 0
const rect = progressBar.value.getBoundingClientRect()
if (rect.width === 0) return 0
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
}
function onProgressMouseDown(e: MouseEvent) {
if (!progressBar.value || !audioEl.value) return
const pct = resolveProgressRatio(e.clientX)
if (!isNaN(audioEl.value.duration)) {
audioEl.value.currentTime = audioEl.value.duration * pct
}
const onMove = (ev: MouseEvent) => {
const p = resolveProgressRatio(ev.clientX)
if (!isNaN(audioEl.value!.duration)) {
audioEl.value!.currentTime = audioEl.value!.duration * p
}
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
function onVolumeInput(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value)
volume.value = val
if (audioEl.value) {
audioEl.value.volume = val
if (val > 0) {
audioEl.value.muted = false
muted.value = false
}
}
}
function initAudio() {
const media = audioEl.value
if (!media) return
media.volume = volume.value
media.addEventListener('play', () => {
isPlaying.value = true
emit('play')
})
media.addEventListener('pause', () => { isPlaying.value = false })
media.addEventListener('timeupdate', () => {
playStat.playedTime = media.currentTime
})
media.addEventListener('durationchange', () => {
if (media.duration !== 1) playStat.duration = media.duration
})
media.addEventListener('progress', () => {
if (media.buffered.length) {
playStat.loadedTime = media.buffered.end(media.buffered.length - 1)
}
})
media.addEventListener('ended', () => {
const currentIndex = props.list.indexOf(currentMusic.value)
if (repeatMode.value === 'repeat_one') {
media.currentTime = 0
play()
} else if (repeatMode.value === 'repeat_all') {
switchTrack(currentIndex + 1)
} else {
if (currentIndex < props.list.length - 1) {
switchTrack(currentIndex + 1)
}
}
})
media.addEventListener('volumechange', () => {
volume.value = media.volume
muted.value = media.muted
})
loadSource(currentMusic.value)
}
function loadSource(music: MusicPlayerItem) {
const media = audioEl.value
const src = music.src
if (!media || !src) return
if (/\.m3u8(?=[#?]|$)/.test(src)) {
if (media.canPlayType('application/x-mpegURL') || media.canPlayType('application/vnd.apple.mpegURL')) {
destroyHls()
media.src = src
} else if (Hls.isSupported()) {
destroyHls()
const hlsInstance = new Hls()
hlsInstance.loadSource(src)
hlsInstance.attachMedia(media)
hls.value = hlsInstance
} else {
destroyHls()
media.src = src
}
} else {
destroyHls()
media.src = src
}
}
watch(currentMusic, (music) => {
if (!music) return
loadSource(music)
currentLineIndex.value = 0
playStat.playedTime = 0
playStat.duration = 0
playStat.loadedTime = 0
})
watch(() => props.music, (music) => {
internalMusic.value = music
})
watch(() => playStat.playedTime, (time) => {
const lines = lrcLines.value
for (let i = 0; i < lines.length; i++) {
const nextLine = lines[i + 1]
if (time >= lines[i][0] && (!nextLine || time < nextLine[0])) {
currentLineIndex.value = i
break
}
}
})
watch(isPlaying, (playing) => {
if (playing) startVisualizer()
else stopVisualizer()
})
watch(() => props.list, () => {
playlistVisible.value = props.list.length > 1
}, { immediate: true })
let resizeObserver: ResizeObserver | null = null
function resizeCanvas() {
const canvas = visualizerCanvas.value
if (!canvas) return
const parent = canvas.parentElement
if (!parent) return
const rect = parent.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return
canvas.width = rect.width
canvas.height = rect.height
}
onMounted(() => {
initAudio()
if (props.autoplay) {
nextTick(() => play())
}
nextTick(() => {
if (audioEl.value) {
initVisualizer(audioEl.value)
}
resizeCanvas()
})
resizeObserver = new ResizeObserver(() => {
resizeCanvas()
if (isPlaying.value && visualizerConnected && !animationId) {
startVisualizer()
}
})
if (visualizerCanvas.value?.parentElement) {
resizeObserver.observe(visualizerCanvas.value.parentElement)
}
})
onBeforeUnmount(() => {
destroyVisualizer()
resizeObserver?.disconnect()
destroyHls()
})
defineExpose({
audio: audioEl,
isPlaying,
currentMusic,
playStat,
play,
pause,
toggle,
switch: switchTrack,
shuffle,
repeatMode,
muted,
volume,
})
</script>
<style lang="less" scoped>
// === 颜色变量 ===
@color-primary: #64c8ff;
@color-primary-purple: #a78bfa;
@color-white: #fff;
@color-text: #e0e0e0;
@color-text-secondary: #aaa;
@color-text-muted: #888;
@color-text-dim: #555;
@color-vinyl-dark: #111;
@color-vinyl-groove: #222;
@color-vinyl-groove-alt: #1a1a1a;
@color-vinyl-border: #333;
// === 尺寸变量 ===
@disc-section-width: 420px;
@disc-size: 360px;
@disc-cover-ratio: 52%;
@disc-cover-border: 6px;
@needle-width: 80px;
@needle-height: 134px;
@lyrics-line-height: 34px;
@ctrl-btn-size: 36px;
@ctrl-btn-play-size: 48px;
@playlist-max-height: 140px;
@info-max-width: 380px;
// === 混合 ===
.text-ellipsis() {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.abs-full() {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.abs-center() {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.flex-center() {
display: flex;
align-items: center;
justify-content: center;
}
.music-player-panel {
height: 100%;
display: flex;
flex-direction: column;
color: @color-text;
padding: 24px 30px;
overflow: hidden;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
// === 上半部分 ===
.player-main {
display: flex;
gap: 40px;
flex: 1;
min-height: 0;
overflow: hidden;
}
// === 唱片区域 ===
.disc-section {
flex-shrink: 0;
width: @disc-section-width;
.flex-center();
}
.disc-container {
position: relative;
width: @disc-size;
height: @disc-size;
}
// === 唱针 ===
.needle {
position: absolute;
top: -16px;
right: -10px;
width: @needle-width;
height: @needle-height;
z-index: 10;
transform-origin: 50% 12%;
transform: rotate(-30deg);
transition: transform 0.8s cubic-bezier(0.4, 0, 0.2, 1);
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.5));
&.playing {
transform: rotate(5deg);
}
}
// === 唱片 ===
.disc-outer {
position: relative;
width: @disc-size;
height: @disc-size;
border-radius: 50%;
background: radial-gradient(circle at 50% 50%,
@color-vinyl-dark 0%, @color-vinyl-dark 28%,
@color-vinyl-groove-alt 28.5%, @color-vinyl-groove 29%, @color-vinyl-groove-alt 29.5%,
@color-vinyl-groove 32%, @color-vinyl-groove-alt 32.5%, @color-vinyl-groove 33%,
@color-vinyl-groove-alt 35%, @color-vinyl-groove 35.5%, @color-vinyl-groove-alt 36%,
@color-vinyl-groove 38%, @color-vinyl-groove-alt 38.5%, @color-vinyl-groove 39%,
@color-vinyl-groove-alt 41%, @color-vinyl-groove 41.5%,
@color-vinyl-groove-alt 60%, @color-vinyl-groove 60.5%, @color-vinyl-groove-alt 61%,
@color-vinyl-groove 63%, @color-vinyl-groove-alt 63.5%, @color-vinyl-groove 64%,
@color-vinyl-groove-alt 66%, @color-vinyl-groove 66.5%, @color-vinyl-groove-alt 67%,
@color-vinyl-groove 69%, @color-vinyl-groove-alt 69.5%, @color-vinyl-groove 70%,
@color-vinyl-groove-alt 72%, @color-vinyl-groove 72.5%, @color-vinyl-groove-alt 73%,
@color-vinyl-groove 75%, @color-vinyl-groove-alt 75.5%, @color-vinyl-groove 76%,
@color-vinyl-groove-alt 78%, @color-vinyl-groove 78.5%, @color-vinyl-groove-alt 79%,
@color-vinyl-groove 81%, @color-vinyl-groove-alt 81.5%, @color-vinyl-groove 82%,
@color-vinyl-groove-alt 84%, @color-vinyl-groove 84.5%, @color-vinyl-groove-alt 85%,
@color-vinyl-groove 87%, @color-vinyl-groove-alt 87.5%, @color-vinyl-groove 88%,
@color-vinyl-groove-alt 90%, @color-vinyl-groove 90.5%, @color-vinyl-groove-alt 91%,
@color-vinyl-groove 93%, @color-vinyl-groove-alt 93.5%, @color-vinyl-groove 94%,
@color-vinyl-groove-alt 96%, @color-vinyl-groove 96.5%, @color-vinyl-groove-alt 97%,
@color-vinyl-border 98%, @color-vinyl-groove 100%
);
box-shadow: 0 0 0 4px @color-vinyl-border, 0 0 40px rgba(0, 0, 0, 0.6), inset 0 0 30px rgba(0, 0, 0, 0.3);
animation: disc-spin 20s linear infinite;
animation-play-state: paused;
&.playing {
animation-play-state: running;
}
}
.vinyl-grooves {
.abs-full();
border-radius: 50%;
background: repeating-radial-gradient(
circle at center,
transparent 0px,
transparent 2px,
rgba(255, 255, 255, 0.02) 2.5px,
transparent 3px
);
pointer-events: none;
}
.visualizer-canvas {
.abs-full();
border-radius: 50%;
z-index: 3;
pointer-events: none;
}
.disc-cover-wrapper {
.abs-center();
width: @disc-cover-ratio;
height: @disc-cover-ratio;
border-radius: 50%;
overflow: hidden;
z-index: 2;
border: @disc-cover-border solid @color-vinyl-border;
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.5);
}
.disc-cover {
width: 100%;
height: 100%;
object-fit: cover;
&.disc-cover-empty {
background: radial-gradient(circle, #2a2a2a 30%, @color-vinyl-dark 70%);
}
}
.disc-center-dot {
.abs-center();
width: 16px;
height: 16px;
border-radius: 50%;
background: radial-gradient(circle, #666 0%, @color-vinyl-border 60%, @color-vinyl-groove 100%);
border: 2px solid @color-dim-border;
z-index: 4;
}
@color-dim-border: #555;
@keyframes disc-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// === 歌曲信息 ===
.info-section {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
max-width: @info-max-width;
}
.song-info {
margin-bottom: 20px;
flex-shrink: 0;
min-height: 60px;
}
.song-title {
font-size: 22px;
font-weight: 600;
margin: 0 0 8px 0;
color: @color-white;
.text-ellipsis();
}
.song-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 13px;
color: @color-text-secondary;
}
// === 歌词 ===
.lyrics-area {
flex: 1;
overflow: hidden;
position: relative;
mask-image: linear-gradient(180deg, transparent 0%, #000 8%, #000 82%, transparent 100%);
-webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 8%, #000 82%, transparent 100%);
}
.lyrics-content {
transition: transform 0.6s cubic-bezier(0.25, 0.1, 0.25, 1);
p {
line-height: @lyrics-line-height;
height: @lyrics-line-height;
margin: 0;
padding: 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.35);
transition: all 0.4s ease;
.text-ellipsis();
&.current-line {
color: @color-white;
font-size: 16px;
font-weight: 500;
}
}
}
.no-lyrics {
.flex-center();
height: 100%;
color: @color-text-dim;
font-size: 14px;
}
// === 进度条 ===
.progress-section {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0 6px 0;
flex-shrink: 0;
}
.time-label {
font-size: 12px;
color: @color-text-muted;
min-width: 42px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.progress-bar {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
position: relative;
cursor: pointer;
&:hover .progress-thumb {
opacity: 1;
}
}
.progress-loaded {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: rgba(255, 255, 255, 0.12);
border-radius: 2px;
transition: width 0.3s;
}
.progress-played {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: linear-gradient(90deg, @color-primary, @color-primary-purple);
border-radius: 2px;
}
.progress-thumb {
position: absolute;
right: -5px;
top: -3px;
width: 10px;
height: 10px;
border-radius: 50%;
background: @color-white;
box-shadow: 0 0 6px rgba(100, 200, 255, 0.8);
opacity: 0;
transition: opacity 0.2s;
}
// === 控制按钮 ===
.controls-section {
.flex-center();
gap: 16px;
padding: 6px 0 10px 0;
flex-shrink: 0;
}
.ctrl-btn {
width: @ctrl-btn-size;
height: @ctrl-btn-size;
border: none;
background: transparent;
cursor: pointer;
.flex-center();
border-radius: 50%;
transition: all 0.2s;
padding: 8px;
color: rgba(255, 255, 255, 0.5);
&:hover {
color: @color-white;
background: rgba(255, 255, 255, 0.08);
}
&.active {
color: @color-primary;
}
svg {
width: 100%;
height: 100%;
}
&.ctrl-btn-play {
width: @ctrl-btn-play-size;
height: @ctrl-btn-play-size;
background: rgba(100, 200, 255, 0.15);
color: @color-white;
padding: 12px;
border: 1px solid rgba(100, 200, 255, 0.3);
&:hover {
background: rgba(100, 200, 255, 0.3);
}
}
}
.volume-control {
display: flex;
align-items: center;
gap: 4px;
margin-left: 8px;
}
.volume-slider {
width: 70px;
height: 3px;
accent-color: @color-primary;
cursor: pointer;
}
// === 播放列表 ===
.playlist-section {
flex-shrink: 0;
margin-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.playlist-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
cursor: pointer;
font-size: 13px;
color: @color-text-muted;
&:hover {
color: #ccc;
}
}
.playlist-toggle {
font-size: 12px;
color: @color-text-dim;
}
.playlist-body {
max-height: @playlist-max-height;
overflow-y: auto;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
}
.playlist-item {
display: flex;
align-items: center;
padding: 5px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.06);
}
&.active {
background: rgba(100, 200, 255, 0.12);
color: @color-primary;
.playlist-index {
color: @color-primary;
}
}
}
.playlist-index {
width: 24px;
text-align: center;
color: @color-text-dim;
flex-shrink: 0;
}
.playlist-title {
flex: 1;
.text-ellipsis();
}
.playlist-artist {
color: @color-text-dim;
margin-left: 12px;
flex-shrink: 0;
font-size: 12px;
}
// === 过渡动画 ===
.slide-enter-active, .slide-leave-active {
transition: all 0.3s ease;
max-height: @playlist-max-height;
overflow: hidden;
}
.slide-enter-from, .slide-leave-to {
max-height: 0;
opacity: 0;
}
</style>
+195
View File
@@ -0,0 +1,195 @@
/**
* 音频可视化 - 粒子系统与圆形频谱绘制
*
* 负责唱片周围的圆形音频频谱条绘制, 以及随节奏从唱片边缘飘散的粒子效果。
* 频谱条根据音频频率数据径向排列在唱片外圈, 粒子根据低频/高频能量动态生成。
*/
/** 单个粒子的状态描述 */
export interface Particle {
x: number // 当前 x 坐标
y: number // 当前 y 坐标
vx: number // x 方向速度
vy: number // y 方向速度
life: number // 已存活帧数
maxLife: number // 最大存活帧数
size: number // 粒子半径
r: number // 颜色 R 分量
g: number // 颜色 G 分量
b: number // 颜色 B 分量
}
/** 粒子数量上限, 防止性能问题 */
const MAX_PARTICLES = 200
/**
* 在唱片边缘生成新粒子
*
* 粒子从唱片圆周的随机角度向外发射, 数量由低频能量决定, 速度由高频能量加成。
* 颜色在蓝紫色调范围内随机偏移。
*
* @param particles - 粒子数组(会被直接修改)
* @param centerX - 唱片圆心 x 坐标
* @param centerY - 唱片圆心 y 坐标
* @param discRadius - 唱片半径(像素)
* @param energy - 低频能量(0~1), 决定生成数量和粒子大小
* @param highEnergy - 高频能量(0~1), 决定发射速度和颜色偏移
*/
export function spawnParticles(particles: Particle[], centerX: number, centerY: number, discRadius: number, energy: number, highEnergy: number) {
const count = Math.floor(energy * 4)
for (let i = 0; i < count && particles.length < MAX_PARTICLES; i++) {
const angle = Math.random() * Math.PI * 2
const speed = 0.5 + energy * 2 + highEnergy * 1.5
const hue = Math.random()
particles.push({
x: centerX + Math.cos(angle) * (discRadius + 4),
y: centerY + Math.sin(angle) * (discRadius + 4),
vx: Math.cos(angle) * speed * (0.6 + Math.random() * 0.8),
vy: Math.sin(angle) * speed * (0.6 + Math.random() * 0.8),
life: 1,
maxLife: 40 + Math.random() * 40,
size: 1.5 + energy * 2 + Math.random() * 1.5,
r: Math.floor(100 + hue * 155),
g: Math.floor(180 - hue * 100 + highEnergy * 60),
b: Math.floor(220 + hue * 35),
})
}
}
/**
* 更新粒子物理状态并绘制到 Canvas
*
* 每帧对所有粒子: 移动位置、衰减速度、递增生命周期。
* 生命结束的粒子被移除, 存活的粒子按透明度渐变绘制, 较大粒子额外绘制发光光圈。
*
* @param ctx - Canvas 2D 渲染上下文
* @param particles - 粒子数组(会被直接修改, 移除死亡粒子)
*/
export function updateAndDrawParticles(ctx: CanvasRenderingContext2D, particles: Particle[]) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i]
p.x += p.vx
p.y += p.vy
p.vx *= 0.98
p.vy *= 0.98
p.life += 1
const progress = p.life / p.maxLife
if (progress >= 1) {
particles.splice(i, 1)
continue
}
const alpha = 1 - progress
const size = p.size * (1 - progress * 0.5)
ctx.beginPath()
ctx.arc(p.x, p.y, size, 0, Math.PI * 2)
ctx.fillStyle = `rgba(${p.r}, ${p.g}, ${p.b}, ${alpha * 0.7})`
ctx.fill()
// 发光效果: 在粒子外围画一个更大的半透明圆
if (size > 1.5) {
ctx.beginPath()
ctx.arc(p.x, p.y, size * 2, 0, Math.PI * 2)
ctx.fillStyle = `rgba(${p.r}, ${p.g}, ${p.b}, ${alpha * 0.15})`
ctx.fill()
}
}
}
/** drawCircularSpectrum 的入参 */
export interface SpectrumParams {
ctx: CanvasRenderingContext2D
width: number // Canvas 宽度
height: number // Canvas 高度
dataArray: Uint8Array<ArrayBuffer> // Web Audio API 频率数据
}
/** 频率能量分析结果 */
export interface EnergyResult {
lowEnergy: number // 低频段能量(0~1), 对应鼓点/贝斯
highEnergy: number // 高频段能量(0~1), 对应人声/镲片
}
/**
* 从频率数据中计算低频和高频的平均能量
*
* 将频率数据分为低频段(前25%)和高频段(后40%), 分别求归一化均值。
* 用于驱动粒子系统的生成参数。
*
* @param dataArray - FFT 频率数据(0~255)
* @returns 低频和高频的归一化能量值
*/
function computeFrequencyEnergy(dataArray: Uint8Array<ArrayBuffer>): EnergyResult {
const barCount = dataArray.length
let lowSum = 0, highSum = 0
const lowEnd = Math.floor(barCount * 0.25)
const highStart = Math.floor(barCount * 0.6)
for (let i = 0; i < lowEnd; i++) lowSum += dataArray[i]
for (let i = highStart; i < barCount; i++) highSum += dataArray[i]
return {
lowEnergy: lowSum / (lowEnd * 255),
highEnergy: highSum / ((barCount - highStart) * 255),
}
}
/**
* 绘制圆形音频频谱
*
* 以 Canvas 中心为圆心, 将频率数据的每个频段绘制为一条从唱片边缘向外辐射的线段。
* 线段长度与频率振幅成正比, 颜色沿频率轴从蓝色渐变到紫粉色。
* 绘制完成后自动计算并返回频率能量, 供粒子系统使用。
*
* @param params - 包含 Canvas 上下文、尺寸和频率数据
* @returns 低频/高频能量分析结果
*/
export function drawCircularSpectrum(params: SpectrumParams): EnergyResult {
const { ctx, width, height, dataArray } = params
const centerX = width / 2
const centerY = height / 2
const discRadius = Math.min(width, height) * 0.30
const barCount = dataArray.length
const maxBarLength = Math.min(width, height) * 0.18
for (let i = 0; i < barCount; i++) {
const value = dataArray[i]
const barLength = (value / 255) * maxBarLength
if (barLength < 1) continue
const angle = (i / barCount) * Math.PI * 2 - Math.PI / 2
const startX = centerX + Math.cos(angle) * (discRadius + 2)
const startY = centerY + Math.sin(angle) * (discRadius + 2)
const endX = centerX + Math.cos(angle) * (discRadius + 2 + barLength)
const endY = centerY + Math.sin(angle) * (discRadius + 2 + barLength)
const ratio = i / barCount
const r = Math.floor(100 + ratio * 155)
const g = Math.floor(180 - ratio * 100)
const b = Math.floor(230 + ratio * 25)
const alpha = 0.4 + (value / 255) * 0.6
ctx.beginPath()
ctx.moveTo(startX, startY)
ctx.lineTo(endX, endY)
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`
ctx.lineWidth = Math.max(1.5, (Math.PI * 2 * discRadius) / barCount * 0.7)
ctx.lineCap = 'round'
ctx.stroke()
}
return computeFrequencyEnergy(dataArray)
}
/**
* 将秒数格式化为 MM:SS 格式
*
* @param sec - 秒数
* @returns 格式化后的时间字符串, 如 "03:25"
*/
export function formatTime(sec: number) {
if (isNaN(sec)) return '00:00'
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
-859
View File
@@ -1,859 +0,0 @@
<template>
<div
class="aplayer"
:class="{
'aplayer-narrow': mini,
'aplayer-withlist' : !mini && list.length > 0,
'aplayer-withlrc': !mini && (!!$slots.display || showLrc),
'aplayer-float': isFloatMode,
'aplayer-loading': isPlaying && isLoading
}"
:style="floatStyleObj"
>
<div class="aplayer-body">
<thumbnail
:pic="currentMusic.pic"
:playing="isPlaying"
:enable-drag="isFloatMode"
:theme="currentMusic.theme || theme"
@toggleplay="toggle"
@dragbegin="onDragBegin"
@dragging="onDragAround"
@adaptingTheme="setSelfAdaptingTheme"
/>
<div class="aplayer-info" v-show="!mini">
<div class="aplayer-music">
<span class="aplayer-title">{{ currentMusic.title || 'Untitled' }}</span>
<span class="aplayer-author">{{ currentMusic.artist || currentMusic.author || 'Unknown' }}</span>
</div>
<slot name="display" :current-music="currentMusic" :play-stat="playStat">
<lyrics :lrc-source="currentMusic.lrc" :play-stat="playStat" v-if="showLrc && currentMusic.lrc" />
</slot>
<controls
:shuffle="shouldShuffle"
:repeat="repeatMode"
:stat="playStat"
:volume="audioVolume"
:muted="isAudioMuted"
:theme="currentTheme"
:showList="showList"
:isMobile="isMobile"
@toggleshuffle="shouldShuffle = !shouldShuffle"
@togglelist="showList = !showList"
@togglemute="toggleMute"
@setvolume="setAudioVolume"
@dragbegin="onProgressDragBegin"
@dragend="onProgressDragEnd"
@dragging="onProgressDragging"
@nextmode="setNextMode"
/>
</div>
</div>
<audio ref="audio"></audio>
<music-list
:show="showList && !mini"
:current-music="currentMusic"
:music-list="list"
:play-index="playIndex"
:listMaxHeight="listMaxHeight"
:theme="currentTheme"
@selectsong="onSelectSong"
/>
</div>
</template>
<script>
import Thumbnail from './components/aplayer-thumbnail.vue'
import MusicList from './components/aplayer-list.vue'
import Controls from './components/aplayer-controller.vue'
import Lyrics from './components/aplayer-lrc.vue'
import { warn } from './utils'
// mutex playing instance
let activeMutex = null
const REPEAT = {
NONE: 'none',
MUSIC: 'music',
LIST: 'list',
NO_REPEAT: 'no_repeat',
REPEAT_ONE: 'repeat_one',
REPEAT_ALL: 'repeat_all',
};
const VueAPlayer = {
name: 'APlayer',
disableVersionBadge: false,
components: {
Thumbnail,
Controls,
MusicList,
Lyrics,
},
props: {
music: {
type: Object,
required: true,
validator (song) {
return song.src
},
},
list: {
type: Array,
default () {
return []
},
},
mini: {
type: Boolean,
default: false,
},
showLrc: {
type: Boolean,
default: false,
},
mutex: {
type: Boolean,
default: true,
},
theme: {
type: String,
default: '#41b883',
},
listMaxHeight: String,
/**
* @since 1.4.1
* Fold playlist initially
*/
listFolded: {
type: Boolean,
default: false,
},
/**
* @since 1.2.0 Float mode
*/
float: {
type: Boolean,
default: false,
},
// Audio attributes as props
// since 1.4.0
// autoplay muted preload volume
// autoplay is not observable
/**
* @since 1.4.0
* not observable
*/
autoplay: {
type: Boolean,
default: false,
},
/**
* @since 1.4.0
* observable, sync
*/
muted: {
type: Boolean,
default: false,
},
/**
* @since 1.4.0
* observable
*/
preload: {
type: String,
default: 'none'
},
/**
* @since 1.4.0
* observable, sync
*/
volume: {
type: Number,
default: 0.5,
validator (value) {
return value >= 0 && value <= 1
},
},
// play order control
// since 1.5.0
/**
* @since 1.5.0
* @see https://support.apple.com/en-us/HT207230
* twoWay
*/
shuffle: {
type: Boolean,
default: false,
},
/**
* @since 1.5.0
* @see https://support.apple.com/en-us/HT207230
* twoWay
*/
repeat: {
type: String,
default: REPEAT.NO_REPEAT,
}
},
data () {
return {
internalMusic: this.music,
isPlaying: false,
isSeeking: false,
wasPlayingBeforeSeeking: false,
isMobile: /mobile/i.test(window.navigator.userAgent),
playStat: {
duration: 0,
loadedTime: 0,
playedTime: 0,
},
showList: !this.listFolded,
// handle Promise returned from audio.play()
// @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play
audioPlayPromise: Promise.resolve(),
rejectPlayPromise: Promise.reject(),
// @since 1.2.0 float mode
floatOriginX: 0,
floatOriginY: 0,
floatOffsetLeft: 0,
floatOffsetTop: 0,
// @since 1.3.0 self adapting theme
selfAdaptingTheme: null,
// @since 1.4.0
// sync muted, volume
internalMuted: this.muted,
internalVolume: this.volume,
// @since 1.4.1
// Loading indicator
isLoading: false,
// @since 1.5.1
// sync shuffle, repeat
internalShuffle: this.shuffle,
internalRepeat: this.repeat,
// for shuffling
shuffledList: [],
hls: null
}
},
computed: {
// alias for $refs.audio
audio () {
return this.$refs.audio
},
// sync music
currentMusic: {
get () {
return this.internalMusic
},
set (val) {
this.$emit('update:music', val)
this.internalMusic = val
},
},
// props wrappers
currentTheme () {
return this.selfAdaptingTheme || this.currentMusic.theme || this.theme
},
isFloatMode () {
return this.float && !this.isMobile
},
shouldAutoplay () {
if (this.isMobile) return false
return this.autoplay
},
// useful
floatStyleObj () {
// transform: translate(floatOffsetLeft, floatOffsetY)
return {
transform: `translate(${this.floatOffsetLeft}px, ${this.floatOffsetTop}px)`,
webkitTransform: `translate(${this.floatOffsetLeft}px, ${this.floatOffsetTop}px)`,
}
},
currentPicStyleObj () {
if (this.currentMusic && this.currentMusic.pic) {
return {
backgroundImage: `url(${this.currentMusic.pic})`,
}
}
return {}
},
loadProgress () {
if (this.playStat.duration === 0) return 0
return this.playStat.loadedTime / this.playStat.duration
},
playProgress () {
if (this.playStat.duration === 0) return 0
return this.playStat.playedTime / this.playStat.duration
},
playIndex: {
get () {
return this.shuffledList.indexOf(this.currentMusic)
},
set (val) {
this.currentMusic = this.shuffledList[val % this.shuffledList.length]
},
},
// since 1.4.0
// sync muted, volume
isAudioMuted: {
get () {
return this.internalMuted
},
set (val) {
this.$emit('update:muted', val)
this.internalMuted = val
},
},
audioVolume: {
get () {
return this.internalVolume
},
set (val) {
this.$emit('update:volume', val)
this.internalVolume = val
},
},
// since 1.5.0
// sync shuffle, repeat
shouldShuffle: {
get () {
return this.internalShuffle
},
set (val) {
this.$emit('update:shuffle', val)
this.internalShuffle = val
},
},
repeatMode: {
get () {
switch (this.internalRepeat) {
case REPEAT.NONE:
case REPEAT.NO_REPEAT:
return REPEAT.NO_REPEAT
case REPEAT.MUSIC:
case REPEAT.REPEAT_ONE:
return REPEAT.REPEAT_ONE
default:
return REPEAT.REPEAT_ALL
}
},
set (val) {
this.$emit('update:repeat', val)
this.internalRepeat = val
},
},
},
methods: {
// Float mode
onDragBegin () {
this.floatOriginX = this.floatOffsetLeft
this.floatOriginY = this.floatOffsetTop
},
onDragAround ({ offsetLeft, offsetTop }) {
this.floatOffsetLeft = this.floatOriginX + offsetLeft
this.floatOffsetTop = this.floatOriginY + offsetTop
},
// functions
setNextMode () {
if (this.repeatMode === REPEAT.REPEAT_ALL) {
this.repeatMode = REPEAT.REPEAT_ONE
} else if (this.repeatMode === REPEAT.REPEAT_ONE) {
this.repeatMode = REPEAT.NO_REPEAT
} else {
this.repeatMode = REPEAT.REPEAT_ALL
}
},
thenPlay () {
this.$nextTick(() => {
this.play()
})
},
toggle () {
if (!this.audio.paused) {
this.pause()
} else {
this.play()
}
},
play () {
if (this.mutex) {
if (activeMutex && activeMutex !== this) {
activeMutex.pause()
}
activeMutex = this
}
// handle .play() Promise
const audioPlayPromise = this.audio.play()
if (audioPlayPromise) {
return this.audioPlayPromise = new Promise((resolve, reject) => {
// rejectPlayPromise is to force reject audioPlayPromise if it's still pending when pause() is called
this.rejectPlayPromise = reject
audioPlayPromise.then((res) => {
this.rejectPlayPromise = null
resolve(res)
}).catch(warn)
})
}
},
pause () {
this.audioPlayPromise
.then(() => {
this.audio.pause()
})
// Avoid force rejection throws Uncaught
.catch(() => {
this.audio.pause()
})
// audioPlayPromise is still pending
if (this.rejectPlayPromise) {
// force reject playPromise
this.rejectPlayPromise()
this.rejectPlayPromise = null
}
},
switch(index) {
this.playIndex = index
this.thenPlay()
},
// progress bar
onProgressDragBegin (val) {
this.wasPlayingBeforeSeeking = this.isPlaying
this.pause()
this.isSeeking = true
// handle load failures
if (!isNaN(this.audio.duration)) {
this.audio.currentTime = this.audio.duration * val
}
},
onProgressDragging (val) {
if (isNaN(this.audio.duration)) {
this.playStat.playedTime = 0
} else {
this.audio.currentTime = this.audio.duration * val
}
},
onProgressDragEnd (val) {
this.isSeeking = false
if (this.wasPlayingBeforeSeeking) {
this.thenPlay()
}
},
// volume
toggleMute () {
this.setAudioMuted(!this.audio.muted)
},
setAudioMuted (val) {
this.audio.muted = val
},
setAudioVolume (val) {
this.audio.volume = val
if (val > 0) {
this.setAudioMuted(false)
}
},
// playlist
getShuffledList () {
if (!this.list.length) {
return [this.internalMusic]
}
let unshuffled = [...this.list]
if (!this.internalShuffle || unshuffled.length <= 1) {
return unshuffled
}
let indexOfCurrentMusic = unshuffled.indexOf(this.internalMusic)
if (unshuffled.length === 2 && indexOfCurrentMusic !== -1) {
if (indexOfCurrentMusic === 0) {
return unshuffled
} else {
return [this.internalMusic, unshuffled[0]]
}
}
// shuffle list
// @see https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array
for (let i = unshuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
const tmp = unshuffled[i]
unshuffled[i] = unshuffled[j]
unshuffled[j] = tmp
}
// take currentMusic to first
if (indexOfCurrentMusic !== -1) {
indexOfCurrentMusic = unshuffled.indexOf(this.internalMusic)
if (indexOfCurrentMusic !== 0) {
[unshuffled[0], unshuffled[indexOfCurrentMusic]] = [unshuffled[indexOfCurrentMusic], unshuffled[0]]
}
}
return unshuffled
},
onSelectSong (song) {
if (this.currentMusic === song) {
this.toggle()
} else {
this.currentMusic = song
this.thenPlay()
}
},
// event handlers
// for keeping up with audio states
onAudioPlay () {
this.isPlaying = true
},
onAudioPause () {
this.isPlaying = false
},
onAudioWaiting () {
this.isLoading = true
},
onAudioCanplay () {
this.isLoading = false
},
onAudioDurationChange () {
if (this.audio.duration !== 1) {
this.playStat.duration = this.audio.duration
}
},
onAudioProgress () {
if (this.audio.buffered.length) {
this.playStat.loadedTime = this.audio.buffered.end(this.audio.buffered.length - 1)
} else {
this.playStat.loadedTime = 0
}
},
onAudioTimeUpdate () {
this.playStat.playedTime = this.audio.currentTime
},
onAudioSeeking () {
this.playStat.playedTime = this.audio.currentTime
},
onAudioSeeked () {
this.playStat.playedTime = this.audio.currentTime
},
onAudioVolumeChange () {
this.audioVolume = this.audio.volume
this.isAudioMuted = this.audio.muted
},
onAudioEnded () {
// determine next song according to shuffle and repeat
if (this.repeatMode === REPEAT.REPEAT_ALL) {
if (this.shouldShuffle && this.playIndex === this.shuffledList.length - 1) {
this.shuffledList = this.getShuffledList()
}
this.playIndex++
this.thenPlay()
} else if (this.repeatMode === REPEAT.REPEAT_ONE) {
this.thenPlay()
} else {
this.playIndex++
if (this.playIndex !== 0) {
this.thenPlay()
} else if (this.shuffledList.length === 1) {
this.audio.currentTime = 0
}
}
},
initAudio () {
// since 1.4.0 Audio attributes as props
this.audio.muted = this.muted
this.audio.preload = this.preload
this.audio.volume = this.volume
// since 1.4.0 Emit as many native audio events
// @see https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Media_events
const mediaEvents = [
'abort',
'canplay', 'canplaythrough',
'durationchange',
'emptied', 'encrypted', 'ended', 'error',
'interruptbegin', 'interruptend',
'loadeddata', 'loadedmetadata', 'loadstart',
'mozaudioavailable',
'pause', 'play', 'playing', 'progress',
'ratechange',
'seeked', 'seeking', 'stalled', 'suspend',
'timeupdate',
'volumechange',
'waiting',
]
mediaEvents.forEach(event => {
this.audio.addEventListener(event, e => this.$emit(event, e))
})
// event handlers
// they don't emit native media events
this.audio.addEventListener('play', this.onAudioPlay)
this.audio.addEventListener('pause', this.onAudioPause)
this.audio.addEventListener('abort', this.onAudioPause)
this.audio.addEventListener('waiting', this.onAudioWaiting)
this.audio.addEventListener('canplay', this.onAudioCanplay)
this.audio.addEventListener('progress', this.onAudioProgress)
this.audio.addEventListener('durationchange', this.onAudioDurationChange)
this.audio.addEventListener('seeking', this.onAudioSeeking)
this.audio.addEventListener('seeked', this.onAudioSeeked)
this.audio.addEventListener('timeupdate', this.onAudioTimeUpdate)
this.audio.addEventListener('volumechange', this.onAudioVolumeChange)
this.audio.addEventListener('ended', this.onAudioEnded)
if (this.currentMusic) {
this.audio.src = this.currentMusic.src || this.currentMusic.url
}
},
setSelfAdaptingTheme (color) {
this.selfAdaptingTheme = color
}
},
watch: {
music (music) {
this.internalMusic = music
},
currentMusic: {
handler (music) {
const src = music.src || music.url
// HLS support
if (/\.m3u8(?=(#|\?|$))/.test(src)) {
if (this.audio.canPlayType('application/x-mpegURL') || this.audio.canPlayType('application/vnd.apple.mpegURL')) {
this.audio.src = src
} else {
try {
const Hls = require('hls.js')
if (Hls.isSupported()) {
if (!this.hls) {
this.hls = new Hls()
}
this.hls.loadSource(src)
this.hls.attachMedia(this.audio)
} else {
warn('HLS is not supported on your browser')
this.audio.src = src
}
} catch (e) {
warn('hls.js is required to support m3u8')
this.audio.src = src
}
}
} else {
this.audio.src = src
}
// self-adapting theme color
},
},
// since 1.4.0
// observe muted, preload, volume
isAudioMuted (val) {
this.audio.muted = val
},
preload (val) {
this.audio.preload = val
},
audioVolume (val) {
this.audio.volume = val
},
// sync muted, volume
muted (val) {
this.internalMuted = val
},
volume (val) {
this.internalVolume = val
},
// sync shuffle, repeat
shuffle (val) {
this.internalShuffle = val
},
repeat (val) {
this.internalRepeat = val
},
},
created () {
this.shuffledList = this.getShuffledList()
},
mounted () {
this.initAudio()
if (this.autoplay) this.play()
},
beforeDestroy () {
if (activeMutex === this) {
activeMutex = null
}
if (this.hls) {
this.hls.destroy()
}
},
}
export default VueAPlayer
</script>
<style lang="less" scoped>
@import "./less/variables";
.aplayer {
font-family: Arial, Helvetica, sans-serif;
color: #000;
background-color: #fff;
margin: 5px;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.07), 0 1px 5px 0 rgba(0, 0, 0, 0.1);
border-radius: 2px;
overflow: hidden;
user-select: none;
line-height: initial;
* {
box-sizing: content-box;
}
.aplayer-lrc-content {
display: none;
}
.aplayer-body {
display: flex;
position: relative;
.aplayer-info {
flex-grow: 1;
display: flex;
flex-direction: column;
text-align: start;
padding: 14px 7px 0 10px;
height: @aplayer-height;
box-sizing: border-box;
overflow: hidden;
.aplayer-music {
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-left: 5px;
user-select: text;
cursor: default;
padding-bottom: 2px;
.aplayer-title {
font-size: 14px;
}
.aplayer-author {
font-size: 12px;
color: #666;
margin-left: 10px;
}
}
.aplayer-lrc {
z-index: 0;
}
}
}
// Mini mode
&.aplayer-narrow {
width: @aplayer-height;
}
&.aplayer-withlrc {
.aplayer-body {
.aplayer-pic {
height: @aplayer-height-lrc;
width: @aplayer-height-lrc;
}
.aplayer-info {
height: @aplayer-height-lrc;
}
.aplayer-info {
padding: 10px 7px 0 7px;
}
}
}
&.aplayer-withlist {
.aplayer-body {
.aplayer-info {
border-bottom: 1px solid #e9e9e9;
}
.aplayer-controller .aplayer-time .aplayer-icon.aplayer-icon-menu {
display: block;
}
}
}
/* floating player on top */
position: relative;
&.aplayer-float {
z-index: 1;
}
}
@keyframes aplayer-roll {
0% {
left: 0
}
100% {
left: -100%
}
}
</style>
+150
View File
@@ -0,0 +1,150 @@
<template>
<div>
<div class="hero-actions">
<el-button type="primary" @click="startCaptcha" plain>开始验证</el-button>
<el-button @click="reloadCaptcha" :disabled="!captchaActive">刷新</el-button>
<el-button @click="destroyCaptcha" :disabled="!captchaActive" plain>销毁</el-button>
</div>
<div class="sandbox-grid">
<el-card shadow="never">
<template #header>验证码类型</template>
<p class="type-hint">
可同时勾选多种类型;不选时将按随机类型请求。
</p>
<el-checkbox-group v-model="selectedTypes" size="large">
<el-checkbox-button v-for="item in captchaTypeOptions" :key="item.value" :value="item.value">
{{ item.label }}
</el-checkbox-button>
</el-checkbox-group>
<div class="type-actions">
<el-button link type="primary" @click="clearTypes" :disabled="selectedTypes.length === 0">恢复随机</el-button>
</div>
</el-card>
<el-card class="preview-card" shadow="never">
<template #header>验证码预览</template>
<CaptchaPanel
ref="captchaRef"
class="captcha-box"
mode="validate"
:types="selectedTypes"
@close="handleCaptchaClose"
@state-change="handleCaptchaStateChange"
@valid-fail="handleValidFail"
@valid-success="handleValidSuccess"
/>
</el-card>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import CaptchaPanel from '@/components/CaptchaPanel.vue'
type CaptchaType = 'SLIDER' | 'ROTATE' | 'CONCAT' | 'WORD_IMAGE_CLICK'
type CaptchaPanelRef = {
destroy: () => void
init: () => Promise<void>
reload: () => void
}
const captchaTypeOptions: Array<{ label: string, value: CaptchaType }> = [
{ label: '滑块拼图', value: 'SLIDER' },
{ label: '旋转', value: 'ROTATE' },
{ label: '拼接', value: 'CONCAT' },
{ label: '汉字点选', value: 'WORD_IMAGE_CLICK' },
]
const captchaRef = ref<CaptchaPanelRef | null>(null)
const captchaActive = ref(false)
const selectedTypes = ref<CaptchaType[]>([])
async function startCaptcha() {
console.info('[captcha sandbox] init captcha', {
types: selectedTypes.value,
mode: 'validate'
})
await captchaRef.value?.init()
}
function clearTypes() {
selectedTypes.value = []
}
function reloadCaptcha() {
if (!captchaActive.value) return
console.info('[captcha sandbox] manual reload')
captchaRef.value?.reload()
}
function destroyCaptcha(showMessage = true) {
if (!captchaActive.value) return
captchaRef.value?.destroy()
if (showMessage) {
console.info('[captcha sandbox] destroy captcha instance')
}
}
function handleCaptchaClose() {
console.info('[captcha sandbox] close captcha window')
}
function handleCaptchaStateChange(active: boolean) {
captchaActive.value = active
}
function handleValidSuccess(res: any) {
console.log('[captcha sandbox] valid success', res)
captchaRef.value?.destroy()
}
function handleValidFail(res: any) {
console.warn('[captcha sandbox] valid fail', res)
captchaRef.value?.reload()
}
onBeforeUnmount(() => captchaRef.value?.destroy())
</script>
<style lang="less" scoped>
.hero-actions {
margin-bottom: 24px;
}
.type-hint {
margin: 0 0 16px;
color: #606266;
line-height: 1.6;
}
.type-actions {
margin-top: 12px;
}
.sandbox-grid {
display: flex;
gap: 20px;
.el-card {
flex: 1;
&.preview-card {
flex: 0 0 420px;
}
}
}
.captcha-box {
min-height: 350px;
display: flex;
align-items: center;
justify-content: center;
}
</style>
+131 -151
View File
@@ -1,6 +1,5 @@
<template>
<div>
<div class="search-panel">
<div class="page-wrapper">
<el-form inline :model="search">
<el-form-item label="标题">
<el-input v-model="search.title" />
@@ -30,34 +29,28 @@
</el-select>
</el-form-item>
</el-form>
</div>
<div class="btn-container">
<el-button type="primary" @click="splitWord" style="vertical-align: bottom">分词处理</el-button>
<el-button @click="pullArticles" style="vertical-align: bottom">拉取文章</el-button>
<el-upload
action="/api/system/deployBlog"
accept="application/zip"
name="blogZip"
:headers="{token: $store.state.loginInfo.token}"
:before-upload="beforeUpload"
:on-success="uploadSuccess"
:on-error="uploadError"
auto-upload
:show-file-list="false"
style="display: inline-block;margin-left: 10px;">
<el-button type="primary" icon="Upload" :loading="isUploading">发布博客</el-button>
</el-upload>
<el-button type="primary" @click="splitWord" style="vertical-align: bottom" v-permission="'article:edit'" plain>分词处理</el-button>
<el-button @click="pullArticles" style="vertical-align: bottom" v-permission="'article:edit'" plain>拉取文章</el-button>
<div class="search-btn">
<el-button type="primary" @click="loadDataBase(true)" icon="Search">搜索</el-button>
<el-button @click="reset" icon="RefreshLeft">重置</el-button>
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
</div>
</div>
<el-row>
<el-col :span="4" style="height: 520px;overflow: auto;">
<el-tree :props="treeProps" :load="loadTreeData" lazy highlight-current @node-click="articlePreview" />
<el-row style="flex: 1; overflow: hidden;">
<el-col :span="4" style="height: 100%; overflow: auto;">
<el-tree :props="treeProps" :load="loadTreeData" lazy highlight-current @node-click="articlePreview">
<template #default="{ data }">
<el-icon style="margin-right: 4px; flex-shrink: 0;">
<Document v-if="data.isLeaf" />
<Folder v-else />
</el-icon>
<span>{{ data.title }}</span>
</template>
</el-tree>
</el-col>
<el-col :span="20">
<el-table :data="articleData" v-loading="loading" stripe @selection-change="dataSelect">
<el-col :span="20" style="height: 100%; display: flex; flex-direction: column;">
<el-table class="table-container" :data="articleData" v-loading="loading" stripe @selection-change="dataSelect" height="100%">
<el-table-column type="selection" width="55" />
<el-table-column prop="title" label="标题" />
<el-table-column prop="path" label="路径" >
@@ -75,16 +68,16 @@
{{ typeof scope.row.tags === 'string' ? scope.row.tags : scope.row.tags?.join(',') }}
</template>
</el-table-column>
<el-table-column prop="content_len" label="正文长度" width="100" />
<el-table-column prop="create_date" label="创建时间" width="180" >
<el-table-column prop="contentLen" label="正文长度" width="100" />
<el-table-column prop="createDate" label="创建时间" width="180" >
<template #default="scope">
{{ datetimeFormat(scope.row.create_date) }}
{{ datetimeFormat(scope.row.createDate) }}
</template>
</el-table-column>
<el-table-column prop="tags" label="是否已分词" width="120" >
<template #default="scope">
<div style="width: 18px">
<Check v-if="scope.row.is_splited" />
<Check v-if="scope.row.isSplited" />
<Close v-else/>
</div>
</template>
@@ -92,8 +85,8 @@
</el-table>
<div class="page-container">
<el-pagination background
:page-sizes="$store.state.pageSizeOpts"
:layout="$store.state.pageLayout"
:page-sizes="store.state.pageSizeOpts"
:layout="store.state.pageLayout"
:current-page="search.pageNum"
:total="total"
@size-change="pageSizeChange"
@@ -106,135 +99,29 @@
v-model="markdownPreview.show"
:title="markdownPreview.title"
direction="rtl"
class="custom-drawer">
<div v-html="markdownPreview.content"></div>
size="50%"
class="custom-drawer article-preview-drawer">
<div class="markdown-body" v-html="markdownPreview.content"></div>
</el-drawer>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useStore } from 'vuex'
import hyperdown from 'hyperdown'
import { ArticleModel, TreeNodeData, TreeNodeSource } from '@/model/system/article'
import { ElMessage, ElMessageBox } from 'element-plus'
import Node from 'element-plus/lib/components/tree/src/model/node'
import { Options } from 'vue-class-component'
import BaseList from '@/model/baselist'
import { Page, MsgResult } from '@/model/common.dto'
import { useBaseList } from '@/model/baselist'
import { Page } from '@/model/common.dto'
import http from '@/utils/http'
let selectedData: string[] = []
@Options({
name: 'Article'
})
export default class Article extends BaseList<ArticlePage> {
search = new ArticlePage()
articleData: ArticleModel[] = []
tags: string[] = [] // 所有标签
categories: string[] = [] // 所有分类
markdownPreview :{ // markdown内容预览
show: boolean,
title: string | null,
content: string | null
} = {
show: false,
title: null,
content: null
}
isUploading: boolean = false
async loadData() {
this.loading = true
const data = await this.$http.get<ArticlePage, any>('/api/v1/article/list', {params:this.search})
selectedData = []
this.loading = false
this.total = data.total
this.articleData = data.data
}
splitWord() {
if(!selectedData.length) {
ElMessage.warning('请选择要执行分词的文章')
return
}
ElMessageBox.confirm(`是否确认对选中的${selectedData.length}篇文章执行分词处理?`, '操作确认', {type: 'info'}).then(async () => {
const data = await this.$http.put<{_ids: string[]}, any>('/api/v1/article/splitWord', {_ids: selectedData})
if(data.status) {
ElMessage.success(data.message)
} else {
ElMessage.warning(data.message)
}
}).catch(() => {})
}
pullArticles() {
ElMessageBox.confirm('确认拉取全部文章?', '操作确认', {type: 'info'}).then(async () => {
const data = await this.$http.put<never, any>('/api/v1/article/pull')
if(data.status) {
ElMessage.success(data.message)
this.loadData()
} else {
ElMessage.warning(data.message)
}
}).catch(() => {})
}
dataSelect(selection: ArticleModel[]) {
selectedData = selection.map(item => item._id)
}
beforeUpload(file: File): boolean {
this.isUploading = true
return true
}
uploadSuccess(response: MsgResult) {
if(response.status) {
ElMessage.success(response.message)
} else {
ElMessage.warning(response.message)
}
this.isUploading = false
}
uploadError(error: Error) {
this.isUploading = false
ElMessage.error(error.message)
}
readonly treeProps = {
label: 'name',
children: 'children',
isLeaf: 'isLeaf',
}
async loadTreeData(node: Node, resolve: Function) {
const childItems: TreeNodeSource[] = await this.$http.get('/api/v1/article/tree', {params:{deep: node.level, parent: node.data.name}})
resolve(childItems.map((childItem): TreeNodeData => {
const treeNode: TreeNodeData = {
name: childItem._id,
title: childItem.article_id ? childItem._id : `${childItem._id}(${childItem.cnt})`,
id: childItem.article_id || childItem._id,
isLeaf: !!childItem.article_id
}
return treeNode
}))
}
/**
* 树节点选中事件
* @param selectNodes 当前已选中的节点(适用于带复选框的)
* @param curNode 本次选中的节点
*/
async articlePreview(node: TreeNodeData) {
if(!node.isLeaf) return
// 加载文章markdown预览
const mdText = await this.$http.get<never, any>('/api/v1/article/markdown', {params:{id: node.id}})
this.markdownPreview.show = true
const markdownHtml = new hyperdown().makeHtml(mdText)
this.markdownPreview.content = markdownHtml.replace(/(?<=<pre><code[^>]*?>)[\s\S]*?(?=<\/code><\/pre>)/gi, content => {
return content.replace(/_&/g, ' ').replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
})
this.markdownPreview.title = node.name
}
created() {
this.loadData()
this.$http.get<never, any>('/api/v1/article/listCategories').then(data => {
this.categories = data
})
this.$http.get<never, any>('/api/v1/article/listTags').then(data => {
this.tags = data
})
}
type ArticleTreeNode = {
level: number
data: Pick<TreeNodeData, 'name'>
}
type TreeDataResolver = (data: TreeNodeData[]) => void
class ArticlePage extends Page {
title?: string
createDate?: [Date, Date]
@@ -250,4 +137,97 @@ class ArticlePage extends Page {
this.isSplited = undefined
}
}
const store = useStore()
const { loading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new ArticlePage())
const articleData = ref<ArticleModel[]>([])
const tags = ref<string[]>([])
const categories = ref<string[]>([])
const markdownPreview = reactive<{
show: boolean
title: string | null
content: string | null
}>({
show: false,
title: null,
content: null
})
let selectedData: string[] = []
async function loadData() {
loading.value = true
// 处理日期范围:结束日期设置为 23:59:59
const params = { ...search }
if (params.createDate && Array.isArray(params.createDate) && params.createDate[1]) {
const endDate = new Date(params.createDate[1])
endDate.setHours(23, 59, 59, 999)
params.createDate[1] = endDate
}
const data = await http.get<ArticlePage, any>('/article/list', {params})
selectedData = []
loading.value = false
total.value = data.total
articleData.value = data.list
}
setLoadData(loadData)
function splitWord() {
if (!selectedData.length) {
ElMessage.warning('请选择要执行分词的文章')
return
}
ElMessageBox.confirm(`是否确认对选中的${selectedData.length}篇文章执行分词处理?`, '操作确认', {type: 'info'}).then(async () => {
const successNum = await http.put<{_ids: string[]}, any>('/article/splitWord', {_ids: selectedData})
ElMessage.success(`${successNum}篇文章分词处理成功`)
})
}
function pullArticles() {
ElMessageBox.confirm('确认拉取全部文章?', '操作确认', {type: 'info'}).then(async () => {
const { updateCount, createCount } = await http.put<never, any>('/article/pull')
ElMessage.success(`拉取文章完成,更新 ${updateCount} 篇,创建 ${createCount} 篇`)
loadData()
})
}
function dataSelect(selection: ArticleModel[]) {
selectedData = selection.map(item => item._id)
}
const treeProps = {
label: 'name',
children: 'children',
isLeaf: 'isLeaf',
}
async function loadTreeData(node: ArticleTreeNode, resolve: TreeDataResolver) {
const childItems: TreeNodeSource[] = await http.get('/article/tree', {params: {deep: node.level, parent: node.data.name}})
resolve(childItems.map((childItem): TreeNodeData => {
const treeNode: TreeNodeData = {
name: childItem._id,
title: childItem.articleId ? childItem._id : `${childItem._id}(${childItem.cnt})`,
id: childItem.articleId || childItem._id,
isLeaf: !!childItem.articleId
}
return treeNode
}))
}
async function articlePreview(node: TreeNodeData) {
if (!node.isLeaf) return
const mdText = await http.get<never, any>('/article/markdown', {params: {id: node.id}})
markdownPreview.show = true
const markdownHtml = new hyperdown().makeHtml(mdText)
markdownPreview.content = markdownHtml.replace(/(?<=<pre><code[^>]*?>)[\s\S]*?(?=<\/code><\/pre>)/gi, (content: string) => {
return content.replace(/_&/g, ' ').replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
})
markdownPreview.title = node.name
}
// created
loadData()
http.get<never, any>('/article/listCategories').then(data => {
categories.value = data
})
http.get<never, any>('/article/listTags').then(data => {
tags.value = data
})
</script>
+518 -163
View File
@@ -1,209 +1,564 @@
<template>
<div>
<div class="echarts-container">
<div ref="categoriesChart" v-loading="categoriesChartLoading"></div>
<div ref="publishDatesChart" v-loading="publishDatesChartLoading"></div>
<div class="timeline-chart" ref="timelineWordsChart" v-loading="timelineWordsChartLoading"></div>
<div class="statistics-page">
<!-- 顶部统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<div class="stat-icon" style="background-image: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<el-icon><Document /></el-icon>
</div>
<div class="stat-info">
<div class="stat-label">文章总数</div>
<div class="stat-value">{{ totalArticles }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background-image: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<el-icon><FolderOpened /></el-icon>
</div>
<div class="stat-info">
<div class="stat-label">分类数量</div>
<div class="stat-value">{{ totalCategories }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background-image: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
<el-icon><Calendar /></el-icon>
</div>
<div class="stat-info">
<div class="stat-label">最早发布</div>
<div class="stat-value">{{ earliestDate }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background-image: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);">
<el-icon><TrendCharts /></el-icon>
</div>
<div class="stat-info">
<div class="stat-label">高频词汇</div>
<div class="stat-value">{{ topKeyword }}</div>
</div>
</div>
</div>
<!-- 图表区域 -->
<div class="charts-grid">
<!-- 文章分类 -->
<div class="chart-card">
<div class="chart-header">
<div class="chart-title">
<el-icon><PieChart /></el-icon>
<span>文章分类分布</span>
</div>
<div class="chart-subtitle">各类别文章数量占比</div>
</div>
<div ref="categoriesChart" v-loading="categoriesChartLoading" class="chart-body"></div>
</div>
<!-- 发布时间趋势 -->
<div class="chart-card">
<div class="chart-header">
<div class="chart-title">
<el-icon><TrendCharts /></el-icon>
<span>文章发布趋势</span>
</div>
<div class="chart-subtitle">各时间段文章发布数量变化</div>
</div>
<div ref="publishDatesChart" v-loading="publishDatesChartLoading" class="chart-body"></div>
</div>
<!-- 年度高频词汇 -->
<div class="chart-card chart-card-full">
<div class="chart-header">
<div class="chart-title">
<el-icon><Histogram /></el-icon>
<span>年度高频词汇分析</span>
</div>
<div class="chart-subtitle">基于文章分词结果的年度关键词统计</div>
</div>
<div ref="timelineWordsChart" v-loading="timelineWordsChartLoading" class="chart-body timeline-chart-body"></div>
</div>
</div>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import * as echarts from 'echarts'
import { Options, Vue } from 'vue-class-component'
import http from '@/utils/http'
import { Document, FolderOpened, Calendar, TrendCharts, PieChart, Histogram } from '@element-plus/icons-vue'
@Options({
name: 'Statistics'
})
export default class Statistics extends Vue {
categoriesChartLoading: boolean = false
publishDatesChartLoading: boolean = false
timelineWordsChartLoading: boolean = false
async mounted() {
this.categoriesChartLoading = true
this.publishDatesChartLoading = true
this.timelineWordsChartLoading = true
const categoriesChart = ref<HTMLElement>()
const publishDatesChart = ref<HTMLElement>()
const timelineWordsChart = ref<HTMLElement>()
const articleData = await this.$http.get<{params:{type:string}}, any>('/api/v1/article/statistics', {params:{type:'normal'}})
this.categoriesChartOption.legend.data = articleData.categories.map((item: any) => item._id)
this.categoriesChartOption.series.data = articleData.categories.map((item: any) => {
return {name: item._id, value: item.cnt}
})
this.publishDatesChartOption.xAxis.data = articleData.publishDates.map((item: any) => item._id)
this.publishDatesChartOption.series.data = articleData.publishDates.map((item: any) => item.cnt)
const categoriesChartLoading = ref(false)
const publishDatesChartLoading = ref(false)
const timelineWordsChartLoading = ref(false)
const categoriesChart = echarts.init(this.$refs.categoriesChart as HTMLElement)
categoriesChart.setOption(this.categoriesChartOption)
const publishDatesChart = echarts.init(this.$refs.publishDatesChart as HTMLElement)
publishDatesChart.setOption(this.publishDatesChartOption)
this.categoriesChartLoading = false
this.publishDatesChartLoading = false
// 统计卡片数据
const totalArticles = ref(0)
const totalCategories = ref(0)
const earliestDate = ref('-')
const topKeyword = ref('-')
const timelineData = await this.$http.get<{params:{type:string}}, any>('/api/v1/article/statistics', {params:{type:'timelineWords'}})
this.timelineWordsChartOption.timeline.data = timelineData.timelineWords.map((item: any) => item._id)
timelineData.timelineWords.forEach((item: any) => {
this.timelineWordsChartOption.options.push({
title: { text: `${item._id}年发布的文章` },
xAxis: { data: item.keys.map((keyItem: any) => keyItem.key) },
series: { data: item.keys.map((keyItem: any) => keyItem.total) }
})
})
const timelineWordsChart = echarts.init(this.$refs.timelineWordsChart as HTMLElement)
timelineWordsChart.setOption(this.timelineWordsChartOption)
this.timelineWordsChartLoading = false
}
categoriesChartOption = {
title : {
text: '文章分类',
x: 'center',
top: 10
},
tooltip : {
trigger: 'item',
formatter: "{a} <br/>{b} : {c} ({d}%)"
},
legend: {
type: 'scroll',
orient: 'vertical',
right: 10,
top: 50,
bottom: 20,
data: [],
},
series: {
name: '类别',
type: 'pie',
radius: ['40%', '70%'],
center: ['40%', '50%'],
itemStyle: {
borderRadius: 5,
borderColor: '#FFF',
borderWidth: 1
},
data: []
const categoriesChartOption: any = {
tooltip: {
trigger: 'item',
formatter: "{b}: {c}篇 ({d}%)"
},
legend: {
type: 'scroll',
orient: 'vertical',
right: 10,
top: 40,
bottom: 20,
textStyle: {
color: '#606266'
}
}
publishDatesChartOption = {
title: {
left: 'center',
text: '文章发布时间',
top: 10
},
series: {
name: '类别',
type: 'pie',
radius: ['45%', '75%'],
center: ['35%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 8,
borderColor: '#fff',
borderWidth: 2
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
animation: false,
label: {
backgroundColor: '#ccc',
borderColor: '#aaa',
borderWidth: 1,
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
color: '#222'
}
},
label: {
show: false
},
xAxis: {
name: '发布时间',
type: 'category',
boundaryGap: false,
data: []
},
yAxis: {
name: '文章数量',
type: 'value',
max: function(value: {max: number}) {
return value.max + 10
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold'
}
},
dataZoom: [{
type: 'inside',
start: 0,
end: 100
},{
start: 0,
end: 10
}],
series: {
name: '文章数量',
type: 'line',
smooth: true,
symbol: 'none',
sampling: 'average',
itemStyle: {
color: 'rgb(255, 70, 131)'
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgb(255, 158, 68)'
},{
offset: 1,
color: 'rgb(255, 70, 131)'
}
])
},
data: []
}
labelLine: {
show: false
},
data: []
}
timelineWordsChartOption: any = {
options: [],
}
const publishDatesChartOption: any = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e4e7ed',
borderWidth: 1,
textStyle: {
color: '#606266'
},
axisPointer: {
type: 'line',
lineStyle: {
color: '#409eff',
width: 2
}
},
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '10%',
containLabel: true
},
xAxis: {
name: '发布时间',
nameTextStyle: {
color: '#909399'
},
type: 'category',
boundaryGap: false,
data: [],
axisLine: {
lineStyle: {
color: '#dcdfe6'
}
},
axisLabel: {
color: '#606266'
}
},
yAxis: {
name: '文章数量',
nameTextStyle: {
color: '#909399'
},
type: 'value',
axisLine: {
show: false
},
axisTick: {
show: false
},
axisLabel: {
color: '#606266'
},
splitLine: {
lineStyle: {
color: '#ebeef5'
}
}
},
dataZoom: [{
type: 'inside',
start: 0,
end: 100
}, {
start: 0,
end: 100,
height: 20,
bottom: 10,
handleSize: '100%',
handleStyle: {
color: '#409eff'
}
}],
series: {
name: '文章数量',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
sampling: 'average',
itemStyle: {
color: '#409eff'
},
lineStyle: {
width: 3,
shadowColor: 'rgba(64, 158, 255, 0.3)',
shadowBlur: 10,
shadowOffsetY: 5
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(64, 158, 255, 0.4)'
}, {
offset: 1,
color: 'rgba(64, 158, 255, 0.05)'
}
])
},
data: []
}
}
const timelineWordsChartOption: any = {
baseOption: {
timeline: {
axisType: 'category',
autoPlay: false,
playInterval: 1000,
playInterval: 1500,
data: [],
bottom: 10,
left: 50,
right: 50,
lineStyle: {
color: '#dcdfe6'
},
itemStyle: {
color: '#c0c4cc'
},
checkpointStyle: {
color: '#409eff',
borderColor: '#409eff'
},
controlStyle: {
itemSize: 20,
color: '#409eff',
borderColor: '#409eff'
},
label: {
color: '#606266'
}
},
title: {
left: 'center',
subtext: '数据来自文章分词结果'
top: 10,
textStyle: {
fontSize: 16,
fontWeight: 'normal',
color: '#303133'
}
},
calculable : true,
calculable: true,
grid: {
top: 80,
bottom: 80
top: 60,
bottom: 80,
left: 60,
right: 40
},
xAxis: {
name: '高频词汇',
nameTextStyle: {
color: '#909399'
},
type: 'category',
splitLine: {show: false}
axisLine: {
lineStyle: {
color: '#dcdfe6'
}
},
axisLabel: {
color: '#606266',
interval: 0,
rotate: 30
}
},
yAxis: {
name: '词汇出现次数',
type: 'value'
name: '出现次数',
nameTextStyle: {
color: '#909399'
},
type: 'value',
axisLine: {
show: false
},
axisTick: {
show: false
},
axisLabel: {
color: '#606266'
},
splitLine: {
lineStyle: {
color: '#ebeef5'
}
}
},
tooltip : {
tooltip: {
trigger: 'axis',
axisPointer : { // 坐标轴指示器,坐标轴触发有效
type : 'shadow' // 默认为直线,可选为:'line' | 'shadow'
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e4e7ed',
borderWidth: 1,
textStyle: {
color: '#606266'
},
axisPointer: {
type: 'shadow',
shadowStyle: {
color: 'rgba(64, 158, 255, 0.1)'
}
}
},
series: {
type: 'bar',
itemStyle: {
borderRadius: 5
}
borderRadius: [6, 6, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#409eff' },
{ offset: 1, color: '#67c23a' }
])
},
barWidth: '50%'
}
}
},
options: []
}
onMounted(async () => {
categoriesChartLoading.value = true
publishDatesChartLoading.value = true
timelineWordsChartLoading.value = true
// 加载基础统计数据
const articleData = await http.get<{params:{type:string}}, any>('/article/statistics', {params: {type: 'normal'}})
// 更新统计卡片
totalArticles.value = articleData.categories.reduce((sum: number, item: any) => sum + item.cnt, 0)
totalCategories.value = articleData.categories.length
if (articleData.publishDates.length > 0) {
earliestDate.value = articleData.publishDates[0]._id
}
// 分类饼图
categoriesChartOption.series.data = articleData.categories.map((item: any) => ({
name: item._id,
value: item.cnt
}))
const categoriesChartInstance = echarts.init(categoriesChart.value as HTMLElement)
categoriesChartInstance.setOption(categoriesChartOption)
categoriesChartLoading.value = false
// 发布趋势图
publishDatesChartOption.xAxis.data = articleData.publishDates.map((item: any) => item._id)
publishDatesChartOption.series.data = articleData.publishDates.map((item: any) => item.cnt)
const publishDatesChartInstance = echarts.init(publishDatesChart.value as HTMLElement)
publishDatesChartInstance.setOption(publishDatesChartOption)
publishDatesChartLoading.value = false
// 时间轴词汇统计
const timelineData = await http.get<{params:{type:string}}, any>('/article/statistics', {params: {type: 'timelineWords'}})
if (timelineData.timelineWords.length > 0 && timelineData.timelineWords[0].keys.length > 0) {
topKeyword.value = timelineData.timelineWords[0].keys[0].key
}
timelineWordsChartOption.baseOption.timeline.data = timelineData.timelineWords.map((item: any) => item._id)
timelineWordsChartOption.options = timelineData.timelineWords.map((item: any) => ({
title: {text: `${item._id}年发布的文章 - 高频词汇TOP10`},
xAxis: {data: item.keys.map((keyItem: any) => keyItem.key)},
series: {data: item.keys.map((keyItem: any) => keyItem.total)}
}))
const timelineWordsChartInstance = echarts.init(timelineWordsChart.value as HTMLElement)
timelineWordsChartInstance.setOption(timelineWordsChartOption)
timelineWordsChartLoading.value = false
// 窗口大小变化时重绘
window.addEventListener('resize', () => {
categoriesChartInstance.resize()
publishDatesChartInstance.resize()
timelineWordsChartInstance.resize()
})
})
</script>
<style lang="less" scoped>
.echarts-container {
display: grid;
grid-template-columns: 50% 50%;
grid-template-rows: 50% 50%;
.statistics-page {
padding: 20px;
height: 100%;
> .echarts {
border: 1px solid #ccc;
width: auto;
height: auto;
overflow: auto;
box-sizing: border-box;
}
// 顶部统计卡片
.stat-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 24px;
}
.stat-card {
background: var(--el-bg-color);
border-radius: var(--el-border-radius-large);
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
border: 1px solid var(--el-border-color-lighter);
transition: transform 0.3s, box-shadow 0.3s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}
}
.timeline-chart {
grid-column-start: 1;
grid-column-end: 3;
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 28px;
flex-shrink: 0;
}
.stat-info {
flex: 1;
}
.stat-label {
font-size: 14px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
}
// 图表区域
.charts-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: auto auto;
gap: 20px;
}
.chart-card {
background: var(--el-bg-color);
border-radius: var(--el-border-radius-large);
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
border: 1px solid var(--el-border-color-lighter);
display: flex;
flex-direction: column;
&-full {
grid-column: span 2;
}
}
.chart-header {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.chart-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
.el-icon {
color: var(--el-color-primary);
font-size: 18px;
}
}
.chart-subtitle {
font-size: 13px;
color: var(--el-text-color-secondary);
padding-left: 26px;
}
.chart-body {
flex: 1;
min-height: 320px;
}
.timeline-chart-body {
min-height: 420px;
}
// 响应式
@media (max-width: 1200px) {
.stat-cards {
grid-template-columns: repeat(2, 1fr);
}
.charts-grid {
grid-template-columns: 1fr;
}
.chart-card-full {
grid-column: span 1;
}
}
@media (max-width: 768px) {
.stat-cards {
grid-template-columns: 1fr;
}
}
</style>
+102 -117
View File
@@ -6,144 +6,129 @@
</el-form-item>
</el-form>
<div class="btn-container">
<el-button type="primary" @click="add">添加</el-button>
<el-button type="primary" @click="add" v-permission="'config:save'" plain>添加</el-button>
<div class="search-btn">
<el-button type="primary" @click="loadData" icon="Search">搜索</el-button>
<el-button @click="reset" icon="RefreshLeft">重置</el-button>
<el-button type="primary" @click="loadData" icon="Search" plain>搜索</el-button>
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
</div>
</div>
<div class="table-container">
<el-table :data="systemConfigData" v-loading="loading" stripe>
<el-table-column type="expand">
<template #default="scope">
<pre style="margin:0 10px;">{{ JSON.stringify(scope.row.value, null, ' ') }}</pre>
</template>
</el-table-column>
<el-table-column prop="name" label="配置项名称" />
<el-table-column prop="description" label="配置项描述" />
<el-table-column prop="is_public" label="是否公开" >
<template #default="scope">
{{ scope.row.is_public ? '是' : '否' }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" >
<template #default="scope">
{{ datetimeFormat(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" >
<template #default="scope">
{{ datetimeFormat(scope.row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" >
<template #default="scope">
<el-button link icon="Edit" @click="update(scope.row)" title="修改"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除"></el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-table class="table-container" :data="systemConfigData" v-loading="loading" stripe>
<el-table-column type="expand">
<template #default="scope">
<pre style="margin:0 10px;">{{ JSON.stringify(scope.row.value, null, ' ') }}</pre>
</template>
</el-table-column>
<el-table-column prop="name" label="配置项名称" />
<el-table-column prop="description" label="配置项描述" />
<el-table-column prop="isPublic" label="是否公开" >
<template #default="scope">
{{ scope.row.isPublic ? '是' : '否' }}
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" >
<template #default="scope">
{{ datetimeFormat(scope.row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" >
<template #default="scope">
{{ datetimeFormat(scope.row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" >
<template #default="scope">
<el-button link icon="Edit" @click="update(scope.row)" title="修改" v-permission="'config:save'"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除" v-permission="'config:delete'"></el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="addModal" :title="modalTitle" >
<system-config-add ref="addForm" :formData="formData" />
<template #footer>
<span class="dialog-footer">
<el-button @click="addModal = false">取消</el-button>
<el-button type="primary" @click="save" :loading="modalLoading">确定</el-button>
<el-button @click="addModal = false" plain>取消</el-button>
<el-button type="primary" @click="save" :loading="modalLoading" plain>确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
import { Options, Vue } from 'vue-class-component'
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import moment from 'moment'
import http from '@/utils/http'
import SystemConfigAdd from './SystemConfigAdd.vue'
import { SystemConfigModel } from '@/model/system/system-config'
import { VForm } from '@/types'
@Options({
name: 'SystemConfig',
components: { SystemConfigAdd }
const modalLoading = ref(false)
const loading = ref(false)
const search = ref<{name?: string}>({})
const systemConfigData = ref<SystemConfigModel[]>([])
const addModal = ref(false)
const modalTitle = ref('')
const formData = ref<SystemConfigModel>({
name: '',
value: '',
description: '',
isPublic: false
})
export default class SystemConfig extends Vue {
modalLoading: boolean = false
loading: boolean = false
search: {name?:string} = {}
systemConfigData: SystemConfigModel[] = []
addModal: boolean = false
modalTitle: string = ''
formData: SystemConfigModel = {
const addForm = ref<InstanceType<typeof SystemConfigAdd>>()
function reset() {
search.value = {}
loadData()
}
async function loadData() {
loading.value = true
systemConfigData.value = await http.get('/system/config/list', {params: search.value})
loading.value = false
}
function add() {
formData.value = {
name: '',
value: '',
description: '',
is_public: false
}
reset() {
this.search = {}
this.loadData()
}
async loadData() {
this.loading = true
this.systemConfigData = await this.$http.get('/api/v1/system/config/list', {params:this.search})
this.loading = false
}
add() {
// 清空表单
this.formData = {
name: '',
value: '',
description: '',
is_public: false
}
this.modalTitle = '新增配置项'
this.addModal = true
}
update(row: SystemConfigModel) {
const formData = Object.assign({}, row)
formData.value = JSON.stringify(formData.value, null, ' ')
this.formData = formData
this.modalTitle = '修改配置项'
this.addModal = true
}
async save() {
((this.$refs.addForm as Vue).$refs.configForm as VForm).validate(async (valid: boolean) => {
if (!valid) return
this.modalLoading = true
const data = await this.$http.post<SystemConfigModel, any>('/api/v1/system/config/save', this.formData)
this.modalLoading = false
this.addModal = false
ElMessage.success(data.message)
this.loadData()
})
}
remove(row: SystemConfigModel) {
ElMessageBox.confirm(`是否确认删除 ${row.name} 配置项?`, '确认删除', {type: 'warning'}).then(async () => {
const data = await this.$http.delete<{params: {id: string}}, any>('/api/v1/system/config/delete', {params: {id: row._id}})
if(data.status) {
ElMessage.success(data.message)
this.loadData()
} else {
ElMessage.warning(data.message)
}
}).catch(() => {})
}
created() {
this.loadData()
}
clearValidate() {
this.$nextTick(() => {
((this.$refs.addForm as Vue).$refs.configForm as VForm).clearValidate()
})
}
/**
* 日期时间格式化
* @param dateStr 日期时间
*/
datetimeFormat(dateStr: string) {
return dateStr ? moment(dateStr).format('YYYY-MM-DD HH:mm:ss') : null
isPublic: false
}
modalTitle.value = '新增配置项'
addModal.value = true
}
function update(row: SystemConfigModel) {
const data = Object.assign({}, row)
data.value = JSON.stringify(data.value, null, ' ')
formData.value = data
modalTitle.value = '修改配置项'
addModal.value = true
}
async function save() {
addForm.value?.configForm?.validate(async (valid: boolean) => {
if (!valid) return
modalLoading.value = true
await http.post<SystemConfigModel, any>('/system/config/save', formData.value)
modalLoading.value = false
addModal.value = false
ElMessage.success("保存成功")
loadData()
})
}
function remove(row: SystemConfigModel) {
ElMessageBox.confirm(`是否确认删除 ${row.name} 配置项?`, '确认删除', {type: 'warning'}).then(async () => {
await http.delete<{params: {id: string}}, any>('/system/config/delete', {params: {id: row._id}})
ElMessage.success('删除成功')
loadData()
})
}
function datetimeFormat(dateStr: string) {
return dateStr ? moment(dateStr).format('YYYY-MM-DD HH:mm:ss') : null
}
loadData()
</script>
+39 -40
View File
@@ -12,53 +12,52 @@
</el-form-item>
<el-form-item label="公开">
<el-switch
v-model="formData.is_public"
v-model="formData.isPublic"
active-text="是"
inactive-text="否" />
</el-form-item>
</el-form>
</div>
</template>
<script lang="ts">
import { Options, Vue } from 'vue-class-component'
<script setup lang="ts">
import { computed, ref } from 'vue'
import http from '@/utils/http'
import { SystemConfigModel } from '@/model/system/system-config'
import { VForm } from '@/types'
@Options({
name: 'SystemConfigAdd',
props: {
formData: Object
}
})
export default class SystenConfigAdd extends Vue {
formData!: SystemConfigModel
get ruleValidate() {
return {
name: [
{ required: true, message: '请输入配置项名称', trigger: 'blur' },
{ validator: (rule: object, value: string, callback: Function) => {
this.$http.get<any, any>('/api/v1/system/config/exists', {params: {name: value, id: this.formData._id}}).then(data => {
if(data.data.exists) {
callback(new Error('配置项名称已存在'))
} else {
callback()
}
})
}, trigger: 'blur'
}
],
value: [
{ required: true, message: '请输入配置项值', trigger: 'blur' },
{ validator: (rule: object, value: string, callback: Function) => {
try {
JSON.parse(value)
callback()
} catch (e) {
callback(new Error('值不符合JSON字符串格式'))
}
}, trigger: 'blur'
}
],
const props = defineProps<{
formData: SystemConfigModel
}>()
const configForm = ref<VForm>()
const ruleValidate = computed(() => ({
name: [
{ required: true, message: '请输入配置项名称', trigger: 'blur' },
{ validator: (rule: object, value: string, callback: Function) => {
http.get<any, any>('/system/config/exists', {params: {name: value, id: props.formData._id}}).then(data => {
if(data.exists) {
callback(new Error('配置项名称已存在'))
} else {
callback()
}
})
}, trigger: 'blur'
}
}
}
],
value: [
{ required: true, message: '请输入配置项值', trigger: 'blur' },
{ validator: (rule: object, value: string, callback: Function) => {
try {
JSON.parse(value)
callback()
} catch (e) {
callback(new Error('值不符合JSON字符串格式'))
}
}, trigger: 'blur'
}
],
}))
defineExpose({ configForm })
</script>
+262 -171
View File
@@ -1,47 +1,41 @@
<template>
<div>
<div class="page-wrapper">
<el-form inline :model="search" @submit.prevent>
<el-form-item label="角色名称/描述">
<el-input v-model="search.name" />
</el-form-item>
</el-form>
<div class="btn-container">
<el-button type="primary" @click="add">添加</el-button>
<el-button type="primary" @click="add" v-permission="'role:save'" plain>添加</el-button>
<div class="search-btn">
<el-button type="primary" @click="loadDataBase(true)" icon="Search">搜索</el-button>
<el-button @click="reset" icon="RefreshLeft">重置</el-button>
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
</div>
</div>
<div class="table-container">
<el-table :data="systemRoleData" v-loading="loading" stripe>
<el-table-column prop="name" label="角色名称" />
<el-table-column prop="methods" label="允许的请求类型" >
<template #default="scope">
<el-tag type="info" v-for="method in scope.row.methods" :key="method">{{method}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" >
<template #default="scope">
{{ datetimeFormat(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" >
<template #default="scope">
{{ datetimeFormat(scope.row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" >
<template #default="scope">
<el-button link icon="Edit" @click="update(scope.row)" title="修改"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除"></el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-table class="table-container" :data="systemRoleData" v-loading="loading" stripe>
<el-table-column prop="name" label="角色名称" />
<el-table-column prop="description" label="描述" />
<el-table-column prop="createdAt" label="创建时间" >
<template #default="scope">
{{ datetimeFormat(scope.row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" >
<template #default="scope">
{{ datetimeFormat(scope.row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" >
<template #default="scope">
<el-button link icon="Edit" @click="update(scope.row)" title="修改" v-permission="'role:save'"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除" v-permission="'role:delete'"></el-button>
</template>
</el-table-column>
</el-table>
<div class="page-container">
<el-pagination background
:page-sizes="$store.state.pageSizeOpts"
:layout="$store.state.pageLayout"
:page-sizes="store.state.pageSizeOpts"
:layout="store.state.pageLayout"
:current-page="search.pageNum"
:total="total"
@size-change="pageSizeChange"
@@ -56,157 +50,40 @@
<el-form-item label="描述">
<el-input v-model="formData.description" />
</el-form-item>
<el-form-item label="允许的请求类型">
<el-select v-model="formData.methods" multiple collapse-tags>
<el-option value="GET">GET</el-option>
<el-option value="POST">POST</el-option>
<el-option value="PUT">PUT</el-option>
<el-option value="DELETE">DELETE</el-option>
</el-select>
</el-form-item>
<el-form-item label="允许的URI">
<el-input v-model="uri.include">
<template #append>
<el-button icon="Plus" @click="addUri('include_uri', uri.include)"></el-button>
</template>
</el-input>
<el-tag v-for="uri in formData.include_uri" :key="uri" closable @close="removeUri('include_uri', uri)">{{uri}}</el-tag>
</el-form-item>
<el-form-item label="禁止的URI">
<el-input v-model="uri.exclude">
<template #append>
<el-button icon="Plus" @click="addUri('exclude_uri', uri.exclude)"></el-button>
</template>
</el-input>
<el-tag v-for="uri in formData.exclude_uri" :key="uri" closable @close="removeUri('exclude_uri', uri)">{{uri}}</el-tag>
<el-form-item label="权限" prop="permissions">
<el-tree
ref="permTree"
:data="permissionTreeData"
:props="treeProps"
show-checkbox
node-key="code"
:default-checked-keys="formData.permissions"
:check-strictly="true"
@check="handlePermCheckWithValidate"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="addModal = false">取消</el-button>
<el-button type="primary" @click="save" :loading="modalLoading">确定</el-button>
<el-button @click="addModal = false" plain>取消</el-button>
<el-button type="primary" @click="save" :loading="modalLoading" plain>确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
import { Options } from 'vue-class-component'
import BaseList from '@/model/baselist'
<script setup lang="ts">
import { ref, reactive, nextTick } from 'vue'
import { useStore } from 'vuex'
import { useBaseList } from '@/model/baselist'
import { Page } from '@/model/common.dto'
import { SystemRoleModel } from '@/model/system/system-role'
import { ElButton, ElForm, ElFormItem, ElInput, ElTable, ElTableColumn, ElTag, ElPagination, ElDialog, ElSelect, ElOption, ElMessage, ElMessageBox } from 'element-plus'
import { VForm } from '@/types'
import http from '@/utils/http'
import { SystemRoleModel, PermissionTreeNode } from '@/model/system/system-role'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { VForm } from '@/types'
@Options({
name: 'SystemRole',
components: { ElButton, ElForm, ElFormItem, ElInput, ElTable, ElTableColumn, ElTag, ElPagination, ElDialog, ElSelect, ElOption }
})
export default class SystemRole extends BaseList<SystemRolePage> {
ruleValidate = {
name: [
{ required: true, message: '请输入角色名称', trigger: 'blur' }
],
}
systemRoleData: SystemRoleModel[] = []
search = new SystemRolePage()
addModal: boolean = false
modalTitle: string | null = null
uri: {
include: string | null,
exclude: string | null
} = {
include: null,
exclude: null
}
formData: SystemRoleModel = {
_id: null,
name: null,
description: null,
methods: [],
include_uri: [],
exclude_uri: []
}
async loadData() {
this.loading = true
const data = await this.$http.get<{params: SystemRolePage}, any>('/api/v1/system/role/list', {params:this.search})
this.loading = false
this.total = data.total
this.systemRoleData = data.data
}
created() {
this.loadData()
}
add() {
// 清空表单
this.uri.include = null
this.uri.exclude = null
this.formData = {
_id: null,
name: null,
description: null,
methods: [],
include_uri: [],
exclude_uri: []
}
this.modalTitle = '新增角色'
this.addModal = true
this.clearValidate()
}
addUri(fieldName: 'include_uri' | 'exclude_uri', uri: string | null) {
if(!uri) return
if(this.formData[fieldName].indexOf(uri) === -1) {
this.formData[fieldName].push(uri)
}
}
removeUri(fieldName: 'include_uri' | 'exclude_uri', uri: string) {
let index = this.formData[fieldName].indexOf(uri)
if(index !== -1) {
this.formData[fieldName].splice(index, 1)
}
}
update(row: SystemRoleModel) {
this.uri.include = null
this.uri.exclude = null
this.formData._id = row._id
this.formData.name = row.name
this.formData.description = row.description
this.formData.methods = row.methods
this.formData.include_uri = row.include_uri
this.formData.exclude_uri = row.exclude_uri
this.modalTitle = '修改角色'
this.addModal = true
this.clearValidate()
}
remove(row: SystemRoleModel) {
ElMessageBox.confirm(`是否确认删除 ${row.name} 角色?`, '确认删除', {type: 'warning'}).then(async () => {
const data = await this.$http.delete<{params: {id: string}}, any>('/api/v1/system/role/delete', {params: {id: row._id}})
if(data.status) {
ElMessage.success(data.message)
this.loadData()
} else {
ElMessage.warning(data.message)
}
}).catch(() => {})
}
async save() {
(this.$refs.roleForm as VForm).validate(async (valid: boolean) => {
if (!valid) return
this.modalLoading = true
const data = await this.$http.post<SystemRoleModel, any>('/api/v1/system/role/save', this.formData)
this.modalLoading = false
this.addModal = false
ElMessage.success(data.message)
this.loadData()
})
}
clearValidate() {
this.$nextTick(() => {
(this.$refs.roleForm as VForm).clearValidate()
})
}
}
const store = useStore()
class SystemRolePage extends Page {
name?: string
@@ -215,4 +92,218 @@ class SystemRolePage extends Page {
this.name = undefined
}
}
const { loading, modalLoading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new SystemRolePage())
const ruleValidate = {
name: [
{ required: true, message: '请输入角色名称', trigger: 'blur' }
],
permissions: [
{ validator: (rule: any, value: any, callback: any) => {
const permissions = getPermissionsToSave()
if (permissions.length === 0) {
callback(new Error('至少需要勾选一项权限'))
} else {
callback()
}
}, trigger: 'change' }
]
}
const systemRoleData = ref<SystemRoleModel[]>([])
const addModal = ref(false)
const modalTitle = ref<string | null>(null)
const permissionTreeData = ref<PermissionTreeNode[]>([])
const treeProps = {
children: 'children',
label: 'name'
}
const formData = reactive<SystemRoleModel>({
_id: null,
name: null,
description: null,
permissions: []
})
const roleForm = ref<VForm>()
const permTree = ref<any>()
async function loadData() {
loading.value = true
const data = await http.get<{params: SystemRolePage}, any>('/system/role/list', {params: search})
loading.value = false
total.value = data.total
systemRoleData.value = data.list
}
setLoadData(loadData)
function add() {
Object.assign(formData, {
_id: null,
name: null,
description: null,
permissions: []
})
modalTitle.value = '新增角色'
addModal.value = true
clearValidate()
nextTick(() => {
permTree.value?.setCheckedKeys([])
})
}
/**
* 处理权限树勾选:
* - 勾选父节点时,自动勾选所有子节点
* - 取消父节点时,自动取消所有子节点
* - 所有子节点都被勾选时,自动勾选父节点并取消子节点勾选(只保留父code)
*/
function handlePermCheck(nodeData: PermissionTreeNode, checkState: { checkedKeys: string[], halfCheckedKeys: string[] }) {
const tree = permTree.value
if (!tree) return
const node = tree.getNode(nodeData.code)
const isChecked = node.checked
if (!nodeData.leaf) {
// 父节点操作:联动所有子节点
const childCodes = nodeData.children.map((c: PermissionTreeNode) => c.code)
if (isChecked) {
// 勾选父节点 → 勾选所有子节点
childCodes.forEach((code: string) => tree.setChecked(code, true, false))
} else {
// 取消父节点 → 取消所有子节点
childCodes.forEach((code: string) => tree.setChecked(code, false, false))
}
} else {
// 子节点操作:检查是否所有兄弟节点都被勾选
const parentNode = findParentNode(permissionTreeData.value, nodeData.code)
if (parentNode) {
const allChildCodes = parentNode.children.map(c => c.code)
const allChecked = allChildCodes.every(code => tree.getNode(code)?.checked)
if (allChecked) {
// 所有子节点勾选 → 勾选父节点
tree.setChecked(parentNode.code, true, false)
} else {
// 不是全部 → 取消父节点
tree.setChecked(parentNode.code, false, false)
}
}
}
}
function handlePermCheckWithValidate(nodeData: PermissionTreeNode, checkState: { checkedKeys: string[], halfCheckedKeys: string[] }) {
handlePermCheck(nodeData, checkState)
roleForm.value?.validateField('permissions')
}
function findParentNode(nodes: PermissionTreeNode[], childCode: string): PermissionTreeNode | null {
for (const node of nodes) {
if (node.children.some(c => c.code === childCode)) {
return node
}
const found = findParentNode(node.children, childCode)
if (found) return found
}
return null
}
/**
* 从树中获取要保存的 permissions:
* 如果父节点被勾选,只传父节点code;否则传各个被勾选的子节点code
*/
function getPermissionsToSave(): string[] {
const tree = permTree.value
if (!tree) return []
const result: string[] = []
for (const group of permissionTreeData.value) {
const parentNode = tree.getNode(group.code)
if (parentNode?.checked) {
result.push(group.code)
} else {
for (const child of group.children) {
const childNode = tree.getNode(child.code)
if (childNode?.checked) {
result.push(child.code)
}
}
}
}
return result
}
/**
* 编辑角色时,根据已有 permissions 回显树的勾选状态
* 如果有父级code(如'user'),需要勾选父节点及所有子节点
*/
function setTreeCheckedByPermissions(permissions: string[]) {
nextTick(() => {
const tree = permTree.value
if (!tree) return
tree.setCheckedKeys([])
for (const perm of permissions) {
// 查找是否是父节点
const parentGroup = permissionTreeData.value.find(g => g.code === perm)
if (parentGroup) {
// 是父节点:勾选自身和所有子节点
tree.setChecked(perm, true, false)
parentGroup.children.forEach(c => tree.setChecked(c.code, true, false))
} else {
tree.setChecked(perm, true, false)
}
}
// 再次检查:如果某个父级的所有子节点都勾选了,勾选父级
for (const group of permissionTreeData.value) {
if (!permissions.includes(group.code)) {
const allChildChecked = group.children.length > 0 && group.children.every(c => tree.getNode(c.code)?.checked)
if (allChildChecked) {
tree.setChecked(group.code, true, false)
}
}
}
})
}
function update(row: SystemRoleModel) {
Object.assign(formData, {
_id: row._id,
name: row.name,
description: row.description,
permissions: [...row.permissions]
})
modalTitle.value = '修改角色'
addModal.value = true
clearValidate()
setTreeCheckedByPermissions(row.permissions)
}
function remove(row: SystemRoleModel) {
ElMessageBox.confirm(`是否确认删除 ${row.name} 角色?`, '确认删除', {type: 'warning'}).then(async () => {
await http.delete<{params: {id: string}}, any>('/system/role/delete', {params: {id: row._id}})
ElMessage.success('删除成功')
loadData()
})
}
async function save() {
roleForm.value?.validate(async (valid: boolean) => {
if (!valid) return
modalLoading.value = true
const saveData = { ...formData, permissions: getPermissionsToSave() }
await http.post<SystemRoleModel, any>('/system/role/save', saveData)
modalLoading.value = false
addModal.value = false
ElMessage.success("保存成功")
loadData()
})
}
function clearValidate() {
nextTick(() => {
roleForm.value?.clearValidate()
})
}
loadData()
http.get<never, any>('/system/role/permissionTree').then(data => {
permissionTreeData.value = data
})
</script>
+132 -135
View File
@@ -1,43 +1,41 @@
<template>
<div>
<div class="page-wrapper">
<el-form inline :model="search" @submit.prevent>
<el-form-item label="用户名/昵称">
<el-input v-model="search.username" />
</el-form-item>
</el-form>
<div class="btn-container">
<el-button type="primary" @click="add">添加</el-button>
<el-button type="primary" @click="add" v-permission="'user:save'" plain>添加</el-button>
<div class="search-btn">
<el-button type="primary" @click="loadDataBase(true)" icon="Search">搜索</el-button>
<el-button @click="reset" icon="RefreshLeft">重置</el-button>
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
</div>
</div>
<div class="table-container">
<el-table :data="systemUserData" v-loading="loading" stripe>
<el-table-column prop="username" label="用户名" />
<el-table-column prop="realname" label="昵称" />
<el-table-column prop="created_at" label="创建时间" >
<template #default="scope">
{{ datetimeFormat(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" >
<template #default="scope">
{{ datetimeFormat(scope.row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" >
<template #default="scope">
<el-button link icon="Edit" @click="update(scope.row)" title="修改"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除"></el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-table class="table-container" :data="systemUserData" v-loading="loading" stripe>
<el-table-column prop="username" label="用户名" />
<el-table-column prop="realname" label="昵称" />
<el-table-column prop="createdAt" label="创建时间" >
<template #default="scope">
{{ datetimeFormat(scope.row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" >
<template #default="scope">
{{ datetimeFormat(scope.row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" >
<template #default="scope">
<el-button link icon="Edit" @click="update(scope.row)" title="修改" v-permission="'user:save'"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除" v-permission="'user:delete'"></el-button>
</template>
</el-table-column>
</el-table>
<div class="page-container">
<el-pagination background
:page-sizes="$store.state.pageSizeOpts"
:layout="$store.state.pageLayout"
:page-sizes="store.state.pageSizeOpts"
:layout="store.state.pageLayout"
:current-page="search.pageNum"
:total="total"
@size-change="pageSizeChange"
@@ -55,130 +53,33 @@
<el-form-item label="昵称" prop="realname">
<el-input v-model="formData.realname" />
</el-form-item>
<el-form-item label="角色" prop="realname">
<el-select v-model="formData.role_ids" multiple >
<el-form-item label="角色" prop="roleIds">
<el-select v-model="formData.roleIds" multiple >
<el-option v-for="role in roles" :key="role._id" :value="role._id" :label="role.name"></el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="addModal = false">取消</el-button>
<el-button type="primary" @click="save" :loading="modalLoading">确定</el-button>
<el-button @click="addModal = false" plain>取消</el-button>
<el-button type="primary" @click="save" :loading="modalLoading" plain>确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
import { Options } from 'vue-class-component'
import BaseList from '@/model/baselist'
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { useStore } from 'vuex'
import { useBaseList } from '@/model/baselist'
import { Page } from '@/model/common.dto'
import http from '@/utils/http'
import { SystemUserModel } from '@/model/system/system-user'
import { SystemRoleModel } from '@/model/system/system-role'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VForm } from '@/types'
@Options({
name: 'SystemUser'
})
export default class SystemUser extends BaseList<SystemUserPage> {
get ruleValidate() {
return {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ validator: async (rule: object, value: string, callback: Function) => {
const data = await this.$http.get<any, any>('/api/v1/system/user/exists', {params: {username: value, id: this.formData._id}})
if(data.data.exists) {
callback(new Error('用户名已存在'))
} else {
callback()
}
}, trigger: 'blur'
}
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 16, message: '密码长度8~16位', trigger: 'blur' },
{ pattern: /^(?![\d]+$)(?![a-zA-Z]+$)(?![-=+_.,]+$)[\da-zA-Z-=+_.,]{8,16}$/, message: '密码由字母、数字、特殊字符中的任意两种组成', trigger: 'blur' }
],
}
}
search = new SystemUserPage()
systemUserData: SystemUserModel[] = []
roles: SystemRoleModel[] = []
addModal: boolean = false
modalTitle: string | null = null
formData: SystemUserModel = {
_id: null,
username: null,
password: null,
realname: null,
role_ids: []
}
async loadData() {
this.loading = true
const data = await this.$http.get<{params: SystemUserPage}, any>('/api/v1/system/user/list', {params:this.search})
this.loading = false
this.total = data.total
this.systemUserData = data.data
}
add() {
// 清空表单
this.formData = {
_id: null,
username: null,
password: null,
realname: null,
role_ids: []
}
this.modalTitle = '新增用户'
this.addModal = true
this.clearValidate()
}
update(row: SystemUserModel) {
this.formData._id = row._id
this.formData.username = row.username
this.formData.realname = row.realname
this.formData.role_ids = row.role_ids
this.modalTitle = '修改用户'
this.addModal = true
this.clearValidate()
}
async save() {
(this.$refs.userForm as VForm).validate(async (valid: boolean) => {
if (!valid) return
this.modalLoading = true
const data = await this.$http.post<SystemUserModel, any>('/api/v1/system/user/save', this.formData)
this.modalLoading = false
this.addModal = false
ElMessage.success(data.message)
this.loadData()
})
}
remove(row: SystemUserModel) {
ElMessageBox.confirm(`是否确认删除 ${row.username} 用户?`, '确认删除', {type: 'warning'}).then(async () => {
const data = await this.$http.delete<{params: {id: string}}, any>('/api/v1/system/user/delete', {params: {id: row._id}})
if(data.status) {
ElMessage.success(data.message)
this.loadData()
} else {
ElMessage.warning(data.message)
}
}).catch(() => {})
}
created() {
this.loadData()
this.$http.get<never, any>('/api/v1/system/role/listAll').then(data => {
this.roles = data
})
}
clearValidate() {
this.$nextTick(() => {
(this.$refs.userForm as VForm).clearValidate()
})
}
}
const store = useStore()
class SystemUserPage extends Page {
username?: string
@@ -187,4 +88,100 @@ class SystemUserPage extends Page {
this.username = undefined
}
}
const { loading, modalLoading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new SystemUserPage())
const systemUserData = ref<SystemUserModel[]>([])
const roles = ref<SystemRoleModel[]>([])
const addModal = ref(false)
const modalTitle = ref<string | null>(null)
const formData = reactive<SystemUserModel>({
_id: null,
username: null,
password: null,
realname: null,
roleIds: []
})
const userForm = ref<VForm>()
const ruleValidate = computed(() => ({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ validator: async (rule: object, value: string, callback: Function) => {
const res = await http.get<any, any>('/system/user/exists', {params: {username: value, id: formData._id}})
res.exists ? callback(new Error('用户名已存在')) : callback()
}, trigger: 'blur'
}
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 16, message: '密码长度8~16位', trigger: 'blur' },
{ pattern: /^(?![\d]+$)(?![a-zA-Z]+$)(?![-=+_.,]+$)[\da-zA-Z-=+_.,]{8,16}$/, message: '密码由字母、数字、特殊字符中的任意两种组成', trigger: 'blur' }
],
}))
async function loadData() {
loading.value = true
const data = await http.get<{params: SystemUserPage}, any>('/system/user/list', {params: search})
loading.value = false
total.value = data.total
systemUserData.value = data.list
}
setLoadData(loadData)
function add() {
Object.assign(formData, {
_id: null,
username: null,
password: null,
realname: null,
roleIds: []
})
modalTitle.value = '新增用户'
addModal.value = true
clearValidate()
}
function update(row: SystemUserModel) {
Object.assign(formData, {
_id: row._id,
username: row.username,
realname: row.realname,
roleIds: row.roleIds
})
modalTitle.value = '修改用户'
addModal.value = true
clearValidate()
}
async function save() {
userForm.value?.validate(async (valid: boolean) => {
if (!valid) return
modalLoading.value = true
await http.post<SystemUserModel, any>('/system/user/save', formData)
modalLoading.value = false
addModal.value = false
ElMessage.success("保存成功")
loadData()
})
}
function remove(row: SystemUserModel) {
ElMessageBox.confirm(`是否确认删除 ${row.username} 用户?`, '确认删除', {type: 'warning'}).then(async () => {
await http.delete<{params: {id: string}}, any>('/system/user/delete', {params: {id: row._id}})
ElMessage.success('删除成功')
loadData()
})
}
function clearValidate() {
nextTick(() => {
userForm.value?.clearValidate()
})
}
loadData()
http.get<never, any>('/system/role/listAll').then(data => {
roles.value = data
})
</script>
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_API_BASE: string
readonly VITE_APP_PROXY_TARGET?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare const __APP_VERSION__: string
-10
View File
@@ -1,10 +0,0 @@
import { ComponentCustomProperties } from 'vue'
import { Store } from 'vuex'
import { StateType } from './store/types'
declare module '@vue/runtime-core' {
// provide typings for `this.$store`
interface ComponentCustomProperties {
$store: Store<StateType>
}
}
+5 -3
View File
@@ -3,18 +3,20 @@
"target": "esnext",
"useDefineForClassFields": true,
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"lib": ["esnext", "dom"],
"types": ["node"],
"allowJs": true,
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
"@/*": ["./src/*"],
"vuex": ["./node_modules/vuex/types/index.d.ts"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
+86
View File
@@ -0,0 +1,86 @@
import { execSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { defineConfig, loadEnv } from 'vite'
const srcDir = fileURLToPath(new URL('./src', import.meta.url))
function resolveCommitInfo() {
try {
return execSync('git show -s --format=%cs(%h)', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
} catch {
return 'unknown'
}
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
base: './',
plugins: [
vue(),
AutoImport({
dts: './auto-imports.d.ts',
resolvers: [ElementPlusResolver()]
}),
Components({
dts: './components.d.ts',
resolvers: [ElementPlusResolver()]
})
],
resolve: {
alias: {
'@': srcDir
}
},
define: {
__APP_VERSION__: JSON.stringify(resolveCommitInfo())
},
server: {
port: 8080,
proxy: {
'/api': {
target: env.VITE_APP_PROXY_TARGET,
changeOrigin: true
}
}
},
build: {
sourcemap: false,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('/node_modules/vue/') || id.includes('/node_modules/vue-router/') || id.includes('/node_modules/vuex/')) {
return 'vue'
}
if (id.includes('/node_modules/element-plus/') || id.includes('/node_modules/@element-plus/')) {
return 'element-plus'
}
if (id.includes('/node_modules/echarts/')) {
return 'echarts'
}
if (id.includes('/node_modules/hls.js/')) {
return 'media'
}
if (id.includes('/node_modules/')) {
return 'vendor'
}
if (id.includes('/src/views/system/')) {
return 'system'
}
if (id.includes('/src/views/api/')) {
return 'api'
}
if (id.includes('/src/views/debug/')) {
return 'debug'
}
}
}
}
}
}
})
-41
View File
@@ -1,41 +0,0 @@
const path = require('path')
const AutoImport = require('unplugin-auto-import/webpack')
const Components = require('unplugin-vue-components/webpack')
const { ElementPlusResolver } = require('unplugin-vue-components/resolvers')
const { defineConfig } = require('@vue/cli-service')
const { DefinePlugin } = require('webpack')
const { execSync } = require('child_process')
const commitInfo = execSync('git show -s --format=%cs(%h)').toString().trim()
module.exports = defineConfig({
publicPath: './',
transpileDependencies: true,
productionSourceMap: false,
configureWebpack: {
resolve: {
alias: { '@': path.resolve(__dirname, './src') }
},
plugins: [
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
new DefinePlugin({
'process.env.VERSION': `'${commitInfo}'`
})
]
},
devServer: {
port: 8080,
proxy: {
'^/api': {
// target: 'http://localhost:3301'
target: 'https://www.colorfulsweet.site',
changeOrigin: true
}
}
}
})
+666 -5598
View File
File diff suppressed because it is too large Load Diff