Compare commits

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

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

Keep captcha.js as the single style entry for the captcha module and remove per-component less imports from the submodule JavaScript files.
2026-05-16 12:25:52 +08:00
sookie e0f6175fed 登录功能接入验证码 2026-05-16 12:11:22 +08:00
sookie 4c2d41b699 删除不支持的图形验证码类型 2026-05-15 22:52:26 +08:00
sookie ee88c8abe9 删除废弃代码 2026-05-15 15:53:59 +08:00
sookie 3e331f58ce 验证码调试页面 2026-05-15 15:43:59 +08:00
sookie a22f9e5ff4 上传成功message提示 2026-05-12 22:59:09 +08:00
sookie aac55b6a39 去掉一层app div 2026-05-03 21:15:11 +08:00
sookie 895a902d96 后端版本获取 2026-04-30 11:34:25 +08:00
62 changed files with 4955 additions and 2809 deletions
+7
View File
@@ -7,12 +7,19 @@ export {}
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
CaptchaPanel: typeof import('./src/components/CaptchaPanel.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside'] ElAside: typeof import('element-plus/es')['ElAside']
ElButton: typeof import('element-plus/es')['ElButton'] 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'] ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer'] ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] 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'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdown: typeof import('element-plus/es')['ElDropdown']
+775 -791
View File
File diff suppressed because it is too large Load Diff
+2 -6
View File
@@ -21,16 +21,12 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@vitejs/plugin-vue": "^5.2.4", "@vitejs/plugin-vue": "^6.0.7",
"less": "^4.6.4", "less": "^4.6.4",
"typescript": "^5.4.0", "typescript": "^5.4.0",
"unplugin-auto-import": "^0.12.1", "unplugin-auto-import": "^0.12.1",
"unplugin-vue-components": "^0.22.12", "unplugin-vue-components": "^0.22.12",
"vite": "^5.4.19" "vite": "^8.0.13"
},
"overrides": {
"postcss": ">=8.4.31",
"serialize-javascript": ">=7.0.5"
}, },
"browserslist": [ "browserslist": [
"> 1%", "> 1%",
+3 -5
View File
@@ -1,9 +1,7 @@
<template> <template>
<div id="app" > <el-config-provider :locale="locale" >
<el-config-provider :locale="locale" > <router-view ></router-view>
<router-view ></router-view> </el-config-provider>
</el-config-provider>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
+115
View File
@@ -0,0 +1,115 @@
<template>
<div ref="captchaBoxRef"></div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref } from 'vue'
import {
createTianAiCaptcha,
type TianAiCaptchaConfig,
type TianAiCaptchaInstance,
type TianAiCaptchaResult,
type TianAiCaptchaStyle
} from '@/vendor/tianai-captcha'
type CaptchaMode = 'data' | 'validate'
type CaptchaType = 'SLIDER' | 'ROTATE' | 'CONCAT' | 'WORD_IMAGE_CLICK'
const props = withDefaults(defineProps<{
mode?: CaptchaMode
types?: CaptchaType[]
styleConfig?: TianAiCaptchaStyle
}>(), {
mode: 'data',
types: () => []
})
const emit = defineEmits<{
(e: 'close'): void
(e: 'data-ready', result: TianAiCaptchaResult): void
(e: 'init-error', error: Error): void
(e: 'state-change', active: boolean): void
(e: 'valid-fail', res: any): void
(e: 'valid-success', res: any): void
}>()
const captchaBoxRef = ref<HTMLElement | null>(null)
const captchaInstance = ref<TianAiCaptchaInstance | null>(null)
const active = ref(false)
const requestCaptchaDataUrl = buildCaptchaApiUrl('/captcha/generate')
const validCaptchaUrl = buildCaptchaApiUrl('/captcha/check')
async function init() {
await nextTick()
if (!captchaBoxRef.value) {
emit('init-error', new Error('验证码容器初始化失败'))
return
}
destroy()
const config: TianAiCaptchaConfig = {
bindEl: captchaBoxRef.value,
requestCaptchaDataUrl,
types: props.types,
btnRefreshFun: (_el, tac) => {
tac.reloadCaptcha()
},
btnCloseFun: () => {
destroy()
emit('close')
}
}
switch (props.mode) {
case 'validate':
config.validCaptchaUrl = validCaptchaUrl
config.validSuccess = res => emit('valid-success', res)
config.validFail = res => emit('valid-fail', res)
break
case 'data':
config.onDataReady = result => emit('data-ready', result)
break
default:
emit('init-error', new Error(`未知的验证码模式: ${props.mode}`))
return
}
captchaInstance.value = createTianAiCaptcha(config, props.styleConfig)
captchaInstance.value.init()
setActive(true)
}
function reload() {
captchaInstance.value?.reloadCaptcha()
}
function destroy() {
if (captchaInstance.value) {
captchaInstance.value.destroyWindow()
captchaInstance.value = null
}
setActive(false)
}
function setActive(nextValue: boolean) {
if (active.value === nextValue) return
active.value = nextValue
emit('state-change', nextValue)
}
defineExpose({
destroy,
init,
reload,
})
function buildCaptchaApiUrl(path: string) {
const normalizedBase = (import.meta.env.VITE_APP_API_BASE || '').replace(/\/+$/, '')
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${normalizedBase}${normalizedPath}`
}
onBeforeUnmount(() => destroy())
</script>
+8
View File
@@ -33,6 +33,14 @@ const menus: MenuGroup[] = [
{ title: '图片资源库', path: '/api/sourceImage', permission: 'sourceImage:list' }, { title: '图片资源库', path: '/api/sourceImage', permission: 'sourceImage:list' },
{ title: '歌曲库', path: '/api/music', permission: 'music:list' } { title: '歌曲库', path: '/api/music', permission: 'music:list' }
] ]
},
{
name: 'debug',
title: '调试工具',
icon: 'Tools',
child: [
{ title: '图形验证码', path: '/debug/captcha', permission: 'captcha:list' }
]
} }
] ]
+2
View File
@@ -18,6 +18,8 @@ const routes: Array<RouteRecordRaw> = [
{ path: '/api/hitokoto', name: 'Hitokoto', component: () => import('@/views/api/Hitokoto.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/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: '/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: '图形验证码' } },
]} ]}
] ]
-6
View File
@@ -83,12 +83,6 @@ html, body, #app, .layout {
flex-basis: 0; flex-basis: 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); 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 { .page-wrapper {
+174
View File
@@ -0,0 +1,174 @@
import "./captcha.less";
import Slider from "./slider/slider";
import Rotate from "./rotate/rotate";
import Concat from "./concat/concat";
import Disable from "./disable/disable";
import ImageClick from "./image_click/image_click";
import WordImageClick from "./word_image_click/word_image_click";
import { CaptchaConfig, wrapConfig, wrapStyle } from "./config/config";
import { clearAllPreventDefault } from "./common/common";
const template = `
<div class="__captcha-parent">
<div class="captcha-box">
<div class="captcha-loading loading"></div>
</div>
<!-- 底部 -->
<div class="slider-bottom">
<div class="refresh-btn"></div>
<div class="close-btn"></div>
</div>
</div>
`;
function createCaptchaByType(type, tac) {
const box = tac.config.domBindEl.find(".captcha-box");
const styleConfig = tac.style;
switch (type) {
case "SLIDER":
return new Slider(box, styleConfig);
case "ROTATE":
return new Rotate(box, styleConfig);
case "CONCAT":
return new Concat(box, styleConfig);
case "IMAGE_CLICK":
return new ImageClick(box, styleConfig);
case "WORD_IMAGE_CLICK":
return new WordImageClick(box, styleConfig);
case "DISABLED":
return new Disable(box, styleConfig);
default:
return null;
}
}
class TianAiCaptcha {
constructor(config, style) {
this.config = wrapConfig(config);
if (this.config.btnRefreshFun) {
this.btnRefreshFun = this.config.btnRefreshFun;
}
if (this.config.btnCloseFun) {
this.btnCloseFun = this.config.btnCloseFun;
}
this.style = wrapStyle(style);
}
init() {
this.destroyWindow();
this.config.domBindEl.append(template);
this.domTemplate = this.config.domBindEl.find(".__captcha-parent");
clearAllPreventDefault(this.domTemplate);
// 绑定按钮事件
this.config.domBindEl
.find(".refresh-btn")
.click((el) => {
this.btnRefreshFun(el, this);
});
this.config.domBindEl
.find(".close-btn")
.click((el) => {
this.btnCloseFun(el, this);
});
// 加载验证码
this.reloadCaptcha();
return this;
}
btnRefreshFun(el, tac) {
tac.reloadCaptcha();
}
btnCloseFun(el, tac) {
tac.destroyWindow();
}
reloadCaptcha() {
this.showLoading();
this.destroyCaptcha(() => {
this.createCaptcha();
});
}
showLoading() {
this.config.domBindEl
.find(".captcha-loading")
.css("display", "block");
}
closeLoading() {
this.config.domBindEl
.find(".captcha-loading")
.css("display", "none");
}
destroyWindow() {
if (this.C) {
this.C.destroy();
this.C = undefined;
}
if (this.domTemplate) {
this.domTemplate.remove();
}
}
openCaptcha() {
setTimeout(() => {
this.C.el.css("transform", "translateX(0)");
}, 10);
}
createCaptcha() {
this.config.requestCaptchaData().then((data) => {
this.closeLoading();
if (!data.code) {
throw new Error("[TAC] 后台验证码接口数据错误!!!");
}
let captchaType = data.code === 200 ? data.data?.type : "DISABLED";
const captcha = createCaptchaByType(captchaType, this);
if (captcha == null) {
throw new Error("[TAC] 未知的验证码类型[" + captchaType + "]");
}
captcha.init(data, (d, c) => {
// 验证
const currentCaptchaData = c.currentCaptchaData;
const data = {
bgImageWidth: currentCaptchaData.bgImageWidth,
bgImageHeight: currentCaptchaData.bgImageHeight,
templateImageWidth: currentCaptchaData.templateImageWidth,
templateImageHeight: currentCaptchaData.templateImageHeight,
startTime: currentCaptchaData.startTime.getTime(),
stopTime: currentCaptchaData.stopTime.getTime(),
trackList: currentCaptchaData.trackList,
};
if (c.type === "ROTATE_DEGREE" || c.type === "ROTATE") {
data.bgImageWidth = c.currentCaptchaData.end;
}
if (currentCaptchaData.data) {
data.data = currentCaptchaData.data;
}
// 清空
const id = c.currentCaptchaData.currentCaptchaId;
c.currentCaptchaData = undefined;
if (this.config.onDataReady) {
Promise.resolve(this.config.onDataReady({ id, data }, c, this)).catch((error) => {
console.error("[TAC] onDataReady callback failed", error);
});
return;
}
// 调用验证接口
this.config.validCaptcha(id, data, c, this);
});
this.C = captcha;
this.openCaptcha();
});
}
destroyCaptcha(callback) {
if (this.C) {
this.C.el.css("transform", "translateX(300px)");
setTimeout(() => {
this.C.destroy();
callback && callback();
}, 500);
} else {
callback();
}
}
}
export { TianAiCaptcha, CaptchaConfig };
+98
View File
@@ -0,0 +1,98 @@
.__captcha-parent {
box-shadow: 0 0 11px 0 #999999;
width: 316px;
overflow: hidden;
position: relative;
z-index: 997;
box-sizing: border-box;
border-radius: 5px;
padding: 8px;
.captcha-box {
height: 260px;
width: 100%;
position: relative;
overflow: hidden;
.loading {
width: 120px;
height: 20px;
mask: linear-gradient(90deg, #000 70%, #0000 0) 0/20%;
background: linear-gradient(#f7b645 0 0) 0 / 0% no-repeat #dddddd6b;
animation: cartoon 1s infinite steps(6);
margin: 120px auto;
@keyframes cartoon {
100% {
background-size: 120%;
}
}
}
.captcha {
transform-style: preserve-3d;
will-change: transform;
transition-duration: 0.45s;
transform: translateX(-100%);
}
}
.slider-bottom {
text-align: right;
margin-top: 8px;
display: flex;
justify-content: flex-end;
gap: 10px;
.close-btn, .refresh-btn {
width: 20px;
height: 20px;
background-repeat: no-repeat;
cursor: pointer;
}
.close-btn {
background-image: url("../images/icon.png");
background-position: 0 -14px;
}
.refresh-btn {
background-image: url("../images/icon.png");
background-position: 0 -167px;
}
}
.slider-move-shadow {
animation: move-shadow 2s infinite;
height: 100%;
width: 5px;
background-color: #fff;
position: absolute;
top: 0;
left: 0;
filter: opacity(0.5);
box-shadow: 1px 1px 1px #fff;
border-radius: 50%;
}
.captcha-slider-move-track-mask {
border-width: 1px;
border-style: solid;
border-color: #00f4ab;
width: 0;
height: 32px;
background-color: #a9ffe5;
opacity: 0.5;
position: absolute;
top: -1px;
left: -1px;
border-radius: 5px;
}
@keyframes move-shadow {
from {
left: 0;
}
to {
left: 289px;
}
}
@import "./common/common.less";
@import "./slider/slider.less";
@import "./rotate/rotate.less";
@import "./concat/concat.less";
@import "./disable/disable.less";
@import "./image_click/image_click.less";
}
+462
View File
@@ -0,0 +1,462 @@
/** 是否打印日志 */
var isPrintLog = false;
function printLog(params) {
if (isPrintLog) {
console.log(JSON.stringify(params));
}
}
/**
* 清除默认事件
* @param event event
*/
function clearPreventDefault(event) {
if (event.preventDefault) {
event.preventDefault();
}
}
/**
* 阻止某div默认事件
* @param dom
*/
function clearAllPreventDefault(dom) {
Dom(dom).each((el) => {
// 手机端
el.addEventListener("touchmove", clearPreventDefault, { passive: false });
// pc端
el.addEventListener("mousemove", clearPreventDefault, { passive: false });
});
}
/**
* 获取当前坐标
* @param event 事件
* @returns {{x: number, y: number}}
*/
function getCurrentCoordinate(event) {
if (event.pageX !== null && event.pageX !== undefined) {
return {
x: Math.round(event.pageX),
y: Math.round(event.pageY),
};
}
let targetTouches;
if (event.changedTouches) {
// 抬起事件
targetTouches = event.changedTouches;
} else if (event.targetTouches) {
// pc 按下事件
targetTouches = event.targetTouches;
} else if (event.originalEvent && event.originalEvent.targetTouches) {
// 鼠标触摸事件
targetTouches = event.originalEvent.targetTouches;
}
if (targetTouches[0].pageX !== null && targetTouches[0].pageX !== undefined) {
return {
x: Math.round(targetTouches[0].pageX),
y: Math.round(targetTouches[0].pageY),
};
}
return {
x: Math.round(targetTouches[0].clientX),
y: Math.round(targetTouches[0].clientY),
};
}
function down(currentCaptcha, event) {
// debugger
const coordinate = getCurrentCoordinate(event);
let startX = coordinate.x;
let startY = coordinate.y;
currentCaptcha.currentCaptchaData.startX = startX;
currentCaptcha.currentCaptchaData.startY = startY;
const trackList = currentCaptcha.currentCaptchaData.trackList;
currentCaptcha.currentCaptchaData.startTime = new Date();
const startTime = currentCaptcha.currentCaptchaData.startTime;
trackList.push({
x: coordinate.x,
y: coordinate.y,
type: "down",
t: new Date().getTime() - startTime.getTime(),
});
printLog(["start", startX, startY]);
currentCaptcha.__m__ = move.bind(null, currentCaptcha);
currentCaptcha.__u__ = up.bind(null, currentCaptcha);
// pc
window.addEventListener("mousemove", currentCaptcha.__m__);
window.addEventListener("mouseup", currentCaptcha.__u__);
// 手机端
window.addEventListener("touchmove", currentCaptcha.__m__, false);
window.addEventListener("touchend", currentCaptcha.__u__, false);
if (currentCaptcha && currentCaptcha.doDown) {
currentCaptcha.doDown(event, currentCaptcha);
}
}
function move(currentCaptcha, event) {
if (event.touches && event.touches.length > 0) {
event = event.touches[0];
}
// debugger
const coordinate = getCurrentCoordinate(event);
let pageX = coordinate.x;
let pageY = coordinate.y;
const startX = currentCaptcha.currentCaptchaData.startX;
const startY = currentCaptcha.currentCaptchaData.startY;
const startTime = currentCaptcha.currentCaptchaData.startTime;
const end = currentCaptcha.currentCaptchaData.end;
const bgImageWidth = currentCaptcha.currentCaptchaData.bgImageWidth;
const trackList = currentCaptcha.currentCaptchaData.trackList;
let moveX = pageX - startX;
let moveY = pageY - startY;
const track = {
x: coordinate.x,
y: coordinate.y,
type: "move",
t: new Date().getTime() - startTime.getTime(),
};
trackList.push(track);
if (moveX < 0) {
moveX = 0;
} else if (moveX > end) {
moveX = end;
}
currentCaptcha.currentCaptchaData.moveX = moveX;
currentCaptcha.currentCaptchaData.moveY = moveY;
if (currentCaptcha.doMove) {
currentCaptcha.doMove(event, currentCaptcha);
}
printLog(["move", track]);
}
function destroyEvent(currentCaptcha) {
if (currentCaptcha) {
if (currentCaptcha.__m__) {
window.removeEventListener("mousemove", currentCaptcha.__m__);
window.removeEventListener("touchmove", currentCaptcha.__m__);
}
if (currentCaptcha.__u__) {
window.removeEventListener("mouseup", currentCaptcha.__u__);
window.removeEventListener("touchend", currentCaptcha.__u__);
}
}
}
function up(currentCaptcha, event) {
destroyEvent(currentCaptcha);
const coordinate = getCurrentCoordinate(event);
currentCaptcha.currentCaptchaData.stopTime = new Date();
const startTime = currentCaptcha.currentCaptchaData.startTime;
const trackList = currentCaptcha.currentCaptchaData.trackList;
const track = {
x: coordinate.x,
y: coordinate.y,
type: "up",
t: new Date().getTime() - startTime.getTime(),
};
trackList.push(track);
printLog(["up", track]);
printLog(["tracks", trackList]);
if (currentCaptcha.doUp) {
currentCaptcha.doUp(event, currentCaptcha);
}
currentCaptcha.endCallback(currentCaptcha.currentCaptchaData, currentCaptcha);
}
function initConfig(
bgImageWidth,
bgImageHeight,
templateImageWidth,
templateImageHeight,
end,
) {
// bugfix 图片宽高可能会有小数情况,强转一下整数
const currentCaptchaConfig = {
startTime: new Date(),
trackList: [],
movePercent: 0,
clickCount: 0,
bgImageWidth: Math.round(bgImageWidth),
bgImageHeight: Math.round(bgImageHeight),
templateImageWidth: Math.round(templateImageWidth),
templateImageHeight: Math.round(templateImageHeight),
end: end,
};
printLog(["init", currentCaptchaConfig]);
return currentCaptchaConfig;
}
function closeTips(el, callback) {
const tipEl = Dom(el).find(".captcha-tips");
tipEl.removeClass("captcha-tips-on");
// tipEl.removeClass("captcha-tips-success")
// tipEl.removeClass("captcha-tips-error")
// 延时
if (callback) {
setTimeout(callback, 0.35);
}
}
function showTips(el, msg, type, callback) {
const tipEl = Dom(el).find(".captcha-tips");
tipEl.text(msg);
if (type === 1) {
// 成功
tipEl.removeClass("captcha-tips-error");
tipEl.addClass("captcha-tips-success");
} else {
// 失败
tipEl.removeClass("captcha-tips-success");
tipEl.addClass("captcha-tips-error");
}
tipEl.addClass("captcha-tips-on");
// 延时
setTimeout(callback, 1000);
}
class CommonCaptcha {
showTips(msg, type, callback) {
showTips(this.el, msg, type, callback);
}
closeTips(msg, callback) {
closeTips(this.el, msg, callback);
}
}
function Dom(domStr, dom) {
return new DomEl(domStr, dom);
}
class DomEl {
constructor(domStr, dom) {
if (dom instanceof HTMLElement) {
this.dom = dom;
this.domStr = domStr;
return;
}
if (domStr instanceof DomEl) {
this.dom = domStr.dom;
this.domStr = domStr.domStr;
} else if (typeof domStr === "string") {
this.dom = document.querySelector(domStr);
this.domStr = domStr;
} else if (domStr instanceof HTMLElement) {
this.dom = domStr;
this.domStr = domStr.nodeName;
} else {
throw new Error("不支持的类型");
}
}
each(callback) {
this.getTarget().querySelectorAll("*").forEach(callback);
}
removeClass(className) {
let element = this.getTarget();
if (element.classList) {
// 使用 classList API 移除类
element.classList.remove(className);
} else {
// 兼容旧版本浏览器
const currentClass = element.className;
const regex = new RegExp("(?:^|\\s)" + className + "(?!\\S)", "g");
element.className = currentClass.replace(regex, "");
}
return this;
}
addClass(className) {
const element = this.getTarget();
if (element.classList) {
// 使用 classList API 添加类
element.classList.add(className);
} else {
// 兼容旧版本浏览器
let currentClass = element.className;
if (currentClass.indexOf(className) === -1) {
element.className = currentClass + " " + className;
}
}
return this;
}
find(str) {
const el = this.getTarget().querySelector(str);
if (el) {
return new DomEl(str, el);
}
return null;
}
children(selector) {
const childNodes = this.getTarget().childNodes;
for (let i = 0; i < childNodes.length; i++) {
if (childNodes[i].nodeType === 1 && childNodes[i].matches(selector)) {
return new DomEl(selector, childNodes[i]);
}
}
return null;
}
remove() {
this.getTarget().remove();
return null;
}
css(property, value) {
if (typeof property === "string" && typeof value === "string") {
// 设置单个属性
this.getTarget().style[property] = value;
} else if (typeof property === "object") {
// 设置多个属性
for (var prop in property) {
if (property.hasOwnProperty(prop)) {
this.getTarget().style[prop] = property[prop];
}
}
} else if (typeof property === "string" && typeof value === "undefined") {
// 获取单个属性
return window.getComputedStyle(element)[property];
}
}
attr(attributeName, value) {
if (value === undefined) {
// 如果未提供值,则返回属性的当前值
return this.getTarget().getAttribute(attributeName);
} else {
// 如果提供了值,则设置属性的值
this.getTarget().setAttribute(attributeName, value);
}
return this;
}
text(str) {
this.getTarget().innerText = str;
return this;
}
html(str) {
this.getTarget().innerHtml = str;
return this;
}
is(dom) {
if (dom && typeof dom === "object" && typeof dom.nodeType !== "undefined") {
return this.dom === dom;
}
if (dom instanceof DomEl) {
return this.dom === dom.dom;
}
}
append(content) {
if (typeof content === "string") {
this.getTarget().insertAdjacentHTML("beforeend", content);
} else if (content instanceof HTMLElement) {
this.getTarget().appendChild(content);
} else {
throw new Error("Invalid content type");
}
return this;
}
click(fun) {
this.on("click", fun);
return this;
}
mousedown(fun) {
this.on("mousedown", fun);
return this;
}
touchstart(fun) {
this.on("touchstart", fun);
return this;
}
on(eventType, fun) {
this.getTarget().addEventListener(eventType, fun, { passive: true });
return this;
}
width() {
return this.getTarget().offsetWidth;
}
height() {
return this.getTarget().offsetHeight;
}
getTarget() {
if (this.dom) {
return this.dom;
}
throw new Error("dom不存在: [" + this.domStr + "]");
}
}
function http(options) {
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open(options.method || "GET", options.url);
// 设置请求头
if (options.headers) {
for (const header in options.headers) {
if (options.headers.hasOwnProperty(header)) {
xhr.setRequestHeader(header, options.headers[header]);
}
}
}
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status >= 200 && xhr.status <= 500) {
const contentType = xhr.getResponseHeader("Content-Type");
if (contentType && contentType.indexOf("application/json") !== -1) {
resolve(JSON.parse(xhr.responseText));
} else {
resolve(xhr.responseText);
}
} else {
reject(new Error("Request failed with status: " + xhr.status));
}
}
};
xhr.onerror = function () {
reject(new Error("Network Error"));
};
xhr.send(options.data);
});
}
function isEmptyObject(obj) {
for (var key in obj) {
if (obj.hasOwnProperty(key)) {
return false; // 对象不为空
}
}
return true; // 对象为空
}
export {
isEmptyObject,
http,
Dom,
DomEl,
CommonCaptcha,
clearAllPreventDefault,
down,
move,
up,
initConfig,
showTips,
closeTips,
destroyEvent,
};
+73
View File
@@ -0,0 +1,73 @@
.captcha {
text-align: left;
box-sizing: content-box;
width: 100%;
height: 260px;
z-index: 999;
.slider-bottom .logo {
height: 30px;
}
.slider-bottom {
height: 19px;
width: 100%;
}
.content {
.captcha-tips {
height: 25px;
width: 100%;
position: absolute;
bottom: -25px;
left: 0;
z-index: 999;
font-size: 15px;
line-height: 25px;
color: #fff;
text-align: center;
transition: bottom 0.3s ease-in-out;
}
.captcha-tips.captcha-tips-error {
background-color: #ff5d39;
}
.captcha-tips.captcha-tips-success {
background-color: #39c522;
}
.captcha-tips.captcha-tips-on {
bottom: 0;
}
.captcha-loading {
z-index: 9999;
background-color: #f5f5f5;
text-align: center;
height: 100%;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
align-items: center;
img {
display: block;
width: 45px;
height: 45px;
}
}
}
.captcha-slider-bg-canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border-radius: 5px;
}
.captcha-slider-bg-div {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
.captcha-slider-bg-div-slice {
position: absolute;
}
}
}
+137
View File
@@ -0,0 +1,137 @@
import {
Dom,
CommonCaptcha,
clearAllPreventDefault,
down,
initConfig,
destroyEvent,
} from "../common/common.js";
const TYPE = "CONCAT";
function getTemplate() {
return `
<div class="captcha captcha-slider captcha-concat">
<div class="slider-tip">
<span class="captcha-slider-move-track-font" >拖动滑块完成拼图</span>
</div>
<div class="content">
<div class="captcha-slider-concat-img-div"></div>
<div class="captcha-slider-concat-bg-img"></div>
<div class="captcha-tips"></div>
</div>
<div class="slider-move">
<div class="slider-move-track">
<div class="captcha-slider-move-track-mask"></div>
<div class="slider-move-shadow"></div>
</div>
<div class="slider-move-btn captcha-slider-move-btn">
</div>
</div>
</div>
`;
}
class Concat extends CommonCaptcha {
constructor(divId, styleConfig) {
super();
this.boxEl = Dom(divId);
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate());
this.el = this.boxEl.find(".captcha");
this.loadStyle();
// 按钮绑定事件
this.el
.find(".captcha-slider-move-btn")
.mousedown(down.bind(null, this));
this.el
.find(".captcha-slider-move-btn")
.touchstart(down.bind(null, this));
clearAllPreventDefault(this.el);
// 绑定全局
window.currentCaptcha = this;
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy() {
destroyEvent();
const existsCaptchaEl = this.boxEl.children(".captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
}
doMove() {
const moveX = this.currentCaptchaData.moveX;
this.el
.find(".captcha-slider-move-btn")
.css("transform", "translate(" + moveX + "px, 0px)");
this.el
.find(".captcha-slider-concat-img-div")
.css("background-position-x", moveX + "px");
this.el
.find(".captcha-slider-move-track-mask")
.css("width", moveX + "px");
}
loadStyle() {
let sliderImg = "";
let moveTrackMaskBorderColor = "#00f4ab";
let moveTrackMaskBgColor = "#a9ffe5";
const styleConfig = this.styleConfig;
if (styleConfig) {
sliderImg = styleConfig.btnUrl;
moveTrackMaskBgColor = styleConfig.moveTrackMaskBgColor;
moveTrackMaskBorderColor = styleConfig.moveTrackMaskBorderColor;
}
this.el
.find(".slider-move .slider-move-btn")
.css("background-image", "url(" + sliderImg + ")");
// this.el.find(".captcha-slider-move-track-font").text(title);
this.el
.find(".captcha-slider-move-track-mask")
.css("border-color", moveTrackMaskBorderColor);
this.el
.find(".captcha-slider-move-track-mask")
.css("background-color", moveTrackMaskBgColor);
}
loadCaptchaForData(that, data) {
const bgImg = that.el.find(".captcha-slider-concat-bg-img");
const sliderImg = that.el.find(".captcha-slider-concat-img-div");
bgImg.css("background-image", "url(" + data.data.backgroundImage + ")");
sliderImg.css("background-image", "url(" + data.data.backgroundImage + ")");
sliderImg.css("background-position", "0px 0px");
var backgroundImageHeight = data.data.backgroundImageHeight;
var height =
((backgroundImageHeight - data.data.data.randomY) /
backgroundImageHeight) *
180;
sliderImg.css("height", height + "px");
that.currentCaptchaData = initConfig(
bgImg.width(),
bgImg.height(),
sliderImg.width(),
sliderImg.height(),
300 - 63 + 5,
);
that.currentCaptchaData.currentCaptchaId = data.data.id;
}
}
export default Concat;
+29
View File
@@ -0,0 +1,29 @@
.captcha.captcha-concat {
.captcha-slider-concat-img-div {
background-size: 100% 180px;
position: absolute;
transform: translate(0px, 0px);
z-index: 1;
width: 100%;
&::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: rgba(255, 255, 255, 0.92);
box-shadow:
0 0 6px 3px rgb(255 255 255 / 60%),
0 1px 4px rgb(0 0 0 / 90%);
pointer-events: none;
}
}
.captcha-slider-concat-bg-img {
width: 100%;
height: 100%;
position: absolute;
transform: translate(0px, 0px);
background-size: 100% 180px;
}
}
+206
View File
@@ -0,0 +1,206 @@
import StyleConfig from "./styleConfig";
import { Dom, http } from "../common/common";
class CaptchaConfig {
constructor(args) {
if (!args.bindEl) {
throw new Error("[TAC] 必须配置 [bindEl]用于将验证码绑定到该元素上");
}
if (!args.requestCaptchaDataUrl) {
throw new Error("[TAC] 必须配置 [requestCaptchaDataUrl]请求验证码接口");
}
if (!args.validCaptchaUrl && !args.onDataReady) {
throw new Error("[TAC] 必须配置 [validCaptchaUrl]验证验证码接口");
}
this.bindEl = args.bindEl;
this.domBindEl = Dom(args.bindEl);
this.requestCaptchaDataUrl = args.requestCaptchaDataUrl;
this.types = Array.isArray(args.types) ? args.types.filter(Boolean) : [];
this.validCaptchaUrl = args.validCaptchaUrl;
if (args.validSuccess) {
this.validSuccess = args.validSuccess;
}
if (args.validFail) {
this.validFail = args.validFail;
}
if (args.onDataReady) {
this.onDataReady = args.onDataReady;
}
if (args.requestHeaders) {
this.requestHeaders = args.requestHeaders;
} else {
this.requestHeaders = {};
}
if (args.btnCloseFun) {
this.btnCloseFun = args.btnCloseFun;
}
if (args.btnRefreshFun) {
this.btnRefreshFun = args.btnRefreshFun;
}
this.requestChain = [];
// 时间戳转换
this.timeToTimestamp = args.timeToTimestamp || true;
this.insertRequestChain(0, {
preRequest(type, param, c, tac) {
if (this.timeToTimestamp && param.data) {
for (let key in param.data) {
// 将date全部转换为时间戳
if (param.data[key] instanceof Date) {
param.data[key] = param.data[key].getTime();
}
}
}
return true;
},
});
}
addRequestChain(fun) {
this.requestChain.push(fun);
}
insertRequestChain(index, chain) {
this.requestChain.splice(index, 0, chain);
}
removeRequestChain(index) {
this.requestChain.splice(index, 1);
}
requestCaptchaData() {
const requestParam = {};
requestParam.headers = this.requestHeaders || {};
requestParam.data = { types: this.types };
// 设置默认值
requestParam.headers["Content-Type"] = "application/json;charset=UTF-8";
requestParam.method = "POST";
requestParam.url = this.requestCaptchaDataUrl;
// 请求前装载参数
this._preRequest("requestCaptchaData", requestParam);
// 发送请求
const request = this.doSendRequest(requestParam);
// 返回结果
return request.then((res) => {
// 装返回结果
this._postRequest("requestCaptchaData", requestParam, res);
// 返回结果
return res;
});
}
doSendRequest(requestParam) {
// 如果content-type是json,那么data就是json字符串, 这里直接匹配所有header是否包含application/json
if (requestParam.headers) {
for (const key in requestParam.headers) {
if (requestParam.headers[key].indexOf("application/json") > -1) {
if (typeof requestParam.data !== "string") {
requestParam.data = JSON.stringify(requestParam.data);
}
break;
}
}
}
return http(requestParam).then((res) => {
try {
return JSON.parse(res);
} catch (e) {
return res;
}
});
}
_preRequest(type, requestParam, c, tac) {
for (let i = 0; i < this.requestChain.length; i++) {
const r = this.requestChain[i];
if (r.preRequest) {
if (!r.preRequest(type, requestParam, this, c, tac)) {
break;
}
}
}
}
_postRequest(type, requestParam, res, c, tac) {
for (let i = 0; i < this.requestChain.length; i++) {
const r = this.requestChain[i];
// 判断r是否存圩postRequest方法
if (r.postRequest) {
if (!r.postRequest(type, requestParam, res, this, c, tac)) {
break;
}
}
}
}
validCaptcha(currentCaptchaId, data, c, tac) {
const sendParam = {
id: currentCaptchaId,
data: data,
};
let requestParam = {};
requestParam.headers = this.requestHeaders || {};
requestParam.data = sendParam;
requestParam.headers["Content-Type"] = "application/json;charset=UTF-8";
requestParam.method = "POST";
requestParam.url = this.validCaptchaUrl;
this._preRequest("validCaptcha", requestParam, c, tac);
const request = this.doSendRequest(requestParam);
return request
.then((res) => {
this._postRequest("validCaptcha", requestParam, res, c, tac);
return res;
})
.then((res) => {
if (res.code == 200) {
const useTimes = (data.stopTime - data.startTime) / 1000;
c.showTips(`验证成功,耗时${useTimes}`, 1, () =>
this.validSuccess(res, c, tac),
);
} else {
let tipMsg = "验证失败,请重新尝试!";
if (res.code) {
if (res.code != 4001) {
tipMsg = "验证码被黑洞吸走了!";
}
}
c.showTips(tipMsg, 0, () => this.validFail(res, c, tac));
}
})
.catch((e) => {
let tipMsg = c.styleConfig.i18n.tips_error;
if (e.code && e.code != 200) {
if (e.code != 4001) {
tipMsg = c.styleConfig.i18n.tips_4001;
}
c.showTips(tipMsg, 0, () => this.validFail(e, c, tac));
return;
}
c.showTips(tipMsg, 0, () => this.validFail(e, c, tac));
});
}
validSuccess(res, c, tac) {
console.log(
"验证码校验成功, 请重写 [config.validSuccess] 方法, 用于自定义逻辑处理",
);
window.currentCaptchaRes = res;
tac.destroyWindow();
}
validFail(res, c, tac) {
tac.reloadCaptcha();
}
}
function wrapConfig(config) {
if (config instanceof CaptchaConfig) {
return config;
}
return new CaptchaConfig(config);
}
function wrapStyle(style) {
let margeStyle = { ...StyleConfig, ...style };
margeStyle.i18n = { ...StyleConfig.i18n, ...style?.i18n };
return margeStyle;
}
const captchaRequestChains = {};
export { CaptchaConfig, wrapConfig, wrapStyle };
+23
View File
@@ -0,0 +1,23 @@
export default {
// 按钮图片
btnUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIwAAABkCAYAAABU19jRAAAJcUlEQVR4nO2d63MT1xmHf9rV6mr5fgNMuSW+ENsY8N0EE2BMhinJNB8y/dD2Qz/0v+gMf0w/JHTKNJAhICwbsA02TpNAHEMgQIwNBSEb8F2rvXTeY1kjYyA+TmVJmfeZ8YiRWa9299E57/mdI63Dtm3E+RjAKTDMaj4F8AU9uyzMCQBn+EQxb+EjAF+RMH8AcJrPFLMGvCSMzWeKWSN/I2GiAFx8xpi1oPBZYiTQWRhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGCiefrtShGwZiup74+4qqwu12Z/W7lIVJEfN6FDfv3sPXfYOIRRfpm1UQKC7EkQ+PYFtRcdZKw8KkiLsPJ/CfgSFcH7yOxWhU7MSluYQoR44fxdaCoqyUhoVJEfZ8FN99c1N0Sx6PR+zEMAz0XAgBNtB14hi25OXDkWXHxUVvinA4ln6ScTqdsGwbvRd7EPwyiEcvXyDbvpyHhUkRaq4fe/c3wEWSWFZiJySNYZroCYYQPHsBY1OTWSWNevLkyb/TYwa8lt8UAb8ftluDW9UwPj4hDs0Rb3JUVRXd09j9nwELKKgoR4HXlw2Hb3INkyK8mob9NdUwLROq4sCVKwMrdqRpGkzTFN0TaWR2HcKu0rKMr2lYmBTi1jS01dUt7UBx4PKlfvHP5JaGuqseIY0DjmOHsKukNKOPiYVJMU5VRXt9PSwboO+fvHJ5QEiiKEvlIz3S86HuHiiqAhw9iJ0lpRnb0rAwG4CqKHh/Tz0UhwOWaWGg/5oofEkmJLU4wfPdQia765CQJhNHJCzMBkEtSVtdLRw2YNo2hgaGEDMMMWpahrwJBUMUCkM9djgjE2EWZgOhFqW5rlbMKdm2heHBYUT1mCiAEW9pKKfpPh8Sj5mYCLMwG4zLqWJfTZWQgL5S++uhYURjBrR4S0MtUSYnwixMGvBoGvZUV4quh0S4Pjgsaho1XtOIcM8wxJCb+qmu33dljDS/CWEeTb/E/Pw89EUdebkBVBQWrnnbWVjQoMAtsT9asGDQhf8VUbnX5UJ9VaVoZahVuXZ1cMXoiaSJxWIiEab/dPj4UXFczjRrk/VJ70/hp/jhuxF89o9TGP1+FH6fD9OxGHw5Pnicb34/PJ2dweitu7hwLojvb47A9rhQmJeXGLm8iQeP/4uRH27h88/+iZhhYs40UFZQsK7XrqkqigvyYbk18VrHH74+EX74YAzRqI66mupE15UmzKwW5kEkgtFvRxA8ex7hJ2HMzczgzu0f8fjxExRt2YzcgB9udfUJjuo6Tv/7HE6f+pe4GHd//AkwLRhuDeXFRW+U5v7EI4yMjKI3GMLt0Tt4cO8BAoEcWJoTZYXrl6asqBC6U0GOy42HY+MrZi1JmoWFRZQW5sNyuVBeUpxOabJ7aiASjiB4/iKmnj+H5loaacwvLOL2jRF4AjnY8dc/I/DKbTdoSHvr8SO8DD/DzPSMWHrg1JwYvHZdpK2NVZWU26/aF3VDTyLP0N/bh4mJR3C7XZiZnRVdht/nx7u7tsOzzg5qORFWHAocigO9vX2Jronwej24cXMEbq8XrfW169rH/4usnq02o1FEo9FEE47luN22sTAzC0OPrd7ItnHn9h0MDg3D6/WKbZdHJqYRg26ar92XDgvD39zA2Ng4VKdTbEf7mpmeRX/fAPRfeRch+luNNTXICeSu+h3ti7okUzdgp3luO6uFUTUN9lLmnniOCkdKVnML8uB0r76rD72Di4qL4NI0IUnydpZlw/WmGsY00bRvDzZvKhfFKLAU9VOG8v7BdijW+i8kLX649yyMz0+fwVQksur3NILyejzw5efCoaT3kmW1MN68AMq2bBIXXtd18WMZBt6r242DBzvgda3uWhQ4xNzOkeNdohZYXFjA4vwCfD4/Sio2i9bjdeSoGirKylFYXirykehiFHpUR2FJCbZu+x1yXlMrrQWSZWwygv6Ll3DxXBCX+66u6I7o2DRFRWtbM1o62xNdb7rI7lGSqqBs+zZMTj4XLYY/x49t7+zABx8eReWO7ciLL41ctZmqoqRiE/x+P6amp5FbkI9jx7tw+GgncqmbesPuPAEfduzcgenZOTg0FaWby/GXP/0RdZXvrOvlkyzjzyfR81UIoQs9IpRJniqglszt0tDc1oS9bc2o37lTLMhKI2bW35HtRXQRs3MLmH/xUrzzVb8HJQUFyHX/crJCQ+JwOALFqaKspGjNRWtkbg5zc7PQXC5szl/f6Ig6MFqiSavuqHCmumuFLIYBt+ZEY0sTGtua0VBTJQK/NKPzLfzSQEKWL4NiiG5a1gpZzPhMdnNrE/a3N2NPVaUI+jIAnacGNhiShdbx9pzrFgunSA4tqeUQRbuqoLW9BQ0tjSINzhBZBCzMBvPzVAS950KiG6KWJVkWGnXRELrjQBtqG/eioTqzZAELs3FQy3Iv/BR9wUtiUtGOr+tNhoptGt1V7atD4+4aEehlGizMBnH/WRj9wcuiG7LjI7Vllm8d3nnoAKoaakXq+0tzWumChUkxdlyWge4rYt0uzRMpSck01SzUDR3s7MC7e2pFRqSmOZx7GyxMCrESLcsldAd7oCgrEx6xrldRRM1SvbceHfV1K0K7TISFSREx28L41KRIcGmdruOVz82KBFd1oqWjBe/tb0ArLd3McFnAwqSOiclJ9JwP4fLFXtEtJXdDywluU2uTGDpTgZupNcur8GerU8R0eBJDV6+LRVbJLYdIcF2aSHD3tzaL9b20zjdbYGFShB0z4HY6V9QtFNLRXFATxf2U4FZXZkLcLwULkyJoaUXMNMV6HbyS4O6jicQMS3DXCguTInJKC9HU0YoPOg8k1uy0t7eivnmfSHB9WSgLwZOPKcKwLcT0GL69cxe3b46KoK6+ZS92V2zNyAR3jfBsdaox6LPSpiVyf/rEo/rq11JlFzxbnWoomEMW5CtrhWsYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgr6qGx6b4/BZBXUwnzCl4xZI5844g3MCQBn+Kwxb+EjAGcdST3SxwBO8RljXsOnAL4AgP8BXnVIgIvemwsAAAAASUVORK5CYII=",
// 移动边框背景颜色
moveTrackMaskBgColor: "#89d2ff",
// 移动边框颜色
moveTrackMaskBorderColor: "#0298f8",
// 文字提示
i18n: {
tips_success: "验证成功,耗时%s秒",
tips_error: "验证失败,请重新尝试!",
slider_title: "拖动滑块完成拼图",
concat_title: "拖动滑块完成拼图",
image_click_title: "请依次点击:",
rotate_title: "拖动滑块完成拼图",
// TITLE 大小
slider_title_size: "15px",
concat_title_size: "15px",
image_click_title_size: "20px",
rotate_title_size: "15px",
},
};
+57
View File
@@ -0,0 +1,57 @@
const TYPE = "DISABLE";
function getTemplate(styleConfig) {
return `
<div class="captcha captcha-disable">
<div class="slider-tip">
<span class="captcha-slider-move-track-font" style="font-size: ${styleConfig.i18n.disable_title_size}">${styleConfig.i18n.disable_title}</span>
</div>
<div class="content">
<div class="bg-img-div">
<!-- <svg width="100" height="100" viewBox="0 0 100 100">-->
<!-- <polygon points="50,10 90,90 10,90" fill="none" stroke="#FF9900" stroke-width="4"/>-->
<!-- <path d="M50 35V65 M50 75V75" stroke="#FF9900" stroke-width="4" stroke-linecap="round"/>-->
<!-- </svg>-->
<span class="content-span"></span>
</div>
</div>
</div>
`;
}
class Disable {
constructor(boxEl, styleConfig) {
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find(".captcha");
// 绑定全局
// window.currentCaptcha = this;
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy() {
const existsCaptchaEl = this.boxEl.find(".captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
}
loadCaptchaForData(that, data) {
const msg = data.msg || data.message || "接口异常";
that.el.find(".content-span").text(msg);
}
}
export default Disable;
+25
View File
@@ -0,0 +1,25 @@
.captcha.captcha-disable {
z-index: 999;
position: absolute;
left: 0;
top: 0;
.content {
width: 100%;
height: 180px;
position: relative;
overflow: hidden;
.bg-img-div {
background-image: url("../../images/dun.jpeg");
width: 100%;
height: 100%;
overflow: hidden;
.content-span {
color: #fff;
overflow: hidden;
margin-top: 132px;
display: block;
text-align: center;
}
}
}
}
@@ -0,0 +1,131 @@
import {
CommonCaptcha,
move,
initConfig,
destroyEvent,
} from "../common/common.js";
/**
* 滑动验证码
*/
const TYPE = "IMAGE_CLICK";
function getTemplate(styleConfig) {
return `
<div class="captcha captcha-slider captcha-word-click">
<div class="click-tip">
<span class="captcha-click-track-font" style="font-size: ${styleConfig.i18n.image_click_title_size}">${styleConfig.i18n.image_click_title}</span>
<img src="" class="captcha-tip-img tip-img">
</div>
<div class="content">
<div class="bg-img-div">
<img class="captcha-slider-bg-img" src="" alt/>
<canvas class="captcha-slider-bg-canvas"></canvas>
<div class="bg-img-click-mask"></div>
</div>
<div class="captcha-tips"></div>
</div>
<div class="click-confirm-btn">确定</div>
</div>
`;
}
class ImageClick extends CommonCaptcha {
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find(".captcha");
// 绑定全局
// window.currentCaptcha = this;
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
const moveFun = move.bind(null, this);
// 绑定事件
this.el.find(".bg-img-click-mask").click((event) => {
if (event.target.className === "click-span") {
return;
}
this.currentCaptchaData.clickCount++;
const trackList = this.currentCaptchaData.trackList;
if (this.currentCaptchaData.clickCount === 1) {
this.currentCaptchaData.startTime = new Date();
// move 轨迹
window.addEventListener("mousemove", moveFun);
this.currentCaptchaData.startX = event.offsetX;
this.currentCaptchaData.startY = event.offsetY;
}
const startTime = this.currentCaptchaData.startTime;
trackList.push({
x: Math.round(event.offsetX),
y: Math.round(event.offsetY),
type: "click",
t: new Date().getTime() - startTime.getTime(),
});
const left = event.offsetX - 10;
const top = event.offsetY - 10;
this.el
.find(".bg-img-click-mask")
.append(
"<span class='click-span' style='left:" +
left +
"px;top: " +
top +
"px'>" +
this.currentCaptchaData.clickCount +
"</span>",
);
// if (this.currentCaptchaData.clickCount === 4) {
// // 校验
// this.currentCaptchaData.stopTime = new Date();
// window.removeEventListener("mousemove", move);
// this.endCallback(this.currentCaptchaData,this);
// }
});
this.el.find(".click-confirm-btn").click(() => {
if (this.currentCaptchaData.clickCount > 0) {
// 校验
this.currentCaptchaData.stopTime = new Date();
window.removeEventListener("mousemove", moveFun);
this.endCallback(this.currentCaptchaData, this);
}
});
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy() {
const existsCaptchaEl = this.boxEl.children(".captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData(that, data) {
const bgImg = that.el.find(".captcha-slider-bg-img");
const tipImg = that.el.find(".captcha-tip-img");
bgImg.on("load", () => {
that.currentCaptchaData = initConfig(
bgImg.width(),
bgImg.height(),
tipImg.width(),
tipImg.height(),
);
that.currentCaptchaData.currentCaptchaId = data.data.id;
});
bgImg.attr("src", data.data.backgroundImage);
tipImg.attr("src", data.data.templateImage);
}
}
export default ImageClick;
@@ -0,0 +1,66 @@
.captcha.captcha-word-click {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
.tip-img {
height: 35px;
position: absolute;
right: 15px;
}
.captcha-click-track-font {
font-size: 18px;
display: inline-block;
height: 40px;
line-height: 40px;
position: absolute;
}
}
.slider-bottom {
position: relative;
top: 6px;
}
.content {
.bg-img-click-mask {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
.click-span {
position: absolute;
left: 0;
top: 0;
border-radius: 50px;
background-color: #409eff;
width: 20px;
height: 20px;
text-align: center;
line-height: 20px;
color: #fff;
border: 2px solid #fff;
box-sizing: content-box;
}
}
}
.click-confirm-btn {
width: 100%;
height: 35px;
border-radius: 4px;
background-image: linear-gradient(
173deg,
hsl(38.09deg 91% 57.89%) 0%,
hsl(38.09deg 89.38% 71.74%) 100%
);
font-size: 15px;
text-align: center;
box-sizing: border-box;
line-height: 35px;
color: #fff;
margin-top: 3px;
}
.click-confirm-btn:hover {
cursor: pointer;
}
}
+134
View File
@@ -0,0 +1,134 @@
import {
CommonCaptcha,
down,
initConfig,
destroyEvent,
} from "../common/common.js";
/**
* 滑动验证码
*/
const TYPE = "ROTATE";
function getTemplate(styleConfig) {
return `
<div class="captcha captcha-slider captcha-rotate">
<div class="slider-tip">
<span class="captcha-slider-move-track-font" style="font-size: ${styleConfig.i18n.rotate_title_size}">${styleConfig.i18n.rotate_title}</span>
</div>
<div class="content">
<div class="bg-img-div">
<img class="captcha-slider-bg-img" src="" alt/>
<canvas class="captcha-slider-bg-canvas"></canvas>
</div>
<div class="rotate-img-div captcha-slider-img-div">
<img class="captcha-slider-move-img" src="" alt/>
</div>
<div class="captcha-tips"></div>
</div>
<div class="slider-move">
<div class="slider-move-track">
<div class="captcha-slider-move-track-mask"></div>
<div class="slider-move-shadow"></div>
</div>
<div class="slider-move-btn captcha-slider-move-btn">
</div>
</div>
</div>
`;
}
class Rotate extends CommonCaptcha {
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find(".captcha");
this.loadStyle();
// 按钮绑定事件
this.el
.find(".captcha-slider-move-btn")
.mousedown(down.bind(null, this));
this.el
.find(".captcha-slider-move-btn")
.touchstart(down.bind(null, this));
// 绑定全局
// window.currentCaptcha = this;
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy() {
const existsCaptchaEl = this.boxEl.children(".captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
doMove() {
const moveX = this.currentCaptchaData.moveX;
this.el
.find(".captcha-slider-move-btn")
.css("transform", "translate(" + moveX + "px, 0px)");
this.el
.find(".captcha-slider-move-img")
.css(
"transform",
"rotate(" + moveX / (this.currentCaptchaData.end / 360) + "deg)",
);
this.el
.find(".captcha-slider-move-track-mask")
.css("width", moveX + "px");
}
loadStyle() {
let sliderImg = "";
let moveTrackMaskBorderColor = "#00f4ab";
let moveTrackMaskBgColor = "#a9ffe5";
const styleConfig = this.styleConfig;
if (styleConfig) {
sliderImg = styleConfig.btnUrl;
moveTrackMaskBgColor = styleConfig.moveTrackMaskBgColor;
moveTrackMaskBorderColor = styleConfig.moveTrackMaskBorderColor;
}
this.el
.find(".slider-move .slider-move-btn")
.css("background-image", "url(" + sliderImg + ")");
// this.el.find(".captcha-slider-move-track-font").text(title);
this.el
.find(".captcha-slider-move-track-mask")
.css("border-color", moveTrackMaskBorderColor);
this.el
.find(".captcha-slider-move-track-mask")
.css("background-color", moveTrackMaskBgColor);
}
loadCaptchaForData(that, data) {
const bgImg = that.el.find(".captcha-slider-bg-img");
const sliderImg = that.el.find(".captcha-slider-move-img");
bgImg.attr("src", data.data.backgroundImage);
sliderImg.attr("src", data.data.templateImage);
bgImg.on("load", () => {
that.currentCaptchaData = initConfig(
bgImg.width(),
bgImg.height(),
sliderImg.width(),
sliderImg.height(),
300 - 63 + 5,
);
that.currentCaptchaData.currentCaptchaId = data.data.id;
});
}
}
export default Rotate;
+11
View File
@@ -0,0 +1,11 @@
.captcha.captcha-rotate {
.rotate-img-div {
height: 100%;
text-align: center;
img {
height: 100%;
transform: rotate(0deg);
display: inline-block;
}
}
}
+142
View File
@@ -0,0 +1,142 @@
import {
CommonCaptcha,
closeTips,
down,
initConfig,
showTips,
destroyEvent,
} from "../common/common.js";
/**
* 滑动验证码
*/
const TYPE = "SLIDER";
function getTemplate(styleConfig) {
return `
<div class="captcha captcha-slider">
<div class="slider-tip">
<span class="captcha-slider-move-track-font" style="font-size: ${styleConfig.i18n.slider_title_size}">${styleConfig.i18n.slider_title}</span>
</div>
<div class="content">
<div class="bg-img-div">
<img class="captcha-slider-bg-img" src="" alt/>
<canvas class="captcha-slider-bg-canvas"></canvas>
<div class="captcha-slider-bg-div"></div>
</div>
<div class="slider-img-div captcha-slider-img-div">
<img class="captcha-slider-move-img" src="" alt/>
</div>
<div class="captcha-tips"></div>
</div>
<div class="slider-move">
<div class="slider-move-track">
<div class="captcha-slider-move-track-mask"></div>
<div class="slider-move-shadow"></div>
</div>
<div class="slider-move-btn captcha-slider-move-btn">
</div>
</div>
</div>
`;
}
class Slider extends CommonCaptcha {
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find(".captcha");
this.loadStyle();
// 按钮绑定事件
this.el
.find(".captcha-slider-move-btn")
.mousedown(down.bind(null, this));
this.el
.find(".captcha-slider-move-btn")
.touchstart(down.bind(null, this));
// 绑定全局
// window.currentCaptcha = this;
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
showTips(msg, type, callback) {
showTips(this.el, msg, type, callback);
}
closeTips(callback) {
closeTips(this.el, callback);
}
destroy() {
const existsCaptchaEl = this.boxEl.children(".captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
doMove() {
const moveX = this.currentCaptchaData.moveX;
this.el
.find(".captcha-slider-move-btn")
.css("transform", "translate(" + moveX + "px, 0px)");
this.el
.find(".captcha-slider-img-div")
.css("transform", "translate(" + moveX + "px, 0px)");
this.el
.find(".captcha-slider-move-track-mask")
.css("width", moveX + "px");
}
loadStyle() {
let sliderImg = "";
let moveTrackMaskBorderColor = "#00f4ab";
let moveTrackMaskBgColor = "#a9ffe5";
const styleConfig = this.styleConfig;
if (styleConfig) {
sliderImg = styleConfig.btnUrl;
moveTrackMaskBgColor = styleConfig.moveTrackMaskBgColor;
moveTrackMaskBorderColor = styleConfig.moveTrackMaskBorderColor;
}
this.el
.find(".slider-move .slider-move-btn")
.css("background-image", "url(" + sliderImg + ")");
// this.el.find(".captcha-slider-move-track-font").text(title);
this.el
.find(".captcha-slider-move-track-mask")
.css("border-color", moveTrackMaskBorderColor);
this.el
.find(".captcha-slider-move-track-mask")
.css("background-color", moveTrackMaskBgColor);
}
loadCaptchaForData(that, data) {
const bgImg = that.el.find(".captcha-slider-bg-img");
const sliderImg = that.el.find(".captcha-slider-move-img");
bgImg.attr("src", data.data.backgroundImage);
sliderImg.attr("src", data.data.templateImage);
bgImg.on("load", () => {
that.currentCaptchaData = initConfig(
bgImg.width(),
bgImg.height(),
sliderImg.width(),
sliderImg.height(),
300 - 63 + 5,
);
that.currentCaptchaData.currentCaptchaId = data.data.id;
});
}
}
export default Slider;
+87
View File
@@ -0,0 +1,87 @@
.captcha.captcha-slider {
z-index: 999;
position: absolute;
left: 0;
top: 0;
.content {
width: 100%;
height: 180px;
position: relative;
overflow: hidden;
}
.bg-img-div {
width: 100%;
height: 100%;
position: absolute;
transform: translate(0px, 0px);
img {
height: 100%;
width: 100%;
}
}
.slider-img-div {
height: 100%;
position: absolute;
left: 0;
transform: translate(0px, 0px);
.captcha-slider-move-img {
height: 100%;
}
}
.slider-move {
height: 34px;
width: 100%;
margin: 11px 0;
position: relative;
}
.slider-move-track {
position: relative;
height: 32px;
line-height: 32px;
text-align: center;
background: #f5f5f5;
color: #999;
transition: 0s;
font-size: 14px;
box-sizing: content-box;
border: 1px solid #f5f5f5;
border-radius: 4px;
}
.refresh-btn,
.close-btn {
display: inline-block;
}
.slider-move {
line-height: 38px;
font-size: 14px;
text-align: center;
white-space: nowrap;
color: #88949d;
-moz-user-select: none;
-webkit-user-select: none;
user-select: none;
filter: opacity(0.8);
}
.slider-move .slider-move-btn {
transform: translate(0px, 0px);
position: absolute;
top: -6px;
left: 0;
width: 63px;
height: 45px;
background-color: #fff;
background-repeat: no-repeat;
background-size: contain;
border-radius: 5px;
}
.slider-tip {
margin-bottom: 5px;
font-weight: bold;
font-size: 15px;
line-height: normal;
}
.slider-move-btn:hover {
cursor: pointer;
}
user-select: none;
}
@@ -0,0 +1,13 @@
import ImageClick from "../image_click/image_click";
/**
* 滑动验证码
*/
const TYPE = "WORD_IMAGE_CLICK";
class WordImageClick extends ImageClick {
constructor(divId, styleConfig) {
super(divId, styleConfig);
this.type = TYPE;
}
}
export default WordImageClick;
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+43
View File
@@ -0,0 +1,43 @@
import { CaptchaConfig, TianAiCaptcha } from "./captcha/captcha.js"
export type TianAiCaptchaBindTarget = string | HTMLElement
export interface TianAiCaptchaResult {
id: string
data: Record<string, any>
}
export interface TianAiCaptchaStyle {
btnUrl?: string
moveTrackMaskBgColor?: string
moveTrackMaskBorderColor?: string
i18n?: Record<string, string>
}
export interface TianAiCaptchaConfig {
bindEl: TianAiCaptchaBindTarget
requestCaptchaDataUrl: string
types?: string[]
validCaptchaUrl?: string
timeToTimestamp?: boolean
validSuccess?: (res: any, captcha: any, tac: TianAiCaptchaInstance) => void
validFail?: (res: any, captcha: any, tac: TianAiCaptchaInstance) => void
onDataReady?: (result: TianAiCaptchaResult, captcha: any, tac: TianAiCaptchaInstance) => void | Promise<void>
btnRefreshFun?: (el: Event, tac: TianAiCaptchaInstance) => void
btnCloseFun?: (el: Event, tac: TianAiCaptchaInstance) => void
}
export interface TianAiCaptchaInstance {
init: () => TianAiCaptchaInstance
reloadCaptcha: () => void
destroyWindow: () => void
}
export function createTianAiCaptcha(
config: TianAiCaptchaConfig,
style?: TianAiCaptchaStyle,
): TianAiCaptchaInstance {
return new TianAiCaptcha(config, style) as TianAiCaptchaInstance
}
export { CaptchaConfig, TianAiCaptcha }
+24 -2
View File
@@ -17,6 +17,7 @@
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="home">返回首页</el-dropdown-item> <el-dropdown-item command="home">返回首页</el-dropdown-item>
<el-dropdown-item command="changePassword">修改密码</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-item command="logout" divided>退出</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
@@ -51,7 +52,6 @@
</keep-alive> </keep-alive>
</router-view> </router-view>
</div> </div>
<div class="layout-copy">2016-{{currentYear}} &copy; colorfulsweet 上次更新{{ version }}</div>
</el-main> </el-main>
</el-container> </el-container>
</el-container> </el-container>
@@ -75,6 +75,17 @@
</span> </span>
</template> </template>
</el-dialog> </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> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -93,12 +104,13 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const version = __APP_VERSION__ const version = __APP_VERSION__
const currentYear = new Date().getFullYear()
const defaultActiveMenuKey = ref<string | null>(null) const defaultActiveMenuKey = ref<string | null>(null)
const openMenuNames = ref<string[]>([]) const openMenuNames = ref<string[]>([])
const mainViewReady = ref(false) const mainViewReady = ref(false)
const isDark = ref(false) const isDark = ref(false)
const aboutModal = ref(false)
const backendVersion = ref('未知')
const menus = computed((): MenuGroup[] => { const menus = computed((): MenuGroup[] => {
return allMenus return allMenus
@@ -194,6 +206,9 @@ function dropdownMenuCommand(command: string): void {
case 'home': case 'home':
router.push('/') router.push('/')
break break
case 'about':
openAboutDialog()
break
case 'changePassword': case 'changePassword':
changePwdModal.value = true changePwdModal.value = true
break break
@@ -205,6 +220,13 @@ function dropdownMenuCommand(command: string): void {
break break
} }
} }
async function openAboutDialog(): Promise<void> {
aboutModal.value = true
backendVersion.value = '正在获取...'
backendVersion.value = await http.get<never, string>('/system/version')
}
function submitChangePassword(): void { function submitChangePassword(): void {
changePwdForm.value?.validate(async (valid: boolean) => { changePwdForm.value?.validate(async (valid: boolean) => {
if (!valid) return if (!valid) return
+145 -13
View File
@@ -18,7 +18,7 @@
placeholder="请输入用户名" placeholder="请输入用户名"
prefix-icon="User" prefix-icon="User"
size="large" size="large"
@keyup.enter="login" @keyup.enter="handleLogin"
/> />
</el-form-item> </el-form-item>
<el-form-item label="密码" prop="password"> <el-form-item label="密码" prop="password">
@@ -29,35 +29,80 @@
prefix-icon="Lock" prefix-icon="Lock"
size="large" size="large"
show-password show-password
@keyup.enter="login" @keyup.enter="handleLogin"
/> />
</el-form-item> </el-form-item>
<el-button class="login-btn" type="primary" size="large" :loading="loading" @click="login" plain> <el-button class="login-btn" type="primary" size="large" :loading="loading" @click="handleLogin" plain>
</el-button> </el-button>
</el-form> </el-form>
</div> </div>
</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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from 'vue' import { nextTick, onBeforeUnmount, reactive, ref } from 'vue'
import { useStore } from 'vuex' import { useStore } from 'vuex'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import type { VForm } from '../types' import type { VForm } from '../types'
import CaptchaPanel from '@/components/CaptchaPanel.vue'
import http from '@/utils/http' import http from '@/utils/http'
import type { TianAiCaptchaResult } from '@/vendor/tianai-captcha'
type UserInfo = { type LoginFormState = {
username: string | null, username: string | null,
password: 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 store = useStore()
const router = useRouter() const router = useRouter()
const loginForm = ref<VForm>() 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, username: null,
password: null password: null
}) })
@@ -77,19 +122,95 @@ store.commit('logout')
store.commit('clearTabs') store.commit('clearTabs')
localStorage.clear() 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) => { loginForm.value?.validate(async (valid: boolean) => {
if (!valid) return if (!valid || loading.value) return
loading.value = true 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 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> </script>
<style lang="less" scoped> <style lang="less" scoped>
.login-page { .login-page {
@@ -227,4 +348,15 @@ async function login() {
border-radius: 8px; 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> </style>
+82 -34
View File
@@ -5,12 +5,12 @@
<el-input v-model="search.title" /> <el-input v-model="search.title" />
</el-form-item> </el-form-item>
<el-form-item label="所属歌单"> <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-option v-for="musicLib in musicLibs" :key="musicLib._id" :value="musicLib._id" :label="musicLib.name" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="文件类型"> <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-option v-for="ext in exts" :key="ext" :value="ext" :label="ext" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -108,9 +108,9 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="uploadModal" title="上传音乐" :width="550" @closed="uploadModalClosed" > <el-dialog v-model="uploadModal" title="上传音乐" :width="550" @closed="uploadModalClosed" >
<el-form :label-width="80"> <el-form ref="uploadForm" :model="uploadFormData" :rules="uploadRules" :label-width="80">
<el-form-item label="歌单"> <el-form-item label="歌单" prop="libId">
<el-select v-model="libIdSelected"> <el-select v-model="uploadFormData.libId">
<el-option v-for="musicLib in musicLibs" :key="musicLib._id" :value="musicLib._id" :label="musicLib.name" /> <el-option v-for="musicLib in musicLibs" :key="musicLib._id" :value="musicLib._id" :label="musicLib.name" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -118,20 +118,23 @@
ref="musicUpload" ref="musicUpload"
:action="`${apiBase}/music/upload`" :action="`${apiBase}/music/upload`"
name="file" name="file"
accept=".mp3,.flac" :accept="uploadAccept"
:headers="{token: store.state.loginInfo.token}" :headers="{token: store.state.loginInfo.token}"
:on-success="uploadSuccess" :on-success="uploadSuccess"
:on-error="uploadError" :on-error="uploadError"
:auto-upload="false" :auto-upload="false"
multiple multiple
drag drag
:data="{libId: libIdSelected}"> :data="{libId: uploadFormData.libId}">
<div class="el-upload__text"> <div class="el-upload__text">
拖拽到此处或<em>点击上传</em> 拖拽到此处或<em>点击上传</em>
</div> </div>
<template #tip> <template #tip>
<div class="el-upload__tip"> <div class="el-upload__tip" style="display: flex; align-items: center; gap: 5px;">
可选择 mp3/flac 文件 <span>支持上传的文件类型</span>
<el-tag v-for="ext in uploadExtList" :key="ext" size="small">
{{ ext }}<span v-if="ext === 'ncm'"> (自动解码)</span>
</el-tag>
</div> </div>
</template> </template>
</el-upload> </el-upload>
@@ -168,20 +171,35 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-drawer> </el-drawer>
<el-drawer v-model="musicPlaying" :close-on-click-modal="false" size="40%" title="播放音乐"> <el-drawer v-model="musicPlaying" :close-on-click-modal="false" size="1200px" title="播放音乐" class="music-drawer">
<a-player v-if="musicPlaying && currentMusic" ref="player" autoplay showLrc :list="musicList" v-model:music="currentMusic" @play="musicPlay"/> <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> </el-drawer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch, computed } from 'vue'
import { useStore } from 'vuex' import { useStore } from 'vuex'
import { useBaseList } from '@/model/baselist' import { useBaseList } from '@/model/baselist'
import { MsgResult, Page } from '@/model/common.dto' import { MsgResult, Page } from '@/model/common.dto'
import { UploadInstance, ElMessage, ElMessageBox } from 'element-plus' import { UploadInstance, ElMessage, ElMessageBox } from 'element-plus'
import { MusicModel, MusicLibModel, MusicLyricModel, MusicPlayerItem } from '@/model/api/music' import { MusicModel, MusicLibModel, MusicLyricModel, MusicPlayerItem } from '@/model/api/music'
import APlayer from './aplayer/vue-aplayer.vue' import MusicPlayerPanel from './aplayer/music-player-panel.vue'
import prettyBytes from 'pretty-bytes' import prettyBytes from 'pretty-bytes'
// 格式化秒为 mm:ss // 格式化秒为 mm:ss
function formatDuration(seconds?: number): string { function formatDuration(seconds?: number): string {
@@ -213,7 +231,8 @@ const store = useStore()
const apiBase = import.meta.env.VITE_APP_API_BASE const apiBase = import.meta.env.VITE_APP_API_BASE
const { loading, modalLoading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange } = useBaseList(new MusicPage()) const { loading, modalLoading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange } = useBaseList(new MusicPage())
const currentRow = ref<MusicModel | null>(null) 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 exts = ref<string[]>([])
const musicLibs = ref<MusicLibModel[]>([]) const musicLibs = ref<MusicLibModel[]>([])
const musicData = ref<MusicModel[]>([]) const musicData = ref<MusicModel[]>([])
@@ -225,12 +244,23 @@ const musicList = ref<MusicPlayerItem[]>([])
const currentMusic = ref<MusicPlayerItem>() const currentMusic = ref<MusicPlayerItem>()
const lyricForm = ref<VForm>() const lyricForm = ref<VForm>()
const musicUpload = ref<UploadInstance>() const musicUpload = ref<UploadInstance>()
const player = ref<InstanceType<typeof APlayer>>() const player = ref<InstanceType<typeof MusicPlayerPanel>>()
const lyricLoading = ref(false) const lyricLoading = ref(false)
const libDrawerVisible = ref(false) const libDrawerVisible = ref(false)
const libTableData = ref<(MusicLibModel & { _isNew?: boolean })[]>([]) const libTableData = ref<(MusicLibModel & { _isNew?: boolean })[]>([])
const libSaving = ref(false) 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 = { const lyricRuleValidate = {
cloudId: [ cloudId: [
{ required: true, message: '请输入网易云ID', trigger: 'blur' } { required: true, message: '请输入网易云ID', trigger: 'blur' }
@@ -246,7 +276,7 @@ const lyricRuleValidate = {
let selectedIds: string[] = [] let selectedIds: string[] = []
watch(() => currentMusic.value, async (newMusic) => { watch(() => currentMusic.value, async (newMusic) => {
if (newMusic && !newMusic.lrc) { if (newMusic?.lyricId && !newMusic?.lrc) {
const { lyric } = await http.get<any, any>('/music/lyric/get', {params: {lyricId: newMusic.lyricId}}) const { lyric } = await http.get<any, any>('/music/lyric/get', {params: {lyricId: newMusic.lyricId}})
newMusic.lrc = lyric newMusic.lrc = lyric
} }
@@ -345,21 +375,20 @@ async function saveMusicLib(row: MusicModel) {
} }
function openUploadModal() { function openUploadModal() {
uploadModal.value = true uploadModal.value = true
libIdSelected.value = null uploadFormData.value = { libId: null }
} }
async function uploadMusic() { async function uploadMusic() {
if (!libIdSelected.value) { uploadForm.value?.validate((valid: boolean) => {
ElMessage.warning('请选择歌单') if (!valid) return
return musicUpload.value?.submit()
} })
musicUpload.value?.submit()
} }
function uploadSuccess(response: MsgResult) { function uploadSuccess(response: MsgResult) {
if (response.code === 0) { if (response.code === 0) {
ElMessage.success(response.message) ElMessage.success('上传成功')
loadData() loadData()
} else { } else {
ElMessage.warning(response.message) ElMessage.warning(response.message || '上传失败')
} }
} }
function uploadError(error: Error) { function uploadError(error: Error) {
@@ -384,20 +413,39 @@ function musicPlay() {
player.value?.pause() player.value?.pause()
}) })
navigator.mediaSession.setActionHandler('previoustrack', () => { navigator.mediaSession.setActionHandler('previoustrack', () => {
if (currentId === 0) { switchPrev()
player.value?.switch(musicList.value.length - 1)
} else {
player.value?.switch(currentId - 1)
}
}) })
navigator.mediaSession.setActionHandler('nexttrack', () => { navigator.mediaSession.setActionHandler('nexttrack', () => {
if (currentId === musicList.value.length - 1) { switchNext()
player.value?.switch(0)
} else {
player.value?.switch(currentId + 1)
}
}) })
} }
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() { function loadLibs() {
return http.get<never, any>('/music/lib/list').then(data => { return http.get<never, any>('/music/lib/list').then(data => {
+392 -131
View File
@@ -1,175 +1,436 @@
<template> <template>
<div class="page-wrapper"> <div class="page-wrapper" ref="rootEl">
<el-form inline :model="search">
<el-form-item label="文件名"> <!-- 操作栏 -->
<el-input v-model="search.name" /> <div class="btn-container">
</el-form-item> <el-upload
<el-form-item label="宽度" > :action="`${apiBase}/photoWall/upload`"
<el-input-number v-model="search.widthMin" :min="0" controls-position="right" step-strictly> accept="image/jpeg,image/png"
<template #prefix></template> name="image"
<template #suffix>px</template> :headers="{ token: store.state.loginInfo.token }"
</el-input-number> :before-upload="beforeUpload"
<el-input-number v-model="search.widthMax" :min="0" controls-position="right" step-strictly> :on-success="uploadSuccess"
<template #prefix></template> :on-error="uploadError"
<template #suffix>px</template> auto-upload
</el-input-number> :show-file-list="false"
</el-form-item> style="margin-right: 10px;">
<el-form-item label="高度" > <el-tooltip :content="`图片格式为${allowUploadExt.join('、')},文件大小不超过10MB。`">
<el-input-number v-model="search.heightMin" :min="0" controls-position="right" step-strictly> <el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'photoWall:save'" plain>上传图片</el-button>
<template #prefix></template> </el-tooltip>
<template #suffix>px</template> </el-upload>
</el-input-number> <el-button
<el-input-number v-model="search.heightMax" :min="0" controls-position="right" step-strictly> v-if="selected.size > 0"
<template #prefix></template> type="danger"
<template #suffix>px</template> icon="Delete"
</el-input-number> plain
</el-form-item> v-permission="'photoWall:delete'"
</el-form> @click="deleteSelected">
<div class="btn-container"> 删除选中 ({{ selected.size }})
<el-upload </el-button>
:action="`${apiBase}/photoWall/upload`" <div class="search-btn">
accept="image/jpeg,image/png" <el-button link type="primary" :disabled="!photos.length" @click="selectAll" v-if="!isAllSelected">全选</el-button>
name="image" <el-button link type="primary" @click="clearSelect" v-else>取消全选</el-button>
:headers="{token: store.state.loginInfo.token}" <el-button link type="primary" :disabled="!photos.length" @click="invertSelect">反选</el-button>
:before-upload="beforeUpload" </div>
:on-success="uploadSuccess"
:on-error="uploadError"
auto-upload
:show-file-list="false"
style="display: inline-block;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 type="danger" @click="deleteAll" style="vertical-align: bottom" v-permission="'photoWall:delete'" plain>批量删除</el-button>
<div class="search-btn">
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
</div> </div>
<!-- 瀑布流可滚动区域 -->
<div class="masonry-scroll">
<div class="masonry-scene">
<div
v-for="(col, ci) in columns"
:key="ci"
class="masonry-col">
<div
v-for="item in col"
:key="item._id"
class="card-wrap"
:class="{ 'is-selected': selected.has(item._id), 'is-entered': entered.has(item._id) }"
@click="toggleSelect(item)">
<div class="card-inner">
<!-- 图片 -->
<img
class="card-img"
:src="cdnBase ? `${cdnBase}/${item.thumbnail || item.name}` : ''"
:alt="item.name"
loading="lazy"
draggable="false" />
<!-- 选中角标 -->
<div class="check-badge">
<el-icon><Check /></el-icon>
</div>
<!-- Hover 浮层 -->
<div class="card-overlay">
<div class="overlay-info">
<p class="overlay-name" :title="item.name">{{ item.name }}</p>
<p class="overlay-size">{{ item.width }} × {{ item.height }}</p>
</div>
<el-button-group class="overlay-actions">
<el-button size="small" icon="View" plain @click.stop="preview(item)">预览</el-button>
<el-button
size="small"
icon="Delete"
type="danger"
plain
v-permission="'photoWall:delete'"
@click.stop="deleteSingle(item)">
删除
</el-button>
</el-button-group>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="page-container">
<el-pagination
background
:page-sizes="store.state.pageSizeOpts"
:layout="store.state.pageLayout"
:current-page="search.pageNum"
:page-size="search.limit"
:total="total"
@size-change="pageSizeChange"
@current-change="pageChange" />
</div>
<!-- 图片预览 -->
<el-image-viewer
v-if="previewVisible"
:url-list="previewUrls"
:initial-index="previewIndex"
@close="previewVisible = false" />
</div> </div>
<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" @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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useStore } from 'vuex' import { useStore } from 'vuex'
import { ElMessage, ElMessageBox } from 'element-plus' 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 { useBaseList } from '@/model/baselist'
import PhotoWallModel from '@/model/api/photowall' import type PhotoWallModel from '@/model/api/photowall'
import http from '@/utils/http' import http from '@/utils/http'
class PhotoWallPage extends Page { // ─── 常量 ─────────────────────────────────────────────
name?: string const COLUMN_WIDTH = 220 // 每列目标宽度(px)
widthMin!: number | null const COL_GAP = 14 // 列间距(px)
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 store = useStore() const store = useStore()
const apiBase = import.meta.env.VITE_APP_API_BASE const apiBase = import.meta.env.VITE_APP_API_BASE
const { loading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange } = useBaseList(new PhotoWallPage()) const rootEl = ref<HTMLElement | null>(null)
const photos = ref<PhotoWallModel[]>([])
const allowUploadExt = ['jpg', 'jpeg', 'png']
const photowallData = ref<PhotoWallModel[]>([])
const isUploading = ref(false) 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 previewVisible = ref(false)
const previewUrlList = ref<string[]>([]) const previewUrls = ref<string[]>([])
const previewIndex = ref(0) 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() { async function loadData() {
loading.value = true loading.value = true
const data = await http.get<PhotoWallPage, any>('/photoWall/list', {params: search}) entered.value = new Set()
selectedData = [] try {
loading.value = false if (!cdnBase.value) {
total.value = data.total cdnBase.value = await http.get('/common/config/picture_cdn')
photowallData.value = data.list }
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) setLoadData(loadData)
function deleteAll() { // ─── 选中 ─────────────────────────────────────────────
if (!selectedData || !selectedData.length) { function toggleSelect(item: PhotoWallModel) {
ElMessage.warning('请选择要删除的数据') const s = new Set(selected.value)
return s.has(item._id) ? s.delete(item._id) : s.add(item._id)
} selected.value = s
ElMessageBox.confirm(`是否确认删除选中的${selectedData.length}条数据?`, '确认删除', {type: 'warning'}).then(async () => {
await http.delete('/photoWall/delete', {params: {ids: selectedData}})
ElMessage.success('删除成功')
loadData()
}).catch(() => {})
} }
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 { function beforeUpload(file: File): boolean {
if (file.size > 10 << 20) { if (file.size > 10 << 20) {
ElMessage.warning('文件大小超过10MB') ElMessage.warning('文件大小超过 10MB')
return false return false
} }
isUploading.value = true isUploading.value = true
return true return true
} }
function uploadSuccess(response: MsgResult) { function uploadSuccess(response: MsgResult) {
isUploading.value = false
if (response.code === 0) { if (response.code === 0) {
ElMessage.success(response.message) ElMessage.success('上传成功')
loadData() loadData()
} else { } else {
ElMessage.warning(response.message) ElMessage.warning(response.message || '上传失败')
} }
isUploading.value = false
} }
function uploadError(error: Error) { function uploadError(error: Error) {
isUploading.value = false isUploading.value = false
ElMessage.error(error.message) 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) let resizeObserver: ResizeObserver | null = null
previewUrlList.value = photowallData.value.map(item => `${pictureCdn}/${item.name}`)
previewIndex.value = index >= 0 ? index : 0 onMounted(async () => {
previewVisible.value = true 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 .masonry-scene {
loadData() display: flex;
</script> 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>
+3 -3
View File
@@ -11,7 +11,7 @@
:on-error="uploadError" :on-error="uploadError"
auto-upload auto-upload
:show-file-list="false" :show-file-list="false"
style="display: inline-block;margin-right: 10px;"> style="margin-right: 10px;">
<el-tooltip :content="`图片格式为${allowUploadExt.join('、')},文件大小不超过10MB。`"> <el-tooltip :content="`图片格式为${allowUploadExt.join('、')},文件大小不超过10MB。`">
<el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'sourceImage:save'" plain>上传图片</el-button> <el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'sourceImage:save'" plain>上传图片</el-button>
</el-tooltip> </el-tooltip>
@@ -146,10 +146,10 @@ function beforeUpload(file: File): boolean {
} }
function uploadSuccess(response: MsgResult): void { function uploadSuccess(response: MsgResult): void {
if (response.code === 0) { if (response.code === 0) {
ElMessage.success(response.message) ElMessage.success('上传成功')
loadData() loadData()
} else { } else {
ElMessage.warning(response.message) ElMessage.warning(response.message || '上传失败')
} }
isUploading.value = false isUploading.value = false
} }
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
<path fill="#FFF" d="M4 16c0-6.6 5.4-12 12-12s12 5.4 12 12c0 1.2-0.8 2-2 2s-2-0.8-2-2c0-4.4-3.6-8-8-8s-8 3.6-8 8 3.6 8 8 8c1.2 0 2 0.8 2 2s-0.8 2-2 2c-6.6 0-12-5.4-12-12z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 265 B

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

Before

Width:  |  Height:  |  Size: 590 B

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

Before

Width:  |  Height:  |  Size: 473 B

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

Before

Width:  |  Height:  |  Size: 357 B

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

Before

Width:  |  Height:  |  Size: 315 B

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

Before

Width:  |  Height:  |  Size: 278 B

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

Before

Width:  |  Height:  |  Size: 240 B

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

Before

Width:  |  Height:  |  Size: 294 B

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

Before

Width:  |  Height:  |  Size: 272 B

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

Before

Width:  |  Height:  |  Size: 585 B

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

Before

Width:  |  Height:  |  Size: 604 B

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

Before

Width:  |  Height:  |  Size: 301 B

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,153 +0,0 @@
<template>
<div
class="aplayer-bar-wrap"
@mousedown="onThumbMouseDown"
ref="barWrap"
>
<div class="aplayer-bar">
<div
class="aplayer-loaded"
:style="{width: `${loadProgress * 100}%`}">
</div>
<div
class="aplayer-played"
:style="{width: `${playProgress * 100}%`, background: theme}"
>
<span
ref="thumb"
@mouseover="thumbHovered = true"
@mouseout="thumbHovered = false"
class="aplayer-thumb"
:style="{borderColor: theme, backgroundColor: thumbHovered ? theme : '#fff'}"
>
<span class="aplayer-loading-icon" :style="{backgroundColor: theme }">
<player-icon type="loading"/>
</span>
</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { getElementViewLeft } from '../utils'
import PlayerIcon from './aplayer-icon.vue'
defineProps<{
loadProgress: number
playProgress: number
theme?: string
}>()
const emit = defineEmits(['dragbegin', 'dragging', 'dragend'])
const thumbHovered = ref(false)
const barWrap = ref<unknown>(null)
const onThumbMouseDown = (e: MouseEvent) => {
const barWidth = (<HTMLElement>barWrap.value).clientWidth
let percentage = (e.clientX - getElementViewLeft(<HTMLElement>barWrap.value)) / barWidth
percentage = percentage > 0 ? percentage : 0
percentage = percentage < 1 ? percentage : 1
emit('dragbegin', percentage)
document.addEventListener('mousemove', onDocumentMouseMove)
document.addEventListener('mouseup', onDocumentMouseUp)
}
const onDocumentMouseMove = (e: MouseEvent) => {
const barWidth = (<HTMLElement>barWrap.value).clientWidth
let percentage = (e.clientX - getElementViewLeft(<HTMLElement>barWrap.value)) / barWidth
percentage = percentage > 0 ? percentage : 0
percentage = percentage < 1 ? percentage : 1
emit('dragging', percentage)
}
const onDocumentMouseUp = (e: MouseEvent) => {
document.removeEventListener('mouseup', onDocumentMouseUp)
document.removeEventListener('mousemove', onDocumentMouseMove)
const barWidth = (<HTMLElement>barWrap.value).clientWidth
let percentage = (e.clientX - getElementViewLeft(<HTMLElement>barWrap.value)) / barWidth
percentage = percentage > 0 ? percentage : 0
percentage = percentage < 1 ? percentage : 1
emit('dragend', percentage)
}
</script>
<style lang="less" scoped>
.aplayer-bar-wrap {
margin: 0 0 0 5px;
padding: 4px 0;
cursor: pointer;
flex: 1;
.aplayer-bar {
position: relative;
height: 2px;
width: 100%;
background: #cdcdcd;
.aplayer-loaded {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background: #aaa;
height: 2px;
transition: all 0.5s ease;
will-change: width;
}
.aplayer-played {
position: absolute;
left: 0;
top: 0;
bottom: 0;
height: 2px;
transition: background-color .3s;
will-change: width;
.aplayer-thumb {
position: absolute;
top: 0;
right: 5px;
margin-top: -5px;
margin-right: -10px;
width: 10px;
height: 10px;
border: 1px solid;
transform: scale(.8);
will-change: transform;
transition: transform 300ms, background-color .3s, border-color .3s;
border-radius: 50%;
background: #fff;
cursor: pointer;
&:hover {
transform: scale(1);
}
overflow: hidden;
.aplayer-loading-icon {
display: none;
width: 100%;
height: 100%;
.icon-svg {
position: absolute;
animation: spin 1s linear infinite;
}
}
}
}
}
}
.aplayer-loading {
.aplayer-bar-wrap .aplayer-bar .aplayer-thumb .aplayer-loading-icon {
display: block;
}
.aplayer-info .aplayer-controller .aplayer-bar-wrap .aplayer-bar .aplayer-played .aplayer-thumb {
transform: scale(1);
}
}
@keyframes spin {
0% {
transform: rotate(0)
}
100% {
transform: rotate(360deg)
}
}
</style>
@@ -1,117 +0,0 @@
<template>
<div class="aplayer-volume-wrap">
<icon-button
:class="`aplayer-icon-${volumeIcon}`"
:icon="volumeIcon"
@click="emit('togglemute')"
/>
<div
class="aplayer-volume-bar-wrap"
@mousedown="onBarMouseDown"
>
<div class="aplayer-volume-bar" ref="bar">
<div
class="aplayer-volume"
:style="{
height: muted ? 0 : `${Math.trunc(volume * 100)}%`,
background: theme
}"
>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import IconButton from './aplayer-iconbutton.vue'
import {getElementViewTop} from '../utils'
const props = defineProps<{
volume: number
muted: boolean
theme: string
}>()
const emit = defineEmits(['setvolume', 'togglemute'])
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,130 +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 emit = defineEmits(['dragbegin', 'dragend', 'dragging', 'togglemute', 'setvolume', 'toggleshuffle', 'nextmode', 'togglelist'])
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,25 +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 svgModules = import.meta.glob('../assets/*.svg', { eager: true, import: 'default' }) as Record<string, string>
const SVGs = Object.entries(svgModules).reduce((svgs: {[propName:string]: string}, [path, svgFile]) => {
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,136 +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 emit = defineEmits(['selectsong'])
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,108 +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 currentLineIndex = ref(0)
const lrcLines = computed(() => parseLrc(props.lrcSource))
const transformStyle = computed(() => {
return {
transform: `translateY(${-currentLineIndex.value * 16}px)`,
webkitTransform: `translateY(${-currentLineIndex.value * 16}px)`,
}
})
watch(() => props.playStat.playedTime, (playedTime: number) => {
for (let i = 0; i < lrcLines.value.length; i++) {
const line = lrcLines.value[i]
const nextLine = lrcLines.value[i + 1]
if (playedTime >= line[0] && (!nextLine || playedTime < nextLine[0])) {
currentLineIndex.value = i
}
}
})
</script>
<style lang="less" scoped>
@import "../less/variables";
.aplayer-lrc {
position: relative;
height: @lrc-height;
text-align: center;
overflow: hidden;
margin-bottom: 7px;
&:before {
position: absolute;
top: 0;
z-index: 1;
display: block;
overflow: hidden;
width: 100%;
height: 10%;
content: ' ';
background: -moz-linear-gradient(top, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
background: -webkit-linear-gradient(top, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
background: linear-gradient(to bottom, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#00ffffff', GradientType=0);
}
&:after {
position: absolute;
bottom: 0;
z-index: 1;
display: block;
overflow: hidden;
width: 100%;
height: 33%;
content: ' ';
background: -moz-linear-gradient(top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%);
background: -webkit-linear-gradient(top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%);
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ffffff', endColorstr='#ccffffff', GradientType=0);
}
p {
font-size: 12px;
color: #666;
line-height: 16px;
height: 16px;
padding: 0;
margin: 0;
transition: all 0.5s ease-out;
opacity: 0.4;
overflow: hidden;
&.aplayer-lrc-current {
opacity: 1;
overflow: visible;
height: initial;
}
}
.aplayer-lrc-contents {
width: 100%;
transition: all 0.5s ease-out;
user-select: text;
cursor: default;
}
}
</style>
@@ -1,134 +0,0 @@
<template>
<div
class="aplayer-pic"
:style="{backgroundColor: theme}"
@mousedown="onDragBegin"
@click="onClick"
>
<img v-if="pic" ref="thumbnailPic" :src="pic" @load="imgLoad" />
<div class="aplayer-button" :class="playing ? 'aplayer-pause' : 'aplayer-play'">
<icon-button
:icon="playing ? 'pause' : 'play'"
:class="playing ? 'aplayer-icon-pause' : 'aplayer-icon-play'"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import IconButton from './aplayer-iconbutton.vue'
import { adaptingThemeColor } from '../utils'
const props = withDefaults(defineProps<{
pic?: string
theme: string
playing: boolean
enableDrag: boolean
}>(), {
playing: false,
enableDrag: false
})
const emit = defineEmits(['dragbegin', 'dragging', 'dragend', 'toggleplay', 'adaptingTheme'])
const hasMovedSinceMouseDown = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const thumbnailPic = ref<unknown>(null)
const onDragBegin = (e: MouseEvent) => {
if (props.enableDrag) {
hasMovedSinceMouseDown.value = false
emit('dragbegin')
dragStartX.value = e.clientX
dragStartY.value = e.clientY
document.addEventListener('mousemove', onDocumentMouseMove)
document.addEventListener('mouseup', onDocumentMouseUp)
}
}
const onDocumentMouseMove = (e: MouseEvent) => {
hasMovedSinceMouseDown.value = true
emit('dragging', {offsetLeft: e.clientX - dragStartX.value, offsetTop: e.clientY - dragStartY.value})
}
const onDocumentMouseUp = () => {
document.removeEventListener('mouseup', onDocumentMouseUp)
document.removeEventListener('mousemove', onDocumentMouseMove)
emit('dragend')
}
const onClick = () => {
if (!hasMovedSinceMouseDown.value) {
emit('toggleplay')
}
}
const imgLoad = () => {
const color = adaptingThemeColor(<HTMLImageElement>thumbnailPic.value)
emit('adaptingTheme', `rgb(${color.r},${color.g},${color.b})`)
}
watch(() => props.pic, (pic?: string) => {
if (!pic) emit('adaptingTheme', null)
}, { immediate: true })
</script>
<style lang="less" scoped>
@import "../less/variables";
.aplayer-float {
.aplayer-pic:active {
cursor: move;
}
}
.aplayer-pic {
flex-shrink: 0;
position: relative;
height: @aplayer-height;
width: @aplayer-height;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
.aplayer-button {
opacity: 1;
}
}
.aplayer-button {
position: absolute;
border-radius: 50%;
opacity: 0.8;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
background: rgba(0, 0, 0, 0.2);
transition: all 0.1s ease;
}
.aplayer-play {
width: 26px;
height: 26px;
border: 2px solid #fff;
bottom: 50%;
right: 50%;
margin: 0 -15px -15px 0;
.aplayer-icon-play {
position: absolute;
top: 3px;
left: 4px;
height: 20px;
width: 20px;
}
}
.aplayer-pause {
width: 16px;
height: 16px;
border: 2px solid #fff;
bottom: 4px;
right: 4px;
.aplayer-icon-pause {
position: absolute;
top: 2px;
left: 2px;
height: 12px;
width: 12px;
}
}
img {
width: 100%;
height: 100%;
}
}
</style>
@@ -1,3 +0,0 @@
@aplayer-height: 66px;
@lrc-height: 30px;
@aplayer-height-lrc: @aplayer-height + @lrc-height - 6;
@@ -0,0 +1,999 @@
<template>
<div class="music-player-panel" :style="panelBgStyle">
<!-- 上半部分唱片 + 歌曲信息 -->
<div class="player-main">
<!-- 左侧唱片盘面 + 圆形频谱 -->
<div class="disc-section">
<div class="disc-container">
<!-- 唱片 -->
<div class="disc-outer" :class="{ playing: isPlaying }">
<canvas ref="visualizerCanvas" class="visualizer-canvas"></canvas>
<div class="vinyl-grooves"></div>
<div class="disc-cover-wrapper">
<img v-if="currentMusic?.pic" ref="coverImg" :src="currentMusic.pic" class="disc-cover" crossorigin="anonymous" @load="onCoverLoad" />
<div v-else class="disc-cover disc-cover-empty"></div>
</div>
<div class="disc-center-dot"></div>
</div>
<!-- 唱针 SVG -->
<svg class="needle" :class="{ playing: isPlaying }" viewBox="0 0 120 200" xmlns="http://www.w3.org/2000/svg">
<circle cx="60" cy="16" r="14" fill="#555" stroke="#777" stroke-width="2"/>
<circle cx="60" cy="16" r="6" fill="#888" stroke="#aaa" stroke-width="1"/>
<rect x="57" y="16" width="6" height="120" rx="3" fill="url(#armGradient)"/>
<rect x="55" y="130" width="10" height="30" rx="2" fill="#444" stroke="#666" stroke-width="1"/>
<rect x="58" y="158" width="4" height="14" rx="1" fill="#333"/>
<circle cx="60" cy="174" r="3" fill="#222" stroke="#555" stroke-width="1"/>
<defs>
<linearGradient id="armGradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#777"/>
<stop offset="50%" stop-color="#bbb"/>
<stop offset="100%" stop-color="#777"/>
</linearGradient>
</defs>
</svg>
</div>
</div>
<!-- 右侧歌曲信息 + 歌词 -->
<div class="info-section">
<div class="song-info">
<h2 class="song-title">{{ currentMusic?.title || 'Untitled' }}</h2>
<div class="song-meta">
<span v-if="currentMusic?.album">专辑{{ currentMusic.album }}</span>
<span v-if="currentMusic?.artist">歌手{{ currentMusic.artist }}</span>
</div>
</div>
<div class="lyrics-area" ref="lyricsContainer">
<div class="lyrics-content" :style="lyricsTransformStyle">
<p
v-for="(line, index) in lrcLines"
:key="index"
:class="{ 'current-line': index === currentLineIndex }"
>{{ line[1] || '' }}</p>
</div>
<div v-if="!lrcLines.length" class="no-lyrics">暂无歌词</div>
</div>
</div>
</div>
<!-- 进度条 -->
<div class="progress-section">
<span class="time-label">{{ formatTime(playedTime) }}</span>
<div class="progress-bar" ref="progressBar" @mousedown="onProgressMouseDown">
<div class="progress-loaded" :style="{ width: loadProgress + '%' }"></div>
<div class="progress-played" :style="{ width: playProgress + '%' }">
<span class="progress-thumb"></span>
</div>
</div>
<span class="time-label">{{ formatTime(duration) }}</span>
</div>
<!-- 播放控制 -->
<div class="controls-section">
<button class="ctrl-btn" :class="{ active: shuffle }" @click="emit('toggleShuffle')" title="随机播放">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>
</button>
<button class="ctrl-btn" @click="emit('prev')" title="上一首">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button>
<button class="ctrl-btn ctrl-btn-play" @click="emit('toggle')" :title="isPlaying ? '暂停' : '播放'">
<svg v-if="isPlaying" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="ctrl-btn" @click="emit('next')" title="下一首">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
<button class="ctrl-btn" :class="{ active: repeatMode !== 'no_repeat' }" @click="emit('nextMode')" :title="repeatTitle">
<svg v-if="repeatMode === 'repeat_one'" viewBox="0 0 24 24" fill="currentColor"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4zm-4-2V9h-1l-2 1v1h1.5v4H13z"/></svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
</button>
<div class="volume-control">
<button class="ctrl-btn" @click="emit('toggleMute')" :title="muted ? '取消静音' : '静音'">
<svg v-if="muted" viewBox="0 0 24 24" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>
<svg v-else-if="volume > 0.5" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/></svg>
</button>
<input type="range" class="volume-slider" min="0" max="1" step="0.01" :value="volume" @input="onVolumeInput" />
</div>
</div>
<!-- 播放列表 -->
<div class="playlist-section" v-if="list.length > 1">
<div class="playlist-header" @click="playlistVisible = !playlistVisible">
<span>播放列表 ({{ list.length }})</span>
<span class="playlist-toggle">{{ playlistVisible ? '收起' : '展开' }}</span>
</div>
<transition name="slide">
<div class="playlist-body" v-show="playlistVisible">
<div
v-for="(item, index) in list"
:key="index"
class="playlist-item"
:class="{ active: item === currentMusic }"
@click="emit('selectSong', item)"
>
<span class="playlist-index">{{ index + 1 }}</span>
<span class="playlist-title">{{ item.title || 'Untitled' }}</span>
<span class="playlist-artist">{{ item.artist || '' }}</span>
</div>
</div>
</transition>
</div>
<!-- 隐藏的audio -->
<audio ref="audioEl"></audio>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, reactive, nextTick } from 'vue'
import { MusicPlayerItem, StatType } from '@/model/api/music'
import { parseLrc, adaptingThemeColor } from './utils'
import { Particle, spawnParticles, updateAndDrawParticles, drawCircularSpectrum, formatTime } from './visualizer'
import Hls from 'hls.js'
const props = defineProps<{
music: MusicPlayerItem
list: MusicPlayerItem[]
autoplay?: boolean
}>()
const emit = defineEmits(['update:music', 'toggle', 'prev', 'next', 'toggleShuffle', 'nextMode', 'toggleMute', 'selectSong', 'play'])
const audioEl = ref<HTMLAudioElement | null>(null)
const progressBar = ref<HTMLDivElement | null>(null)
const visualizerCanvas = ref<HTMLCanvasElement | null>(null)
const coverImg = ref<HTMLImageElement | null>(null)
const playlistVisible = ref(false)
const themeColor = ref<{ r: number; g: number; b: number } | null>(null)
const panelBgStyle = computed(() => {
if (!themeColor.value) return null
const { r, g, b } = themeColor.value
const dr = Math.floor(r * 0.25), dg = Math.floor(g * 0.25), db = Math.floor(b * 0.25)
return { backgroundColor: `rgb(${dr}, ${dg}, ${db})` }
})
function onCoverLoad() {
if (!coverImg.value) return
try {
const color = adaptingThemeColor(coverImg.value)
themeColor.value = color
} catch {
themeColor.value = null
}
}
const isPlaying = ref(false)
const shuffle = ref(false)
const repeatMode = ref<'no_repeat' | 'repeat_one' | 'repeat_all'>('no_repeat')
const muted = ref(false)
const volume = ref(0.5)
const currentLineIndex = ref(0)
const hls = ref<Hls | null>(null)
const playStat = reactive<StatType>({
duration: 0,
loadedTime: 0,
playedTime: 0,
})
const internalMusic = ref<MusicPlayerItem>(props.music)
const currentMusic = computed({
get: () => internalMusic.value,
set: (val) => {
internalMusic.value = val
emit('update:music', val)
},
})
const playedTime = computed(() => playStat.playedTime)
const duration = computed(() => playStat.duration)
const loadProgress = computed(() => playStat.duration ? (playStat.loadedTime / playStat.duration) * 100 : 0)
const playProgress = computed(() => playStat.duration ? (playStat.playedTime / playStat.duration) * 100 : 0)
const lrcLines = computed(() => {
if (!currentMusic.value?.lrc) return []
return parseLrc(currentMusic.value.lrc)
})
const lyricsContainer = ref<HTMLDivElement | null>(null)
const lyricsTransformStyle = computed(() => {
const lineHeight = 34
const containerHeight = lyricsContainer.value?.clientHeight || 300
const centerOffset = Math.floor(containerHeight / 2 / lineHeight)
const offset = Math.max(0, currentLineIndex.value - centerOffset)
return { transform: `translateY(-${offset * lineHeight}px)` }
})
const repeatTitle = computed(() => {
if (repeatMode.value === 'no_repeat') return '顺序播放'
if (repeatMode.value === 'repeat_one') return '单曲循环'
return '列表循环'
})
// Audio Visualizer
let audioCtx: AudioContext | null = null
let analyser: AnalyserNode | null = null
let source: MediaElementAudioSourceNode | null = null
let animationId: number | null = null
let dataArray: Uint8Array<ArrayBuffer> | null = null
let visualizerConnected = false
// 粒子系统
const particles: Particle[] = []
function initVisualizer(audio: HTMLAudioElement) {
if (visualizerConnected) return
audioCtx = new AudioContext()
analyser = audioCtx.createAnalyser()
analyser.fftSize = 256
source = audioCtx.createMediaElementSource(audio)
source.connect(analyser)
analyser.connect(audioCtx.destination)
dataArray = new Uint8Array(analyser.frequencyBinCount)
visualizerConnected = true
}
function drawVisualizer() {
if (!visualizerCanvas.value || !analyser || !dataArray) return
const canvas = visualizerCanvas.value
const ctx = canvas.getContext('2d')
if (!ctx) return
const width = canvas.width
const height = canvas.height
if (width === 0 || height === 0) return
analyser.getByteFrequencyData(dataArray)
ctx.clearRect(0, 0, width, height)
const { lowEnergy, highEnergy } = drawCircularSpectrum({ ctx, width, height, dataArray })
const centerX = width / 2
const centerY = height / 2
const discRadius = Math.min(width, height) * 0.30
spawnParticles(particles, centerX, centerY, discRadius, lowEnergy, highEnergy)
updateAndDrawParticles(ctx, particles)
animationId = requestAnimationFrame(drawVisualizer)
}
function startVisualizer() {
if (animationId) return
if (audioCtx?.state === 'suspended') audioCtx.resume()
drawVisualizer()
}
function stopVisualizer() {
if (animationId) {
cancelAnimationFrame(animationId)
animationId = null
}
particles.length = 0
}
function destroyVisualizer() {
stopVisualizer()
source?.disconnect()
analyser?.disconnect()
source = null
analyser = null
dataArray = null
visualizerConnected = false
if (audioCtx && audioCtx.state !== 'closed') {
void audioCtx.close()
}
audioCtx = null
}
function destroyHls() {
hls.value?.detachMedia()
hls.value?.destroy()
hls.value = null
}
// Audio controls
function play() {
const media = audioEl.value
if (!media) return
media.play()
}
function pause() {
audioEl.value?.pause()
}
function toggle() {
if (isPlaying.value) pause()
else play()
}
function switchTrack(index: number) {
const normalizedIndex = ((index % props.list.length) + props.list.length) % props.list.length
currentMusic.value = props.list[normalizedIndex]
nextTick(() => play())
}
function resolveProgressRatio(clientX: number) {
if (!progressBar.value) return 0
const rect = progressBar.value.getBoundingClientRect()
if (rect.width === 0) return 0
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
}
function onProgressMouseDown(e: MouseEvent) {
if (!progressBar.value || !audioEl.value) return
const pct = resolveProgressRatio(e.clientX)
if (!isNaN(audioEl.value.duration)) {
audioEl.value.currentTime = audioEl.value.duration * pct
}
const onMove = (ev: MouseEvent) => {
const p = resolveProgressRatio(ev.clientX)
if (!isNaN(audioEl.value!.duration)) {
audioEl.value!.currentTime = audioEl.value!.duration * p
}
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
function onVolumeInput(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value)
volume.value = val
if (audioEl.value) {
audioEl.value.volume = val
if (val > 0) {
audioEl.value.muted = false
muted.value = false
}
}
}
function initAudio() {
const media = audioEl.value
if (!media) return
media.volume = volume.value
media.addEventListener('play', () => {
isPlaying.value = true
emit('play')
})
media.addEventListener('pause', () => { isPlaying.value = false })
media.addEventListener('timeupdate', () => {
playStat.playedTime = media.currentTime
})
media.addEventListener('durationchange', () => {
if (media.duration !== 1) playStat.duration = media.duration
})
media.addEventListener('progress', () => {
if (media.buffered.length) {
playStat.loadedTime = media.buffered.end(media.buffered.length - 1)
}
})
media.addEventListener('ended', () => {
const currentIndex = props.list.indexOf(currentMusic.value)
if (repeatMode.value === 'repeat_one') {
media.currentTime = 0
play()
} else if (repeatMode.value === 'repeat_all') {
switchTrack(currentIndex + 1)
} else {
if (currentIndex < props.list.length - 1) {
switchTrack(currentIndex + 1)
}
}
})
media.addEventListener('volumechange', () => {
volume.value = media.volume
muted.value = media.muted
})
loadSource(currentMusic.value)
}
function loadSource(music: MusicPlayerItem) {
const media = audioEl.value
const src = music.src
if (!media || !src) return
if (/\.m3u8(?=[#?]|$)/.test(src)) {
if (media.canPlayType('application/x-mpegURL') || media.canPlayType('application/vnd.apple.mpegURL')) {
destroyHls()
media.src = src
} else if (Hls.isSupported()) {
destroyHls()
const hlsInstance = new Hls()
hlsInstance.loadSource(src)
hlsInstance.attachMedia(media)
hls.value = hlsInstance
} else {
destroyHls()
media.src = src
}
} else {
destroyHls()
media.src = src
}
}
watch(currentMusic, (music) => {
if (!music) return
loadSource(music)
currentLineIndex.value = 0
playStat.playedTime = 0
playStat.duration = 0
playStat.loadedTime = 0
})
watch(() => props.music, (music) => {
internalMusic.value = music
})
watch(() => playStat.playedTime, (time) => {
const lines = lrcLines.value
for (let i = 0; i < lines.length; i++) {
const nextLine = lines[i + 1]
if (time >= lines[i][0] && (!nextLine || time < nextLine[0])) {
currentLineIndex.value = i
break
}
}
})
watch(isPlaying, (playing) => {
if (playing) startVisualizer()
else stopVisualizer()
})
watch(() => props.list, () => {
playlistVisible.value = props.list.length > 1
}, { immediate: true })
let resizeObserver: ResizeObserver | null = null
function resizeCanvas() {
const canvas = visualizerCanvas.value
if (!canvas) return
const parent = canvas.parentElement
if (!parent) return
const rect = parent.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return
canvas.width = rect.width
canvas.height = rect.height
}
onMounted(() => {
initAudio()
if (props.autoplay) {
nextTick(() => play())
}
nextTick(() => {
if (audioEl.value) {
initVisualizer(audioEl.value)
}
resizeCanvas()
})
resizeObserver = new ResizeObserver(() => {
resizeCanvas()
if (isPlaying.value && visualizerConnected && !animationId) {
startVisualizer()
}
})
if (visualizerCanvas.value?.parentElement) {
resizeObserver.observe(visualizerCanvas.value.parentElement)
}
})
onBeforeUnmount(() => {
destroyVisualizer()
resizeObserver?.disconnect()
destroyHls()
})
defineExpose({
audio: audioEl,
isPlaying,
currentMusic,
playStat,
play,
pause,
toggle,
switch: switchTrack,
shuffle,
repeatMode,
muted,
volume,
})
</script>
<style lang="less" scoped>
// === 颜色变量 ===
@color-primary: #64c8ff;
@color-primary-purple: #a78bfa;
@color-white: #fff;
@color-text: #e0e0e0;
@color-text-secondary: #aaa;
@color-text-muted: #888;
@color-text-dim: #555;
@color-vinyl-dark: #111;
@color-vinyl-groove: #222;
@color-vinyl-groove-alt: #1a1a1a;
@color-vinyl-border: #333;
// === 尺寸变量 ===
@disc-section-width: 420px;
@disc-size: 360px;
@disc-cover-ratio: 52%;
@disc-cover-border: 6px;
@needle-width: 80px;
@needle-height: 134px;
@lyrics-line-height: 34px;
@ctrl-btn-size: 36px;
@ctrl-btn-play-size: 48px;
@playlist-max-height: 140px;
@info-max-width: 380px;
// === 混合 ===
.text-ellipsis() {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.abs-full() {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.abs-center() {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.flex-center() {
display: flex;
align-items: center;
justify-content: center;
}
.music-player-panel {
height: 100%;
display: flex;
flex-direction: column;
color: @color-text;
padding: 24px 30px;
overflow: hidden;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
// === 上半部分 ===
.player-main {
display: flex;
gap: 40px;
flex: 1;
min-height: 0;
overflow: hidden;
}
// === 唱片区域 ===
.disc-section {
flex-shrink: 0;
width: @disc-section-width;
.flex-center();
}
.disc-container {
position: relative;
width: @disc-size;
height: @disc-size;
}
// === 唱针 ===
.needle {
position: absolute;
top: -16px;
right: -10px;
width: @needle-width;
height: @needle-height;
z-index: 10;
transform-origin: 50% 12%;
transform: rotate(-30deg);
transition: transform 0.8s cubic-bezier(0.4, 0, 0.2, 1);
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.5));
&.playing {
transform: rotate(5deg);
}
}
// === 唱片 ===
.disc-outer {
position: relative;
width: @disc-size;
height: @disc-size;
border-radius: 50%;
background: radial-gradient(circle at 50% 50%,
@color-vinyl-dark 0%, @color-vinyl-dark 28%,
@color-vinyl-groove-alt 28.5%, @color-vinyl-groove 29%, @color-vinyl-groove-alt 29.5%,
@color-vinyl-groove 32%, @color-vinyl-groove-alt 32.5%, @color-vinyl-groove 33%,
@color-vinyl-groove-alt 35%, @color-vinyl-groove 35.5%, @color-vinyl-groove-alt 36%,
@color-vinyl-groove 38%, @color-vinyl-groove-alt 38.5%, @color-vinyl-groove 39%,
@color-vinyl-groove-alt 41%, @color-vinyl-groove 41.5%,
@color-vinyl-groove-alt 60%, @color-vinyl-groove 60.5%, @color-vinyl-groove-alt 61%,
@color-vinyl-groove 63%, @color-vinyl-groove-alt 63.5%, @color-vinyl-groove 64%,
@color-vinyl-groove-alt 66%, @color-vinyl-groove 66.5%, @color-vinyl-groove-alt 67%,
@color-vinyl-groove 69%, @color-vinyl-groove-alt 69.5%, @color-vinyl-groove 70%,
@color-vinyl-groove-alt 72%, @color-vinyl-groove 72.5%, @color-vinyl-groove-alt 73%,
@color-vinyl-groove 75%, @color-vinyl-groove-alt 75.5%, @color-vinyl-groove 76%,
@color-vinyl-groove-alt 78%, @color-vinyl-groove 78.5%, @color-vinyl-groove-alt 79%,
@color-vinyl-groove 81%, @color-vinyl-groove-alt 81.5%, @color-vinyl-groove 82%,
@color-vinyl-groove-alt 84%, @color-vinyl-groove 84.5%, @color-vinyl-groove-alt 85%,
@color-vinyl-groove 87%, @color-vinyl-groove-alt 87.5%, @color-vinyl-groove 88%,
@color-vinyl-groove-alt 90%, @color-vinyl-groove 90.5%, @color-vinyl-groove-alt 91%,
@color-vinyl-groove 93%, @color-vinyl-groove-alt 93.5%, @color-vinyl-groove 94%,
@color-vinyl-groove-alt 96%, @color-vinyl-groove 96.5%, @color-vinyl-groove-alt 97%,
@color-vinyl-border 98%, @color-vinyl-groove 100%
);
box-shadow: 0 0 0 4px @color-vinyl-border, 0 0 40px rgba(0, 0, 0, 0.6), inset 0 0 30px rgba(0, 0, 0, 0.3);
animation: disc-spin 20s linear infinite;
animation-play-state: paused;
&.playing {
animation-play-state: running;
}
}
.vinyl-grooves {
.abs-full();
border-radius: 50%;
background: repeating-radial-gradient(
circle at center,
transparent 0px,
transparent 2px,
rgba(255, 255, 255, 0.02) 2.5px,
transparent 3px
);
pointer-events: none;
}
.visualizer-canvas {
.abs-full();
border-radius: 50%;
z-index: 3;
pointer-events: none;
}
.disc-cover-wrapper {
.abs-center();
width: @disc-cover-ratio;
height: @disc-cover-ratio;
border-radius: 50%;
overflow: hidden;
z-index: 2;
border: @disc-cover-border solid @color-vinyl-border;
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.5);
}
.disc-cover {
width: 100%;
height: 100%;
object-fit: cover;
&.disc-cover-empty {
background: radial-gradient(circle, #2a2a2a 30%, @color-vinyl-dark 70%);
}
}
.disc-center-dot {
.abs-center();
width: 16px;
height: 16px;
border-radius: 50%;
background: radial-gradient(circle, #666 0%, @color-vinyl-border 60%, @color-vinyl-groove 100%);
border: 2px solid @color-dim-border;
z-index: 4;
}
@color-dim-border: #555;
@keyframes disc-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// === 歌曲信息 ===
.info-section {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
max-width: @info-max-width;
}
.song-info {
margin-bottom: 20px;
flex-shrink: 0;
min-height: 60px;
}
.song-title {
font-size: 22px;
font-weight: 600;
margin: 0 0 8px 0;
color: @color-white;
.text-ellipsis();
}
.song-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 13px;
color: @color-text-secondary;
}
// === 歌词 ===
.lyrics-area {
flex: 1;
overflow: hidden;
position: relative;
mask-image: linear-gradient(180deg, transparent 0%, #000 8%, #000 82%, transparent 100%);
-webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 8%, #000 82%, transparent 100%);
}
.lyrics-content {
transition: transform 0.6s cubic-bezier(0.25, 0.1, 0.25, 1);
p {
line-height: @lyrics-line-height;
height: @lyrics-line-height;
margin: 0;
padding: 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.35);
transition: all 0.4s ease;
.text-ellipsis();
&.current-line {
color: @color-white;
font-size: 16px;
font-weight: 500;
}
}
}
.no-lyrics {
.flex-center();
height: 100%;
color: @color-text-dim;
font-size: 14px;
}
// === 进度条 ===
.progress-section {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0 6px 0;
flex-shrink: 0;
}
.time-label {
font-size: 12px;
color: @color-text-muted;
min-width: 42px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.progress-bar {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
position: relative;
cursor: pointer;
&:hover .progress-thumb {
opacity: 1;
}
}
.progress-loaded {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: rgba(255, 255, 255, 0.12);
border-radius: 2px;
transition: width 0.3s;
}
.progress-played {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: linear-gradient(90deg, @color-primary, @color-primary-purple);
border-radius: 2px;
}
.progress-thumb {
position: absolute;
right: -5px;
top: -3px;
width: 10px;
height: 10px;
border-radius: 50%;
background: @color-white;
box-shadow: 0 0 6px rgba(100, 200, 255, 0.8);
opacity: 0;
transition: opacity 0.2s;
}
// === 控制按钮 ===
.controls-section {
.flex-center();
gap: 16px;
padding: 6px 0 10px 0;
flex-shrink: 0;
}
.ctrl-btn {
width: @ctrl-btn-size;
height: @ctrl-btn-size;
border: none;
background: transparent;
cursor: pointer;
.flex-center();
border-radius: 50%;
transition: all 0.2s;
padding: 8px;
color: rgba(255, 255, 255, 0.5);
&:hover {
color: @color-white;
background: rgba(255, 255, 255, 0.08);
}
&.active {
color: @color-primary;
}
svg {
width: 100%;
height: 100%;
}
&.ctrl-btn-play {
width: @ctrl-btn-play-size;
height: @ctrl-btn-play-size;
background: rgba(100, 200, 255, 0.15);
color: @color-white;
padding: 12px;
border: 1px solid rgba(100, 200, 255, 0.3);
&:hover {
background: rgba(100, 200, 255, 0.3);
}
}
}
.volume-control {
display: flex;
align-items: center;
gap: 4px;
margin-left: 8px;
}
.volume-slider {
width: 70px;
height: 3px;
accent-color: @color-primary;
cursor: pointer;
}
// === 播放列表 ===
.playlist-section {
flex-shrink: 0;
margin-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.playlist-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
cursor: pointer;
font-size: 13px;
color: @color-text-muted;
&:hover {
color: #ccc;
}
}
.playlist-toggle {
font-size: 12px;
color: @color-text-dim;
}
.playlist-body {
max-height: @playlist-max-height;
overflow-y: auto;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
}
.playlist-item {
display: flex;
align-items: center;
padding: 5px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.06);
}
&.active {
background: rgba(100, 200, 255, 0.12);
color: @color-primary;
.playlist-index {
color: @color-primary;
}
}
}
.playlist-index {
width: 24px;
text-align: center;
color: @color-text-dim;
flex-shrink: 0;
}
.playlist-title {
flex: 1;
.text-ellipsis();
}
.playlist-artist {
color: @color-text-dim;
margin-left: 12px;
flex-shrink: 0;
font-size: 12px;
}
// === 过渡动画 ===
.slide-enter-active, .slide-leave-active {
transition: all 0.3s ease;
max-height: @playlist-max-height;
overflow: hidden;
}
.slide-enter-from, .slide-leave-to {
max-height: 0;
opacity: 0;
}
</style>
+195
View File
@@ -0,0 +1,195 @@
/**
* 音频可视化 - 粒子系统与圆形频谱绘制
*
* 负责唱片周围的圆形音频频谱条绘制, 以及随节奏从唱片边缘飘散的粒子效果。
* 频谱条根据音频频率数据径向排列在唱片外圈, 粒子根据低频/高频能量动态生成。
*/
/** 单个粒子的状态描述 */
export interface Particle {
x: number // 当前 x 坐标
y: number // 当前 y 坐标
vx: number // x 方向速度
vy: number // y 方向速度
life: number // 已存活帧数
maxLife: number // 最大存活帧数
size: number // 粒子半径
r: number // 颜色 R 分量
g: number // 颜色 G 分量
b: number // 颜色 B 分量
}
/** 粒子数量上限, 防止性能问题 */
const MAX_PARTICLES = 200
/**
* 在唱片边缘生成新粒子
*
* 粒子从唱片圆周的随机角度向外发射, 数量由低频能量决定, 速度由高频能量加成。
* 颜色在蓝紫色调范围内随机偏移。
*
* @param particles - 粒子数组(会被直接修改)
* @param centerX - 唱片圆心 x 坐标
* @param centerY - 唱片圆心 y 坐标
* @param discRadius - 唱片半径(像素)
* @param energy - 低频能量(0~1), 决定生成数量和粒子大小
* @param highEnergy - 高频能量(0~1), 决定发射速度和颜色偏移
*/
export function spawnParticles(particles: Particle[], centerX: number, centerY: number, discRadius: number, energy: number, highEnergy: number) {
const count = Math.floor(energy * 4)
for (let i = 0; i < count && particles.length < MAX_PARTICLES; i++) {
const angle = Math.random() * Math.PI * 2
const speed = 0.5 + energy * 2 + highEnergy * 1.5
const hue = Math.random()
particles.push({
x: centerX + Math.cos(angle) * (discRadius + 4),
y: centerY + Math.sin(angle) * (discRadius + 4),
vx: Math.cos(angle) * speed * (0.6 + Math.random() * 0.8),
vy: Math.sin(angle) * speed * (0.6 + Math.random() * 0.8),
life: 1,
maxLife: 40 + Math.random() * 40,
size: 1.5 + energy * 2 + Math.random() * 1.5,
r: Math.floor(100 + hue * 155),
g: Math.floor(180 - hue * 100 + highEnergy * 60),
b: Math.floor(220 + hue * 35),
})
}
}
/**
* 更新粒子物理状态并绘制到 Canvas
*
* 每帧对所有粒子: 移动位置、衰减速度、递增生命周期。
* 生命结束的粒子被移除, 存活的粒子按透明度渐变绘制, 较大粒子额外绘制发光光圈。
*
* @param ctx - Canvas 2D 渲染上下文
* @param particles - 粒子数组(会被直接修改, 移除死亡粒子)
*/
export function updateAndDrawParticles(ctx: CanvasRenderingContext2D, particles: Particle[]) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i]
p.x += p.vx
p.y += p.vy
p.vx *= 0.98
p.vy *= 0.98
p.life += 1
const progress = p.life / p.maxLife
if (progress >= 1) {
particles.splice(i, 1)
continue
}
const alpha = 1 - progress
const size = p.size * (1 - progress * 0.5)
ctx.beginPath()
ctx.arc(p.x, p.y, size, 0, Math.PI * 2)
ctx.fillStyle = `rgba(${p.r}, ${p.g}, ${p.b}, ${alpha * 0.7})`
ctx.fill()
// 发光效果: 在粒子外围画一个更大的半透明圆
if (size > 1.5) {
ctx.beginPath()
ctx.arc(p.x, p.y, size * 2, 0, Math.PI * 2)
ctx.fillStyle = `rgba(${p.r}, ${p.g}, ${p.b}, ${alpha * 0.15})`
ctx.fill()
}
}
}
/** drawCircularSpectrum 的入参 */
export interface SpectrumParams {
ctx: CanvasRenderingContext2D
width: number // Canvas 宽度
height: number // Canvas 高度
dataArray: Uint8Array<ArrayBuffer> // Web Audio API 频率数据
}
/** 频率能量分析结果 */
export interface EnergyResult {
lowEnergy: number // 低频段能量(0~1), 对应鼓点/贝斯
highEnergy: number // 高频段能量(0~1), 对应人声/镲片
}
/**
* 从频率数据中计算低频和高频的平均能量
*
* 将频率数据分为低频段(前25%)和高频段(后40%), 分别求归一化均值。
* 用于驱动粒子系统的生成参数。
*
* @param dataArray - FFT 频率数据(0~255)
* @returns 低频和高频的归一化能量值
*/
function computeFrequencyEnergy(dataArray: Uint8Array<ArrayBuffer>): EnergyResult {
const barCount = dataArray.length
let lowSum = 0, highSum = 0
const lowEnd = Math.floor(barCount * 0.25)
const highStart = Math.floor(barCount * 0.6)
for (let i = 0; i < lowEnd; i++) lowSum += dataArray[i]
for (let i = highStart; i < barCount; i++) highSum += dataArray[i]
return {
lowEnergy: lowSum / (lowEnd * 255),
highEnergy: highSum / ((barCount - highStart) * 255),
}
}
/**
* 绘制圆形音频频谱
*
* 以 Canvas 中心为圆心, 将频率数据的每个频段绘制为一条从唱片边缘向外辐射的线段。
* 线段长度与频率振幅成正比, 颜色沿频率轴从蓝色渐变到紫粉色。
* 绘制完成后自动计算并返回频率能量, 供粒子系统使用。
*
* @param params - 包含 Canvas 上下文、尺寸和频率数据
* @returns 低频/高频能量分析结果
*/
export function drawCircularSpectrum(params: SpectrumParams): EnergyResult {
const { ctx, width, height, dataArray } = params
const centerX = width / 2
const centerY = height / 2
const discRadius = Math.min(width, height) * 0.30
const barCount = dataArray.length
const maxBarLength = Math.min(width, height) * 0.18
for (let i = 0; i < barCount; i++) {
const value = dataArray[i]
const barLength = (value / 255) * maxBarLength
if (barLength < 1) continue
const angle = (i / barCount) * Math.PI * 2 - Math.PI / 2
const startX = centerX + Math.cos(angle) * (discRadius + 2)
const startY = centerY + Math.sin(angle) * (discRadius + 2)
const endX = centerX + Math.cos(angle) * (discRadius + 2 + barLength)
const endY = centerY + Math.sin(angle) * (discRadius + 2 + barLength)
const ratio = i / barCount
const r = Math.floor(100 + ratio * 155)
const g = Math.floor(180 - ratio * 100)
const b = Math.floor(230 + ratio * 25)
const alpha = 0.4 + (value / 255) * 0.6
ctx.beginPath()
ctx.moveTo(startX, startY)
ctx.lineTo(endX, endY)
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`
ctx.lineWidth = Math.max(1.5, (Math.PI * 2 * discRadius) / barCount * 0.7)
ctx.lineCap = 'round'
ctx.stroke()
}
return computeFrequencyEnergy(dataArray)
}
/**
* 将秒数格式化为 MM:SS 格式
*
* @param sec - 秒数
* @returns 格式化后的时间字符串, 如 "03:25"
*/
export function formatTime(sec: number) {
if (isNaN(sec)) return '00:00'
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
-834
View File
@@ -1,834 +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="thumbnailPlaying"
: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 setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import type { PropType } from 'vue'
import Hls from 'hls.js'
import type { MusicPlayerItem, StatType } from '@/model/api/music'
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'
defineOptions({
name: 'APlayer',
})
interface PlayerMusicItem extends MusicPlayerItem {
author?: string
theme?: string
url?: string
}
interface DragOffset {
offsetLeft: number
offsetTop: number
}
interface PlayerExpose {
play: () => Promise<void> | undefined
pause: () => void
toggle: () => void
switch: (index: number) => void
}
const REPEAT = {
NONE: 'none',
MUSIC: 'music',
LIST: 'list',
NO_REPEAT: 'no_repeat',
REPEAT_ONE: 'repeat_one',
REPEAT_ALL: 'repeat_all',
} as const
type RepeatValue = typeof REPEAT[keyof typeof REPEAT]
type NormalizedRepeatValue = typeof REPEAT.NO_REPEAT | typeof REPEAT.REPEAT_ONE | typeof REPEAT.REPEAT_ALL
const MEDIA_EVENTS = [
'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',
] as const
type MediaEventName = typeof MEDIA_EVENTS[number]
let activeMutex: PlayerExpose | null = null
const props = defineProps({
music: {
type: Object as PropType<PlayerMusicItem>,
required: true,
validator: (song: PlayerMusicItem) => Boolean(song?.src),
},
list: {
type: Array as PropType<PlayerMusicItem[]>,
default: () => [],
},
mini: {
type: Boolean,
default: false,
},
showLrc: {
type: Boolean,
default: false,
},
mutex: {
type: Boolean,
default: true,
},
theme: {
type: String,
default: '#41b883',
},
listMaxHeight: String,
listFolded: {
type: Boolean,
default: false,
},
float: {
type: Boolean,
default: false,
},
autoplay: {
type: Boolean,
default: false,
},
muted: {
type: Boolean,
default: false,
},
preload: {
type: String as PropType<HTMLMediaElement['preload']>,
default: 'none',
},
volume: {
type: Number,
default: 0.5,
validator: (value: number) => value >= 0 && value <= 1,
},
shuffle: {
type: Boolean,
default: false,
},
repeat: {
type: String as PropType<RepeatValue>,
default: 'no_repeat',
},
})
const emit = defineEmits([
'update:music',
'update:muted',
'update:volume',
'update:shuffle',
'update:repeat',
'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',
] as const)
const audio = ref<HTMLAudioElement | null>(null)
const internalMusic = ref<PlayerMusicItem>(props.music)
const isPlaying = ref(false)
const isSeeking = ref(false)
const wasPlayingBeforeSeeking = ref(false)
const isMobile = /mobile/i.test(window.navigator.userAgent)
const playStat = reactive<StatType>({
duration: 0,
loadedTime: 0,
playedTime: 0,
})
const showList = ref(!props.listFolded)
const audioPlayPromise = ref<Promise<void>>(Promise.resolve())
const rejectPlayPromise = ref<((reason?: unknown) => void) | null>(null)
const floatOriginX = ref(0)
const floatOriginY = ref(0)
const floatOffsetLeft = ref(0)
const floatOffsetTop = ref(0)
const selfAdaptingTheme = ref<string | null>(null)
const internalMuted = ref(props.muted)
const internalVolume = ref(props.volume)
const isLoading = ref(false)
const internalShuffle = ref(props.shuffle)
const internalRepeat = ref<RepeatValue>(props.repeat)
const shuffledList = ref<PlayerMusicItem[]>([])
const hls = ref<Hls | null>(null)
const currentMusic = computed({
get: () => internalMusic.value,
set: (value: PlayerMusicItem) => {
emit('update:music', value)
internalMusic.value = value
},
})
const currentTheme = computed(() => selfAdaptingTheme.value || currentMusic.value.theme || props.theme)
const isFloatMode = computed(() => props.float && !isMobile)
const thumbnailPlaying = computed(() => isPlaying.value || (isSeeking.value && wasPlayingBeforeSeeking.value))
const floatStyleObj = computed(() => {
return {
transform: `translate(${floatOffsetLeft.value}px, ${floatOffsetTop.value}px)`,
webkitTransform: `translate(${floatOffsetLeft.value}px, ${floatOffsetTop.value}px)`,
}
})
const playIndex = computed({
get: () => shuffledList.value.indexOf(currentMusic.value),
set: (value: number) => {
if (!shuffledList.value.length) return
const normalizedIndex = value % shuffledList.value.length
currentMusic.value = shuffledList.value[normalizedIndex < 0 ? normalizedIndex + shuffledList.value.length : normalizedIndex]
},
})
const isAudioMuted = computed({
get: () => internalMuted.value,
set: (value: boolean) => {
emit('update:muted', value)
internalMuted.value = value
},
})
const audioVolume = computed({
get: () => internalVolume.value,
set: (value: number) => {
emit('update:volume', value)
internalVolume.value = value
},
})
const shouldShuffle = computed({
get: () => internalShuffle.value,
set: (value: boolean) => {
emit('update:shuffle', value)
internalShuffle.value = value
},
})
const repeatMode = computed<NormalizedRepeatValue>({
get: () => {
switch (internalRepeat.value) {
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: (value) => {
emit('update:repeat', value)
internalRepeat.value = value
},
})
function onDragBegin() {
floatOriginX.value = floatOffsetLeft.value
floatOriginY.value = floatOffsetTop.value
}
function onDragAround({ offsetLeft, offsetTop }: DragOffset) {
floatOffsetLeft.value = floatOriginX.value + offsetLeft
floatOffsetTop.value = floatOriginY.value + offsetTop
}
function setNextMode() {
if (repeatMode.value === REPEAT.REPEAT_ALL) {
repeatMode.value = REPEAT.REPEAT_ONE
} else if (repeatMode.value === REPEAT.REPEAT_ONE) {
repeatMode.value = REPEAT.NO_REPEAT
} else {
repeatMode.value = REPEAT.REPEAT_ALL
}
}
function thenPlay() {
nextTick(() => {
void play()
})
}
function toggle() {
const media = audio.value
if (!media) return
if (!media.paused) {
pause()
} else {
void play()
}
}
function play() {
const media = audio.value
if (!media) return
if (props.mutex) {
if (activeMutex && activeMutex !== playerExpose) {
activeMutex.pause()
}
activeMutex = playerExpose
}
const mediaPlayPromise = media.play()
if (mediaPlayPromise) {
audioPlayPromise.value = new Promise<void>((resolve, reject) => {
rejectPlayPromise.value = reject
mediaPlayPromise.then(() => {
rejectPlayPromise.value = null
resolve()
}).catch((error) => {
warn(String(error))
})
})
return audioPlayPromise.value
}
}
function pause() {
const media = audio.value
if (!media) return
audioPlayPromise.value
.then(() => {
media.pause()
})
.catch(() => {
media.pause()
})
if (rejectPlayPromise.value) {
rejectPlayPromise.value()
rejectPlayPromise.value = null
}
}
function switchTrack(index: number) {
playIndex.value = index
thenPlay()
}
function onProgressDragBegin(value: number) {
const media = audio.value
if (!media) return
wasPlayingBeforeSeeking.value = isPlaying.value
isSeeking.value = true
pause()
if (!isNaN(media.duration)) {
media.currentTime = media.duration * value
}
}
function onProgressDragging(value: number) {
const media = audio.value
if (!media) return
if (isNaN(media.duration)) {
playStat.playedTime = 0
} else {
media.currentTime = media.duration * value
}
}
function onProgressDragEnd() {
if (wasPlayingBeforeSeeking.value) {
thenPlay()
} else {
isSeeking.value = false
wasPlayingBeforeSeeking.value = false
}
}
function toggleMute() {
const media = audio.value
if (!media) return
setAudioMuted(!media.muted)
}
function setAudioMuted(value: boolean) {
const media = audio.value
if (!media) return
media.muted = value
}
function setAudioVolume(value: number) {
const media = audio.value
if (!media) return
media.volume = value
if (value > 0) {
setAudioMuted(false)
}
}
function getShuffledList() {
if (!props.list.length) {
return [internalMusic.value]
}
const unshuffled = [...props.list]
if (!internalShuffle.value || unshuffled.length <= 1) {
return unshuffled
}
let indexOfCurrentMusic = unshuffled.indexOf(internalMusic.value)
if (unshuffled.length === 2 && indexOfCurrentMusic !== -1) {
if (indexOfCurrentMusic === 0) {
return unshuffled
}
return [internalMusic.value, unshuffled[0]]
}
for (let index = unshuffled.length - 1; index > 0; index--) {
const randomIndex = Math.floor(Math.random() * (index + 1))
const tmp = unshuffled[index]
unshuffled[index] = unshuffled[randomIndex]
unshuffled[randomIndex] = tmp
}
if (indexOfCurrentMusic !== -1) {
indexOfCurrentMusic = unshuffled.indexOf(internalMusic.value)
if (indexOfCurrentMusic !== 0) {
[unshuffled[0], unshuffled[indexOfCurrentMusic]] = [unshuffled[indexOfCurrentMusic], unshuffled[0]]
}
}
return unshuffled
}
function onSelectSong(song: PlayerMusicItem) {
if (currentMusic.value === song) {
toggle()
} else {
currentMusic.value = song
thenPlay()
}
}
function onAudioPlay() {
isPlaying.value = true
isSeeking.value = false
wasPlayingBeforeSeeking.value = false
}
function onAudioPause() {
isPlaying.value = false
if (!isSeeking.value) {
wasPlayingBeforeSeeking.value = false
}
}
function onAudioWaiting() {
isLoading.value = true
}
function onAudioCanplay() {
isLoading.value = false
}
function onAudioDurationChange() {
const media = audio.value
if (!media) return
if (media.duration !== 1) {
playStat.duration = media.duration
}
}
function onAudioProgress() {
const media = audio.value
if (!media) return
if (media.buffered.length) {
playStat.loadedTime = media.buffered.end(media.buffered.length - 1)
} else {
playStat.loadedTime = 0
}
}
function onAudioTimeUpdate() {
if (!audio.value) return
playStat.playedTime = audio.value.currentTime
}
function onAudioSeeking() {
if (!audio.value) return
playStat.playedTime = audio.value.currentTime
}
function onAudioSeeked() {
if (!audio.value) return
playStat.playedTime = audio.value.currentTime
}
function onAudioVolumeChange() {
const media = audio.value
if (!media) return
audioVolume.value = media.volume
isAudioMuted.value = media.muted
}
function onAudioEnded() {
const media = audio.value
if (repeatMode.value === REPEAT.REPEAT_ALL) {
if (shouldShuffle.value && playIndex.value === shuffledList.value.length - 1) {
shuffledList.value = getShuffledList()
}
playIndex.value += 1
thenPlay()
} else if (repeatMode.value === REPEAT.REPEAT_ONE) {
thenPlay()
} else {
playIndex.value += 1
if (playIndex.value !== 0) {
thenPlay()
} else if (shuffledList.value.length === 1 && media) {
media.currentTime = 0
}
}
}
function initAudio() {
const media = audio.value
if (!media) return
media.muted = props.muted
media.preload = props.preload
media.volume = props.volume
MEDIA_EVENTS.forEach((eventName) => {
media.addEventListener(eventName, (event) => emitMediaEvent(eventName, event))
})
media.addEventListener('play', onAudioPlay)
media.addEventListener('pause', onAudioPause)
media.addEventListener('abort', onAudioPause)
media.addEventListener('waiting', onAudioWaiting)
media.addEventListener('canplay', onAudioCanplay)
media.addEventListener('progress', onAudioProgress)
media.addEventListener('durationchange', onAudioDurationChange)
media.addEventListener('seeking', onAudioSeeking)
media.addEventListener('seeked', onAudioSeeked)
media.addEventListener('timeupdate', onAudioTimeUpdate)
media.addEventListener('volumechange', onAudioVolumeChange)
media.addEventListener('ended', onAudioEnded)
media.src = currentMusic.value.src || currentMusic.value.url || ''
}
function setSelfAdaptingTheme(color: string | null) {
selfAdaptingTheme.value = color
}
function emitMediaEvent<T extends MediaEventName>(eventName: T, event: Event) {
emit(eventName, event)
}
watch(() => props.music, (music) => {
internalMusic.value = music
})
watch(currentMusic, (music) => {
const media = audio.value
const src = music.src || music.url
if (!media || !src) return
if (/\.m3u8(?=(#|\?|$))/.test(src)) {
if (media.canPlayType('application/x-mpegURL') || media.canPlayType('application/vnd.apple.mpegURL')) {
media.src = src
} else if (Hls.isSupported()) {
if (!hls.value) {
hls.value = new Hls()
}
hls.value.loadSource(src)
hls.value.attachMedia(media)
} else {
warn('HLS is not supported on your browser')
media.src = src
}
} else {
media.src = src
}
})
watch(isAudioMuted, (value) => {
if (!audio.value) return
audio.value.muted = value
})
watch(() => props.preload, (value) => {
if (!audio.value) return
audio.value.preload = value
})
watch(audioVolume, (value) => {
if (!audio.value) return
audio.value.volume = value
})
watch(() => props.muted, (value) => {
internalMuted.value = value
})
watch(() => props.volume, (value) => {
internalVolume.value = value
})
watch(() => props.shuffle, (value) => {
internalShuffle.value = value
})
watch(() => props.repeat, (value) => {
internalRepeat.value = value
})
shuffledList.value = getShuffledList()
onMounted(() => {
initAudio()
if (props.autoplay) {
void play()
}
})
const playerExpose: PlayerExpose = {
play,
pause,
toggle,
switch: switchTrack,
}
defineExpose({
audio,
currentMusic,
isPlaying,
playStat,
...playerExpose,
})
onBeforeUnmount(() => {
if (activeMutex === playerExpose) {
activeMutex = null
}
hls.value?.destroy()
})
</script>
<style lang="less" scoped>
@import "./less/variables";
.aplayer {
font-family: Arial, Helvetica, sans-serif;
color: #000;
background-color: #fff;
margin: 5px;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.07), 0 1px 5px 0 rgba(0, 0, 0, 0.1);
border-radius: 2px;
overflow: hidden;
user-select: none;
line-height: initial;
* {
box-sizing: content-box;
}
.aplayer-lrc-content {
display: none;
}
.aplayer-body {
display: flex;
position: relative;
.aplayer-info {
flex-grow: 1;
display: flex;
flex-direction: column;
text-align: start;
padding: 14px 7px 0 10px;
height: @aplayer-height;
box-sizing: border-box;
overflow: hidden;
.aplayer-music {
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-left: 5px;
user-select: text;
cursor: default;
padding-bottom: 2px;
.aplayer-title {
font-size: 14px;
}
.aplayer-author {
font-size: 12px;
color: #666;
margin-left: 10px;
}
}
.aplayer-lrc {
z-index: 0;
}
}
}
// Mini mode
&.aplayer-narrow {
width: @aplayer-height;
}
&.aplayer-withlrc {
.aplayer-body {
.aplayer-pic {
height: @aplayer-height-lrc;
width: @aplayer-height-lrc;
}
.aplayer-info {
height: @aplayer-height-lrc;
}
.aplayer-info {
padding: 10px 7px 0 7px;
}
}
}
&.aplayer-withlist {
.aplayer-body {
.aplayer-info {
border-bottom: 1px solid #e9e9e9;
}
.aplayer-controller .aplayer-time .aplayer-icon.aplayer-icon-menu {
display: block;
}
}
}
/* floating player on top */
position: relative;
&.aplayer-float {
z-index: 1;
}
}
@keyframes aplayer-roll {
0% {
left: 0
}
100% {
left: -100%
}
}
</style>
+150
View File
@@ -0,0 +1,150 @@
<template>
<div>
<div class="hero-actions">
<el-button type="primary" @click="startCaptcha" plain>开始验证</el-button>
<el-button @click="reloadCaptcha" :disabled="!captchaActive">刷新</el-button>
<el-button @click="destroyCaptcha" :disabled="!captchaActive" plain>销毁</el-button>
</div>
<div class="sandbox-grid">
<el-card shadow="never">
<template #header>验证码类型</template>
<p class="type-hint">
可同时勾选多种类型不选时将按随机类型请求
</p>
<el-checkbox-group v-model="selectedTypes" size="large">
<el-checkbox-button v-for="item in captchaTypeOptions" :key="item.value" :value="item.value">
{{ item.label }}
</el-checkbox-button>
</el-checkbox-group>
<div class="type-actions">
<el-button link type="primary" @click="clearTypes" :disabled="selectedTypes.length === 0">恢复随机</el-button>
</div>
</el-card>
<el-card class="preview-card" shadow="never">
<template #header>验证码预览</template>
<CaptchaPanel
ref="captchaRef"
class="captcha-box"
mode="validate"
:types="selectedTypes"
@close="handleCaptchaClose"
@state-change="handleCaptchaStateChange"
@valid-fail="handleValidFail"
@valid-success="handleValidSuccess"
/>
</el-card>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import CaptchaPanel from '@/components/CaptchaPanel.vue'
type CaptchaType = 'SLIDER' | 'ROTATE' | 'CONCAT' | 'WORD_IMAGE_CLICK'
type CaptchaPanelRef = {
destroy: () => void
init: () => Promise<void>
reload: () => void
}
const captchaTypeOptions: Array<{ label: string, value: CaptchaType }> = [
{ label: '滑块拼图', value: 'SLIDER' },
{ label: '旋转', value: 'ROTATE' },
{ label: '拼接', value: 'CONCAT' },
{ label: '汉字点选', value: 'WORD_IMAGE_CLICK' },
]
const captchaRef = ref<CaptchaPanelRef | null>(null)
const captchaActive = ref(false)
const selectedTypes = ref<CaptchaType[]>([])
async function startCaptcha() {
console.info('[captcha sandbox] init captcha', {
types: selectedTypes.value,
mode: 'validate'
})
await captchaRef.value?.init()
}
function clearTypes() {
selectedTypes.value = []
}
function reloadCaptcha() {
if (!captchaActive.value) return
console.info('[captcha sandbox] manual reload')
captchaRef.value?.reload()
}
function destroyCaptcha(showMessage = true) {
if (!captchaActive.value) return
captchaRef.value?.destroy()
if (showMessage) {
console.info('[captcha sandbox] destroy captcha instance')
}
}
function handleCaptchaClose() {
console.info('[captcha sandbox] close captcha window')
}
function handleCaptchaStateChange(active: boolean) {
captchaActive.value = active
}
function handleValidSuccess(res: any) {
console.log('[captcha sandbox] valid success', res)
captchaRef.value?.destroy()
}
function handleValidFail(res: any) {
console.warn('[captcha sandbox] valid fail', res)
captchaRef.value?.reload()
}
onBeforeUnmount(() => captchaRef.value?.destroy())
</script>
<style lang="less" scoped>
.hero-actions {
margin-bottom: 24px;
}
.type-hint {
margin: 0 0 16px;
color: #606266;
line-height: 1.6;
}
.type-actions {
margin-top: 12px;
}
.sandbox-grid {
display: flex;
gap: 20px;
.el-card {
flex: 1;
&.preview-card {
flex: 0 0 420px;
}
}
}
.captcha-box {
min-height: 350px;
display: flex;
align-items: center;
justify-content: center;
}
</style>
+3
View File
@@ -75,6 +75,9 @@ export default defineConfig(({ mode }) => {
if (id.includes('/src/views/api/')) { if (id.includes('/src/views/api/')) {
return 'api' return 'api'
} }
if (id.includes('/src/views/debug/')) {
return 'debug'
}
} }
} }
} }
+139 -106
View File
@@ -42,11 +42,6 @@
resolved "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz" resolved "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz"
integrity sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A== integrity sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==
"@esbuild/win32-x64@0.21.5":
version "0.21.5"
resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz"
integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==
"@floating-ui/core@^1.0.5": "@floating-ui/core@^1.0.5":
version "1.1.0" version "1.1.0"
resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.1.0.tgz" resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.1.0.tgz"
@@ -114,11 +109,26 @@
"@nodelib/fs.scandir" "2.1.5" "@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0" fastq "^1.6.0"
"@oxc-project/types@=0.130.0":
version "0.130.0"
resolved "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz"
integrity sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==
"@popperjs/core@npm:@sxzz/popperjs-es@^2.11.7": "@popperjs/core@npm:@sxzz/popperjs-es@^2.11.7":
version "2.11.7" version "2.11.7"
resolved "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz" resolved "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz"
integrity sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ== integrity sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==
"@rolldown/binding-darwin-arm64@1.0.1":
version "1.0.1"
resolved "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz"
integrity sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==
"@rolldown/pluginutils@^1.0.0", "@rolldown/pluginutils@^1.0.1":
version "1.0.1"
resolved "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz"
integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==
"@rollup/pluginutils@^5.0.2": "@rollup/pluginutils@^5.0.2":
version "5.0.2" version "5.0.2"
resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz" resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz"
@@ -128,17 +138,7 @@
estree-walker "^2.0.2" estree-walker "^2.0.2"
picomatch "^2.3.1" picomatch "^2.3.1"
"@rollup/rollup-win32-x64-gnu@4.60.2": "@types/estree@^1.0.0":
version "4.60.2"
resolved "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz"
integrity sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==
"@rollup/rollup-win32-x64-msvc@4.60.2":
version "4.60.2"
resolved "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz"
integrity sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==
"@types/estree@^1.0.0", "@types/estree@1.0.8":
version "1.0.8" version "1.0.8"
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
@@ -155,7 +155,7 @@
resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz" resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz"
integrity sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ== integrity sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==
"@types/node@^18.0.0 || >=20.0.0", "@types/node@^25.6.0": "@types/node@^20.19.0 || >=22.12.0", "@types/node@^25.6.0":
version "25.6.0" version "25.6.0"
resolved "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz" resolved "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz"
integrity sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ== integrity sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==
@@ -167,10 +167,12 @@
resolved "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz" resolved "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz"
integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow== integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==
"@vitejs/plugin-vue@^5.2.4": "@vitejs/plugin-vue@^6.0.7":
version "5.2.4" version "6.0.7"
resolved "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz" resolved "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz"
integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA== integrity sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==
dependencies:
"@rolldown/pluginutils" "^1.0.1"
"@vue/compiler-core@3.5.30": "@vue/compiler-core@3.5.30":
version "3.5.30" version "3.5.30"
@@ -284,6 +286,13 @@ acorn@^8.15.0, acorn@^8.8.1:
resolved "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz" resolved "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz"
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
agent-base@6:
version "6.0.2"
resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz"
integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
dependencies:
debug "4"
anymatch@~3.1.2: anymatch@~3.1.2:
version "3.1.3" version "3.1.3"
resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz" resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz"
@@ -303,12 +312,13 @@ asynckit@^0.4.0:
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
axios@^1.13.6: axios@^1.13.6:
version "1.15.0" version "1.16.1"
resolved "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz" resolved "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz"
integrity sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q== integrity sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==
dependencies: dependencies:
follow-redirects "^1.15.11" follow-redirects "^1.16.0"
form-data "^4.0.5" form-data "^4.0.5"
https-proxy-agent "^5.0.1"
proxy-from-env "^2.1.0" proxy-from-env "^2.1.0"
balanced-match@^1.0.0: balanced-match@^1.0.0:
@@ -399,7 +409,7 @@ debug@^3.2.6:
dependencies: dependencies:
ms "^2.1.1" ms "^2.1.1"
debug@^4.3.4: debug@^4.3.4, debug@4:
version "4.4.3" version "4.4.3"
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@@ -411,6 +421,11 @@ delayed-stream@~1.0.0:
resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
detect-libc@^2.0.3:
version "2.1.2"
resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz"
integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
dunder-proto@^1.0.1: dunder-proto@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz" resolved "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz"
@@ -487,35 +502,6 @@ es-set-tostringtag@^2.1.0:
has-tostringtag "^1.0.2" has-tostringtag "^1.0.2"
hasown "^2.0.2" hasown "^2.0.2"
esbuild@^0.21.3:
version "0.21.5"
resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz"
integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==
optionalDependencies:
"@esbuild/aix-ppc64" "0.21.5"
"@esbuild/android-arm" "0.21.5"
"@esbuild/android-arm64" "0.21.5"
"@esbuild/android-x64" "0.21.5"
"@esbuild/darwin-arm64" "0.21.5"
"@esbuild/darwin-x64" "0.21.5"
"@esbuild/freebsd-arm64" "0.21.5"
"@esbuild/freebsd-x64" "0.21.5"
"@esbuild/linux-arm" "0.21.5"
"@esbuild/linux-arm64" "0.21.5"
"@esbuild/linux-ia32" "0.21.5"
"@esbuild/linux-loong64" "0.21.5"
"@esbuild/linux-mips64el" "0.21.5"
"@esbuild/linux-ppc64" "0.21.5"
"@esbuild/linux-riscv64" "0.21.5"
"@esbuild/linux-s390x" "0.21.5"
"@esbuild/linux-x64" "0.21.5"
"@esbuild/netbsd-x64" "0.21.5"
"@esbuild/openbsd-x64" "0.21.5"
"@esbuild/sunos-x64" "0.21.5"
"@esbuild/win32-arm64" "0.21.5"
"@esbuild/win32-ia32" "0.21.5"
"@esbuild/win32-x64" "0.21.5"
escape-string-regexp@^5.0.0: escape-string-regexp@^5.0.0:
version "5.0.0" version "5.0.0"
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz" resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz"
@@ -544,6 +530,11 @@ fastq@^1.6.0:
dependencies: dependencies:
reusify "^1.0.4" reusify "^1.0.4"
fdir@^6.5.0:
version "6.5.0"
resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
fill-range@^7.1.1: fill-range@^7.1.1:
version "7.1.1" version "7.1.1"
resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz" resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz"
@@ -551,10 +542,10 @@ fill-range@^7.1.1:
dependencies: dependencies:
to-regex-range "^5.0.1" to-regex-range "^5.0.1"
follow-redirects@^1.15.11: follow-redirects@^1.16.0:
version "1.15.11" version "1.16.0"
resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz" resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz"
integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
form-data@^4.0.5: form-data@^4.0.5:
version "4.0.5" version "4.0.5"
@@ -567,6 +558,11 @@ form-data@^4.0.5:
hasown "^2.0.2" hasown "^2.0.2"
mime-types "^2.1.12" mime-types "^2.1.12"
fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2: function-bind@^1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
@@ -637,6 +633,14 @@ hls.js@^1.6.15:
resolved "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz" resolved "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz"
integrity sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA== integrity sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==
https-proxy-agent@^5.0.1:
version "5.0.1"
resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz"
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
dependencies:
agent-base "6"
debug "4"
hyperdown@^2.4.31: hyperdown@^2.4.31:
version "2.4.31" version "2.4.31"
resolved "https://registry.npmjs.org/hyperdown/-/hyperdown-2.4.31.tgz" resolved "https://registry.npmjs.org/hyperdown/-/hyperdown-2.4.31.tgz"
@@ -695,7 +699,7 @@ jsonc-parser@^3.2.0:
resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz" resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz"
integrity sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w== integrity sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==
less@*, less@^4.6.4: less@^4.0.0, less@^4.6.4:
version "4.6.4" version "4.6.4"
resolved "https://registry.npmjs.org/less/-/less-4.6.4.tgz" resolved "https://registry.npmjs.org/less/-/less-4.6.4.tgz"
integrity sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg== integrity sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==
@@ -711,6 +715,30 @@ less@*, less@^4.6.4:
needle "^3.1.0" needle "^3.1.0"
source-map "~0.6.0" source-map "~0.6.0"
lightningcss-darwin-arm64@1.32.0:
version "1.32.0"
resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz"
integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==
lightningcss@^1.32.0:
version "1.32.0"
resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz"
integrity sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==
dependencies:
detect-libc "^2.0.3"
optionalDependencies:
lightningcss-android-arm64 "1.32.0"
lightningcss-darwin-arm64 "1.32.0"
lightningcss-darwin-x64 "1.32.0"
lightningcss-freebsd-x64 "1.32.0"
lightningcss-linux-arm-gnueabihf "1.32.0"
lightningcss-linux-arm64-gnu "1.32.0"
lightningcss-linux-arm64-musl "1.32.0"
lightningcss-linux-x64-gnu "1.32.0"
lightningcss-linux-x64-musl "1.32.0"
lightningcss-win32-arm64-msvc "1.32.0"
lightningcss-win32-x64-msvc "1.32.0"
local-pkg@^0.4.2: local-pkg@^0.4.2:
version "0.4.2" version "0.4.2"
resolved "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.2.tgz" resolved "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.2.tgz"
@@ -869,6 +897,11 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz"
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
"picomatch@^3 || ^4", picomatch@^4.0.4:
version "4.0.4"
resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
pify@^4.0.1: pify@^4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz" resolved "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz"
@@ -883,10 +916,10 @@ pkg-types@^1.0.1:
mlly "^1.0.0" mlly "^1.0.0"
pathe "^1.0.0" pathe "^1.0.0"
postcss@>=8.4.31: postcss@^8.5.14, postcss@^8.5.8:
version "8.5.9" version "8.5.14"
resolved "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz" resolved "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz"
integrity sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw== integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==
dependencies: dependencies:
nanoid "^3.3.11" nanoid "^3.3.11"
picocolors "^1.1.1" picocolors "^1.1.1"
@@ -933,6 +966,30 @@ reusify@^1.0.4:
resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz" resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz"
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
rolldown@1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz"
integrity sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==
dependencies:
"@oxc-project/types" "=0.130.0"
"@rolldown/pluginutils" "^1.0.0"
optionalDependencies:
"@rolldown/binding-android-arm64" "1.0.1"
"@rolldown/binding-darwin-arm64" "1.0.1"
"@rolldown/binding-darwin-x64" "1.0.1"
"@rolldown/binding-freebsd-x64" "1.0.1"
"@rolldown/binding-linux-arm-gnueabihf" "1.0.1"
"@rolldown/binding-linux-arm64-gnu" "1.0.1"
"@rolldown/binding-linux-arm64-musl" "1.0.1"
"@rolldown/binding-linux-ppc64-gnu" "1.0.1"
"@rolldown/binding-linux-s390x-gnu" "1.0.1"
"@rolldown/binding-linux-x64-gnu" "1.0.1"
"@rolldown/binding-linux-x64-musl" "1.0.1"
"@rolldown/binding-openharmony-arm64" "1.0.1"
"@rolldown/binding-wasm32-wasi" "1.0.1"
"@rolldown/binding-win32-arm64-msvc" "1.0.1"
"@rolldown/binding-win32-x64-msvc" "1.0.1"
rollup@^1.20.0||^2.0.0||^3.0.0: rollup@^1.20.0||^2.0.0||^3.0.0:
version "3.30.0" version "3.30.0"
resolved "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz" resolved "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz"
@@ -940,40 +997,6 @@ rollup@^1.20.0||^2.0.0||^3.0.0:
optionalDependencies: optionalDependencies:
fsevents "~2.3.2" fsevents "~2.3.2"
rollup@^4.20.0:
version "4.60.2"
resolved "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz"
integrity sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==
dependencies:
"@types/estree" "1.0.8"
optionalDependencies:
"@rollup/rollup-android-arm-eabi" "4.60.2"
"@rollup/rollup-android-arm64" "4.60.2"
"@rollup/rollup-darwin-arm64" "4.60.2"
"@rollup/rollup-darwin-x64" "4.60.2"
"@rollup/rollup-freebsd-arm64" "4.60.2"
"@rollup/rollup-freebsd-x64" "4.60.2"
"@rollup/rollup-linux-arm-gnueabihf" "4.60.2"
"@rollup/rollup-linux-arm-musleabihf" "4.60.2"
"@rollup/rollup-linux-arm64-gnu" "4.60.2"
"@rollup/rollup-linux-arm64-musl" "4.60.2"
"@rollup/rollup-linux-loong64-gnu" "4.60.2"
"@rollup/rollup-linux-loong64-musl" "4.60.2"
"@rollup/rollup-linux-ppc64-gnu" "4.60.2"
"@rollup/rollup-linux-ppc64-musl" "4.60.2"
"@rollup/rollup-linux-riscv64-gnu" "4.60.2"
"@rollup/rollup-linux-riscv64-musl" "4.60.2"
"@rollup/rollup-linux-s390x-gnu" "4.60.2"
"@rollup/rollup-linux-x64-gnu" "4.60.2"
"@rollup/rollup-linux-x64-musl" "4.60.2"
"@rollup/rollup-openbsd-x64" "4.60.2"
"@rollup/rollup-openharmony-arm64" "4.60.2"
"@rollup/rollup-win32-arm64-msvc" "4.60.2"
"@rollup/rollup-win32-ia32-msvc" "4.60.2"
"@rollup/rollup-win32-x64-gnu" "4.60.2"
"@rollup/rollup-win32-x64-msvc" "4.60.2"
fsevents "~2.3.2"
run-parallel@^1.1.9: run-parallel@^1.1.9:
version "1.2.0" version "1.2.0"
resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz" resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz"
@@ -1031,7 +1054,7 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz" resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
terser@^5.4.0: terser@^5.16.0:
version "5.46.1" version "5.46.1"
resolved "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz" resolved "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz"
integrity sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ== integrity sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==
@@ -1041,6 +1064,14 @@ terser@^5.4.0:
commander "^2.20.0" commander "^2.20.0"
source-map-support "~0.5.20" source-map-support "~0.5.20"
tinyglobby@^0.2.16:
version "0.2.16"
resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz"
integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==
dependencies:
fdir "^6.5.0"
picomatch "^4.0.4"
to-regex-range@^5.0.1: to-regex-range@^5.0.1:
version "5.0.1" version "5.0.1"
resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz" resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz"
@@ -1123,14 +1154,16 @@ unplugin@^1.0.1:
webpack-sources "^3.2.3" webpack-sources "^3.2.3"
webpack-virtual-modules "^0.5.0" webpack-virtual-modules "^0.5.0"
"vite@^5.0.0 || ^6.0.0", vite@^5.4.19: "vite@^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", vite@^8.0.13:
version "5.4.21" version "8.0.13"
resolved "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz" resolved "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz"
integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw== integrity sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==
dependencies: dependencies:
esbuild "^0.21.3" lightningcss "^1.32.0"
postcss "^8.4.43" picomatch "^4.0.4"
rollup "^4.20.0" postcss "^8.5.14"
rolldown "1.0.1"
tinyglobby "^0.2.16"
optionalDependencies: optionalDependencies:
fsevents "~2.3.3" fsevents "~2.3.3"