Compare commits

...
47 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
82 changed files with 6241 additions and 17625 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_APP_PROXY_TARGET=https://www.colorfulsweet.site
VITE_APP_API_BASE=/api/v2
+10 -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']
@@ -23,7 +29,9 @@ declare module '@vue/runtime-core' {
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']
@@ -38,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
@@ -8,5 +8,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>
+926 -9826
View File
File diff suppressed because it is too large Load Diff
+7 -9
View File
@@ -2,8 +2,9 @@
"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.3.2",
@@ -19,16 +20,13 @@
"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",
"@types/node": "^25.6.0",
"@vitejs/plugin-vue": "^6.0.7",
"less": "^4.6.4",
"less-loader": "^8.0.0",
"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%",
+3 -5
View File
@@ -1,9 +1,7 @@
<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 setup lang="ts">
+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>
+34 -11
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
+4
View File
@@ -7,7 +7,10 @@ import type { RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
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'
import { permissionDirective } from '@/utils/permission'
// 全局路由导航前置守卫
router.beforeEach(function (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) {
@@ -30,4 +33,5 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.use(router)
.use(store)
.directive('loading', ElLoading.directive)
.directive('permission', permissionDirective)
.mount('#app')
+2
View File
@@ -16,6 +16,7 @@ export interface MusicLibModel {
_id: string
name: string // 歌单名称
path: string // 歌单文件路径
musicCount: number // 歌曲数量
}
export interface MusicLyricModel {
@@ -33,6 +34,7 @@ export interface MusicPlayerItem {
src: string
pic: string
lrc?: string
lyricId?: string
}
export interface StatType {
+9 -3
View File
@@ -2,9 +2,15 @@ export interface SystemRoleModel {
_id: string | null
name: string | null // 角色名称
description: string | null // 描述
methods: string[] // 允许的请求类型(优先级3)
includeUri: string[] // 包含的URI(优先级2)
excludeUri: string[] // 排除的URI(优先级1)
permissions: string[] // 权限标识列表
createdAt?: Date // 创建时间
updatedAt?: Date // 更新时间
}
export interface PermissionTreeNode {
code: string
name: string
description: string
leaf: boolean
children: PermissionTreeNode[]
}
+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: '图形验证码' } },
]}
]
+211 -47
View File
@@ -63,9 +63,9 @@ html, body, #app, .layout {
}
/* ===== 主内容区 ===== */
.layout-main {
padding: 0 !important;
display: flex !important;
.el-main.layout-main {
--el-main-padding: 0;
display: flex;
flex-direction: column;
background: var(--el-fill-color);
@@ -83,31 +83,15 @@ html, body, #app, .layout {
flex-basis: 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
> .layout-copy {
text-align: center;
padding: 8px 0 12px;
color: var(--el-text-color-placeholder);
font-size: var(--el-font-size-extra-small);
/* ===== 页面布局(用于带分页的表格页面)===== */
.page-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
}
/* ===== 页面布局(用于带分页的表格页面)===== */
.page-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
/* ===== 表格美化 ===== */
.table-container {
position: relative;
flex: 1;
overflow: hidden;
margin-bottom: 12px;
display: flex;
flex-direction: column;
.el-table {
.table-container {
// CSS 变量覆盖:表头背景/文字色、行悬停色、边框色、正文色
--el-table-header-bg-color: var(--el-fill-color-light);
--el-table-header-text-color: var(--el-text-color-regular);
@@ -119,7 +103,9 @@ html, body, #app, .layout {
flex: 1;
border-radius: var(--el-border-radius-base);
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
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;
@@ -141,26 +127,22 @@ html, body, #app, .layout {
padding: 4px 6px;
}
}
}
/* ===== 操作栏 ===== */
.btn-container {
margin-bottom: 14px;
display: flex;
align-items: center;
.search-btn {
margin-left: auto;
/* ===== 分页 ===== */
.page-container {
margin-top: 20px;
display: flex;
gap: 8px;
justify-content: center;
}
}
/* ===== 操作栏 ===== */
.btn-container {
margin-bottom: 14px;
display: flex;
align-items: center;
/* ===== 分页 ===== */
.page-container {
padding: 16px 0 4px;
display: flex;
justify-content: center;
.search-btn {
margin-left: auto;
}
}
}
/* ===== 表单弹窗 ===== */
@@ -206,9 +188,6 @@ body .el-dialog {
min-width: 160px;
}
}
.search-panel {
margin-bottom: 14px;
}
/* ===== 其他工具类 ===== */
.carsouel-img {
@@ -230,6 +209,191 @@ body .el-dialog {
overflow: auto;
}
}
/* ===== 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;
+7 -2
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,12 +22,15 @@ class Store {
* @param {Object} state
* @param {UserInfo} data 登录数据
*/
login(state: StateType, data: {token?: string, userInfo: UserInfo}): void {
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
}
},
/**
* 注销
@@ -36,6 +40,7 @@ class Store {
localStorage.removeItem('login_token')
state.loginInfo.token = null
state.loginInfo.userInfo = null
state.loginInfo.permissions = []
},
/**
* 添加导航页签
+1
View File
@@ -15,6 +15,7 @@ export interface StateType {
loginInfo: { // 登录信息
userInfo: null | UserInfo
token: null | string
permissions: string[] // 用户权限列表
}
tabs: TabItem[] // 导航页签
activeTab: string | null // 当前激活的页签
+2 -5
View File
@@ -4,6 +4,7 @@ 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
@@ -42,11 +43,7 @@ http.interceptors.response.use(res => {
ElMessage.error('服务器内部错误')
} else if (err.response?.status >= 400) {
const message = err.response.data?.message
if (typeof message === 'string') {
ElMessage.error(message)
} else if (Array.isArray(message)) {
ElMessage.error(message.join('<br/>'))
}
message && ElMessage.error(message)
if (err.response.status === 403) {
router.push('/login')
}
+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 }
+77 -13
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>
@@ -40,14 +45,13 @@
<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" v-if="mainViewReady">
<router-view class="main-view" v-slot="{ Component }">
<div class="layout-content">
<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>
@@ -66,32 +70,82 @@
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="changePwdModal = false">取消</el-button>
<el-button type="primary" @click="submitChangePassword" :loading="changePwdLoading">确定</el-button>
<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 setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { useStore } from 'vuex'
import { useRouter, useRoute } from 'vue-router'
import menus from '../config/menu'
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'
const store = useStore()
const router = useRouter()
const route = useRoute()
const version = process.env.VERSION
const currentYear = new Date().getFullYear()
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)
})
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)
}
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>()
@@ -139,8 +193,8 @@ if (defaultActiveMenuKey.value) {
if (!store.state.loginInfo.token) {
router.push('/login')
} else {
http.post<{token: string}, any>('/api/v2/common/verifyToken', {token: store.state.loginInfo.token}).then(data => {
store.commit('login', {userInfo: data.userInfo})
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')
@@ -152,22 +206,32 @@ function dropdownMenuCommand(command: string): void {
case 'home':
router.push('/')
break
case 'about':
openAboutDialog()
break
case 'changePassword':
changePwdModal.value = true
break
case 'logout':
http.post('/api/v2/common/logout').finally(() => {
http.post('/common/logout').finally(() => {
store.commit('logout')
router.push('/login')
})
break
}
}
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('/api/v2/system/user/changePassword', changePwdData).finally(() => {
await http.post('/system/user/changePassword', changePwdData).finally(() => {
changePwdLoading.value = false
})
changePwdModal.value = false
+149 -17
View File
@@ -18,7 +18,7 @@
placeholder="请输入用户名"
prefix-icon="User"
size="large"
@keyup.enter="login"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
@@ -29,35 +29,80 @@
prefix-icon="Lock"
size="large"
show-password
@keyup.enter="login"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-button class="login-btn" type="primary" size="large" :loading="loading" @click="login">
<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 setup lang="ts">
import { reactive, ref } from 'vue'
import { nextTick, onBeforeUnmount, reactive, ref } from 'vue'
import { useStore } from 'vuex'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { VForm } from '../types'
import CaptchaPanel from '@/components/CaptchaPanel.vue'
import http from '@/utils/http'
import type { TianAiCaptchaResult } from '@/vendor/tianai-captcha'
type UserInfo = {
type LoginFormState = {
username: string | null,
password: string | null
}
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: UserInfo = reactive({
const userInfo: LoginFormState = reactive({
username: null,
password: null
})
@@ -77,19 +122,95 @@ store.commit('logout')
store.commit('clearTabs')
localStorage.clear()
async function login() {
async function openCaptchaDialog() {
captchaDialogVisible.value = true
await nextTick()
await captchaRef.value?.init()
}
async function handleLogin() {
loginForm.value?.validate(async (valid: boolean) => {
if (!valid) return
if (!valid || loading.value) return
loading.value = true
const data = await http.post<UserInfo, any>('/api/v2/common/login', userInfo).finally(() => {
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
})
if (data.token) {
store.commit('login', data)
router.push('/')
}
})
}
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 {
@@ -195,7 +316,7 @@ async function login() {
.card-right {
flex: 1.1;
background: #fff;
background: var(--el-bg-color);
padding: 52px 48px;
display: flex;
flex-direction: column;
@@ -204,18 +325,18 @@ async function login() {
.form-title {
font-size: 26px;
font-weight: 700;
color: #1d2129;
color: var(--el-text-color-primary);
margin: 0 0 8px;
}
.form-subtitle {
font-size: 14px;
color: #86909c;
color: var(--el-text-color-secondary);
margin: 0 0 36px;
}
:deep(.el-form-item__label) {
font-weight: 600;
color: #4e5969;
color: var(--el-text-color-regular);
}
.login-btn {
@@ -227,4 +348,15 @@ async function login() {
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>
+22 -10
View File
@@ -26,7 +26,19 @@
</div>
</template>
<script setup lang="ts">
import menus from '../config/menu'
import { computed } from 'vue'
import allMenus from '../config/menu'
import type { MenuGroup } from '../config/menu'
import { hasPermission } from '@/utils/permission'
const menus = computed((): MenuGroup[] => {
return allMenus
.map(group => ({
...group,
child: group.child.filter(item => hasPermission(item.permission))
}))
.filter(group => group.child.length > 0)
})
</script>
<style lang="less" scoped>
.welcome-page {
@@ -40,12 +52,12 @@ import menus from '../config/menu'
h2 {
font-size: 26px;
font-weight: 700;
color: #1d2129;
color: var(--el-text-color-primary);
margin: 0 0 8px;
}
p {
font-size: 14px;
color: #86909c;
color: var(--el-text-color-secondary);
margin: 0;
}
}
@@ -57,11 +69,11 @@ import menus from '../config/menu'
}
.menu-section {
background: #fff;
background: var(--el-bg-color);
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
border: 1px solid #f0f0f0;
border: 1px solid var(--el-border-color-lighter);
}
.section-title {
@@ -70,10 +82,10 @@ import menus from '../config/menu'
gap: 8px;
font-size: 15px;
font-weight: 600;
color: #4e5969;
color: var(--el-text-color-regular);
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f5f5f5;
border-bottom: 1px solid var(--el-border-color-light);
.el-icon {
font-size: 17px;
@@ -93,16 +105,16 @@ import menus from '../config/menu'
justify-content: space-between;
padding: 14px 16px;
border-radius: 8px;
background: #f7f8fa;
background: var(--el-fill-color-light);
text-decoration: none;
color: #1d2129;
color: var(--el-text-color-primary);
font-size: 14px;
transition: all 0.2s;
border: 1px solid transparent;
.item-arrow {
font-size: 13px;
color: #c9cdd4;
color: var(--el-text-color-placeholder);
transition: transform 0.2s, color 0.2s;
}
+27 -29
View File
@@ -19,32 +19,30 @@
</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="createdAt" label="创建时间" width="180">
<template #default="scope">
{{ datetimeFormat(scope.row.createdAt) }}
</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"
@@ -59,8 +57,8 @@
<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>
@@ -108,7 +106,7 @@ async function loadData() {
endDate.setHours(23, 59, 59, 999)
params.createdAt[1] = endDate
}
const data = await http.get<HitokotoPage, any>('/api/v2/hitokoto/list', {params})
const data = await http.get<HitokotoPage, any>('/hitokoto/list', {params})
selectedData = []
loading.value = false
total.value = data.total
@@ -120,7 +118,7 @@ async function save() {
addForm.value?.hitokotoForm?.validate(async (valid: boolean) => {
if (!valid) return
modalLoading.value = true
const data = await http.post<any, any>('/api/v2/hitokoto/save', formData)
const data = await http.post<any, any>('/hitokoto/save', formData)
modalLoading.value = false
addModal.value = false
ElMessage.success('保存成功')
@@ -134,7 +132,7 @@ function deleteAll() {
return
}
ElMessageBox.confirm(`是否确认删除选中的${selectedData.length}条数据?`, '确认删除', {type: 'warning'}).then(async () => {
await http.delete<any, any>('/api/v2/hitokoto/delete', {params: {ids: selectedData}})
await http.delete<any, any>('/hitokoto/delete', {params: {ids: selectedData}})
ElMessage.success('删除成功')
loadData()
}).catch(() => {})
@@ -149,7 +147,7 @@ function findTypeText(value: string): string | null {
// created
loadData()
http.get<never, any>('/api/v2/common/config/hitokoto_type').then(data => {
http.get<never, any>('/common/config/hitokoto_type').then(data => {
typeList.value = data
})
</script>
+238 -107
View File
@@ -5,12 +5,12 @@
<el-input v-model="search.title" />
</el-form-item>
<el-form-item label="所属歌单">
<el-select v-model="search.libIds" 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.exts" 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>
@@ -23,57 +23,61 @@
</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="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>
<template v-else>
{{ findMusicLib(scope.row.libId) }}
<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="lyricId" label="歌词" width="120" >
<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="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"
@@ -85,7 +89,7 @@
</el-pagination>
</div>
<el-dialog v-model="modifyLyricModal" title="编辑歌词" :width="600" >
<el-form ref="lyricForm" :model="lyricFormData" :rules="lyricRuleValidate" :label-width="120">
<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>
@@ -98,62 +102,112 @@
</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"
: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 setup lang="ts">
import { ref } from 'vue'
import { ref, watch, computed } from 'vue'
import { useStore } from 'vuex'
import { useBaseList } from '@/model/baselist'
import { MsgResult, Page } from '@/model/common.dto'
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'
// 格式化秒为 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'
@@ -174,10 +228,11 @@ class MusicPage extends Page {
}
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 libIdSelected = ref<string | 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[]>([])
@@ -189,7 +244,22 @@ const musicList = ref<MusicPlayerItem[]>([])
const currentMusic = ref<MusicPlayerItem>()
const lyricForm = ref<VForm>()
const musicUpload = ref<UploadInstance>()
const player = ref<any>()
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: [
@@ -205,9 +275,16 @@ const lyricRuleValidate = {
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>('/api/v2/music/list', {params: search})
const data = await http.get<MusicPage, any>('/music/list', {params: search})
selectedIds = []
loading.value = false
total.value = data.total
@@ -224,18 +301,16 @@ function findMusicLib(value: string): string | null {
}
async function playMusic() {
try {
const data = await http.get<any, any>('/api/v2/music/list/all', {params: selectedIds.length ? {ids: selectedIds} : search})
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: `/api/v2/common/music/load/${item._id}`,
pic: `/api/v2/common/music/album/${item._id}`,
}
if (item.lyricId) {
musicItem.lrc = `${location.origin}/api/v2/common/music/lyric/${item.lyricId}`
src: `${apiBase}/common/music/load/${item._id}`,
pic: `${apiBase}/common/music/album/${item._id}`,
lyricId: item.lyricId,
}
return musicItem
})
@@ -252,34 +327,38 @@ function updateLib(row: MusicModel) {
}
function download(row: MusicModel) {
const link = document.createElement('a')
link.setAttribute('href', `/api/v2/common/music/load/${row._id}`)
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>('/api/v2/music/delete', {params: {id: row._id}})
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) {
const data = (await http.get<any, any>('/api/v2/music/lyric/get', {params: {lyricId: row.lyricId}}))
data.cloudId = data.cloudId ? data.cloudId.toString() : null
lyricFormData.value = data
} else {
lyricFormData.value = {}
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>(`/api/v2/music/lyric/save?musicId=${currentRow.value ? currentRow.value._id : ''}`, lyricFormData.value)
await http.post<MusicLyricModel, any>(`/music/lyric/save?musicId=${currentRow.value ? currentRow.value._id : ''}`, lyricFormData.value)
modalLoading.value = false
modifyLyricModal.value = false
ElMessage.success("歌词保存成功")
@@ -289,28 +368,27 @@ async function saveLyric() {
}
async function saveMusicLib(row: MusicModel) {
if (!currentRow.value) return
await http.post<{id: string, libId: string}, any>('/api/v2/music/updateLib', {id: currentRow.value._id, libId: currentRow.value.libId})
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
libIdSelected.value = null
uploadFormData.value = { libId: null }
}
async function uploadMusic() {
if (!libIdSelected.value) {
ElMessage.warning('请选择歌单')
return
}
musicUpload.value?.submit()
uploadForm.value?.validate((valid: boolean) => {
if (!valid) return
musicUpload.value?.submit()
})
}
function uploadSuccess(response: MsgResult) {
if (response.code === 0) {
ElMessage.success(response.message)
ElMessage.success('上传成功')
loadData()
} else {
ElMessage.warning(response.message)
ElMessage.warning(response.message || '上传失败')
}
}
function uploadError(error: Error) {
@@ -329,33 +407,86 @@ function musicPlay() {
artwork: [{src: location.origin + currentMusic.value.pic}]
})
navigator.mediaSession.setActionHandler('play', () => {
player.value.play()
void player.value?.play()
})
navigator.mediaSession.setActionHandler('pause', () => {
player.value.pause()
player.value?.pause()
})
navigator.mediaSession.setActionHandler('previoustrack', () => {
if (currentId === 0) {
player.value.switch(musicList.value.length - 1)
} else {
player.value.switch(currentId - 1)
}
switchPrev()
})
navigator.mediaSession.setActionHandler('nexttrack', () => {
if (currentId === musicList.value.length - 1) {
player.value.switch(0)
} else {
player.value.switch(currentId + 1)
}
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
http.get<never, any>('/api/v2/music/lib/list').then(data => {
musicLibs.value = data
loadLibs().then(() => {
loadData()
})
http.get<never, any>('/api/v2/music/listExts').then(data => {
http.get<never, any>('/music/listExts').then(data => {
exts.value = data
})
</script>
+397 -134
View File
@@ -1,173 +1,436 @@
<template>
<div class="page-wrapper">
<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" :min="0" >
<template #prepend></template>
</el-input>
</el-form-item>
<el-form-item>
<el-input v-model="search.widthMax" type="number" :min="0" >
<template #prepend></template>
</el-input>
</el-form-item>
<el-form-item label="高度" >
<el-input v-model="search.heightMin" type="number" :min="0" >
<template #prepend></template>
</el-input>
</el-form-item>
<el-form-item>
<el-input v-model="search.heightMax" type="number" :min="0" >
<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 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="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>
<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 setup lang="ts">
import { ref, h } from 'vue'
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 type { MsgResult } from '@/model/common.dto'
import { Page } from '@/model/common.dto'
import { useBaseList } from '@/model/baselist'
import PhotoWallModel from '@/model/api/photowall'
import type PhotoWallModel from '@/model/api/photowall'
import http from '@/utils/http'
class PhotoWallPage extends Page {
name?: string
widthMin!: number | null
widthMax!: number | null
heightMin!: number | null
heightMax!: number | null
reset() {
super.reset()
this.name = undefined
this.widthMin = null
this.widthMax = null
this.heightMin = null
this.heightMax = null
}
// ─── 常量 ─────────────────────────────────────────────
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 store = useStore()
const { loading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange } = useBaseList(new PhotoWallPage())
const allowUploadExt = ['jpg', 'jpeg', 'png']
const photowallData = ref([])
const isUploading = ref(false)
let selectedData: string[] = []
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
})
// ─── 数据加载 ─────────────────────────────────────────
async function loadData() {
loading.value = true
const data = await http.get<PhotoWallPage, any>('/api/v2/photoWall/list', {params: search})
selectedData = []
loading.value = false
total.value = data.total
photowallData.value = data.list
entered.value = new Set()
try {
if (!cdnBase.value) {
cdnBase.value = await http.get('/common/config/picture_cdn')
}
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
}
}
setLoadData(loadData)
function deleteAll() {
if (!selectedData || !selectedData.length) {
ElMessage.warning('请选择要删除的数据')
return
}
ElMessageBox.confirm(`是否确认删除选中的${selectedData.length}条数据?`, '确认删除', {type: 'warning'}).then(async () => {
await http.delete('/api/v2/photoWall/delete', {params: {ids: selectedData}})
ElMessage.success('删除成功')
loadData()
}).catch(() => {})
// ─── 选中 ─────────────────────────────────────────────
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 dataSelect(selection: PhotoWallModel[]) {
selectedData = selection.map(item => item._id)
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')
ElMessage.warning('文件大小超过 10MB')
return false
}
isUploading.value = true
return true
}
function uploadSuccess(response: MsgResult) {
isUploading.value = false
if (response.code === 0) {
ElMessage.success(response.message)
ElMessage.success('上传成功')
loadData()
} else {
ElMessage.warning(response.message)
ElMessage.warning(response.message || '上传失败')
}
isUploading.value = false
}
function uploadError(error: Error) {
isUploading.value = false
ElMessage.error(error.message)
}
async function preview(row: PhotoWallModel) {
const previewHeight = Math.floor(row.height * (500 / row.width))
const pictureCdn = await http.get('/api/v2/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(() => {})
// ─── 响应式列数 ───────────────────────────────────────
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;
}
// created
loadData()
</script>
.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);
}
}
&:hover .card-overlay {
opacity: 1;
transform: translateY(0);
}
&:hover .overlay-info {
opacity: 1;
transform: translateY(0);
}
&:hover .overlay-actions {
opacity: 1;
transform: translateY(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);
}
}
// ─── 选中角标 ─────────────────────────────────────────
.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>
+54 -51
View File
@@ -1,12 +1,8 @@
<template>
<div class="page-wrapper">
<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="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}"
@@ -15,39 +11,39 @@
: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="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" 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"
@@ -72,10 +68,16 @@
:data="labels"
></el-transfer>
</el-dialog>
<el-image-viewer
v-if="previewVisible"
:url-list="previewUrlList"
:initial-index="previewIndex"
@close="previewVisible = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, h } from 'vue'
import { ref, computed } from 'vue'
import { useStore } from 'vuex'
import { ElMessage, ElMessageBox } from 'element-plus'
import prettyBytes from 'pretty-bytes'
@@ -85,6 +87,7 @@ import { SourceImageModel } from '@/model/api/source-image'
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']
@@ -94,6 +97,9 @@ 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)
@@ -108,7 +114,7 @@ let selectedData: string[] = []
async function loadData(): Promise<void> {
loading.value = true
const data = await http.get<Page, any>('/api/v2/source-image/list', {params: search})
const data = await http.get<Page, any>('/source-image/list', {params: search})
selectedData = []
loading.value = false
total.value = data.total
@@ -122,7 +128,7 @@ function deleteAll(): void {
return
}
ElMessageBox.confirm(`是否确认删除选中的${selectedData.length}条数据?`, '确认删除', {type: 'warning'}).then(async () => {
await http.delete('/api/v2/source-image/delete', {params: {ids: selectedData}})
await http.delete('/source-image/delete', {params: {ids: selectedData}})
ElMessage.success('删除成功')
loadData()
}).catch(() => {})
@@ -140,10 +146,10 @@ function beforeUpload(file: File): boolean {
}
function uploadSuccess(response: MsgResult): void {
if (response.code === 0) {
ElMessage.success(response.message)
ElMessage.success('上传成功')
loadData()
} else {
ElMessage.warning(response.message)
ElMessage.warning(response.message || '上传失败')
}
isUploading.value = false
}
@@ -152,13 +158,10 @@ function uploadError(error: Error): void {
ElMessage.error(error.message)
}
function preview(row: SourceImageModel): void {
ElMessageBox({
title: '图片预览',
message: h('img', { style: `width:500px`, src: `/api/v2/common/randomBg?id=${row._id}` }, ''),
showCancelButton: false,
confirmButtonText: '关闭',
customStyle: {width: '530px', maxWidth: 'unset'}
}).catch(() => {})
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
@@ -169,11 +172,11 @@ function modifyTags(item: SourceImageModel): void {
modifyModal.value = true
}
async function tarnsferChange(newTargetKeys: string[], direction: 'right' | 'left', moveKeys: string[]) {
await http.post('/api/v2/source-image/updateLabel', {id: curId.value, labels: newTargetKeys})
await http.post('/source-image/updateLabel', {id: curId.value, labels: newTargetKeys})
}
// created
http.get<never, any>('/api/v2/common/config/image_label').then(data => {
http.get<never, any>('/common/config/image_label').then(data => {
labelList.value.push(...data)
loadData()
})
-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>
+34 -53
View File
@@ -1,6 +1,5 @@
<template>
<div class="page-wrapper">
<div class="search-panel">
<el-form inline :model="search">
<el-form-item label="标题">
<el-input v-model="search.title" />
@@ -30,35 +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 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" />
<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" style="height: 100%; display: flex; flex-direction: column;">
<div class="table-container" style="margin-bottom: 0;">
<el-table :data="articleData" v-loading="loading" stripe @selection-change="dataSelect" height="100%">
<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="路径" >
@@ -91,7 +83,7 @@
</template>
</el-table-column>
</el-table>
<div class="page-container" style="margin-top: 12px; padding: 0;">
<div class="page-container">
<el-pagination background
:page-sizes="store.state.pageSizeOpts"
:layout="store.state.pageLayout"
@@ -101,15 +93,15 @@
@current-change="pageChange">
</el-pagination>
</div>
</div>
</el-col>
</el-row>
<el-drawer
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>
@@ -119,11 +111,17 @@ 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 { useBaseList } from '@/model/baselist'
import { Page, MsgResult } from '@/model/common.dto'
import { Page } from '@/model/common.dto'
import http from '@/utils/http'
type ArticleTreeNode = {
level: number
data: Pick<TreeNodeData, 'name'>
}
type TreeDataResolver = (data: TreeNodeData[]) => void
class ArticlePage extends Page {
title?: string
createDate?: [Date, Date]
@@ -155,7 +153,6 @@ const markdownPreview = reactive<{
title: null,
content: null
})
const isUploading = ref(false)
let selectedData: string[] = []
@@ -168,7 +165,7 @@ async function loadData() {
endDate.setHours(23, 59, 59, 999)
params.createDate[1] = endDate
}
const data = await http.get<ArticlePage, any>('/api/v2/article/list', {params})
const data = await http.get<ArticlePage, any>('/article/list', {params})
selectedData = []
loading.value = false
total.value = data.total
@@ -182,13 +179,13 @@ function splitWord() {
return
}
ElMessageBox.confirm(`是否确认对选中的${selectedData.length}篇文章执行分词处理?`, '操作确认', {type: 'info'}).then(async () => {
const successNum = await http.put<{_ids: string[]}, any>('/api/v2/article/splitWord', {_ids: selectedData})
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>('/api/v2/article/pull')
const { updateCount, createCount } = await http.put<never, any>('/article/pull')
ElMessage.success(`拉取文章完成,更新 ${updateCount} 篇,创建 ${createCount}`)
loadData()
})
@@ -196,30 +193,14 @@ function pullArticles() {
function dataSelect(selection: ArticleModel[]) {
selectedData = selection.map(item => item._id)
}
function beforeUpload(file: File): boolean {
isUploading.value = true
return true
}
function uploadSuccess(response: MsgResult) {
if (response.code === 0) {
ElMessage.success(response.message)
} else {
ElMessage.warning(response.message)
}
isUploading.value = false
}
function uploadError(error: Error) {
isUploading.value = false
ElMessage.error(error.message)
}
const treeProps = {
label: 'name',
children: 'children',
isLeaf: 'isLeaf',
}
async function loadTreeData(node: Node, resolve: Function) {
const childItems: TreeNodeSource[] = await http.get('/api/v2/article/tree', {params: {deep: node.level, parent: node.data.name}})
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,
@@ -232,7 +213,7 @@ async function loadTreeData(node: Node, resolve: Function) {
}
async function articlePreview(node: TreeNodeData) {
if (!node.isLeaf) return
const mdText = await http.get<never, any>('/api/v2/article/markdown', {params: {id: node.id}})
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) => {
@@ -243,10 +224,10 @@ async function articlePreview(node: TreeNodeData) {
// created
loadData()
http.get<never, any>('/api/v2/article/listCategories').then(data => {
http.get<never, any>('/article/listCategories').then(data => {
categories.value = data
})
http.get<never, any>('/api/v2/article/listTags').then(data => {
http.get<never, any>('/article/listTags').then(data => {
tags.value = data
})
</script>
+2 -2
View File
@@ -370,7 +370,7 @@ onMounted(async () => {
timelineWordsChartLoading.value = true
// 加载基础统计数据
const articleData = await http.get<{params:{type:string}}, any>('/api/v2/article/statistics', {params: {type: 'normal'}})
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)
@@ -397,7 +397,7 @@ onMounted(async () => {
publishDatesChartLoading.value = false
// 时间轴词汇统计
const timelineData = await http.get<{params:{type:string}}, any>('/api/v2/article/statistics', {params: {type: 'timelineWords'}})
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
+38 -40
View File
@@ -6,50 +6,48 @@
</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="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="修改"></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>
@@ -85,7 +83,7 @@ function reset() {
async function loadData() {
loading.value = true
systemConfigData.value = await http.get('/api/v2/system/config/list', {params: search.value})
systemConfigData.value = await http.get('/system/config/list', {params: search.value})
loading.value = false
}
@@ -112,7 +110,7 @@ async function save() {
addForm.value?.configForm?.validate(async (valid: boolean) => {
if (!valid) return
modalLoading.value = true
await http.post<SystemConfigModel, any>('/api/v2/system/config/save', formData.value)
await http.post<SystemConfigModel, any>('/system/config/save', formData.value)
modalLoading.value = false
addModal.value = false
ElMessage.success("保存成功")
@@ -122,7 +120,7 @@ async function save() {
function remove(row: SystemConfigModel) {
ElMessageBox.confirm(`是否确认删除 ${row.name} 配置项?`, '确认删除', {type: 'warning'}).then(async () => {
await http.delete<{params: {id: string}}, any>('/api/v2/system/config/delete', {params: {id: row._id}})
await http.delete<{params: {id: string}}, any>('/system/config/delete', {params: {id: row._id}})
ElMessage.success('删除成功')
loadData()
})
+1 -1
View File
@@ -35,7 +35,7 @@ const ruleValidate = computed(() => ({
name: [
{ required: true, message: '请输入配置项名称', trigger: 'blur' },
{ validator: (rule: object, value: string, callback: Function) => {
http.get<any, any>('/api/v2/system/config/exists', {params: {name: value, id: props.formData._id}}).then(data => {
http.get<any, any>('/system/config/exists', {params: {name: value, id: props.formData._id}}).then(data => {
if(data.exists) {
callback(new Error('配置项名称已存在'))
} else {
+173 -87
View File
@@ -6,38 +6,32 @@
</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="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="修改"></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"
@@ -56,35 +50,23 @@
<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('includeUri', uri.include)"></el-button>
</template>
</el-input>
<el-tag v-for="uri in formData.includeUri" :key="uri" closable @close="removeUri('includeUri', 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('excludeUri', uri.exclude)"></el-button>
</template>
</el-input>
<el-tag v-for="uri in formData.excludeUri" :key="uri" closable @close="removeUri('excludeUri', 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>
@@ -97,9 +79,9 @@ import { useStore } from 'vuex'
import { useBaseList } from '@/model/baselist'
import { Page } from '@/model/common.dto'
import http from '@/utils/http'
import { SystemRoleModel } from '@/model/system/system-role'
import { SystemRoleModel, PermissionTreeNode } from '@/model/system/system-role'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VForm } from '@/types'
import type { VForm } from '@/types'
const store = useStore()
@@ -117,30 +99,37 @@ 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 uri = reactive<{
include: string | null,
exclude: string | null
}>({
include: null,
exclude: null
})
const permissionTreeData = ref<PermissionTreeNode[]>([])
const treeProps = {
children: 'children',
label: 'name'
}
const formData = reactive<SystemRoleModel>({
_id: null,
name: null,
description: null,
methods: [],
includeUri: [],
excludeUri: []
permissions: []
})
const roleForm = ref<VForm>()
const permTree = ref<any>()
async function loadData() {
loading.value = true
const data = await http.get<{params: SystemRolePage}, any>('/api/v2/system/role/list', {params: search})
const data = await http.get<{params: SystemRolePage}, any>('/system/role/list', {params: search})
loading.value = false
total.value = data.total
systemRoleData.value = data.list
@@ -148,54 +137,147 @@ async function loadData() {
setLoadData(loadData)
function add() {
uri.include = null
uri.exclude = null
Object.assign(formData, {
_id: null,
name: null,
description: null,
methods: [],
includeUri: [],
excludeUri: []
permissions: []
})
modalTitle.value = '新增角色'
addModal.value = true
clearValidate()
nextTick(() => {
permTree.value?.setCheckedKeys([])
})
}
function addUri(fieldName: 'includeUri' | 'excludeUri', uriValue: string | null) {
if(!uriValue) return
if(formData[fieldName].indexOf(uriValue) === -1) {
formData[fieldName].push(uriValue)
/**
* 处理权限树勾选:
* - 勾选父节点时,自动勾选所有子节点
* - 取消父节点时,自动取消所有子节点
* - 所有子节点都被勾选时,自动勾选父节点并取消子节点勾选(只保留父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 removeUri(fieldName: 'includeUri' | 'excludeUri', uriValue: string) {
let index = formData[fieldName].indexOf(uriValue)
if(index !== -1) {
formData[fieldName].splice(index, 1)
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) {
uri.include = null
uri.exclude = null
Object.assign(formData, {
_id: row._id,
name: row.name,
description: row.description,
methods: row.methods,
includeUri: row.includeUri,
excludeUri: row.excludeUri
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>('/api/v2/system/role/delete', {params: {id: row._id}})
await http.delete<{params: {id: string}}, any>('/system/role/delete', {params: {id: row._id}})
ElMessage.success('删除成功')
loadData()
})
@@ -205,7 +287,8 @@ async function save() {
roleForm.value?.validate(async (valid: boolean) => {
if (!valid) return
modalLoading.value = true
await http.post<SystemRoleModel, any>('/api/v2/system/role/save', formData)
const saveData = { ...formData, permissions: getPermissionsToSave() }
await http.post<SystemRoleModel, any>('/system/role/save', saveData)
modalLoading.value = false
addModal.value = false
ElMessage.success("保存成功")
@@ -220,4 +303,7 @@ function clearValidate() {
}
loadData()
http.get<never, any>('/system/role/permissionTree').then(data => {
permissionTreeData.value = data
})
</script>
+30 -32
View File
@@ -6,34 +6,32 @@
</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="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="修改"></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"
@@ -63,8 +61,8 @@
</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>
@@ -110,7 +108,7 @@ const ruleValidate = computed(() => ({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ validator: async (rule: object, value: string, callback: Function) => {
const res = await http.get<any, any>('/api/v2/system/user/exists', {params: {username: value, id: formData._id}})
const res = await http.get<any, any>('/system/user/exists', {params: {username: value, id: formData._id}})
res.exists ? callback(new Error('用户名已存在')) : callback()
}, trigger: 'blur'
}
@@ -124,7 +122,7 @@ const ruleValidate = computed(() => ({
async function loadData() {
loading.value = true
const data = await http.get<{params: SystemUserPage}, any>('/api/v2/system/user/list', {params: search})
const data = await http.get<{params: SystemUserPage}, any>('/system/user/list', {params: search})
loading.value = false
total.value = data.total
systemUserData.value = data.list
@@ -160,7 +158,7 @@ async function save() {
userForm.value?.validate(async (valid: boolean) => {
if (!valid) return
modalLoading.value = true
await http.post<SystemUserModel, any>('/api/v2/system/user/save', formData)
await http.post<SystemUserModel, any>('/system/user/save', formData)
modalLoading.value = false
addModal.value = false
ElMessage.success("保存成功")
@@ -170,7 +168,7 @@ async function save() {
function remove(row: SystemUserModel) {
ElMessageBox.confirm(`是否确认删除 ${row.username} 用户?`, '确认删除', {type: 'warning'}).then(async () => {
await http.delete<{params: {id: string}}, any>('/api/v2/system/user/delete', {params: {id: row._id}})
await http.delete<{params: {id: string}}, any>('/system/user/delete', {params: {id: row._id}})
ElMessage.success('删除成功')
loadData()
})
@@ -183,7 +181,7 @@ function clearValidate() {
}
loadData()
http.get<never, any>('/api/v2/system/role/listAll').then(data => {
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
+3 -3
View File
@@ -12,11 +12,11 @@
"esModuleInterop": true,
"experimentalDecorators": true,
"lib": ["esnext", "dom"],
"types": ["node"],
"allowJs": true,
"baseUrl": "./",
"paths": {
"@/*": ["src/*"],
"vuex": ["node_modules/vuex/types/index.d.ts"]
"@/*": ["./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'
}
}
}
}
}
}
})
-46
View File
@@ -1,46 +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}'`
})
]
},
chainWebpack: config => {
// fork-ts-checker-webpack-plugin v6 与 TypeScript 5 不兼容(无法覆写只读的 performance.mark
// 类型检查改由 tsc --noEmit 承担
config.plugins.delete('fork-ts-checker')
},
devServer: {
port: 8080,
proxy: {
'^/api': {
// target: 'http://localhost:3301'
target: 'https://www.colorfulsweet.site',
changeOrigin: true
}
}
}
})
+260 -5335
View File
File diff suppressed because it is too large Load Diff