Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
018d1d0b2a
|
||
|
|
263f8ef8d3
|
||
|
|
6ccbec1f82
|
||
|
|
5b2d25fe78
|
||
|
|
8fc5ef572c
|
||
|
|
10b19a3311
|
||
|
|
4533a754c9
|
||
|
|
6d2e522818
|
||
|
|
25e80d73ae
|
||
|
|
f9b0b05c7e
|
||
|
|
0aef7d0ad5
|
||
|
|
e581ced7e8
|
||
|
|
78bed71168
|
||
|
|
02e12f6e3d
|
||
|
|
662b22576d
|
||
|
|
e0f6175fed
|
||
|
|
4c2d41b699
|
||
|
|
ee88c8abe9
|
||
|
|
3e331f58ce
|
||
|
|
a22f9e5ff4
|
||
|
|
aac55b6a39
|
||
|
|
895a902d96
|
||
|
|
ed7e8a7768
|
||
|
|
83a814c386
|
||
|
|
c55b75686e
|
||
|
|
042c1e54ed
|
||
|
|
bcc09d7064
|
||
|
|
893cfe6a39
|
||
|
|
e729ac1af2
|
||
|
|
d4ec727437
|
||
|
|
18224299aa
|
||
|
|
d5d82d3488
|
||
|
|
c144830fd7
|
||
|
|
55877c5e92
|
||
|
|
df11fa47a5
|
||
|
|
11f52789eb
|
||
|
|
ca4c17042d
|
||
|
|
a4f7db415e
|
@@ -1,2 +1,2 @@
|
||||
VUE_APP_PROXY_TARGET=https://www.colorfulsweet.site
|
||||
VUE_APP_API_BASE=/api/v2
|
||||
VITE_APP_PROXY_TARGET=https://www.colorfulsweet.site
|
||||
VITE_APP_API_BASE=/api/v2
|
||||
|
||||
@@ -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']
|
||||
@@ -25,6 +31,7 @@ declare module '@vue/runtime-core' {
|
||||
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']
|
||||
@@ -39,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']
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -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%",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -10,6 +10,7 @@ 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) {
|
||||
@@ -32,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')
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface MusicPlayerItem {
|
||||
src: string
|
||||
pic: string
|
||||
lrc?: string
|
||||
lyricId?: string
|
||||
}
|
||||
|
||||
export interface StatType {
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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: '图形验证码' } },
|
||||
]}
|
||||
]
|
||||
|
||||
|
||||
@@ -83,12 +83,6 @@ html, body, #app, .layout {
|
||||
flex-basis: 0;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
> .layout-copy {
|
||||
text-align: center;
|
||||
padding-bottom: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: var(--el-font-size-extra-small);
|
||||
}
|
||||
|
||||
/* ===== 页面布局(用于带分页的表格页面)===== */
|
||||
.page-wrapper {
|
||||
@@ -215,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;
|
||||
|
||||
@@ -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 = []
|
||||
},
|
||||
/**
|
||||
* 添加导航页签
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface StateType {
|
||||
loginInfo: { // 登录信息
|
||||
userInfo: null | UserInfo
|
||||
token: null | string
|
||||
permissions: string[] // 用户权限列表
|
||||
}
|
||||
tabs: TabItem[] // 导航页签
|
||||
activeTab: string | null // 当前激活的页签
|
||||
|
||||
@@ -4,7 +4,7 @@ import store from '@/store'
|
||||
import { router } from '@/router'
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: process.env.VUE_APP_API_BASE,
|
||||
baseURL: import.meta.env.VITE_APP_API_BASE,
|
||||
timeout: 10000,
|
||||
paramsSerializer: {
|
||||
indexes: null
|
||||
@@ -43,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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,11 @@
|
||||
.captcha.captcha-rotate {
|
||||
.rotate-img-div {
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
img {
|
||||
height: 100%;
|
||||
transform: rotate(0deg);
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
@@ -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 }
|
||||
@@ -3,12 +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;">
|
||||
<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"/>
|
||||
@@ -17,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>
|
||||
@@ -51,7 +52,6 @@
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
<div class="layout-copy">2016-{{currentYear}} © colorfulsweet 上次更新:{{ version }}</div>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
@@ -70,33 +70,57 @@
|
||||
</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)
|
||||
@@ -170,7 +194,7 @@ if (!store.state.loginInfo.token) {
|
||||
router.push('/login')
|
||||
} else {
|
||||
http.post<{token: string}, any>('/common/verifyToken', {token: store.state.loginInfo.token}).then(data => {
|
||||
store.commit('login', {userInfo: data.userInfo})
|
||||
store.commit('login', {userInfo: data.userInfo, permissions: data.permissions})
|
||||
mainViewReady.value = true
|
||||
}).catch(() => {
|
||||
router.push('/login')
|
||||
@@ -182,6 +206,9 @@ function dropdownMenuCommand(command: string): void {
|
||||
case 'home':
|
||||
router.push('/')
|
||||
break
|
||||
case 'about':
|
||||
openAboutDialog()
|
||||
break
|
||||
case 'changePassword':
|
||||
changePwdModal.value = true
|
||||
break
|
||||
@@ -193,6 +220,13 @@ function dropdownMenuCommand(command: string): void {
|
||||
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
|
||||
|
||||
@@ -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>('/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 {
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
</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>
|
||||
<el-table class="table-container" :data="hitokotoData" v-loading="loading" stripe @selection-change="dataSelect">
|
||||
@@ -57,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>
|
||||
|
||||
@@ -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,11 +23,11 @@
|
||||
</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="warning" plain icon="Notebook" @click="libDrawerVisible = true">歌单管理</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>
|
||||
<el-table class="table-container" :data="musicData" v-loading="loading" stripe @selection-change="dataSelect">
|
||||
@@ -72,9 +72,9 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" >
|
||||
<template #default="scope">
|
||||
<el-button link icon="Document" @click="updateLyric(scope.row)" title="歌词"></el-button>
|
||||
<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="删除"></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>
|
||||
@@ -102,15 +102,15 @@
|
||||
</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>
|
||||
@@ -118,33 +118,36 @@
|
||||
ref="musicUpload"
|
||||
: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="libDrawerVisible" title="歌单管理" size="900px">
|
||||
<el-button type="primary" icon="Plus" @click="addLibRow" style="margin-bottom: 12px">添加歌单</el-button>
|
||||
<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">
|
||||
@@ -168,20 +171,35 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
<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="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 {
|
||||
@@ -210,10 +228,11 @@ class MusicPage extends Page {
|
||||
}
|
||||
|
||||
const store = useStore()
|
||||
const apiBase = process.env.VUE_APP_API_BASE as string
|
||||
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[]>([])
|
||||
@@ -225,12 +244,23 @@ 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: [
|
||||
{ required: true, message: '请输入网易云ID', trigger: 'blur' }
|
||||
@@ -245,6 +275,13 @@ 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>('/music/list', {params: search})
|
||||
@@ -273,9 +310,7 @@ async function playMusic() {
|
||||
album: item.album,
|
||||
src: `${apiBase}/common/music/load/${item._id}`,
|
||||
pic: `${apiBase}/common/music/album/${item._id}`,
|
||||
}
|
||||
if (item.lyricId) {
|
||||
musicItem.lrc = `${apiBase}/common/music/lyric/${item.lyricId}`
|
||||
lyricId: item.lyricId,
|
||||
}
|
||||
return musicItem
|
||||
})
|
||||
@@ -340,21 +375,20 @@ async function saveMusicLib(row: MusicModel) {
|
||||
}
|
||||
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) {
|
||||
@@ -373,26 +407,45 @@ 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 => {
|
||||
|
||||
@@ -1,177 +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="`${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="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>
|
||||
|
||||
<el-table class="table-container" :data="photowallData" v-loading="loading" stripe @selection-change="dataSelect">
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column prop="name" label="文件名" />
|
||||
<el-table-column prop="md5" label="md5" width="300" />
|
||||
<el-table-column prop="thumbnail" label="缩略图" />
|
||||
<el-table-column prop="width" label="宽度" width="70" />
|
||||
<el-table-column prop="height" label="高度" width="70" />
|
||||
<el-table-column label="操作" width="100" >
|
||||
<template #default="scope">
|
||||
<el-button link icon="View" plain @click="preview(scope.row)" title="预览"></el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="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>
|
||||
<el-image-viewer
|
||||
v-if="previewVisible"
|
||||
:url-list="previewUrlList"
|
||||
:initial-index="previewIndex"
|
||||
@close="previewVisible = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } 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 = process.env.VUE_APP_API_BASE as string
|
||||
const { loading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange } = useBaseList(new PhotoWallPage())
|
||||
|
||||
const allowUploadExt = ['jpg', 'jpeg', 'png']
|
||||
const photowallData = ref<PhotoWallModel[]>([])
|
||||
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 previewUrlList = ref<string[]>([])
|
||||
const previewUrls = ref<string[]>([])
|
||||
const previewIndex = ref(0)
|
||||
|
||||
let selectedData: string[] = []
|
||||
// ─── 瀑布流分列 ───────────────────────────────────────
|
||||
const colCount = ref(4)
|
||||
|
||||
function recalcCols() {
|
||||
if (!rootEl.value) return
|
||||
const w = rootEl.value.clientWidth - 32
|
||||
colCount.value = Math.max(1, Math.floor((w + COL_GAP) / (COLUMN_WIDTH + COL_GAP)))
|
||||
}
|
||||
|
||||
const columns = computed<PhotoWallModel[][]>(() => {
|
||||
const cols: PhotoWallModel[][] = Array.from({ length: colCount.value }, () => [])
|
||||
photos.value.forEach((item, i) => {
|
||||
cols[i % colCount.value].push(item)
|
||||
})
|
||||
return cols
|
||||
})
|
||||
|
||||
// ─── 数据加载 ─────────────────────────────────────────
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
const data = await http.get<PhotoWallPage, any>('/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('/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 pictureCdn = await http.get('/common/config/picture_cdn')
|
||||
const index = photowallData.value.findIndex(item => item._id === row._id)
|
||||
previewUrlList.value = photowallData.value.map(item => `${pictureCdn}/${item.name}`)
|
||||
previewIndex.value = index >= 0 ? index : 0
|
||||
previewVisible.value = true
|
||||
|
||||
// ─── 响应式列数 ───────────────────────────────────────
|
||||
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>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<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="`${apiBase}/source-image/upload`"
|
||||
accept="image/jpeg,image/png,image/svg+xml,image/x-icon"
|
||||
@@ -15,10 +11,12 @@
|
||||
: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>
|
||||
<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" />
|
||||
@@ -41,8 +39,8 @@
|
||||
</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>
|
||||
<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>
|
||||
@@ -89,7 +87,7 @@ import { SourceImageModel } from '@/model/api/source-image'
|
||||
import http from '@/utils/http'
|
||||
|
||||
const store = useStore()
|
||||
const apiBase = process.env.VUE_APP_API_BASE as string
|
||||
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']
|
||||
@@ -148,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
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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')}`
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -30,24 +30,11 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<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="`${apiBase}/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;">
|
||||
@@ -112,8 +99,9 @@
|
||||
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>
|
||||
@@ -123,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]
|
||||
@@ -145,7 +139,6 @@ class ArticlePage extends Page {
|
||||
}
|
||||
|
||||
const store = useStore()
|
||||
const apiBase = process.env.VUE_APP_API_BASE as string
|
||||
const { loading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new ArticlePage())
|
||||
|
||||
const articleData = ref<ArticleModel[]>([])
|
||||
@@ -160,7 +153,6 @@ const markdownPreview = reactive<{
|
||||
title: null,
|
||||
content: null
|
||||
})
|
||||
const isUploading = ref(false)
|
||||
|
||||
let selectedData: string[] = []
|
||||
|
||||
@@ -201,29 +193,13 @@ 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) {
|
||||
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 = {
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
</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>
|
||||
<el-table class="table-container" :data="systemConfigData" v-loading="loading" stripe>
|
||||
@@ -37,8 +37,8 @@
|
||||
</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>
|
||||
<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>
|
||||
@@ -46,8 +46,8 @@
|
||||
<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>
|
||||
|
||||
@@ -6,19 +6,15 @@
|
||||
</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>
|
||||
<el-table class="table-container" :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="description" label="描述" />
|
||||
<el-table-column prop="createdAt" label="创建时间" >
|
||||
<template #default="scope">
|
||||
{{ datetimeFormat(scope.row.createdAt) }}
|
||||
@@ -31,8 +27,8 @@
|
||||
</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>
|
||||
<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>
|
||||
@@ -54,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>
|
||||
@@ -95,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()
|
||||
|
||||
@@ -115,26 +99,33 @@ 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
|
||||
@@ -146,49 +137,142 @@ 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) {
|
||||
@@ -203,7 +287,8 @@ async function save() {
|
||||
roleForm.value?.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
modalLoading.value = true
|
||||
await http.post<SystemRoleModel, any>('/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("保存成功")
|
||||
@@ -218,4 +303,7 @@ function clearValidate() {
|
||||
}
|
||||
|
||||
loadData()
|
||||
http.get<never, any>('/system/role/permissionTree').then(data => {
|
||||
permissionTreeData.value = data
|
||||
})
|
||||
</script>
|
||||
@@ -6,10 +6,10 @@
|
||||
</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>
|
||||
<el-table class="table-container" :data="systemUserData" v-loading="loading" stripe>
|
||||
@@ -27,8 +27,8 @@
|
||||
</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>
|
||||
<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>
|
||||
@@ -61,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>
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,45 +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: process.env.VUE_APP_PROXY_TARGET,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||