Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fd79a7b5a
|
||
|
|
0ecb6010e2
|
||
|
|
4305eb3fe6
|
||
|
|
263f8ef8d3
|
||
|
|
6ccbec1f82
|
||
|
|
5b2d25fe78
|
||
|
|
8fc5ef572c
|
||
|
|
10b19a3311
|
||
|
|
4533a754c9
|
||
|
|
6d2e522818
|
||
|
|
25e80d73ae
|
||
|
|
f9b0b05c7e
|
||
|
|
0aef7d0ad5
|
||
|
|
e581ced7e8
|
||
|
|
78bed71168
|
||
|
|
02e12f6e3d
|
||
|
|
662b22576d
|
||
|
|
e0f6175fed
|
||
|
|
4c2d41b699
|
||
|
|
ee88c8abe9
|
||
|
|
3e331f58ce
|
||
|
|
a22f9e5ff4
|
||
|
|
aac55b6a39
|
||
|
|
895a902d96
|
@@ -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']
|
||||||
|
|||||||
@@ -16,21 +16,18 @@
|
|||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"pretty-bytes": "^5.6.0",
|
"pretty-bytes": "^5.6.0",
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30",
|
||||||
|
"vue-i18n": "^11.4.4",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
"vuex": "^4.1.0"
|
"vuex": "^4.1.0"
|
||||||
},
|
},
|
||||||
"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%",
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app" >
|
<el-config-provider :locale="epLocale" >
|
||||||
<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">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import zhTw from 'element-plus/es/locale/lang/zh-tw'
|
||||||
|
import en from 'element-plus/es/locale/lang/en'
|
||||||
|
|
||||||
const locale = zhCn
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
const epLocaleMap: Record<string, any> = {
|
||||||
|
'zh-CN': zhCn,
|
||||||
|
'zh-TW': zhTw,
|
||||||
|
'en': en,
|
||||||
|
}
|
||||||
|
const epLocale = computed(() => epLocaleMap[locale.value] || zhCn)
|
||||||
</script>
|
</script>
|
||||||
<style lang="less">
|
<style lang="less">
|
||||||
@import url('./static/common.less');
|
@import url('./static/common.less');
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="captchaBoxRef"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import i18n from '@/i18n'
|
||||||
|
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(i18n.global.t('captcha.initFailed')))
|
||||||
|
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(i18n.global.t('captcha.unknownMode', { mode: props.mode })))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaInstance.value = createTianAiCaptcha(config, props.styleConfig)
|
||||||
|
captchaInstance.value.init()
|
||||||
|
setActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
captchaInstance.value?.reloadCaptcha()
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
if (captchaInstance.value) {
|
||||||
|
captchaInstance.value.destroyWindow()
|
||||||
|
captchaInstance.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
setActive(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(nextValue: boolean) {
|
||||||
|
if (active.value === nextValue) return
|
||||||
|
active.value = nextValue
|
||||||
|
emit('state-change', nextValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
destroy,
|
||||||
|
init,
|
||||||
|
reload,
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildCaptchaApiUrl(path: string) {
|
||||||
|
const normalizedBase = (import.meta.env.VITE_APP_API_BASE || '').replace(/\/+$/, '')
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||||
|
return `${normalizedBase}${normalizedPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => destroy())
|
||||||
|
</script>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
title: string
|
titleKey: string
|
||||||
path: string
|
path: string
|
||||||
permission: string // 需要的权限标识(list类型)
|
permission: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MenuGroup {
|
export interface MenuGroup {
|
||||||
name: string
|
name: string
|
||||||
title: string
|
titleKey: string
|
||||||
icon: string
|
icon: string
|
||||||
child: MenuItem[]
|
child: MenuItem[]
|
||||||
}
|
}
|
||||||
@@ -14,24 +14,32 @@ export interface MenuGroup {
|
|||||||
const menus: MenuGroup[] = [
|
const menus: MenuGroup[] = [
|
||||||
{
|
{
|
||||||
name: 'system',
|
name: 'system',
|
||||||
title: '系统管理',
|
titleKey: 'menu.system.title',
|
||||||
icon: 'Operation',
|
icon: 'Operation',
|
||||||
child: [
|
child: [
|
||||||
{ title: '系统配置', path: '/system/config', permission: 'config:list' },
|
{ titleKey: 'menu.system.config', path: '/system/config', permission: 'config:list' },
|
||||||
{ title: '用户管理', path: '/system/user', permission: 'user:list' },
|
{ titleKey: 'menu.system.user', path: '/system/user', permission: 'user:list' },
|
||||||
{ title: '角色管理', path: '/system/role', permission: 'role:list' },
|
{ titleKey: 'menu.system.role', path: '/system/role', permission: 'role:list' },
|
||||||
{ title: '博客文章', path: '/system/article', permission: 'article:list' },
|
{ titleKey: 'menu.system.article', path: '/system/article', permission: 'article:list' },
|
||||||
{ title: '分析统计', path: '/system/statistics', permission: 'article:list' }
|
{ titleKey: 'menu.system.statistics', path: '/system/statistics', permission: 'article:list' }
|
||||||
]
|
]
|
||||||
},{
|
},{
|
||||||
name: 'api',
|
name: 'api',
|
||||||
title: 'API数据',
|
titleKey: 'menu.api.title',
|
||||||
icon: 'Histogram',
|
icon: 'Histogram',
|
||||||
child: [
|
child: [
|
||||||
{ title: '一言', path: '/api/hitokoto', permission: 'hitokoto:list' },
|
{ titleKey: 'menu.api.hitokoto', path: '/api/hitokoto', permission: 'hitokoto:list' },
|
||||||
{ title: '照片墙', path: '/api/photoWall', permission: 'photoWall:list' },
|
{ titleKey: 'menu.api.photoWall', path: '/api/photoWall', permission: 'photoWall:list' },
|
||||||
{ title: '图片资源库', path: '/api/sourceImage', permission: 'sourceImage:list' },
|
{ titleKey: 'menu.api.sourceImage', path: '/api/sourceImage', permission: 'sourceImage:list' },
|
||||||
{ title: '歌曲库', path: '/api/music', permission: 'music:list' }
|
{ titleKey: 'menu.api.music', path: '/api/music', permission: 'music:list' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'debug',
|
||||||
|
titleKey: 'menu.debug.title',
|
||||||
|
icon: 'Tools',
|
||||||
|
child: [
|
||||||
|
{ titleKey: 'menu.debug.captcha', path: '/debug/captcha', permission: 'captcha:list' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import zhCN from './locales/zh-CN'
|
||||||
|
import zhTW from './locales/zh-TW'
|
||||||
|
import en from './locales/en'
|
||||||
|
|
||||||
|
const SUPPORTED_LOCALES = ['zh-CN', 'zh-TW', 'en'] as const
|
||||||
|
export type SupportedLocale = typeof SUPPORTED_LOCALES[number]
|
||||||
|
|
||||||
|
function detectLocale(): SupportedLocale {
|
||||||
|
const stored = localStorage.getItem('locale')
|
||||||
|
if (stored && SUPPORTED_LOCALES.includes(stored as SupportedLocale)) {
|
||||||
|
return stored as SupportedLocale
|
||||||
|
}
|
||||||
|
const browserLang = navigator.language
|
||||||
|
if (SUPPORTED_LOCALES.includes(browserLang as SupportedLocale)) {
|
||||||
|
return browserLang as SupportedLocale
|
||||||
|
}
|
||||||
|
if (browserLang.startsWith('zh')) {
|
||||||
|
return browserLang.includes('TW') || browserLang.includes('HK') ? 'zh-TW' : 'zh-CN'
|
||||||
|
}
|
||||||
|
if (browserLang.startsWith('en')) {
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
return 'zh-CN'
|
||||||
|
}
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: detectLocale(),
|
||||||
|
fallbackLocale: 'zh-CN',
|
||||||
|
messages: {
|
||||||
|
'zh-CN': zhCN,
|
||||||
|
'zh-TW': zhTW,
|
||||||
|
'en': en,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function setLocale(locale: SupportedLocale) {
|
||||||
|
(i18n.global.locale as unknown as { value: string }).value = locale
|
||||||
|
localStorage.setItem('locale', locale)
|
||||||
|
document.documentElement.lang = locale
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocale(): SupportedLocale {
|
||||||
|
return (i18n.global.locale as unknown as { value: string }).value as SupportedLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SUPPORTED_LOCALES }
|
||||||
|
export default i18n
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
export default {
|
||||||
|
common: {
|
||||||
|
confirm: 'Confirm',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
search: 'Search',
|
||||||
|
reset: 'Reset',
|
||||||
|
add: 'Add',
|
||||||
|
edit: 'Edit',
|
||||||
|
delete: 'Delete',
|
||||||
|
save: 'Save',
|
||||||
|
saveSuccess: 'Saved successfully',
|
||||||
|
deleteSuccess: 'Deleted successfully',
|
||||||
|
deleteConfirm: 'Confirm Delete',
|
||||||
|
deleteConfirmMsg: 'Are you sure you want to delete {name}?',
|
||||||
|
operation: 'Actions',
|
||||||
|
yes: 'Yes',
|
||||||
|
no: 'No',
|
||||||
|
uploadSuccess: 'Uploaded successfully',
|
||||||
|
uploadFailed: 'Upload failed',
|
||||||
|
createdAt: 'Created At',
|
||||||
|
updatedAt: 'Updated At',
|
||||||
|
selectToDelete: 'Please select data to delete',
|
||||||
|
deleteSelectedConfirm: 'Are you sure you want to delete the selected {count} items?',
|
||||||
|
fileSizeExceeded: 'File size exceeds 10MB',
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
timeout: 'Request timed out, please try again later',
|
||||||
|
failed: 'Request failed',
|
||||||
|
serverError: 'Internal server error',
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
header: {
|
||||||
|
title: 'Blog Admin',
|
||||||
|
backHome: 'Back to Site',
|
||||||
|
changePassword: 'Change Password',
|
||||||
|
about: 'About',
|
||||||
|
logout: 'Logout',
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
home: 'Home',
|
||||||
|
},
|
||||||
|
passwordDialog: {
|
||||||
|
title: 'Change Password',
|
||||||
|
oldPassword: 'Old Password',
|
||||||
|
newPassword: 'New Password',
|
||||||
|
confirmPassword: 'Confirm Password',
|
||||||
|
oldPasswordRequired: 'Please enter old password',
|
||||||
|
newPasswordRequired: 'Please enter new password',
|
||||||
|
passwordLength: 'Password must be 8-16 characters',
|
||||||
|
passwordComplexity: 'Password must contain at least two of: letters, numbers, special characters',
|
||||||
|
confirmPasswordRequired: 'Please confirm new password',
|
||||||
|
passwordMismatch: 'Passwords do not match',
|
||||||
|
success: 'Password changed successfully',
|
||||||
|
},
|
||||||
|
aboutDialog: {
|
||||||
|
title: 'About',
|
||||||
|
frontendVersion: 'Frontend Version',
|
||||||
|
backendVersion: 'Backend Version',
|
||||||
|
fetching: 'Fetching...',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
brandTagline: 'Simple · Efficient · Focused Writing',
|
||||||
|
welcomeBack: 'Welcome Back',
|
||||||
|
subtitle: 'Please log in to your admin account',
|
||||||
|
username: 'Username',
|
||||||
|
password: 'Password',
|
||||||
|
usernamePlaceholder: 'Enter username',
|
||||||
|
passwordPlaceholder: 'Enter password',
|
||||||
|
loginBtn: 'Log In',
|
||||||
|
captchaTitle: 'Security Verification',
|
||||||
|
captchaSubtitle: 'Please complete the captcha verification to continue.',
|
||||||
|
usernameRequired: 'Please enter username',
|
||||||
|
passwordRequired: 'Please enter password',
|
||||||
|
captchaInitError: 'Captcha initialization failed',
|
||||||
|
},
|
||||||
|
welcome: {
|
||||||
|
heading: 'Welcome Back',
|
||||||
|
subtitle: 'Select a shortcut below to start managing your blog',
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
system: {
|
||||||
|
title: 'System',
|
||||||
|
config: 'Configuration',
|
||||||
|
user: 'Users',
|
||||||
|
role: 'Roles',
|
||||||
|
article: 'Articles',
|
||||||
|
statistics: 'Statistics',
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
title: 'API Data',
|
||||||
|
hitokoto: 'Hitokoto',
|
||||||
|
photoWall: 'Photo Wall',
|
||||||
|
sourceImage: 'Image Library',
|
||||||
|
music: 'Music Library',
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
title: 'Debug Tools',
|
||||||
|
captcha: 'Captcha Sandbox',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
route: {
|
||||||
|
systemUser: 'Users',
|
||||||
|
systemRole: 'Roles',
|
||||||
|
systemConfig: 'Configuration',
|
||||||
|
article: 'Articles',
|
||||||
|
statistics: 'Statistics',
|
||||||
|
music: 'Music Library',
|
||||||
|
hitokoto: 'Hitokoto',
|
||||||
|
photoWall: 'Photo Wall',
|
||||||
|
sourceImage: 'Image Library',
|
||||||
|
captcha: 'Captcha Sandbox',
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
user: {
|
||||||
|
searchLabel: 'Username/Nickname',
|
||||||
|
tableUsername: 'Username',
|
||||||
|
tableRealname: 'Nickname',
|
||||||
|
formUsername: 'Username',
|
||||||
|
formPassword: 'Password',
|
||||||
|
formRealname: 'Nickname',
|
||||||
|
formRole: 'Role',
|
||||||
|
addTitle: 'Add User',
|
||||||
|
editTitle: 'Edit User',
|
||||||
|
usernameRequired: 'Please enter username',
|
||||||
|
usernameExists: 'Username already exists',
|
||||||
|
passwordRequired: 'Please enter password',
|
||||||
|
passwordLength: 'Password must be 8-16 characters',
|
||||||
|
passwordComplexity: 'Password must contain at least two of: letters, numbers, special characters',
|
||||||
|
deleteConfirmMsg: 'Are you sure you want to delete user {name}?',
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
searchLabel: 'Role Name/Description',
|
||||||
|
tableName: 'Role Name',
|
||||||
|
tableDescription: 'Description',
|
||||||
|
formName: 'Role Name',
|
||||||
|
formDescription: 'Description',
|
||||||
|
formPermission: 'Permissions',
|
||||||
|
addTitle: 'Add Role',
|
||||||
|
editTitle: 'Edit Role',
|
||||||
|
nameRequired: 'Please enter role name',
|
||||||
|
permissionRequired: 'At least one permission must be selected',
|
||||||
|
deleteConfirmMsg: 'Are you sure you want to delete role {name}?',
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
searchLabel: 'Config',
|
||||||
|
searchPlaceholder: 'Name/Description',
|
||||||
|
tableName: 'Config Name',
|
||||||
|
tableDescription: 'Description',
|
||||||
|
tableIsPublic: 'Public',
|
||||||
|
formName: 'Name',
|
||||||
|
formValue: 'Value',
|
||||||
|
formValuePlaceholder: 'Must be valid JSON string format',
|
||||||
|
formDescription: 'Description',
|
||||||
|
formIsPublic: 'Public',
|
||||||
|
addTitle: 'Add Config',
|
||||||
|
editTitle: 'Edit Config',
|
||||||
|
nameRequired: 'Please enter config name',
|
||||||
|
nameExists: 'Config name already exists',
|
||||||
|
valueRequired: 'Please enter config value',
|
||||||
|
valueInvalidJson: 'Value is not valid JSON string format',
|
||||||
|
deleteConfirmMsg: 'Are you sure you want to delete config {name}?',
|
||||||
|
},
|
||||||
|
article: {
|
||||||
|
titleLabel: 'Title',
|
||||||
|
createdAtLabel: 'Created At',
|
||||||
|
rangeSeparator: 'to',
|
||||||
|
startDate: 'Start Date',
|
||||||
|
endDate: 'End Date',
|
||||||
|
categoryLabel: 'Category',
|
||||||
|
tagLabel: 'Tag',
|
||||||
|
isSplitedLabel: 'Segmented',
|
||||||
|
splitWordBtn: 'Segment',
|
||||||
|
pullArticlesBtn: 'Pull Articles',
|
||||||
|
tableTitle: 'Title',
|
||||||
|
tablePath: 'Path',
|
||||||
|
tableCategory: 'Category',
|
||||||
|
tableTag: 'Tags',
|
||||||
|
tableContentLen: 'Content Length',
|
||||||
|
tableIsSplited: 'Segmented',
|
||||||
|
comma: ', ',
|
||||||
|
selectToSplit: 'Please select articles to segment',
|
||||||
|
splitConfirmMsg: 'Are you sure you want to segment the selected {count} articles?',
|
||||||
|
splitConfirmTitle: 'Confirm Action',
|
||||||
|
splitSuccess: '{count} articles segmented successfully',
|
||||||
|
pullConfirmMsg: 'Confirm pulling all articles?',
|
||||||
|
pullSuccess: 'Pull complete, updated {updateCount}, created {createCount}',
|
||||||
|
},
|
||||||
|
statistics: {
|
||||||
|
totalArticles: 'Total Articles',
|
||||||
|
totalCategories: 'Categories',
|
||||||
|
earliestPublish: 'Earliest Publish',
|
||||||
|
topKeywords: 'Top Keywords',
|
||||||
|
categoryDistribution: 'Category Distribution',
|
||||||
|
categoryDistributionSub: 'Article count by category',
|
||||||
|
publishTrend: 'Publishing Trend',
|
||||||
|
publishTrendSub: 'Article publishing volume over time',
|
||||||
|
yearlyKeywords: 'Yearly Keyword Analysis',
|
||||||
|
yearlyKeywordsSub: 'Annual keyword statistics based on article segmentation',
|
||||||
|
articlesUnit: 'articles',
|
||||||
|
categoryName: 'Category',
|
||||||
|
publishTime: 'Publish Date',
|
||||||
|
articleCount: 'Article Count',
|
||||||
|
keywordName: 'Keyword',
|
||||||
|
occurrenceCount: 'Occurrences',
|
||||||
|
occurrencesUnit: 'times',
|
||||||
|
yearlyTitle: 'Articles published in {year} - Top 10 Keywords',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
music: {
|
||||||
|
searchNameTitle: 'Name/Title',
|
||||||
|
searchLibLabel: 'Playlist',
|
||||||
|
searchExtLabel: 'File Type',
|
||||||
|
searchAlbumLabel: 'Album',
|
||||||
|
searchArtistLabel: 'Artist',
|
||||||
|
playBtn: 'Play',
|
||||||
|
uploadBtn: 'Upload Music',
|
||||||
|
libManageBtn: 'Manage Playlists',
|
||||||
|
tableName: 'Name',
|
||||||
|
tableType: 'Type',
|
||||||
|
tableSize: 'File Size',
|
||||||
|
tableDuration: 'Duration',
|
||||||
|
tableTitle: 'Title',
|
||||||
|
tableAlbum: 'Album',
|
||||||
|
tableArtist: 'Artist',
|
||||||
|
tableLib: 'Playlist',
|
||||||
|
tableLyric: 'Lyrics',
|
||||||
|
editLyricTitle: 'Edit Lyrics',
|
||||||
|
formCloudId: 'NetEase ID',
|
||||||
|
formName: 'Name',
|
||||||
|
formLyric: 'Lyrics',
|
||||||
|
uploadTitle: 'Upload Music',
|
||||||
|
formLib: 'Playlist',
|
||||||
|
uploadDragText: 'Drag here or {em}click to upload',
|
||||||
|
uploadTip: 'Supported file types',
|
||||||
|
autoDecode: '(auto decode)',
|
||||||
|
startUpload: 'Start Upload',
|
||||||
|
libDrawerTitle: 'Manage Playlists',
|
||||||
|
addLibBtn: 'Add Playlist',
|
||||||
|
libNamePlaceholder: 'Playlist name',
|
||||||
|
libPathPlaceholder: 'music/playlist-name/',
|
||||||
|
tablePath: 'Path',
|
||||||
|
tableMusicCount: 'Song Count',
|
||||||
|
playDrawerTitle: 'Play Music',
|
||||||
|
cloudIdRequired: 'Please enter NetEase ID',
|
||||||
|
nameRequired: 'Please enter name',
|
||||||
|
lyricRequired: 'Please enter lyrics content',
|
||||||
|
libRequired: 'Please select a playlist',
|
||||||
|
getPlaylistFailed: 'Failed to get playlist',
|
||||||
|
lyricSaveSuccess: 'Lyrics saved successfully',
|
||||||
|
libUpdateSuccess: 'Playlist updated successfully',
|
||||||
|
libNamePathRequired: 'Please enter playlist name and path',
|
||||||
|
libCreateSuccess: 'Playlist created successfully',
|
||||||
|
libDeleteConfirmMsg: 'Are you sure you want to delete playlist "{name}"?',
|
||||||
|
libDeleteSuccess: 'Playlist deleted successfully',
|
||||||
|
actionDownload: 'Download',
|
||||||
|
player: {
|
||||||
|
noLyrics: 'No lyrics available',
|
||||||
|
album: 'Album: ',
|
||||||
|
artist: 'Artist: ',
|
||||||
|
shuffle: 'Shuffle',
|
||||||
|
prev: 'Previous',
|
||||||
|
pause: 'Pause',
|
||||||
|
play: 'Play',
|
||||||
|
next: 'Next',
|
||||||
|
mute: 'Mute',
|
||||||
|
unmute: 'Unmute',
|
||||||
|
playlist: 'Playlist ({count})',
|
||||||
|
collapse: 'Collapse',
|
||||||
|
expand: 'Expand',
|
||||||
|
sequential: 'Sequential',
|
||||||
|
repeatOne: 'Repeat One',
|
||||||
|
repeatAll: 'Repeat All',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hitokoto: {
|
||||||
|
contentLabel: 'Content',
|
||||||
|
typeLabel: 'Type',
|
||||||
|
createdAtLabel: 'Created At',
|
||||||
|
rangeSeparator: 'to',
|
||||||
|
startDate: 'Start Date',
|
||||||
|
endDate: 'End Date',
|
||||||
|
deleteBtn: 'Delete',
|
||||||
|
tableType: 'Type',
|
||||||
|
tableContent: 'Content',
|
||||||
|
tableFrom: 'From',
|
||||||
|
tableCreator: 'Creator',
|
||||||
|
tableNumber: 'No.',
|
||||||
|
addTitle: 'Add Hitokoto',
|
||||||
|
formContent: 'Content',
|
||||||
|
formType: 'Type',
|
||||||
|
formFrom: 'From',
|
||||||
|
formCreator: 'Creator',
|
||||||
|
contentRequired: 'Please enter content',
|
||||||
|
typeRequired: 'Please select type',
|
||||||
|
},
|
||||||
|
photoWall: {
|
||||||
|
searchFileName: 'Filename',
|
||||||
|
searchWidth: 'Width',
|
||||||
|
searchHeight: 'Height',
|
||||||
|
uploadTooltip: 'Image formats: {exts}, max file size 10MB.',
|
||||||
|
uploadBtn: 'Upload Image',
|
||||||
|
deleteSelected: 'Delete Selected ({count})',
|
||||||
|
selectAll: 'Select All',
|
||||||
|
deselectAll: 'Deselect All',
|
||||||
|
invertSelect: 'Invert',
|
||||||
|
previewBtn: 'Preview',
|
||||||
|
deleteSingleConfirm: 'Delete "{name}"?',
|
||||||
|
deleteMultiConfirm: 'Delete {count} selected images?',
|
||||||
|
},
|
||||||
|
sourceImage: {
|
||||||
|
uploadTooltip: 'Image formats: {exts}, max file size 10MB.',
|
||||||
|
uploadBtn: 'Upload Image',
|
||||||
|
batchDeleteBtn: 'Batch Delete',
|
||||||
|
tableSize: 'File Size',
|
||||||
|
tableLabel: 'Labels',
|
||||||
|
tableUploadTime: 'Upload Time',
|
||||||
|
modifyTagsTitle: 'Edit Labels',
|
||||||
|
modifyTagsBtn: 'Edit Labels',
|
||||||
|
previewBtn: 'Preview',
|
||||||
|
availableLabels: 'Available',
|
||||||
|
selectedLabels: 'Selected',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
captcha: {
|
||||||
|
startBtn: 'Start',
|
||||||
|
refreshBtn: 'Refresh',
|
||||||
|
destroyBtn: 'Destroy',
|
||||||
|
typeCardTitle: 'Captcha Type',
|
||||||
|
typeHint: 'Multiple types can be selected; if none selected, a random type will be used.',
|
||||||
|
restoreRandom: 'Reset to Random',
|
||||||
|
previewCardTitle: 'Captcha Preview',
|
||||||
|
typeSlider: 'Slider Puzzle',
|
||||||
|
typeRotate: 'Rotate',
|
||||||
|
typeConcat: 'Concat',
|
||||||
|
typeWordClick: 'Word Click',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
captcha: {
|
||||||
|
initFailed: 'Captcha container initialization failed',
|
||||||
|
unknownMode: 'Unknown captcha mode: {mode}',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
export default {
|
||||||
|
common: {
|
||||||
|
confirm: '确定',
|
||||||
|
cancel: '取消',
|
||||||
|
search: '搜索',
|
||||||
|
reset: '重置',
|
||||||
|
add: '添加',
|
||||||
|
edit: '修改',
|
||||||
|
delete: '删除',
|
||||||
|
save: '保存',
|
||||||
|
saveSuccess: '保存成功',
|
||||||
|
deleteSuccess: '删除成功',
|
||||||
|
deleteConfirm: '确认删除',
|
||||||
|
deleteConfirmMsg: '是否确认删除 {name} ?',
|
||||||
|
operation: '操作',
|
||||||
|
yes: '是',
|
||||||
|
no: '否',
|
||||||
|
uploadSuccess: '上传成功',
|
||||||
|
uploadFailed: '上传失败',
|
||||||
|
createdAt: '创建时间',
|
||||||
|
updatedAt: '更新时间',
|
||||||
|
selectToDelete: '请选择要删除的数据',
|
||||||
|
deleteSelectedConfirm: '是否确认删除选中的{count}条数据?',
|
||||||
|
fileSizeExceeded: '文件大小超过10MB',
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
timeout: '请求超时,请稍后再试',
|
||||||
|
failed: '请求失败',
|
||||||
|
serverError: '服务器内部错误',
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
header: {
|
||||||
|
title: '博客管理后台',
|
||||||
|
backHome: '返回首页',
|
||||||
|
changePassword: '修改密码',
|
||||||
|
about: '关于',
|
||||||
|
logout: '退出',
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
home: '首页',
|
||||||
|
},
|
||||||
|
passwordDialog: {
|
||||||
|
title: '修改密码',
|
||||||
|
oldPassword: '旧密码',
|
||||||
|
newPassword: '新密码',
|
||||||
|
confirmPassword: '确认密码',
|
||||||
|
oldPasswordRequired: '请输入旧密码',
|
||||||
|
newPasswordRequired: '请输入新密码',
|
||||||
|
passwordLength: '密码长度8~16位',
|
||||||
|
passwordComplexity: '密码由字母、数字、特殊字符中的任意两种组成',
|
||||||
|
confirmPasswordRequired: '请再次输入新密码',
|
||||||
|
passwordMismatch: '两次输入的密码不一致',
|
||||||
|
success: '密码修改成功',
|
||||||
|
},
|
||||||
|
aboutDialog: {
|
||||||
|
title: '关于',
|
||||||
|
frontendVersion: '前端版本',
|
||||||
|
backendVersion: '后端版本',
|
||||||
|
fetching: '正在获取...',
|
||||||
|
unknown: '未知',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
brandTagline: '简洁 · 高效 · 专注写作',
|
||||||
|
welcomeBack: '欢迎回来',
|
||||||
|
subtitle: '请登录你的管理账号',
|
||||||
|
username: '用户名',
|
||||||
|
password: '密码',
|
||||||
|
usernamePlaceholder: '请输入用户名',
|
||||||
|
passwordPlaceholder: '请输入密码',
|
||||||
|
loginBtn: '登 录',
|
||||||
|
captchaTitle: '安全验证',
|
||||||
|
captchaSubtitle: '请先完成验证码校验,再继续登录。',
|
||||||
|
usernameRequired: '请输入用户名',
|
||||||
|
passwordRequired: '请输入密码',
|
||||||
|
captchaInitError: '验证码初始化失败',
|
||||||
|
},
|
||||||
|
welcome: {
|
||||||
|
heading: '欢迎回来',
|
||||||
|
subtitle: '选择下方快捷入口,开始管理你的博客',
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
system: {
|
||||||
|
title: '系统管理',
|
||||||
|
config: '系统配置',
|
||||||
|
user: '用户管理',
|
||||||
|
role: '角色管理',
|
||||||
|
article: '博客文章',
|
||||||
|
statistics: '分析统计',
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
title: 'API数据',
|
||||||
|
hitokoto: '一言',
|
||||||
|
photoWall: '照片墙',
|
||||||
|
sourceImage: '图片资源库',
|
||||||
|
music: '歌曲库',
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
title: '调试工具',
|
||||||
|
captcha: '图形验证码',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
route: {
|
||||||
|
systemUser: '用户管理',
|
||||||
|
systemRole: '角色管理',
|
||||||
|
systemConfig: '系统配置',
|
||||||
|
article: '博客文章',
|
||||||
|
statistics: '分析统计',
|
||||||
|
music: '歌曲库',
|
||||||
|
hitokoto: '一言',
|
||||||
|
photoWall: '照片墙',
|
||||||
|
sourceImage: '图片资源库',
|
||||||
|
captcha: '图形验证码',
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
user: {
|
||||||
|
searchLabel: '用户名/昵称',
|
||||||
|
tableUsername: '用户名',
|
||||||
|
tableRealname: '昵称',
|
||||||
|
formUsername: '用户名',
|
||||||
|
formPassword: '密码',
|
||||||
|
formRealname: '昵称',
|
||||||
|
formRole: '角色',
|
||||||
|
addTitle: '新增用户',
|
||||||
|
editTitle: '修改用户',
|
||||||
|
usernameRequired: '请输入用户名',
|
||||||
|
usernameExists: '用户名已存在',
|
||||||
|
passwordRequired: '请输入密码',
|
||||||
|
passwordLength: '密码长度8~16位',
|
||||||
|
passwordComplexity: '密码由字母、数字、特殊字符中的任意两种组成',
|
||||||
|
deleteConfirmMsg: '是否确认删除 {name} 用户?',
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
searchLabel: '角色名称/描述',
|
||||||
|
tableName: '角色名称',
|
||||||
|
tableDescription: '描述',
|
||||||
|
formName: '角色名称',
|
||||||
|
formDescription: '描述',
|
||||||
|
formPermission: '权限',
|
||||||
|
addTitle: '新增角色',
|
||||||
|
editTitle: '修改角色',
|
||||||
|
nameRequired: '请输入角色名称',
|
||||||
|
permissionRequired: '至少需要勾选一项权限',
|
||||||
|
deleteConfirmMsg: '是否确认删除 {name} 角色?',
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
searchLabel: '配置项',
|
||||||
|
searchPlaceholder: '名称/描述',
|
||||||
|
tableName: '配置项名称',
|
||||||
|
tableDescription: '配置项描述',
|
||||||
|
tableIsPublic: '是否公开',
|
||||||
|
formName: '名称',
|
||||||
|
formValue: '值',
|
||||||
|
formValuePlaceholder: '必须符合JSON字符串格式',
|
||||||
|
formDescription: '描述',
|
||||||
|
formIsPublic: '公开',
|
||||||
|
addTitle: '新增配置项',
|
||||||
|
editTitle: '修改配置项',
|
||||||
|
nameRequired: '请输入配置项名称',
|
||||||
|
nameExists: '配置项名称已存在',
|
||||||
|
valueRequired: '请输入配置项值',
|
||||||
|
valueInvalidJson: '值不符合JSON字符串格式',
|
||||||
|
deleteConfirmMsg: '是否确认删除 {name} 配置项?',
|
||||||
|
},
|
||||||
|
article: {
|
||||||
|
titleLabel: '标题',
|
||||||
|
createdAtLabel: '创建时间',
|
||||||
|
rangeSeparator: '至',
|
||||||
|
startDate: '开始日期',
|
||||||
|
endDate: '结束日期',
|
||||||
|
categoryLabel: '分类',
|
||||||
|
tagLabel: '标签',
|
||||||
|
isSplitedLabel: '已分词',
|
||||||
|
splitWordBtn: '分词处理',
|
||||||
|
pullArticlesBtn: '拉取文章',
|
||||||
|
tableTitle: '标题',
|
||||||
|
tablePath: '路径',
|
||||||
|
tableCategory: '分类',
|
||||||
|
tableTag: '标签',
|
||||||
|
tableContentLen: '正文长度',
|
||||||
|
tableIsSplited: '是否已分词',
|
||||||
|
comma: ',',
|
||||||
|
selectToSplit: '请选择要执行分词的文章',
|
||||||
|
splitConfirmMsg: '是否确认对选中的{count}篇文章执行分词处理?',
|
||||||
|
splitConfirmTitle: '操作确认',
|
||||||
|
splitSuccess: '{count}篇文章分词处理成功',
|
||||||
|
pullConfirmMsg: '确认拉取全部文章?',
|
||||||
|
pullSuccess: '拉取文章完成,更新 {updateCount} 篇,创建 {createCount} 篇',
|
||||||
|
},
|
||||||
|
statistics: {
|
||||||
|
totalArticles: '文章总数',
|
||||||
|
totalCategories: '分类数量',
|
||||||
|
earliestPublish: '最早发布',
|
||||||
|
topKeywords: '高频词汇',
|
||||||
|
categoryDistribution: '文章分类分布',
|
||||||
|
categoryDistributionSub: '各类别文章数量占比',
|
||||||
|
publishTrend: '文章发布趋势',
|
||||||
|
publishTrendSub: '各时间段文章发布数量变化',
|
||||||
|
yearlyKeywords: '年度高频词汇分析',
|
||||||
|
yearlyKeywordsSub: '基于文章分词结果的年度关键词统计',
|
||||||
|
articlesUnit: '篇',
|
||||||
|
categoryName: '类别',
|
||||||
|
publishTime: '发布时间',
|
||||||
|
articleCount: '文章数量',
|
||||||
|
keywordName: '高频词汇',
|
||||||
|
occurrenceCount: '出现次数',
|
||||||
|
occurrencesUnit: '次',
|
||||||
|
yearlyTitle: '{year}年发布的文章 - 高频词汇TOP10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
music: {
|
||||||
|
searchNameTitle: '名称/标题',
|
||||||
|
searchLibLabel: '所属歌单',
|
||||||
|
searchExtLabel: '文件类型',
|
||||||
|
searchAlbumLabel: '唱片集',
|
||||||
|
searchArtistLabel: '艺术家',
|
||||||
|
playBtn: '播放',
|
||||||
|
uploadBtn: '上传音乐',
|
||||||
|
libManageBtn: '歌单管理',
|
||||||
|
tableName: '名称',
|
||||||
|
tableType: '类型',
|
||||||
|
tableSize: '文件大小',
|
||||||
|
tableDuration: '时长',
|
||||||
|
tableTitle: '标题',
|
||||||
|
tableAlbum: '唱片集',
|
||||||
|
tableArtist: '艺术家',
|
||||||
|
tableLib: '所属歌单',
|
||||||
|
tableLyric: '歌词',
|
||||||
|
editLyricTitle: '编辑歌词',
|
||||||
|
formCloudId: '网易云ID',
|
||||||
|
formName: '名称',
|
||||||
|
formLyric: '歌词',
|
||||||
|
uploadTitle: '上传音乐',
|
||||||
|
formLib: '歌单',
|
||||||
|
uploadDragText: '拖拽到此处或{em}点击上传',
|
||||||
|
uploadTip: '支持上传的文件类型',
|
||||||
|
autoDecode: '(自动解码)',
|
||||||
|
startUpload: '开始上传',
|
||||||
|
libDrawerTitle: '歌单管理',
|
||||||
|
addLibBtn: '添加歌单',
|
||||||
|
libNamePlaceholder: '歌单名称',
|
||||||
|
libPathPlaceholder: 'music/歌单名/',
|
||||||
|
tablePath: '路径',
|
||||||
|
tableMusicCount: '歌曲数量',
|
||||||
|
playDrawerTitle: '播放音乐',
|
||||||
|
cloudIdRequired: '请输入网易云ID',
|
||||||
|
nameRequired: '请输入名称',
|
||||||
|
lyricRequired: '请输入歌词正文',
|
||||||
|
libRequired: '请选择歌单',
|
||||||
|
getPlaylistFailed: '获取播放列表失败',
|
||||||
|
lyricSaveSuccess: '歌词保存成功',
|
||||||
|
libUpdateSuccess: '歌单更新成功',
|
||||||
|
libNamePathRequired: '请输入歌单名称和路径',
|
||||||
|
libCreateSuccess: '歌单创建成功',
|
||||||
|
libDeleteConfirmMsg: '是否确认删除歌单「{name}」?',
|
||||||
|
libDeleteSuccess: '歌单删除成功',
|
||||||
|
actionDownload: '下载',
|
||||||
|
player: {
|
||||||
|
noLyrics: '暂无歌词',
|
||||||
|
album: '专辑:',
|
||||||
|
artist: '歌手:',
|
||||||
|
shuffle: '随机播放',
|
||||||
|
prev: '上一首',
|
||||||
|
pause: '暂停',
|
||||||
|
play: '播放',
|
||||||
|
next: '下一首',
|
||||||
|
mute: '静音',
|
||||||
|
unmute: '取消静音',
|
||||||
|
playlist: '播放列表 ({count})',
|
||||||
|
collapse: '收起',
|
||||||
|
expand: '展开',
|
||||||
|
sequential: '顺序播放',
|
||||||
|
repeatOne: '单曲循环',
|
||||||
|
repeatAll: '列表循环',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hitokoto: {
|
||||||
|
contentLabel: '内容',
|
||||||
|
typeLabel: '类型',
|
||||||
|
createdAtLabel: '创建时间',
|
||||||
|
rangeSeparator: '至',
|
||||||
|
startDate: '开始日期',
|
||||||
|
endDate: '结束日期',
|
||||||
|
deleteBtn: '删除',
|
||||||
|
tableType: '类型',
|
||||||
|
tableContent: '内容',
|
||||||
|
tableFrom: '来自',
|
||||||
|
tableCreator: '作者',
|
||||||
|
tableNumber: '编号',
|
||||||
|
addTitle: '新增一言',
|
||||||
|
formContent: '内容',
|
||||||
|
formType: '类型',
|
||||||
|
formFrom: '来自',
|
||||||
|
formCreator: '作者',
|
||||||
|
contentRequired: '请输入内容',
|
||||||
|
typeRequired: '请选择类型',
|
||||||
|
},
|
||||||
|
photoWall: {
|
||||||
|
searchFileName: '文件名',
|
||||||
|
searchWidth: '宽度',
|
||||||
|
searchHeight: '高度',
|
||||||
|
uploadTooltip: '图片格式为{exts},文件大小不超过10MB。',
|
||||||
|
uploadBtn: '上传图片',
|
||||||
|
deleteSelected: '删除选中 ({count})',
|
||||||
|
selectAll: '全选',
|
||||||
|
deselectAll: '取消全选',
|
||||||
|
invertSelect: '反选',
|
||||||
|
previewBtn: '预览',
|
||||||
|
deleteSingleConfirm: '确认删除「{name}」?',
|
||||||
|
deleteMultiConfirm: '确认删除选中的 {count} 张图片?',
|
||||||
|
},
|
||||||
|
sourceImage: {
|
||||||
|
uploadTooltip: '图片格式为{exts},文件大小不超过10MB。',
|
||||||
|
uploadBtn: '上传图片',
|
||||||
|
batchDeleteBtn: '批量删除',
|
||||||
|
tableSize: '文件大小',
|
||||||
|
tableLabel: '标签',
|
||||||
|
tableUploadTime: '上传时间',
|
||||||
|
modifyTagsTitle: '修改标签',
|
||||||
|
modifyTagsBtn: '修改标签',
|
||||||
|
previewBtn: '预览',
|
||||||
|
availableLabels: '可选标签',
|
||||||
|
selectedLabels: '已选标签',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
captcha: {
|
||||||
|
startBtn: '开始验证',
|
||||||
|
refreshBtn: '刷新',
|
||||||
|
destroyBtn: '销毁',
|
||||||
|
typeCardTitle: '验证码类型',
|
||||||
|
typeHint: '可同时勾选多种类型;不选时将按随机类型请求。',
|
||||||
|
restoreRandom: '恢复随机',
|
||||||
|
previewCardTitle: '验证码预览',
|
||||||
|
typeSlider: '滑块拼图',
|
||||||
|
typeRotate: '旋转',
|
||||||
|
typeConcat: '拼接',
|
||||||
|
typeWordClick: '汉字点选',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
captcha: {
|
||||||
|
initFailed: '验证码容器初始化失败',
|
||||||
|
unknownMode: '未知的验证码模式: {mode}',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
export default {
|
||||||
|
common: {
|
||||||
|
confirm: '確定',
|
||||||
|
cancel: '取消',
|
||||||
|
search: '搜尋',
|
||||||
|
reset: '重置',
|
||||||
|
add: '新增',
|
||||||
|
edit: '修改',
|
||||||
|
delete: '刪除',
|
||||||
|
save: '儲存',
|
||||||
|
saveSuccess: '儲存成功',
|
||||||
|
deleteSuccess: '刪除成功',
|
||||||
|
deleteConfirm: '確認刪除',
|
||||||
|
deleteConfirmMsg: '是否確認刪除 {name} ?',
|
||||||
|
operation: '操作',
|
||||||
|
yes: '是',
|
||||||
|
no: '否',
|
||||||
|
uploadSuccess: '上傳成功',
|
||||||
|
uploadFailed: '上傳失敗',
|
||||||
|
createdAt: '建立時間',
|
||||||
|
updatedAt: '更新時間',
|
||||||
|
selectToDelete: '請選擇要刪除的資料',
|
||||||
|
deleteSelectedConfirm: '是否確認刪除選中的{count}條資料?',
|
||||||
|
fileSizeExceeded: '檔案大小超過10MB',
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
timeout: '請求逾時,請稍後再試',
|
||||||
|
failed: '請求失敗',
|
||||||
|
serverError: '伺服器內部錯誤',
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
header: {
|
||||||
|
title: '部落格管理後台',
|
||||||
|
backHome: '返回首頁',
|
||||||
|
changePassword: '修改密碼',
|
||||||
|
about: '關於',
|
||||||
|
logout: '登出',
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
home: '首頁',
|
||||||
|
},
|
||||||
|
passwordDialog: {
|
||||||
|
title: '修改密碼',
|
||||||
|
oldPassword: '舊密碼',
|
||||||
|
newPassword: '新密碼',
|
||||||
|
confirmPassword: '確認密碼',
|
||||||
|
oldPasswordRequired: '請輸入舊密碼',
|
||||||
|
newPasswordRequired: '請輸入新密碼',
|
||||||
|
passwordLength: '密碼長度8~16位',
|
||||||
|
passwordComplexity: '密碼由字母、數字、特殊字元中的任意兩種組成',
|
||||||
|
confirmPasswordRequired: '請再次輸入新密碼',
|
||||||
|
passwordMismatch: '兩次輸入的密碼不一致',
|
||||||
|
success: '密碼修改成功',
|
||||||
|
},
|
||||||
|
aboutDialog: {
|
||||||
|
title: '關於',
|
||||||
|
frontendVersion: '前端版本',
|
||||||
|
backendVersion: '後端版本',
|
||||||
|
fetching: '正在取得...',
|
||||||
|
unknown: '未知',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
brandTagline: '簡潔 · 高效 · 專注寫作',
|
||||||
|
welcomeBack: '歡迎回來',
|
||||||
|
subtitle: '請登入你的管理帳號',
|
||||||
|
username: '使用者名稱',
|
||||||
|
password: '密碼',
|
||||||
|
usernamePlaceholder: '請輸入使用者名稱',
|
||||||
|
passwordPlaceholder: '請輸入密碼',
|
||||||
|
loginBtn: '登 入',
|
||||||
|
captchaTitle: '安全驗證',
|
||||||
|
captchaSubtitle: '請先完成驗證碼校驗,再繼續登入。',
|
||||||
|
usernameRequired: '請輸入使用者名稱',
|
||||||
|
passwordRequired: '請輸入密碼',
|
||||||
|
captchaInitError: '驗證碼初始化失敗',
|
||||||
|
},
|
||||||
|
welcome: {
|
||||||
|
heading: '歡迎回來',
|
||||||
|
subtitle: '選擇下方快捷入口,開始管理你的部落格',
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
system: {
|
||||||
|
title: '系統管理',
|
||||||
|
config: '系統設定',
|
||||||
|
user: '使用者管理',
|
||||||
|
role: '角色管理',
|
||||||
|
article: '部落格文章',
|
||||||
|
statistics: '分析統計',
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
title: 'API資料',
|
||||||
|
hitokoto: '一言',
|
||||||
|
photoWall: '照片牆',
|
||||||
|
sourceImage: '圖片資源庫',
|
||||||
|
music: '歌曲庫',
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
title: '偵錯工具',
|
||||||
|
captcha: '圖形驗證碼',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
route: {
|
||||||
|
systemUser: '使用者管理',
|
||||||
|
systemRole: '角色管理',
|
||||||
|
systemConfig: '系統設定',
|
||||||
|
article: '部落格文章',
|
||||||
|
statistics: '分析統計',
|
||||||
|
music: '歌曲庫',
|
||||||
|
hitokoto: '一言',
|
||||||
|
photoWall: '照片牆',
|
||||||
|
sourceImage: '圖片資源庫',
|
||||||
|
captcha: '圖形驗證碼',
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
user: {
|
||||||
|
searchLabel: '使用者名稱/暱稱',
|
||||||
|
tableUsername: '使用者名稱',
|
||||||
|
tableRealname: '暱稱',
|
||||||
|
formUsername: '使用者名稱',
|
||||||
|
formPassword: '密碼',
|
||||||
|
formRealname: '暱稱',
|
||||||
|
formRole: '角色',
|
||||||
|
addTitle: '新增使用者',
|
||||||
|
editTitle: '修改使用者',
|
||||||
|
usernameRequired: '請輸入使用者名稱',
|
||||||
|
usernameExists: '使用者名稱已存在',
|
||||||
|
passwordRequired: '請輸入密碼',
|
||||||
|
passwordLength: '密碼長度8~16位',
|
||||||
|
passwordComplexity: '密碼由字母、數字、特殊字元中的任意兩種組成',
|
||||||
|
deleteConfirmMsg: '是否確認刪除 {name} 使用者?',
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
searchLabel: '角色名稱/描述',
|
||||||
|
tableName: '角色名稱',
|
||||||
|
tableDescription: '描述',
|
||||||
|
formName: '角色名稱',
|
||||||
|
formDescription: '描述',
|
||||||
|
formPermission: '權限',
|
||||||
|
addTitle: '新增角色',
|
||||||
|
editTitle: '修改角色',
|
||||||
|
nameRequired: '請輸入角色名稱',
|
||||||
|
permissionRequired: '至少需要勾選一項權限',
|
||||||
|
deleteConfirmMsg: '是否確認刪除 {name} 角色?',
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
searchLabel: '設定項目',
|
||||||
|
searchPlaceholder: '名稱/描述',
|
||||||
|
tableName: '設定項目名稱',
|
||||||
|
tableDescription: '設定項目描述',
|
||||||
|
tableIsPublic: '是否公開',
|
||||||
|
formName: '名稱',
|
||||||
|
formValue: '值',
|
||||||
|
formValuePlaceholder: '必須符合JSON字串格式',
|
||||||
|
formDescription: '描述',
|
||||||
|
formIsPublic: '公開',
|
||||||
|
addTitle: '新增設定項目',
|
||||||
|
editTitle: '修改設定項目',
|
||||||
|
nameRequired: '請輸入設定項目名稱',
|
||||||
|
nameExists: '設定項目名稱已存在',
|
||||||
|
valueRequired: '請輸入設定項目值',
|
||||||
|
valueInvalidJson: '值不符合JSON字串格式',
|
||||||
|
deleteConfirmMsg: '是否確認刪除 {name} 設定項目?',
|
||||||
|
},
|
||||||
|
article: {
|
||||||
|
titleLabel: '標題',
|
||||||
|
createdAtLabel: '建立時間',
|
||||||
|
rangeSeparator: '至',
|
||||||
|
startDate: '開始日期',
|
||||||
|
endDate: '結束日期',
|
||||||
|
categoryLabel: '分類',
|
||||||
|
tagLabel: '標籤',
|
||||||
|
isSplitedLabel: '已分詞',
|
||||||
|
splitWordBtn: '分詞處理',
|
||||||
|
pullArticlesBtn: '拉取文章',
|
||||||
|
tableTitle: '標題',
|
||||||
|
tablePath: '路徑',
|
||||||
|
tableCategory: '分類',
|
||||||
|
tableTag: '標籤',
|
||||||
|
tableContentLen: '正文長度',
|
||||||
|
tableIsSplited: '是否已分詞',
|
||||||
|
comma: ',',
|
||||||
|
selectToSplit: '請選擇要執行分詞的文章',
|
||||||
|
splitConfirmMsg: '是否確認對選中的{count}篇文章執行分詞處理?',
|
||||||
|
splitConfirmTitle: '操作確認',
|
||||||
|
splitSuccess: '{count}篇文章分詞處理成功',
|
||||||
|
pullConfirmMsg: '確認拉取全部文章?',
|
||||||
|
pullSuccess: '拉取文章完成,更新 {updateCount} 篇,建立 {createCount} 篇',
|
||||||
|
},
|
||||||
|
statistics: {
|
||||||
|
totalArticles: '文章總數',
|
||||||
|
totalCategories: '分類數量',
|
||||||
|
earliestPublish: '最早發佈',
|
||||||
|
topKeywords: '高頻詞彙',
|
||||||
|
categoryDistribution: '文章分類分佈',
|
||||||
|
categoryDistributionSub: '各類別文章數量佔比',
|
||||||
|
publishTrend: '文章發佈趨勢',
|
||||||
|
publishTrendSub: '各時間段文章發佈數量變化',
|
||||||
|
yearlyKeywords: '年度高頻詞彙分析',
|
||||||
|
yearlyKeywordsSub: '基於文章分詞結果的年度關鍵詞統計',
|
||||||
|
articlesUnit: '篇',
|
||||||
|
categoryName: '類別',
|
||||||
|
publishTime: '發佈時間',
|
||||||
|
articleCount: '文章數量',
|
||||||
|
keywordName: '高頻詞彙',
|
||||||
|
occurrenceCount: '出現次數',
|
||||||
|
occurrencesUnit: '次',
|
||||||
|
yearlyTitle: '{year}年發佈的文章 - 高頻詞彙TOP10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
music: {
|
||||||
|
searchNameTitle: '名稱/標題',
|
||||||
|
searchLibLabel: '所屬歌單',
|
||||||
|
searchExtLabel: '檔案類型',
|
||||||
|
searchAlbumLabel: '唱片集',
|
||||||
|
searchArtistLabel: '藝術家',
|
||||||
|
playBtn: '播放',
|
||||||
|
uploadBtn: '上傳音樂',
|
||||||
|
libManageBtn: '歌單管理',
|
||||||
|
tableName: '名稱',
|
||||||
|
tableType: '類型',
|
||||||
|
tableSize: '檔案大小',
|
||||||
|
tableDuration: '時長',
|
||||||
|
tableTitle: '標題',
|
||||||
|
tableAlbum: '唱片集',
|
||||||
|
tableArtist: '藝術家',
|
||||||
|
tableLib: '所屬歌單',
|
||||||
|
tableLyric: '歌詞',
|
||||||
|
editLyricTitle: '編輯歌詞',
|
||||||
|
formCloudId: '網易雲ID',
|
||||||
|
formName: '名稱',
|
||||||
|
formLyric: '歌詞',
|
||||||
|
uploadTitle: '上傳音樂',
|
||||||
|
formLib: '歌單',
|
||||||
|
uploadDragText: '拖曳到此處或{em}點擊上傳',
|
||||||
|
uploadTip: '支援上傳的檔案類型',
|
||||||
|
autoDecode: '(自動解碼)',
|
||||||
|
startUpload: '開始上傳',
|
||||||
|
libDrawerTitle: '歌單管理',
|
||||||
|
addLibBtn: '新增歌單',
|
||||||
|
libNamePlaceholder: '歌單名稱',
|
||||||
|
libPathPlaceholder: 'music/歌單名/',
|
||||||
|
tablePath: '路徑',
|
||||||
|
tableMusicCount: '歌曲數量',
|
||||||
|
playDrawerTitle: '播放音樂',
|
||||||
|
cloudIdRequired: '請輸入網易雲ID',
|
||||||
|
nameRequired: '請輸入名稱',
|
||||||
|
lyricRequired: '請輸入歌詞正文',
|
||||||
|
libRequired: '請選擇歌單',
|
||||||
|
getPlaylistFailed: '取得播放列表失敗',
|
||||||
|
lyricSaveSuccess: '歌詞儲存成功',
|
||||||
|
libUpdateSuccess: '歌單更新成功',
|
||||||
|
libNamePathRequired: '請輸入歌單名稱和路徑',
|
||||||
|
libCreateSuccess: '歌單建立成功',
|
||||||
|
libDeleteConfirmMsg: '是否確認刪除歌單「{name}」?',
|
||||||
|
libDeleteSuccess: '歌單刪除成功',
|
||||||
|
actionDownload: '下載',
|
||||||
|
player: {
|
||||||
|
noLyrics: '暫無歌詞',
|
||||||
|
album: '專輯:',
|
||||||
|
artist: '歌手:',
|
||||||
|
shuffle: '隨機播放',
|
||||||
|
prev: '上一首',
|
||||||
|
pause: '暫停',
|
||||||
|
play: '播放',
|
||||||
|
next: '下一首',
|
||||||
|
mute: '靜音',
|
||||||
|
unmute: '取消靜音',
|
||||||
|
playlist: '播放列表 ({count})',
|
||||||
|
collapse: '收起',
|
||||||
|
expand: '展開',
|
||||||
|
sequential: '順序播放',
|
||||||
|
repeatOne: '單曲循環',
|
||||||
|
repeatAll: '列表循環',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hitokoto: {
|
||||||
|
contentLabel: '內容',
|
||||||
|
typeLabel: '類型',
|
||||||
|
createdAtLabel: '建立時間',
|
||||||
|
rangeSeparator: '至',
|
||||||
|
startDate: '開始日期',
|
||||||
|
endDate: '結束日期',
|
||||||
|
deleteBtn: '刪除',
|
||||||
|
tableType: '類型',
|
||||||
|
tableContent: '內容',
|
||||||
|
tableFrom: '來自',
|
||||||
|
tableCreator: '作者',
|
||||||
|
tableNumber: '編號',
|
||||||
|
addTitle: '新增一言',
|
||||||
|
formContent: '內容',
|
||||||
|
formType: '類型',
|
||||||
|
formFrom: '來自',
|
||||||
|
formCreator: '作者',
|
||||||
|
contentRequired: '請輸入內容',
|
||||||
|
typeRequired: '請選擇類型',
|
||||||
|
},
|
||||||
|
photoWall: {
|
||||||
|
searchFileName: '檔案名稱',
|
||||||
|
searchWidth: '寬度',
|
||||||
|
searchHeight: '高度',
|
||||||
|
uploadTooltip: '圖片格式為{exts},檔案大小不超過10MB。',
|
||||||
|
uploadBtn: '上傳圖片',
|
||||||
|
deleteSelected: '刪除選中 ({count})',
|
||||||
|
selectAll: '全選',
|
||||||
|
deselectAll: '取消全選',
|
||||||
|
invertSelect: '反選',
|
||||||
|
previewBtn: '預覽',
|
||||||
|
deleteSingleConfirm: '確認刪除「{name}」?',
|
||||||
|
deleteMultiConfirm: '確認刪除選中的 {count} 張圖片?',
|
||||||
|
},
|
||||||
|
sourceImage: {
|
||||||
|
uploadTooltip: '圖片格式為{exts},檔案大小不超過10MB。',
|
||||||
|
uploadBtn: '上傳圖片',
|
||||||
|
batchDeleteBtn: '批次刪除',
|
||||||
|
tableSize: '檔案大小',
|
||||||
|
tableLabel: '標籤',
|
||||||
|
tableUploadTime: '上傳時間',
|
||||||
|
modifyTagsTitle: '修改標籤',
|
||||||
|
modifyTagsBtn: '修改標籤',
|
||||||
|
previewBtn: '預覽',
|
||||||
|
availableLabels: '可選標籤',
|
||||||
|
selectedLabels: '已選標籤',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
captcha: {
|
||||||
|
startBtn: '開始驗證',
|
||||||
|
refreshBtn: '重新整理',
|
||||||
|
destroyBtn: '銷毀',
|
||||||
|
typeCardTitle: '驗證碼類型',
|
||||||
|
typeHint: '可同時勾選多種類型;不選時將按隨機類型請求。',
|
||||||
|
restoreRandom: '恢復隨機',
|
||||||
|
previewCardTitle: '驗證碼預覽',
|
||||||
|
typeSlider: '滑塊拼圖',
|
||||||
|
typeRotate: '旋轉',
|
||||||
|
typeConcat: '拼接',
|
||||||
|
typeWordClick: '漢字點選',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
captcha: {
|
||||||
|
initFailed: '驗證碼容器初始化失敗',
|
||||||
|
unknownMode: '未知的驗證碼模式: {mode}',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -11,11 +11,11 @@ import 'element-plus/theme-chalk/el-image-viewer.css'
|
|||||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
import { permissionDirective } from '@/utils/permission'
|
import { permissionDirective } from '@/utils/permission'
|
||||||
|
import i18n from '@/i18n'
|
||||||
|
|
||||||
// 全局路由导航前置守卫
|
|
||||||
router.beforeEach(function (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) {
|
router.beforeEach(function (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) {
|
||||||
if (to.meta?.title) {
|
if (to.meta?.titleKey) {
|
||||||
store.commit('addTab', { title: to.meta.title, path: to.path, name: to.name })
|
store.commit('addTab', { title: to.meta.titleKey, path: to.path, name: to.name })
|
||||||
}
|
}
|
||||||
store.state.activeTab = to.path
|
store.state.activeTab = to.path
|
||||||
if(filterExclude.indexOf(to.path) !== -1 || store.state.loginInfo.token) {
|
if(filterExclude.indexOf(to.path) !== -1 || store.state.loginInfo.token) {
|
||||||
@@ -30,7 +30,8 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
|||||||
app.component(key, component)
|
app.component(key, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(router)
|
app.use(i18n)
|
||||||
|
.use(router)
|
||||||
.use(store)
|
.use(store)
|
||||||
.directive('loading', ElLoading.directive)
|
.directive('loading', ElLoading.directive)
|
||||||
.directive('permission', permissionDirective)
|
.directive('permission', permissionDirective)
|
||||||
|
|||||||
@@ -8,16 +8,18 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
{ path: '/login', name: 'Login', component: Login },
|
{ path: '/login', name: 'Login', component: Login },
|
||||||
{ path: '/', name: 'Home', component: Home, children: [
|
{ path: '/', name: 'Home', component: Home, children: [
|
||||||
{ path: '/', name: 'Welcome', component: Welcome },
|
{ path: '/', name: 'Welcome', component: Welcome },
|
||||||
{ path: '/system/user', name: 'SystemUser', component: () => import('@/views/system/SystemUser.vue'), meta: { title: '用户管理' } },
|
{ path: '/system/user', name: 'SystemUser', component: () => import('@/views/system/SystemUser.vue'), meta: { titleKey: 'route.systemUser' } },
|
||||||
{ path: '/system/role', name: 'SystemRole', component: () => import('@/views/system/SystemRole.vue'), meta: { title: '角色管理' } },
|
{ path: '/system/role', name: 'SystemRole', component: () => import('@/views/system/SystemRole.vue'), meta: { titleKey: 'route.systemRole' } },
|
||||||
{ path: '/system/config', name: 'SystemConfig', component: () => import('@/views/system/SystemConfig.vue'), meta: { title: '系统配置' } },
|
{ path: '/system/config', name: 'SystemConfig', component: () => import('@/views/system/SystemConfig.vue'), meta: { titleKey: 'route.systemConfig' } },
|
||||||
{ path: '/system/article', name: 'Article', component: () => import('@/views/system/Article.vue'), meta: { title: '博客文章' } },
|
{ path: '/system/article', name: 'Article', component: () => import('@/views/system/Article.vue'), meta: { titleKey: 'route.article' } },
|
||||||
{ path: '/system/statistics', name: 'Statistics', component: () => import('@/views/system/Statistics.vue'), meta: { title: '分析统计' } },
|
{ path: '/system/statistics', name: 'Statistics', component: () => import('@/views/system/Statistics.vue'), meta: { titleKey: 'route.statistics' } },
|
||||||
|
|
||||||
{ path: '/api/music', name: 'Music', component: () => import('@/views/api/Music.vue'), meta: { title: '歌曲库' } },
|
{ path: '/api/music', name: 'Music', component: () => import('@/views/api/Music.vue'), meta: { titleKey: 'route.music' } },
|
||||||
{ 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: { titleKey: 'route.hitokoto' } },
|
||||||
{ 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: { titleKey: 'route.photoWall' } },
|
||||||
{ 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: { titleKey: 'route.sourceImage' } },
|
||||||
|
|
||||||
|
{ path: '/debug/captcha', name: 'CaptchaSandbox', component: () => import('@/views/debug/CaptchaSandbox.vue'), meta: { titleKey: 'route.captcha' } },
|
||||||
]}
|
]}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ html, body, #app, .layout {
|
|||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
div.main-title {
|
div.main-title {
|
||||||
@@ -21,11 +22,7 @@ html, body, #app, .layout {
|
|||||||
line-height: 60px;
|
line-height: 60px;
|
||||||
}
|
}
|
||||||
.nav-btns-right {
|
.nav-btns-right {
|
||||||
display: inline-block;
|
display: flex;
|
||||||
position: absolute;
|
|
||||||
right: 20px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,12 +80,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 {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import axios from 'axios'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
import { router } from '@/router'
|
import { router } from '@/router'
|
||||||
|
import i18n from '@/i18n'
|
||||||
|
|
||||||
const http = axios.create({
|
const http = axios.create({
|
||||||
baseURL: import.meta.env.VITE_APP_API_BASE,
|
baseURL: import.meta.env.VITE_APP_API_BASE,
|
||||||
@@ -11,7 +12,6 @@ const http = axios.create({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 添加请求拦截器
|
|
||||||
http.interceptors.request.use(config => {
|
http.interceptors.request.use(config => {
|
||||||
const token = store.state.loginInfo.token
|
const token = store.state.loginInfo.token
|
||||||
if (token !== null) {
|
if (token !== null) {
|
||||||
@@ -19,28 +19,24 @@ http.interceptors.request.use(config => {
|
|||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
}, err => {
|
}, err => {
|
||||||
ElMessage.error('请求超时,请稍后再试')
|
ElMessage.error(i18n.global.t('http.timeout'))
|
||||||
return Promise.reject(err)
|
return Promise.reject(err)
|
||||||
})
|
})
|
||||||
|
|
||||||
http.interceptors.response.use(res => {
|
http.interceptors.response.use(res => {
|
||||||
const responseBody = res.data
|
const responseBody = res.data
|
||||||
// 统一响应格式处理
|
|
||||||
switch (responseBody.code) {
|
switch (responseBody.code) {
|
||||||
case 0:
|
case 0:
|
||||||
// 成功,直接返回数据
|
|
||||||
return responseBody.data
|
return responseBody.data
|
||||||
case -1:
|
case -1:
|
||||||
// 失败,显示错误信息
|
ElMessage.error(responseBody.message || i18n.global.t('http.failed'))
|
||||||
ElMessage.error(responseBody.message || '请求失败')
|
return Promise.reject(new Error(responseBody.message || i18n.global.t('http.failed')))
|
||||||
return Promise.reject(new Error(responseBody.message || '请求失败'))
|
|
||||||
default:
|
default:
|
||||||
// 其他情况,兼容没有包装格式的响应
|
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
}, err => {
|
}, err => {
|
||||||
if (err.response?.status >= 500) {
|
if (err.response?.status >= 500) {
|
||||||
ElMessage.error('服务器内部错误')
|
ElMessage.error(i18n.global.t('http.serverError'))
|
||||||
} else if (err.response?.status >= 400) {
|
} else if (err.response?.status >= 400) {
|
||||||
const message = err.response.data?.message
|
const message = err.response.data?.message
|
||||||
message && ElMessage.error(message)
|
message && ElMessage.error(message)
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import "./captcha.less";
|
||||||
|
import Slider from "./slider/slider";
|
||||||
|
import Rotate from "./rotate/rotate";
|
||||||
|
import Concat from "./concat/concat";
|
||||||
|
import Disable from "./disable/disable";
|
||||||
|
import ImageClick from "./image_click/image_click";
|
||||||
|
import WordImageClick from "./word_image_click/word_image_click";
|
||||||
|
import { CaptchaConfig, wrapConfig, wrapStyle } from "./config/config";
|
||||||
|
import { clearAllPreventDefault } from "./common/common";
|
||||||
|
const template = `
|
||||||
|
<div class="__captcha-parent">
|
||||||
|
<div class="captcha-box">
|
||||||
|
<div class="captcha-loading loading"></div>
|
||||||
|
</div>
|
||||||
|
<!-- 底部 -->
|
||||||
|
<div class="slider-bottom">
|
||||||
|
<div class="refresh-btn"></div>
|
||||||
|
<div class="close-btn"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
function createCaptchaByType(type, tac) {
|
||||||
|
const box = tac.config.domBindEl.find(".captcha-box");
|
||||||
|
const styleConfig = tac.style;
|
||||||
|
switch (type) {
|
||||||
|
case "SLIDER":
|
||||||
|
return new Slider(box, styleConfig);
|
||||||
|
case "ROTATE":
|
||||||
|
return new Rotate(box, styleConfig);
|
||||||
|
case "CONCAT":
|
||||||
|
return new Concat(box, styleConfig);
|
||||||
|
case "IMAGE_CLICK":
|
||||||
|
return new ImageClick(box, styleConfig);
|
||||||
|
case "WORD_IMAGE_CLICK":
|
||||||
|
return new WordImageClick(box, styleConfig);
|
||||||
|
case "DISABLED":
|
||||||
|
return new Disable(box, styleConfig);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class TianAiCaptcha {
|
||||||
|
constructor(config, style) {
|
||||||
|
this.config = wrapConfig(config);
|
||||||
|
if (this.config.btnRefreshFun) {
|
||||||
|
this.btnRefreshFun = this.config.btnRefreshFun;
|
||||||
|
}
|
||||||
|
if (this.config.btnCloseFun) {
|
||||||
|
this.btnCloseFun = this.config.btnCloseFun;
|
||||||
|
}
|
||||||
|
this.style = wrapStyle(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.destroyWindow();
|
||||||
|
this.config.domBindEl.append(template);
|
||||||
|
this.domTemplate = this.config.domBindEl.find(".__captcha-parent");
|
||||||
|
clearAllPreventDefault(this.domTemplate);
|
||||||
|
// 绑定按钮事件
|
||||||
|
this.config.domBindEl
|
||||||
|
.find(".refresh-btn")
|
||||||
|
.click((el) => {
|
||||||
|
this.btnRefreshFun(el, this);
|
||||||
|
});
|
||||||
|
this.config.domBindEl
|
||||||
|
.find(".close-btn")
|
||||||
|
.click((el) => {
|
||||||
|
this.btnCloseFun(el, this);
|
||||||
|
});
|
||||||
|
// 加载验证码
|
||||||
|
this.reloadCaptcha();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
btnRefreshFun(el, tac) {
|
||||||
|
tac.reloadCaptcha();
|
||||||
|
}
|
||||||
|
btnCloseFun(el, tac) {
|
||||||
|
tac.destroyWindow();
|
||||||
|
}
|
||||||
|
reloadCaptcha() {
|
||||||
|
this.showLoading();
|
||||||
|
this.destroyCaptcha(() => {
|
||||||
|
this.createCaptcha();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showLoading() {
|
||||||
|
this.config.domBindEl
|
||||||
|
.find(".captcha-loading")
|
||||||
|
.css("display", "block");
|
||||||
|
}
|
||||||
|
|
||||||
|
closeLoading() {
|
||||||
|
this.config.domBindEl
|
||||||
|
.find(".captcha-loading")
|
||||||
|
.css("display", "none");
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyWindow() {
|
||||||
|
if (this.C) {
|
||||||
|
this.C.destroy();
|
||||||
|
this.C = undefined;
|
||||||
|
}
|
||||||
|
if (this.domTemplate) {
|
||||||
|
this.domTemplate.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openCaptcha() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.C.el.css("transform", "translateX(0)");
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
createCaptcha() {
|
||||||
|
this.config.requestCaptchaData().then((data) => {
|
||||||
|
this.closeLoading();
|
||||||
|
if (!data.code) {
|
||||||
|
throw new Error("[TAC] 后台验证码接口数据错误!!!");
|
||||||
|
}
|
||||||
|
let captchaType = data.code === 200 ? data.data?.type : "DISABLED";
|
||||||
|
const captcha = createCaptchaByType(captchaType, this);
|
||||||
|
if (captcha == null) {
|
||||||
|
throw new Error("[TAC] 未知的验证码类型[" + captchaType + "]");
|
||||||
|
}
|
||||||
|
captcha.init(data, (d, c) => {
|
||||||
|
// 验证
|
||||||
|
const currentCaptchaData = c.currentCaptchaData;
|
||||||
|
const data = {
|
||||||
|
bgImageWidth: currentCaptchaData.bgImageWidth,
|
||||||
|
bgImageHeight: currentCaptchaData.bgImageHeight,
|
||||||
|
templateImageWidth: currentCaptchaData.templateImageWidth,
|
||||||
|
templateImageHeight: currentCaptchaData.templateImageHeight,
|
||||||
|
startTime: currentCaptchaData.startTime.getTime(),
|
||||||
|
stopTime: currentCaptchaData.stopTime.getTime(),
|
||||||
|
trackList: currentCaptchaData.trackList,
|
||||||
|
};
|
||||||
|
if (c.type === "ROTATE_DEGREE" || c.type === "ROTATE") {
|
||||||
|
data.bgImageWidth = c.currentCaptchaData.end;
|
||||||
|
}
|
||||||
|
if (currentCaptchaData.data) {
|
||||||
|
data.data = currentCaptchaData.data;
|
||||||
|
}
|
||||||
|
// 清空
|
||||||
|
const id = c.currentCaptchaData.currentCaptchaId;
|
||||||
|
c.currentCaptchaData = undefined;
|
||||||
|
if (this.config.onDataReady) {
|
||||||
|
Promise.resolve(this.config.onDataReady({ id, data }, c, this)).catch((error) => {
|
||||||
|
console.error("[TAC] onDataReady callback failed", error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 调用验证接口
|
||||||
|
this.config.validCaptcha(id, data, c, this);
|
||||||
|
});
|
||||||
|
this.C = captcha;
|
||||||
|
this.openCaptcha();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyCaptcha(callback) {
|
||||||
|
if (this.C) {
|
||||||
|
this.C.el.css("transform", "translateX(300px)");
|
||||||
|
setTimeout(() => {
|
||||||
|
this.C.destroy();
|
||||||
|
callback && callback();
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TianAiCaptcha, CaptchaConfig };
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
.__captcha-parent {
|
||||||
|
box-shadow: 0 0 11px 0 #999999;
|
||||||
|
width: 316px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
z-index: 997;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 8px;
|
||||||
|
.captcha-box {
|
||||||
|
height: 260px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
.loading {
|
||||||
|
width: 120px;
|
||||||
|
height: 20px;
|
||||||
|
mask: linear-gradient(90deg, #000 70%, #0000 0) 0/20%;
|
||||||
|
background: linear-gradient(#f7b645 0 0) 0 / 0% no-repeat #dddddd6b;
|
||||||
|
animation: cartoon 1s infinite steps(6);
|
||||||
|
margin: 120px auto;
|
||||||
|
@keyframes cartoon {
|
||||||
|
100% {
|
||||||
|
background-size: 120%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.captcha {
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
will-change: transform;
|
||||||
|
transition-duration: 0.45s;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-bottom {
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
.close-btn, .refresh-btn {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
background-image: url("../images/icon.png");
|
||||||
|
background-position: 0 -14px;
|
||||||
|
}
|
||||||
|
.refresh-btn {
|
||||||
|
background-image: url("../images/icon.png");
|
||||||
|
background-position: 0 -167px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
.slider-move-shadow {
|
||||||
|
animation: move-shadow 2s infinite;
|
||||||
|
height: 100%;
|
||||||
|
width: 5px;
|
||||||
|
background-color: #fff;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
filter: opacity(0.5);
|
||||||
|
box-shadow: 1px 1px 1px #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.captcha-slider-move-track-mask {
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #00f4ab;
|
||||||
|
|
||||||
|
width: 0;
|
||||||
|
height: 32px;
|
||||||
|
background-color: #a9ffe5;
|
||||||
|
opacity: 0.5;
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
left: -1px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
@keyframes move-shadow {
|
||||||
|
from {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
left: 289px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@import "./common/common.less";
|
||||||
|
@import "./slider/slider.less";
|
||||||
|
@import "./rotate/rotate.less";
|
||||||
|
@import "./concat/concat.less";
|
||||||
|
@import "./disable/disable.less";
|
||||||
|
@import "./image_click/image_click.less";
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
/** 是否打印日志 */
|
||||||
|
var isPrintLog = false;
|
||||||
|
|
||||||
|
function printLog(params) {
|
||||||
|
if (isPrintLog) {
|
||||||
|
console.log(JSON.stringify(params));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除默认事件
|
||||||
|
* @param event event
|
||||||
|
*/
|
||||||
|
function clearPreventDefault(event) {
|
||||||
|
if (event.preventDefault) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阻止某div默认事件
|
||||||
|
* @param dom
|
||||||
|
*/
|
||||||
|
function clearAllPreventDefault(dom) {
|
||||||
|
Dom(dom).each((el) => {
|
||||||
|
// 手机端
|
||||||
|
el.addEventListener("touchmove", clearPreventDefault, { passive: false });
|
||||||
|
// pc端
|
||||||
|
el.addEventListener("mousemove", clearPreventDefault, { passive: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前坐标
|
||||||
|
* @param event 事件
|
||||||
|
* @returns {{x: number, y: number}}
|
||||||
|
*/
|
||||||
|
function getCurrentCoordinate(event) {
|
||||||
|
if (event.pageX !== null && event.pageX !== undefined) {
|
||||||
|
return {
|
||||||
|
x: Math.round(event.pageX),
|
||||||
|
y: Math.round(event.pageY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let targetTouches;
|
||||||
|
if (event.changedTouches) {
|
||||||
|
// 抬起事件
|
||||||
|
targetTouches = event.changedTouches;
|
||||||
|
} else if (event.targetTouches) {
|
||||||
|
// pc 按下事件
|
||||||
|
targetTouches = event.targetTouches;
|
||||||
|
} else if (event.originalEvent && event.originalEvent.targetTouches) {
|
||||||
|
// 鼠标触摸事件
|
||||||
|
targetTouches = event.originalEvent.targetTouches;
|
||||||
|
}
|
||||||
|
if (targetTouches[0].pageX !== null && targetTouches[0].pageX !== undefined) {
|
||||||
|
return {
|
||||||
|
x: Math.round(targetTouches[0].pageX),
|
||||||
|
y: Math.round(targetTouches[0].pageY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: Math.round(targetTouches[0].clientX),
|
||||||
|
y: Math.round(targetTouches[0].clientY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function down(currentCaptcha, event) {
|
||||||
|
// debugger
|
||||||
|
const coordinate = getCurrentCoordinate(event);
|
||||||
|
let startX = coordinate.x;
|
||||||
|
let startY = coordinate.y;
|
||||||
|
currentCaptcha.currentCaptchaData.startX = startX;
|
||||||
|
currentCaptcha.currentCaptchaData.startY = startY;
|
||||||
|
const trackList = currentCaptcha.currentCaptchaData.trackList;
|
||||||
|
currentCaptcha.currentCaptchaData.startTime = new Date();
|
||||||
|
const startTime = currentCaptcha.currentCaptchaData.startTime;
|
||||||
|
|
||||||
|
trackList.push({
|
||||||
|
x: coordinate.x,
|
||||||
|
y: coordinate.y,
|
||||||
|
type: "down",
|
||||||
|
t: new Date().getTime() - startTime.getTime(),
|
||||||
|
});
|
||||||
|
printLog(["start", startX, startY]);
|
||||||
|
currentCaptcha.__m__ = move.bind(null, currentCaptcha);
|
||||||
|
currentCaptcha.__u__ = up.bind(null, currentCaptcha);
|
||||||
|
// pc
|
||||||
|
window.addEventListener("mousemove", currentCaptcha.__m__);
|
||||||
|
window.addEventListener("mouseup", currentCaptcha.__u__);
|
||||||
|
// 手机端
|
||||||
|
window.addEventListener("touchmove", currentCaptcha.__m__, false);
|
||||||
|
window.addEventListener("touchend", currentCaptcha.__u__, false);
|
||||||
|
if (currentCaptcha && currentCaptcha.doDown) {
|
||||||
|
currentCaptcha.doDown(event, currentCaptcha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(currentCaptcha, event) {
|
||||||
|
if (event.touches && event.touches.length > 0) {
|
||||||
|
event = event.touches[0];
|
||||||
|
}
|
||||||
|
// debugger
|
||||||
|
const coordinate = getCurrentCoordinate(event);
|
||||||
|
let pageX = coordinate.x;
|
||||||
|
let pageY = coordinate.y;
|
||||||
|
const startX = currentCaptcha.currentCaptchaData.startX;
|
||||||
|
const startY = currentCaptcha.currentCaptchaData.startY;
|
||||||
|
const startTime = currentCaptcha.currentCaptchaData.startTime;
|
||||||
|
const end = currentCaptcha.currentCaptchaData.end;
|
||||||
|
const bgImageWidth = currentCaptcha.currentCaptchaData.bgImageWidth;
|
||||||
|
const trackList = currentCaptcha.currentCaptchaData.trackList;
|
||||||
|
let moveX = pageX - startX;
|
||||||
|
let moveY = pageY - startY;
|
||||||
|
const track = {
|
||||||
|
x: coordinate.x,
|
||||||
|
y: coordinate.y,
|
||||||
|
type: "move",
|
||||||
|
t: new Date().getTime() - startTime.getTime(),
|
||||||
|
};
|
||||||
|
trackList.push(track);
|
||||||
|
if (moveX < 0) {
|
||||||
|
moveX = 0;
|
||||||
|
} else if (moveX > end) {
|
||||||
|
moveX = end;
|
||||||
|
}
|
||||||
|
currentCaptcha.currentCaptchaData.moveX = moveX;
|
||||||
|
currentCaptcha.currentCaptchaData.moveY = moveY;
|
||||||
|
if (currentCaptcha.doMove) {
|
||||||
|
currentCaptcha.doMove(event, currentCaptcha);
|
||||||
|
}
|
||||||
|
printLog(["move", track]);
|
||||||
|
}
|
||||||
|
function destroyEvent(currentCaptcha) {
|
||||||
|
if (currentCaptcha) {
|
||||||
|
if (currentCaptcha.__m__) {
|
||||||
|
window.removeEventListener("mousemove", currentCaptcha.__m__);
|
||||||
|
window.removeEventListener("touchmove", currentCaptcha.__m__);
|
||||||
|
}
|
||||||
|
if (currentCaptcha.__u__) {
|
||||||
|
window.removeEventListener("mouseup", currentCaptcha.__u__);
|
||||||
|
window.removeEventListener("touchend", currentCaptcha.__u__);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function up(currentCaptcha, event) {
|
||||||
|
destroyEvent(currentCaptcha);
|
||||||
|
const coordinate = getCurrentCoordinate(event);
|
||||||
|
currentCaptcha.currentCaptchaData.stopTime = new Date();
|
||||||
|
const startTime = currentCaptcha.currentCaptchaData.startTime;
|
||||||
|
const trackList = currentCaptcha.currentCaptchaData.trackList;
|
||||||
|
|
||||||
|
const track = {
|
||||||
|
x: coordinate.x,
|
||||||
|
y: coordinate.y,
|
||||||
|
type: "up",
|
||||||
|
t: new Date().getTime() - startTime.getTime(),
|
||||||
|
};
|
||||||
|
|
||||||
|
trackList.push(track);
|
||||||
|
printLog(["up", track]);
|
||||||
|
printLog(["tracks", trackList]);
|
||||||
|
if (currentCaptcha.doUp) {
|
||||||
|
currentCaptcha.doUp(event, currentCaptcha);
|
||||||
|
}
|
||||||
|
currentCaptcha.endCallback(currentCaptcha.currentCaptchaData, currentCaptcha);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initConfig(
|
||||||
|
bgImageWidth,
|
||||||
|
bgImageHeight,
|
||||||
|
templateImageWidth,
|
||||||
|
templateImageHeight,
|
||||||
|
end,
|
||||||
|
) {
|
||||||
|
// bugfix 图片宽高可能会有小数情况,强转一下整数
|
||||||
|
const currentCaptchaConfig = {
|
||||||
|
startTime: new Date(),
|
||||||
|
trackList: [],
|
||||||
|
movePercent: 0,
|
||||||
|
clickCount: 0,
|
||||||
|
bgImageWidth: Math.round(bgImageWidth),
|
||||||
|
bgImageHeight: Math.round(bgImageHeight),
|
||||||
|
templateImageWidth: Math.round(templateImageWidth),
|
||||||
|
templateImageHeight: Math.round(templateImageHeight),
|
||||||
|
end: end,
|
||||||
|
};
|
||||||
|
printLog(["init", currentCaptchaConfig]);
|
||||||
|
return currentCaptchaConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTips(el, callback) {
|
||||||
|
const tipEl = Dom(el).find(".captcha-tips");
|
||||||
|
tipEl.removeClass("captcha-tips-on");
|
||||||
|
// tipEl.removeClass("captcha-tips-success")
|
||||||
|
// tipEl.removeClass("captcha-tips-error")
|
||||||
|
// 延时
|
||||||
|
if (callback) {
|
||||||
|
setTimeout(callback, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTips(el, msg, type, callback) {
|
||||||
|
const tipEl = Dom(el).find(".captcha-tips");
|
||||||
|
tipEl.text(msg);
|
||||||
|
if (type === 1) {
|
||||||
|
// 成功
|
||||||
|
tipEl.removeClass("captcha-tips-error");
|
||||||
|
tipEl.addClass("captcha-tips-success");
|
||||||
|
} else {
|
||||||
|
// 失败
|
||||||
|
tipEl.removeClass("captcha-tips-success");
|
||||||
|
tipEl.addClass("captcha-tips-error");
|
||||||
|
}
|
||||||
|
tipEl.addClass("captcha-tips-on");
|
||||||
|
// 延时
|
||||||
|
setTimeout(callback, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommonCaptcha {
|
||||||
|
showTips(msg, type, callback) {
|
||||||
|
showTips(this.el, msg, type, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeTips(msg, callback) {
|
||||||
|
closeTips(this.el, msg, callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dom(domStr, dom) {
|
||||||
|
return new DomEl(domStr, dom);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DomEl {
|
||||||
|
constructor(domStr, dom) {
|
||||||
|
if (dom instanceof HTMLElement) {
|
||||||
|
this.dom = dom;
|
||||||
|
this.domStr = domStr;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (domStr instanceof DomEl) {
|
||||||
|
this.dom = domStr.dom;
|
||||||
|
this.domStr = domStr.domStr;
|
||||||
|
} else if (typeof domStr === "string") {
|
||||||
|
this.dom = document.querySelector(domStr);
|
||||||
|
this.domStr = domStr;
|
||||||
|
} else if (domStr instanceof HTMLElement) {
|
||||||
|
this.dom = domStr;
|
||||||
|
this.domStr = domStr.nodeName;
|
||||||
|
} else {
|
||||||
|
throw new Error("不支持的类型");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
each(callback) {
|
||||||
|
this.getTarget().querySelectorAll("*").forEach(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeClass(className) {
|
||||||
|
let element = this.getTarget();
|
||||||
|
if (element.classList) {
|
||||||
|
// 使用 classList API 移除类
|
||||||
|
element.classList.remove(className);
|
||||||
|
} else {
|
||||||
|
// 兼容旧版本浏览器
|
||||||
|
const currentClass = element.className;
|
||||||
|
const regex = new RegExp("(?:^|\\s)" + className + "(?!\\S)", "g");
|
||||||
|
element.className = currentClass.replace(regex, "");
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
addClass(className) {
|
||||||
|
const element = this.getTarget();
|
||||||
|
if (element.classList) {
|
||||||
|
// 使用 classList API 添加类
|
||||||
|
element.classList.add(className);
|
||||||
|
} else {
|
||||||
|
// 兼容旧版本浏览器
|
||||||
|
let currentClass = element.className;
|
||||||
|
if (currentClass.indexOf(className) === -1) {
|
||||||
|
element.className = currentClass + " " + className;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
find(str) {
|
||||||
|
const el = this.getTarget().querySelector(str);
|
||||||
|
if (el) {
|
||||||
|
return new DomEl(str, el);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
children(selector) {
|
||||||
|
const childNodes = this.getTarget().childNodes;
|
||||||
|
for (let i = 0; i < childNodes.length; i++) {
|
||||||
|
if (childNodes[i].nodeType === 1 && childNodes[i].matches(selector)) {
|
||||||
|
return new DomEl(selector, childNodes[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove() {
|
||||||
|
this.getTarget().remove();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
css(property, value) {
|
||||||
|
if (typeof property === "string" && typeof value === "string") {
|
||||||
|
// 设置单个属性
|
||||||
|
this.getTarget().style[property] = value;
|
||||||
|
} else if (typeof property === "object") {
|
||||||
|
// 设置多个属性
|
||||||
|
for (var prop in property) {
|
||||||
|
if (property.hasOwnProperty(prop)) {
|
||||||
|
this.getTarget().style[prop] = property[prop];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (typeof property === "string" && typeof value === "undefined") {
|
||||||
|
// 获取单个属性
|
||||||
|
return window.getComputedStyle(element)[property];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attr(attributeName, value) {
|
||||||
|
if (value === undefined) {
|
||||||
|
// 如果未提供值,则返回属性的当前值
|
||||||
|
return this.getTarget().getAttribute(attributeName);
|
||||||
|
} else {
|
||||||
|
// 如果提供了值,则设置属性的值
|
||||||
|
this.getTarget().setAttribute(attributeName, value);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
text(str) {
|
||||||
|
this.getTarget().innerText = str;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
html(str) {
|
||||||
|
this.getTarget().innerHtml = str;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
is(dom) {
|
||||||
|
if (dom && typeof dom === "object" && typeof dom.nodeType !== "undefined") {
|
||||||
|
return this.dom === dom;
|
||||||
|
}
|
||||||
|
if (dom instanceof DomEl) {
|
||||||
|
return this.dom === dom.dom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
append(content) {
|
||||||
|
if (typeof content === "string") {
|
||||||
|
this.getTarget().insertAdjacentHTML("beforeend", content);
|
||||||
|
} else if (content instanceof HTMLElement) {
|
||||||
|
this.getTarget().appendChild(content);
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid content type");
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
click(fun) {
|
||||||
|
this.on("click", fun);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
mousedown(fun) {
|
||||||
|
this.on("mousedown", fun);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
touchstart(fun) {
|
||||||
|
this.on("touchstart", fun);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
on(eventType, fun) {
|
||||||
|
this.getTarget().addEventListener(eventType, fun, { passive: true });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
width() {
|
||||||
|
return this.getTarget().offsetWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
height() {
|
||||||
|
return this.getTarget().offsetHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTarget() {
|
||||||
|
if (this.dom) {
|
||||||
|
return this.dom;
|
||||||
|
}
|
||||||
|
throw new Error("dom不存在: [" + this.domStr + "]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function http(options) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open(options.method || "GET", options.url);
|
||||||
|
// 设置请求头
|
||||||
|
if (options.headers) {
|
||||||
|
for (const header in options.headers) {
|
||||||
|
if (options.headers.hasOwnProperty(header)) {
|
||||||
|
xhr.setRequestHeader(header, options.headers[header]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||||
|
if (xhr.status >= 200 && xhr.status <= 500) {
|
||||||
|
const contentType = xhr.getResponseHeader("Content-Type");
|
||||||
|
if (contentType && contentType.indexOf("application/json") !== -1) {
|
||||||
|
resolve(JSON.parse(xhr.responseText));
|
||||||
|
} else {
|
||||||
|
resolve(xhr.responseText);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(new Error("Request failed with status: " + xhr.status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = function () {
|
||||||
|
reject(new Error("Network Error"));
|
||||||
|
};
|
||||||
|
xhr.send(options.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyObject(obj) {
|
||||||
|
for (var key in obj) {
|
||||||
|
if (obj.hasOwnProperty(key)) {
|
||||||
|
return false; // 对象不为空
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true; // 对象为空
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
isEmptyObject,
|
||||||
|
http,
|
||||||
|
Dom,
|
||||||
|
DomEl,
|
||||||
|
CommonCaptcha,
|
||||||
|
clearAllPreventDefault,
|
||||||
|
down,
|
||||||
|
move,
|
||||||
|
up,
|
||||||
|
initConfig,
|
||||||
|
showTips,
|
||||||
|
closeTips,
|
||||||
|
destroyEvent,
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
.captcha {
|
||||||
|
text-align: left;
|
||||||
|
box-sizing: content-box;
|
||||||
|
width: 100%;
|
||||||
|
height: 260px;
|
||||||
|
z-index: 999;
|
||||||
|
.slider-bottom .logo {
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
.slider-bottom {
|
||||||
|
height: 19px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
.captcha-tips {
|
||||||
|
height: 25px;
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
bottom: -25px;
|
||||||
|
left: 0;
|
||||||
|
z-index: 999;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 25px;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
transition: bottom 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
.captcha-tips.captcha-tips-error {
|
||||||
|
background-color: #ff5d39;
|
||||||
|
}
|
||||||
|
.captcha-tips.captcha-tips-success {
|
||||||
|
background-color: #39c522;
|
||||||
|
}
|
||||||
|
.captcha-tips.captcha-tips-on {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-loading {
|
||||||
|
z-index: 9999;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
text-align: center;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
width: 45px;
|
||||||
|
height: 45px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.captcha-slider-bg-canvas {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.captcha-slider-bg-div {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
.captcha-slider-bg-div-slice {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import {
|
||||||
|
Dom,
|
||||||
|
CommonCaptcha,
|
||||||
|
clearAllPreventDefault,
|
||||||
|
down,
|
||||||
|
initConfig,
|
||||||
|
destroyEvent,
|
||||||
|
} from "../common/common.js";
|
||||||
|
|
||||||
|
const TYPE = "CONCAT";
|
||||||
|
|
||||||
|
function getTemplate() {
|
||||||
|
return `
|
||||||
|
<div class="captcha captcha-slider captcha-concat">
|
||||||
|
<div class="slider-tip">
|
||||||
|
<span class="captcha-slider-move-track-font" >拖动滑块完成拼图</span>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="captcha-slider-concat-img-div"></div>
|
||||||
|
<div class="captcha-slider-concat-bg-img"></div>
|
||||||
|
<div class="captcha-tips"></div>
|
||||||
|
</div>
|
||||||
|
<div class="slider-move">
|
||||||
|
<div class="slider-move-track">
|
||||||
|
<div class="captcha-slider-move-track-mask"></div>
|
||||||
|
<div class="slider-move-shadow"></div>
|
||||||
|
</div>
|
||||||
|
<div class="slider-move-btn captcha-slider-move-btn">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Concat extends CommonCaptcha {
|
||||||
|
constructor(divId, styleConfig) {
|
||||||
|
super();
|
||||||
|
this.boxEl = Dom(divId);
|
||||||
|
this.styleConfig = styleConfig;
|
||||||
|
this.type = TYPE;
|
||||||
|
this.currentCaptchaData = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
init(captchaData, endCallback, loadSuccessCallback) {
|
||||||
|
// 重载样式
|
||||||
|
this.destroy();
|
||||||
|
this.boxEl.append(getTemplate());
|
||||||
|
this.el = this.boxEl.find(".captcha");
|
||||||
|
this.loadStyle();
|
||||||
|
// 按钮绑定事件
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-btn")
|
||||||
|
.mousedown(down.bind(null, this));
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-btn")
|
||||||
|
.touchstart(down.bind(null, this));
|
||||||
|
clearAllPreventDefault(this.el);
|
||||||
|
// 绑定全局
|
||||||
|
window.currentCaptcha = this;
|
||||||
|
// 载入验证码
|
||||||
|
this.loadCaptchaForData(this, captchaData);
|
||||||
|
this.endCallback = endCallback;
|
||||||
|
if (loadSuccessCallback) {
|
||||||
|
// 加载成功
|
||||||
|
loadSuccessCallback(this);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
destroyEvent();
|
||||||
|
const existsCaptchaEl = this.boxEl.children(".captcha");
|
||||||
|
if (existsCaptchaEl) {
|
||||||
|
existsCaptchaEl.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doMove() {
|
||||||
|
const moveX = this.currentCaptchaData.moveX;
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-btn")
|
||||||
|
.css("transform", "translate(" + moveX + "px, 0px)");
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-concat-img-div")
|
||||||
|
.css("background-position-x", moveX + "px");
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-track-mask")
|
||||||
|
.css("width", moveX + "px");
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStyle() {
|
||||||
|
let sliderImg = "";
|
||||||
|
let moveTrackMaskBorderColor = "#00f4ab";
|
||||||
|
let moveTrackMaskBgColor = "#a9ffe5";
|
||||||
|
const styleConfig = this.styleConfig;
|
||||||
|
if (styleConfig) {
|
||||||
|
sliderImg = styleConfig.btnUrl;
|
||||||
|
moveTrackMaskBgColor = styleConfig.moveTrackMaskBgColor;
|
||||||
|
moveTrackMaskBorderColor = styleConfig.moveTrackMaskBorderColor;
|
||||||
|
}
|
||||||
|
this.el
|
||||||
|
.find(".slider-move .slider-move-btn")
|
||||||
|
.css("background-image", "url(" + sliderImg + ")");
|
||||||
|
// this.el.find(".captcha-slider-move-track-font").text(title);
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-track-mask")
|
||||||
|
.css("border-color", moveTrackMaskBorderColor);
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-track-mask")
|
||||||
|
.css("background-color", moveTrackMaskBgColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCaptchaForData(that, data) {
|
||||||
|
const bgImg = that.el.find(".captcha-slider-concat-bg-img");
|
||||||
|
const sliderImg = that.el.find(".captcha-slider-concat-img-div");
|
||||||
|
bgImg.css("background-image", "url(" + data.data.backgroundImage + ")");
|
||||||
|
sliderImg.css("background-image", "url(" + data.data.backgroundImage + ")");
|
||||||
|
sliderImg.css("background-position", "0px 0px");
|
||||||
|
var backgroundImageHeight = data.data.backgroundImageHeight;
|
||||||
|
var height =
|
||||||
|
((backgroundImageHeight - data.data.data.randomY) /
|
||||||
|
backgroundImageHeight) *
|
||||||
|
180;
|
||||||
|
sliderImg.css("height", height + "px");
|
||||||
|
|
||||||
|
that.currentCaptchaData = initConfig(
|
||||||
|
bgImg.width(),
|
||||||
|
bgImg.height(),
|
||||||
|
sliderImg.width(),
|
||||||
|
sliderImg.height(),
|
||||||
|
300 - 63 + 5,
|
||||||
|
);
|
||||||
|
that.currentCaptchaData.currentCaptchaId = data.data.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Concat;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
.captcha.captcha-concat {
|
||||||
|
.captcha-slider-concat-img-div {
|
||||||
|
background-size: 100% 180px;
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(0px, 0px);
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow:
|
||||||
|
0 0 6px 3px rgb(255 255 255 / 60%),
|
||||||
|
0 1px 4px rgb(0 0 0 / 90%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.captcha-slider-concat-bg-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(0px, 0px);
|
||||||
|
background-size: 100% 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import StyleConfig from "./styleConfig";
|
||||||
|
import { Dom, http } from "../common/common";
|
||||||
|
class CaptchaConfig {
|
||||||
|
constructor(args) {
|
||||||
|
if (!args.bindEl) {
|
||||||
|
throw new Error("[TAC] 必须配置 [bindEl]用于将验证码绑定到该元素上");
|
||||||
|
}
|
||||||
|
if (!args.requestCaptchaDataUrl) {
|
||||||
|
throw new Error("[TAC] 必须配置 [requestCaptchaDataUrl]请求验证码接口");
|
||||||
|
}
|
||||||
|
if (!args.validCaptchaUrl && !args.onDataReady) {
|
||||||
|
throw new Error("[TAC] 必须配置 [validCaptchaUrl]验证验证码接口");
|
||||||
|
}
|
||||||
|
this.bindEl = args.bindEl;
|
||||||
|
this.domBindEl = Dom(args.bindEl);
|
||||||
|
this.requestCaptchaDataUrl = args.requestCaptchaDataUrl;
|
||||||
|
this.types = Array.isArray(args.types) ? args.types.filter(Boolean) : [];
|
||||||
|
this.validCaptchaUrl = args.validCaptchaUrl;
|
||||||
|
if (args.validSuccess) {
|
||||||
|
this.validSuccess = args.validSuccess;
|
||||||
|
}
|
||||||
|
if (args.validFail) {
|
||||||
|
this.validFail = args.validFail;
|
||||||
|
}
|
||||||
|
if (args.onDataReady) {
|
||||||
|
this.onDataReady = args.onDataReady;
|
||||||
|
}
|
||||||
|
if (args.requestHeaders) {
|
||||||
|
this.requestHeaders = args.requestHeaders;
|
||||||
|
} else {
|
||||||
|
this.requestHeaders = {};
|
||||||
|
}
|
||||||
|
if (args.btnCloseFun) {
|
||||||
|
this.btnCloseFun = args.btnCloseFun;
|
||||||
|
}
|
||||||
|
if (args.btnRefreshFun) {
|
||||||
|
this.btnRefreshFun = args.btnRefreshFun;
|
||||||
|
}
|
||||||
|
this.requestChain = [];
|
||||||
|
// 时间戳转换
|
||||||
|
this.timeToTimestamp = args.timeToTimestamp || true;
|
||||||
|
this.insertRequestChain(0, {
|
||||||
|
preRequest(type, param, c, tac) {
|
||||||
|
if (this.timeToTimestamp && param.data) {
|
||||||
|
for (let key in param.data) {
|
||||||
|
// 将date全部转换为时间戳
|
||||||
|
if (param.data[key] instanceof Date) {
|
||||||
|
param.data[key] = param.data[key].getTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
addRequestChain(fun) {
|
||||||
|
this.requestChain.push(fun);
|
||||||
|
}
|
||||||
|
insertRequestChain(index, chain) {
|
||||||
|
this.requestChain.splice(index, 0, chain);
|
||||||
|
}
|
||||||
|
removeRequestChain(index) {
|
||||||
|
this.requestChain.splice(index, 1);
|
||||||
|
}
|
||||||
|
requestCaptchaData() {
|
||||||
|
const requestParam = {};
|
||||||
|
requestParam.headers = this.requestHeaders || {};
|
||||||
|
requestParam.data = { types: this.types };
|
||||||
|
// 设置默认值
|
||||||
|
requestParam.headers["Content-Type"] = "application/json;charset=UTF-8";
|
||||||
|
requestParam.method = "POST";
|
||||||
|
requestParam.url = this.requestCaptchaDataUrl;
|
||||||
|
// 请求前装载参数
|
||||||
|
this._preRequest("requestCaptchaData", requestParam);
|
||||||
|
// 发送请求
|
||||||
|
const request = this.doSendRequest(requestParam);
|
||||||
|
// 返回结果
|
||||||
|
return request.then((res) => {
|
||||||
|
// 装返回结果
|
||||||
|
this._postRequest("requestCaptchaData", requestParam, res);
|
||||||
|
// 返回结果
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
doSendRequest(requestParam) {
|
||||||
|
// 如果content-type是json,那么data就是json字符串, 这里直接匹配所有header是否包含application/json
|
||||||
|
if (requestParam.headers) {
|
||||||
|
for (const key in requestParam.headers) {
|
||||||
|
if (requestParam.headers[key].indexOf("application/json") > -1) {
|
||||||
|
if (typeof requestParam.data !== "string") {
|
||||||
|
requestParam.data = JSON.stringify(requestParam.data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return http(requestParam).then((res) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(res);
|
||||||
|
} catch (e) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_preRequest(type, requestParam, c, tac) {
|
||||||
|
for (let i = 0; i < this.requestChain.length; i++) {
|
||||||
|
const r = this.requestChain[i];
|
||||||
|
if (r.preRequest) {
|
||||||
|
if (!r.preRequest(type, requestParam, this, c, tac)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_postRequest(type, requestParam, res, c, tac) {
|
||||||
|
for (let i = 0; i < this.requestChain.length; i++) {
|
||||||
|
const r = this.requestChain[i];
|
||||||
|
// 判断r是否存圩postRequest方法
|
||||||
|
if (r.postRequest) {
|
||||||
|
if (!r.postRequest(type, requestParam, res, this, c, tac)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validCaptcha(currentCaptchaId, data, c, tac) {
|
||||||
|
const sendParam = {
|
||||||
|
id: currentCaptchaId,
|
||||||
|
data: data,
|
||||||
|
};
|
||||||
|
let requestParam = {};
|
||||||
|
requestParam.headers = this.requestHeaders || {};
|
||||||
|
requestParam.data = sendParam;
|
||||||
|
requestParam.headers["Content-Type"] = "application/json;charset=UTF-8";
|
||||||
|
requestParam.method = "POST";
|
||||||
|
requestParam.url = this.validCaptchaUrl;
|
||||||
|
|
||||||
|
this._preRequest("validCaptcha", requestParam, c, tac);
|
||||||
|
const request = this.doSendRequest(requestParam);
|
||||||
|
return request
|
||||||
|
.then((res) => {
|
||||||
|
this._postRequest("validCaptcha", requestParam, res, c, tac);
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
const useTimes = (data.stopTime - data.startTime) / 1000;
|
||||||
|
c.showTips(`验证成功,耗时${useTimes}秒`, 1, () =>
|
||||||
|
this.validSuccess(res, c, tac),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let tipMsg = "验证失败,请重新尝试!";
|
||||||
|
if (res.code) {
|
||||||
|
if (res.code != 4001) {
|
||||||
|
tipMsg = "验证码被黑洞吸走了!";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.showTips(tipMsg, 0, () => this.validFail(res, c, tac));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
let tipMsg = c.styleConfig.i18n.tips_error;
|
||||||
|
if (e.code && e.code != 200) {
|
||||||
|
if (e.code != 4001) {
|
||||||
|
tipMsg = c.styleConfig.i18n.tips_4001;
|
||||||
|
}
|
||||||
|
c.showTips(tipMsg, 0, () => this.validFail(e, c, tac));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
c.showTips(tipMsg, 0, () => this.validFail(e, c, tac));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validSuccess(res, c, tac) {
|
||||||
|
console.log(
|
||||||
|
"验证码校验成功, 请重写 [config.validSuccess] 方法, 用于自定义逻辑处理",
|
||||||
|
);
|
||||||
|
window.currentCaptchaRes = res;
|
||||||
|
tac.destroyWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
validFail(res, c, tac) {
|
||||||
|
tac.reloadCaptcha();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapConfig(config) {
|
||||||
|
if (config instanceof CaptchaConfig) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
return new CaptchaConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapStyle(style) {
|
||||||
|
let margeStyle = { ...StyleConfig, ...style };
|
||||||
|
margeStyle.i18n = { ...StyleConfig.i18n, ...style?.i18n };
|
||||||
|
return margeStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const captchaRequestChains = {};
|
||||||
|
|
||||||
|
export { CaptchaConfig, wrapConfig, wrapStyle };
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export default {
|
||||||
|
// 按钮图片
|
||||||
|
btnUrl:
|
||||||
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIwAAABkCAYAAABU19jRAAAJcUlEQVR4nO2d63MT1xmHf9rV6mr5fgNMuSW+ENsY8N0EE2BMhinJNB8y/dD2Qz/0v+gMf0w/JHTKNJAhICwbsA02TpNAHEMgQIwNBSEb8F2rvXTeY1kjYyA+TmVJmfeZ8YiRWa9299E57/mdI63Dtm3E+RjAKTDMaj4F8AU9uyzMCQBn+EQxb+EjAF+RMH8AcJrPFLMGvCSMzWeKWSN/I2GiAFx8xpi1oPBZYiTQWRhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGCiefrtShGwZiup74+4qqwu12Z/W7lIVJEfN6FDfv3sPXfYOIRRfpm1UQKC7EkQ+PYFtRcdZKw8KkiLsPJ/CfgSFcH7yOxWhU7MSluYQoR44fxdaCoqyUhoVJEfZ8FN99c1N0Sx6PR+zEMAz0XAgBNtB14hi25OXDkWXHxUVvinA4ln6ScTqdsGwbvRd7EPwyiEcvXyDbvpyHhUkRaq4fe/c3wEWSWFZiJySNYZroCYYQPHsBY1OTWSWNevLkyb/TYwa8lt8UAb8ftluDW9UwPj4hDs0Rb3JUVRXd09j9nwELKKgoR4HXlw2Hb3INkyK8mob9NdUwLROq4sCVKwMrdqRpGkzTFN0TaWR2HcKu0rKMr2lYmBTi1jS01dUt7UBx4PKlfvHP5JaGuqseIY0DjmOHsKukNKOPiYVJMU5VRXt9PSwboO+fvHJ5QEiiKEvlIz3S86HuHiiqAhw9iJ0lpRnb0rAwG4CqKHh/Tz0UhwOWaWGg/5oofEkmJLU4wfPdQia765CQJhNHJCzMBkEtSVtdLRw2YNo2hgaGEDMMMWpahrwJBUMUCkM9djgjE2EWZgOhFqW5rlbMKdm2heHBYUT1mCiAEW9pKKfpPh8Sj5mYCLMwG4zLqWJfTZWQgL5S++uhYURjBrR4S0MtUSYnwixMGvBoGvZUV4quh0S4Pjgsaho1XtOIcM8wxJCb+qmu33dljDS/CWEeTb/E/Pw89EUdebkBVBQWrnnbWVjQoMAtsT9asGDQhf8VUbnX5UJ9VaVoZahVuXZ1cMXoiaSJxWIiEab/dPj4UXFczjRrk/VJ70/hp/jhuxF89o9TGP1+FH6fD9OxGHw5Pnicb34/PJ2dweitu7hwLojvb47A9rhQmJeXGLm8iQeP/4uRH27h88/+iZhhYs40UFZQsK7XrqkqigvyYbk18VrHH74+EX74YAzRqI66mupE15UmzKwW5kEkgtFvRxA8ex7hJ2HMzczgzu0f8fjxExRt2YzcgB9udfUJjuo6Tv/7HE6f+pe4GHd//AkwLRhuDeXFRW+U5v7EI4yMjKI3GMLt0Tt4cO8BAoEcWJoTZYXrl6asqBC6U0GOy42HY+MrZi1JmoWFRZQW5sNyuVBeUpxOabJ7aiASjiB4/iKmnj+H5loaacwvLOL2jRF4AjnY8dc/I/DKbTdoSHvr8SO8DD/DzPSMWHrg1JwYvHZdpK2NVZWU26/aF3VDTyLP0N/bh4mJR3C7XZiZnRVdht/nx7u7tsOzzg5qORFWHAocigO9vX2Jronwej24cXMEbq8XrfW169rH/4usnq02o1FEo9FEE47luN22sTAzC0OPrd7ItnHn9h0MDg3D6/WKbZdHJqYRg26ar92XDgvD39zA2Ng4VKdTbEf7mpmeRX/fAPRfeRch+luNNTXICeSu+h3ti7okUzdgp3luO6uFUTUN9lLmnniOCkdKVnML8uB0r76rD72Di4qL4NI0IUnydpZlw/WmGsY00bRvDzZvKhfFKLAU9VOG8v7BdijW+i8kLX649yyMz0+fwVQksur3NILyejzw5efCoaT3kmW1MN68AMq2bBIXXtd18WMZBt6r242DBzvgda3uWhQ4xNzOkeNdohZYXFjA4vwCfD4/Sio2i9bjdeSoGirKylFYXirykehiFHpUR2FJCbZu+x1yXlMrrQWSZWwygv6Ll3DxXBCX+66u6I7o2DRFRWtbM1o62xNdb7rI7lGSqqBs+zZMTj4XLYY/x49t7+zABx8eReWO7ciLL41ctZmqoqRiE/x+P6amp5FbkI9jx7tw+GgncqmbesPuPAEfduzcgenZOTg0FaWby/GXP/0RdZXvrOvlkyzjzyfR81UIoQs9IpRJniqglszt0tDc1oS9bc2o37lTLMhKI2bW35HtRXQRs3MLmH/xUrzzVb8HJQUFyHX/crJCQ+JwOALFqaKspGjNRWtkbg5zc7PQXC5szl/f6Ig6MFqiSavuqHCmumuFLIYBt+ZEY0sTGtua0VBTJQK/NKPzLfzSQEKWL4NiiG5a1gpZzPhMdnNrE/a3N2NPVaUI+jIAnacGNhiShdbx9pzrFgunSA4tqeUQRbuqoLW9BQ0tjSINzhBZBCzMBvPzVAS950KiG6KWJVkWGnXRELrjQBtqG/eioTqzZAELs3FQy3Iv/BR9wUtiUtGOr+tNhoptGt1V7atD4+4aEehlGizMBnH/WRj9wcuiG7LjI7Vllm8d3nnoAKoaakXq+0tzWumChUkxdlyWge4rYt0uzRMpSck01SzUDR3s7MC7e2pFRqSmOZx7GyxMCrESLcsldAd7oCgrEx6xrldRRM1SvbceHfV1K0K7TISFSREx28L41KRIcGmdruOVz82KBFd1oqWjBe/tb0ArLd3McFnAwqSOiclJ9JwP4fLFXtEtJXdDywluU2uTGDpTgZupNcur8GerU8R0eBJDV6+LRVbJLYdIcF2aSHD3tzaL9b20zjdbYGFShB0z4HY6V9QtFNLRXFATxf2U4FZXZkLcLwULkyJoaUXMNMV6HbyS4O6jicQMS3DXCguTInJKC9HU0YoPOg8k1uy0t7eivnmfSHB9WSgLwZOPKcKwLcT0GL69cxe3b46KoK6+ZS92V2zNyAR3jfBsdaox6LPSpiVyf/rEo/rq11JlFzxbnWoomEMW5CtrhWsYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgr6qGx6b4/BZBXUwnzCl4xZI5844g3MCQBn+Kwxb+EjAGcdST3SxwBO8RljXsOnAL4AgP8BXnVIgIvemwsAAAAASUVORK5CYII=",
|
||||||
|
// 移动边框背景颜色
|
||||||
|
moveTrackMaskBgColor: "#89d2ff",
|
||||||
|
// 移动边框颜色
|
||||||
|
moveTrackMaskBorderColor: "#0298f8",
|
||||||
|
// 文字提示
|
||||||
|
i18n: {
|
||||||
|
tips_success: "验证成功,耗时%s秒",
|
||||||
|
tips_error: "验证失败,请重新尝试!",
|
||||||
|
slider_title: "拖动滑块完成拼图",
|
||||||
|
concat_title: "拖动滑块完成拼图",
|
||||||
|
image_click_title: "请依次点击:",
|
||||||
|
rotate_title: "拖动滑块完成拼图",
|
||||||
|
// TITLE 大小
|
||||||
|
slider_title_size: "15px",
|
||||||
|
concat_title_size: "15px",
|
||||||
|
image_click_title_size: "20px",
|
||||||
|
rotate_title_size: "15px",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const TYPE = "DISABLE";
|
||||||
|
|
||||||
|
function getTemplate(styleConfig) {
|
||||||
|
return `
|
||||||
|
<div class="captcha captcha-disable">
|
||||||
|
<div class="slider-tip">
|
||||||
|
<span class="captcha-slider-move-track-font" style="font-size: ${styleConfig.i18n.disable_title_size}">${styleConfig.i18n.disable_title}</span>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="bg-img-div">
|
||||||
|
<!-- <svg width="100" height="100" viewBox="0 0 100 100">-->
|
||||||
|
<!-- <polygon points="50,10 90,90 10,90" fill="none" stroke="#FF9900" stroke-width="4"/>-->
|
||||||
|
<!-- <path d="M50 35V65 M50 75V75" stroke="#FF9900" stroke-width="4" stroke-linecap="round"/>-->
|
||||||
|
<!-- </svg>-->
|
||||||
|
<span class="content-span"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
class Disable {
|
||||||
|
constructor(boxEl, styleConfig) {
|
||||||
|
this.boxEl = boxEl;
|
||||||
|
this.styleConfig = styleConfig;
|
||||||
|
this.type = TYPE;
|
||||||
|
this.currentCaptchaData = {};
|
||||||
|
}
|
||||||
|
init(captchaData, endCallback, loadSuccessCallback) {
|
||||||
|
// 重载样式
|
||||||
|
this.destroy();
|
||||||
|
this.boxEl.append(getTemplate(this.styleConfig));
|
||||||
|
this.el = this.boxEl.find(".captcha");
|
||||||
|
// 绑定全局
|
||||||
|
// window.currentCaptcha = this;
|
||||||
|
// 载入验证码
|
||||||
|
this.loadCaptchaForData(this, captchaData);
|
||||||
|
this.endCallback = endCallback;
|
||||||
|
if (loadSuccessCallback) {
|
||||||
|
// 加载成功
|
||||||
|
loadSuccessCallback(this);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
const existsCaptchaEl = this.boxEl.find(".captcha");
|
||||||
|
if (existsCaptchaEl) {
|
||||||
|
existsCaptchaEl.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadCaptchaForData(that, data) {
|
||||||
|
const msg = data.msg || data.message || "接口异常";
|
||||||
|
that.el.find(".content-span").text(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Disable;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.captcha.captcha-disable {
|
||||||
|
z-index: 999;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
.bg-img-div {
|
||||||
|
background-image: url("../../images/dun.jpeg");
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
.content-span {
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 132px;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import {
|
||||||
|
CommonCaptcha,
|
||||||
|
move,
|
||||||
|
initConfig,
|
||||||
|
destroyEvent,
|
||||||
|
} from "../common/common.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滑动验证码
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TYPE = "IMAGE_CLICK";
|
||||||
|
function getTemplate(styleConfig) {
|
||||||
|
return `
|
||||||
|
<div class="captcha captcha-slider captcha-word-click">
|
||||||
|
<div class="click-tip">
|
||||||
|
<span class="captcha-click-track-font" style="font-size: ${styleConfig.i18n.image_click_title_size}">${styleConfig.i18n.image_click_title}</span>
|
||||||
|
<img src="" class="captcha-tip-img tip-img">
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="bg-img-div">
|
||||||
|
<img class="captcha-slider-bg-img" src="" alt/>
|
||||||
|
<canvas class="captcha-slider-bg-canvas"></canvas>
|
||||||
|
<div class="bg-img-click-mask"></div>
|
||||||
|
</div>
|
||||||
|
<div class="captcha-tips"></div>
|
||||||
|
</div>
|
||||||
|
<div class="click-confirm-btn">确定</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
class ImageClick extends CommonCaptcha {
|
||||||
|
constructor(boxEl, styleConfig) {
|
||||||
|
super();
|
||||||
|
this.boxEl = boxEl;
|
||||||
|
this.styleConfig = styleConfig;
|
||||||
|
this.type = TYPE;
|
||||||
|
this.currentCaptchaData = {};
|
||||||
|
}
|
||||||
|
init(captchaData, endCallback, loadSuccessCallback) {
|
||||||
|
// 重载样式
|
||||||
|
this.destroy();
|
||||||
|
this.boxEl.append(getTemplate(this.styleConfig));
|
||||||
|
this.el = this.boxEl.find(".captcha");
|
||||||
|
// 绑定全局
|
||||||
|
// window.currentCaptcha = this;
|
||||||
|
// 载入验证码
|
||||||
|
this.loadCaptchaForData(this, captchaData);
|
||||||
|
this.endCallback = endCallback;
|
||||||
|
const moveFun = move.bind(null, this);
|
||||||
|
// 绑定事件
|
||||||
|
this.el.find(".bg-img-click-mask").click((event) => {
|
||||||
|
if (event.target.className === "click-span") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.currentCaptchaData.clickCount++;
|
||||||
|
const trackList = this.currentCaptchaData.trackList;
|
||||||
|
if (this.currentCaptchaData.clickCount === 1) {
|
||||||
|
this.currentCaptchaData.startTime = new Date();
|
||||||
|
// move 轨迹
|
||||||
|
window.addEventListener("mousemove", moveFun);
|
||||||
|
this.currentCaptchaData.startX = event.offsetX;
|
||||||
|
this.currentCaptchaData.startY = event.offsetY;
|
||||||
|
}
|
||||||
|
const startTime = this.currentCaptchaData.startTime;
|
||||||
|
trackList.push({
|
||||||
|
x: Math.round(event.offsetX),
|
||||||
|
y: Math.round(event.offsetY),
|
||||||
|
type: "click",
|
||||||
|
t: new Date().getTime() - startTime.getTime(),
|
||||||
|
});
|
||||||
|
const left = event.offsetX - 10;
|
||||||
|
const top = event.offsetY - 10;
|
||||||
|
this.el
|
||||||
|
.find(".bg-img-click-mask")
|
||||||
|
.append(
|
||||||
|
"<span class='click-span' style='left:" +
|
||||||
|
left +
|
||||||
|
"px;top: " +
|
||||||
|
top +
|
||||||
|
"px'>" +
|
||||||
|
this.currentCaptchaData.clickCount +
|
||||||
|
"</span>",
|
||||||
|
);
|
||||||
|
// if (this.currentCaptchaData.clickCount === 4) {
|
||||||
|
// // 校验
|
||||||
|
// this.currentCaptchaData.stopTime = new Date();
|
||||||
|
// window.removeEventListener("mousemove", move);
|
||||||
|
// this.endCallback(this.currentCaptchaData,this);
|
||||||
|
// }
|
||||||
|
});
|
||||||
|
this.el.find(".click-confirm-btn").click(() => {
|
||||||
|
if (this.currentCaptchaData.clickCount > 0) {
|
||||||
|
// 校验
|
||||||
|
this.currentCaptchaData.stopTime = new Date();
|
||||||
|
window.removeEventListener("mousemove", moveFun);
|
||||||
|
this.endCallback(this.currentCaptchaData, this);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loadSuccessCallback) {
|
||||||
|
// 加载成功
|
||||||
|
loadSuccessCallback(this);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
destroy() {
|
||||||
|
const existsCaptchaEl = this.boxEl.children(".captcha");
|
||||||
|
if (existsCaptchaEl) {
|
||||||
|
existsCaptchaEl.remove();
|
||||||
|
}
|
||||||
|
destroyEvent();
|
||||||
|
}
|
||||||
|
loadCaptchaForData(that, data) {
|
||||||
|
const bgImg = that.el.find(".captcha-slider-bg-img");
|
||||||
|
const tipImg = that.el.find(".captcha-tip-img");
|
||||||
|
bgImg.on("load", () => {
|
||||||
|
that.currentCaptchaData = initConfig(
|
||||||
|
bgImg.width(),
|
||||||
|
bgImg.height(),
|
||||||
|
tipImg.width(),
|
||||||
|
tipImg.height(),
|
||||||
|
);
|
||||||
|
that.currentCaptchaData.currentCaptchaId = data.data.id;
|
||||||
|
});
|
||||||
|
bgImg.attr("src", data.data.backgroundImage);
|
||||||
|
tipImg.attr("src", data.data.templateImage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImageClick;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
.captcha.captcha-word-click {
|
||||||
|
box-sizing: border-box;
|
||||||
|
.click-tip {
|
||||||
|
position: relative;
|
||||||
|
height: 40px;
|
||||||
|
width: 100%;
|
||||||
|
.tip-img {
|
||||||
|
height: 35px;
|
||||||
|
position: absolute;
|
||||||
|
right: 15px;
|
||||||
|
}
|
||||||
|
.captcha-click-track-font {
|
||||||
|
font-size: 18px;
|
||||||
|
display: inline-block;
|
||||||
|
height: 40px;
|
||||||
|
line-height: 40px;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.slider-bottom {
|
||||||
|
position: relative;
|
||||||
|
top: 6px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
.bg-img-click-mask {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
.click-span {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
border-radius: 50px;
|
||||||
|
background-color: #409eff;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 20px;
|
||||||
|
color: #fff;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-sizing: content-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.click-confirm-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 35px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
173deg,
|
||||||
|
hsl(38.09deg 91% 57.89%) 0%,
|
||||||
|
hsl(38.09deg 89.38% 71.74%) 100%
|
||||||
|
);
|
||||||
|
font-size: 15px;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
line-height: 35px;
|
||||||
|
color: #fff;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.click-confirm-btn:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import {
|
||||||
|
CommonCaptcha,
|
||||||
|
down,
|
||||||
|
initConfig,
|
||||||
|
destroyEvent,
|
||||||
|
} from "../common/common.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滑动验证码
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TYPE = "ROTATE";
|
||||||
|
function getTemplate(styleConfig) {
|
||||||
|
return `
|
||||||
|
<div class="captcha captcha-slider captcha-rotate">
|
||||||
|
<div class="slider-tip">
|
||||||
|
<span class="captcha-slider-move-track-font" style="font-size: ${styleConfig.i18n.rotate_title_size}">${styleConfig.i18n.rotate_title}</span>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="bg-img-div">
|
||||||
|
<img class="captcha-slider-bg-img" src="" alt/>
|
||||||
|
<canvas class="captcha-slider-bg-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="rotate-img-div captcha-slider-img-div">
|
||||||
|
<img class="captcha-slider-move-img" src="" alt/>
|
||||||
|
</div>
|
||||||
|
<div class="captcha-tips"></div>
|
||||||
|
</div>
|
||||||
|
<div class="slider-move">
|
||||||
|
<div class="slider-move-track">
|
||||||
|
<div class="captcha-slider-move-track-mask"></div>
|
||||||
|
<div class="slider-move-shadow"></div>
|
||||||
|
</div>
|
||||||
|
<div class="slider-move-btn captcha-slider-move-btn">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
class Rotate extends CommonCaptcha {
|
||||||
|
constructor(boxEl, styleConfig) {
|
||||||
|
super();
|
||||||
|
this.boxEl = boxEl;
|
||||||
|
this.styleConfig = styleConfig;
|
||||||
|
this.type = TYPE;
|
||||||
|
this.currentCaptchaData = {};
|
||||||
|
}
|
||||||
|
init(captchaData, endCallback, loadSuccessCallback) {
|
||||||
|
// 重载样式
|
||||||
|
this.destroy();
|
||||||
|
this.boxEl.append(getTemplate(this.styleConfig));
|
||||||
|
this.el = this.boxEl.find(".captcha");
|
||||||
|
this.loadStyle();
|
||||||
|
// 按钮绑定事件
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-btn")
|
||||||
|
.mousedown(down.bind(null, this));
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-btn")
|
||||||
|
.touchstart(down.bind(null, this));
|
||||||
|
// 绑定全局
|
||||||
|
// window.currentCaptcha = this;
|
||||||
|
// 载入验证码
|
||||||
|
this.loadCaptchaForData(this, captchaData);
|
||||||
|
this.endCallback = endCallback;
|
||||||
|
if (loadSuccessCallback) {
|
||||||
|
// 加载成功
|
||||||
|
loadSuccessCallback(this);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
const existsCaptchaEl = this.boxEl.children(".captcha");
|
||||||
|
if (existsCaptchaEl) {
|
||||||
|
existsCaptchaEl.remove();
|
||||||
|
}
|
||||||
|
destroyEvent();
|
||||||
|
}
|
||||||
|
doMove() {
|
||||||
|
const moveX = this.currentCaptchaData.moveX;
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-btn")
|
||||||
|
.css("transform", "translate(" + moveX + "px, 0px)");
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-img")
|
||||||
|
.css(
|
||||||
|
"transform",
|
||||||
|
"rotate(" + moveX / (this.currentCaptchaData.end / 360) + "deg)",
|
||||||
|
);
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-track-mask")
|
||||||
|
.css("width", moveX + "px");
|
||||||
|
}
|
||||||
|
loadStyle() {
|
||||||
|
let sliderImg = "";
|
||||||
|
let moveTrackMaskBorderColor = "#00f4ab";
|
||||||
|
let moveTrackMaskBgColor = "#a9ffe5";
|
||||||
|
const styleConfig = this.styleConfig;
|
||||||
|
if (styleConfig) {
|
||||||
|
sliderImg = styleConfig.btnUrl;
|
||||||
|
moveTrackMaskBgColor = styleConfig.moveTrackMaskBgColor;
|
||||||
|
moveTrackMaskBorderColor = styleConfig.moveTrackMaskBorderColor;
|
||||||
|
}
|
||||||
|
this.el
|
||||||
|
.find(".slider-move .slider-move-btn")
|
||||||
|
.css("background-image", "url(" + sliderImg + ")");
|
||||||
|
// this.el.find(".captcha-slider-move-track-font").text(title);
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-track-mask")
|
||||||
|
.css("border-color", moveTrackMaskBorderColor);
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-track-mask")
|
||||||
|
.css("background-color", moveTrackMaskBgColor);
|
||||||
|
}
|
||||||
|
loadCaptchaForData(that, data) {
|
||||||
|
const bgImg = that.el.find(".captcha-slider-bg-img");
|
||||||
|
const sliderImg = that.el.find(".captcha-slider-move-img");
|
||||||
|
bgImg.attr("src", data.data.backgroundImage);
|
||||||
|
sliderImg.attr("src", data.data.templateImage);
|
||||||
|
bgImg.on("load", () => {
|
||||||
|
that.currentCaptchaData = initConfig(
|
||||||
|
bgImg.width(),
|
||||||
|
bgImg.height(),
|
||||||
|
sliderImg.width(),
|
||||||
|
sliderImg.height(),
|
||||||
|
300 - 63 + 5,
|
||||||
|
);
|
||||||
|
that.currentCaptchaData.currentCaptchaId = data.data.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Rotate;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.captcha.captcha-rotate {
|
||||||
|
.rotate-img-div {
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
img {
|
||||||
|
height: 100%;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import {
|
||||||
|
CommonCaptcha,
|
||||||
|
closeTips,
|
||||||
|
down,
|
||||||
|
initConfig,
|
||||||
|
showTips,
|
||||||
|
destroyEvent,
|
||||||
|
} from "../common/common.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滑动验证码
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TYPE = "SLIDER";
|
||||||
|
function getTemplate(styleConfig) {
|
||||||
|
return `
|
||||||
|
<div class="captcha captcha-slider">
|
||||||
|
<div class="slider-tip">
|
||||||
|
<span class="captcha-slider-move-track-font" style="font-size: ${styleConfig.i18n.slider_title_size}">${styleConfig.i18n.slider_title}</span>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="bg-img-div">
|
||||||
|
<img class="captcha-slider-bg-img" src="" alt/>
|
||||||
|
<canvas class="captcha-slider-bg-canvas"></canvas>
|
||||||
|
<div class="captcha-slider-bg-div"></div>
|
||||||
|
</div>
|
||||||
|
<div class="slider-img-div captcha-slider-img-div">
|
||||||
|
<img class="captcha-slider-move-img" src="" alt/>
|
||||||
|
</div>
|
||||||
|
<div class="captcha-tips"></div>
|
||||||
|
</div>
|
||||||
|
<div class="slider-move">
|
||||||
|
<div class="slider-move-track">
|
||||||
|
<div class="captcha-slider-move-track-mask"></div>
|
||||||
|
<div class="slider-move-shadow"></div>
|
||||||
|
</div>
|
||||||
|
<div class="slider-move-btn captcha-slider-move-btn">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Slider extends CommonCaptcha {
|
||||||
|
constructor(boxEl, styleConfig) {
|
||||||
|
super();
|
||||||
|
this.boxEl = boxEl;
|
||||||
|
this.styleConfig = styleConfig;
|
||||||
|
this.type = TYPE;
|
||||||
|
this.currentCaptchaData = {};
|
||||||
|
}
|
||||||
|
init(captchaData, endCallback, loadSuccessCallback) {
|
||||||
|
// 重载样式
|
||||||
|
this.destroy();
|
||||||
|
this.boxEl.append(getTemplate(this.styleConfig));
|
||||||
|
this.el = this.boxEl.find(".captcha");
|
||||||
|
this.loadStyle();
|
||||||
|
// 按钮绑定事件
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-btn")
|
||||||
|
.mousedown(down.bind(null, this));
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-btn")
|
||||||
|
.touchstart(down.bind(null, this));
|
||||||
|
// 绑定全局
|
||||||
|
// window.currentCaptcha = this;
|
||||||
|
// 载入验证码
|
||||||
|
this.loadCaptchaForData(this, captchaData);
|
||||||
|
this.endCallback = endCallback;
|
||||||
|
if (loadSuccessCallback) {
|
||||||
|
// 加载成功
|
||||||
|
loadSuccessCallback(this);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
showTips(msg, type, callback) {
|
||||||
|
showTips(this.el, msg, type, callback);
|
||||||
|
}
|
||||||
|
closeTips(callback) {
|
||||||
|
closeTips(this.el, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
const existsCaptchaEl = this.boxEl.children(".captcha");
|
||||||
|
if (existsCaptchaEl) {
|
||||||
|
existsCaptchaEl.remove();
|
||||||
|
}
|
||||||
|
destroyEvent();
|
||||||
|
}
|
||||||
|
doMove() {
|
||||||
|
const moveX = this.currentCaptchaData.moveX;
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-btn")
|
||||||
|
.css("transform", "translate(" + moveX + "px, 0px)");
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-img-div")
|
||||||
|
.css("transform", "translate(" + moveX + "px, 0px)");
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-track-mask")
|
||||||
|
.css("width", moveX + "px");
|
||||||
|
}
|
||||||
|
loadStyle() {
|
||||||
|
let sliderImg = "";
|
||||||
|
let moveTrackMaskBorderColor = "#00f4ab";
|
||||||
|
let moveTrackMaskBgColor = "#a9ffe5";
|
||||||
|
const styleConfig = this.styleConfig;
|
||||||
|
if (styleConfig) {
|
||||||
|
sliderImg = styleConfig.btnUrl;
|
||||||
|
moveTrackMaskBgColor = styleConfig.moveTrackMaskBgColor;
|
||||||
|
moveTrackMaskBorderColor = styleConfig.moveTrackMaskBorderColor;
|
||||||
|
}
|
||||||
|
this.el
|
||||||
|
.find(".slider-move .slider-move-btn")
|
||||||
|
.css("background-image", "url(" + sliderImg + ")");
|
||||||
|
// this.el.find(".captcha-slider-move-track-font").text(title);
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-track-mask")
|
||||||
|
.css("border-color", moveTrackMaskBorderColor);
|
||||||
|
this.el
|
||||||
|
.find(".captcha-slider-move-track-mask")
|
||||||
|
.css("background-color", moveTrackMaskBgColor);
|
||||||
|
}
|
||||||
|
loadCaptchaForData(that, data) {
|
||||||
|
const bgImg = that.el.find(".captcha-slider-bg-img");
|
||||||
|
const sliderImg = that.el.find(".captcha-slider-move-img");
|
||||||
|
bgImg.attr("src", data.data.backgroundImage);
|
||||||
|
sliderImg.attr("src", data.data.templateImage);
|
||||||
|
bgImg.on("load", () => {
|
||||||
|
that.currentCaptchaData = initConfig(
|
||||||
|
bgImg.width(),
|
||||||
|
bgImg.height(),
|
||||||
|
sliderImg.width(),
|
||||||
|
sliderImg.height(),
|
||||||
|
300 - 63 + 5,
|
||||||
|
);
|
||||||
|
that.currentCaptchaData.currentCaptchaId = data.data.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Slider;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
.captcha.captcha-slider {
|
||||||
|
z-index: 999;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bg-img-div {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(0px, 0px);
|
||||||
|
img {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.slider-img-div {
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
transform: translate(0px, 0px);
|
||||||
|
.captcha-slider-move-img {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.slider-move {
|
||||||
|
height: 34px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 11px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.slider-move-track {
|
||||||
|
position: relative;
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #999;
|
||||||
|
transition: 0s;
|
||||||
|
font-size: 14px;
|
||||||
|
box-sizing: content-box;
|
||||||
|
border: 1px solid #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.refresh-btn,
|
||||||
|
.close-btn {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.slider-move {
|
||||||
|
line-height: 38px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #88949d;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
filter: opacity(0.8);
|
||||||
|
}
|
||||||
|
.slider-move .slider-move-btn {
|
||||||
|
transform: translate(0px, 0px);
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: 0;
|
||||||
|
width: 63px;
|
||||||
|
height: 45px;
|
||||||
|
background-color: #fff;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.slider-tip {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
.slider-move-btn:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import ImageClick from "../image_click/image_click";
|
||||||
|
/**
|
||||||
|
* 滑动验证码
|
||||||
|
*/
|
||||||
|
const TYPE = "WORD_IMAGE_CLICK";
|
||||||
|
class WordImageClick extends ImageClick {
|
||||||
|
constructor(divId, styleConfig) {
|
||||||
|
super(divId, styleConfig);
|
||||||
|
this.type = TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WordImageClick;
|
||||||
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,43 @@
|
|||||||
|
import { CaptchaConfig, TianAiCaptcha } from "./captcha/captcha.js"
|
||||||
|
|
||||||
|
export type TianAiCaptchaBindTarget = string | HTMLElement
|
||||||
|
|
||||||
|
export interface TianAiCaptchaResult {
|
||||||
|
id: string
|
||||||
|
data: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TianAiCaptchaStyle {
|
||||||
|
btnUrl?: string
|
||||||
|
moveTrackMaskBgColor?: string
|
||||||
|
moveTrackMaskBorderColor?: string
|
||||||
|
i18n?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TianAiCaptchaConfig {
|
||||||
|
bindEl: TianAiCaptchaBindTarget
|
||||||
|
requestCaptchaDataUrl: string
|
||||||
|
types?: string[]
|
||||||
|
validCaptchaUrl?: string
|
||||||
|
timeToTimestamp?: boolean
|
||||||
|
validSuccess?: (res: any, captcha: any, tac: TianAiCaptchaInstance) => void
|
||||||
|
validFail?: (res: any, captcha: any, tac: TianAiCaptchaInstance) => void
|
||||||
|
onDataReady?: (result: TianAiCaptchaResult, captcha: any, tac: TianAiCaptchaInstance) => void | Promise<void>
|
||||||
|
btnRefreshFun?: (el: Event, tac: TianAiCaptchaInstance) => void
|
||||||
|
btnCloseFun?: (el: Event, tac: TianAiCaptchaInstance) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TianAiCaptchaInstance {
|
||||||
|
init: () => TianAiCaptchaInstance
|
||||||
|
reloadCaptcha: () => void
|
||||||
|
destroyWindow: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTianAiCaptcha(
|
||||||
|
config: TianAiCaptchaConfig,
|
||||||
|
style?: TianAiCaptchaStyle,
|
||||||
|
): TianAiCaptchaInstance {
|
||||||
|
return new TianAiCaptcha(config, style) as TianAiCaptchaInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CaptchaConfig, TianAiCaptcha }
|
||||||
@@ -1,8 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-container class="layout">
|
<el-container class="layout">
|
||||||
<el-header class="layout-header">
|
<el-header class="layout-header">
|
||||||
<div class="main-title">博客管理后台</div>
|
<div class="main-title">{{ t('layout.header.title') }}</div>
|
||||||
<div class="nav-btns-right">
|
<div class="nav-btns-right">
|
||||||
|
<el-dropdown @command="handleLangChange" style="margin-right: 12px;">
|
||||||
|
<el-button link style="color: #fff; font-size: 14px;" plain>
|
||||||
|
{{ currentLangLabel }}
|
||||||
|
<ArrowDown style="width: 14px; margin-left: 4px;" />
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item v-for="lang in languageOptions" :key="lang.value" :command="lang.value" :disabled="lang.value === locale">
|
||||||
|
{{ lang.label }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
<el-button link @click="toggleDarkMode" style="color: #fff; font-size: 18px; margin-right: 12px;" plain>
|
<el-button link @click="toggleDarkMode" style="color: #fff; font-size: 18px; margin-right: 12px;" plain>
|
||||||
<Moon v-if="!isDark" style="width: 18px" />
|
<Moon v-if="!isDark" style="width: 18px" />
|
||||||
<Sunny v-else style="width: 18px" />
|
<Sunny v-else style="width: 18px" />
|
||||||
@@ -15,9 +28,10 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item command="home">返回首页</el-dropdown-item>
|
<el-dropdown-item command="home">{{ t('layout.header.backHome') }}</el-dropdown-item>
|
||||||
<el-dropdown-item command="changePassword">修改密码</el-dropdown-item>
|
<el-dropdown-item command="changePassword">{{ t('layout.header.changePassword') }}</el-dropdown-item>
|
||||||
<el-dropdown-item command="logout" divided>退出</el-dropdown-item>
|
<el-dropdown-item command="about">{{ t('layout.header.about') }}</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="logout" divided>{{ t('layout.header.logout') }}</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
@@ -29,10 +43,10 @@
|
|||||||
<el-sub-menu v-for="menu in menus" :key="menu.name" :index="menu.name">
|
<el-sub-menu v-for="menu in menus" :key="menu.name" :index="menu.name">
|
||||||
<template #title>
|
<template #title>
|
||||||
<component :is="menu.icon" style="width: 18px; margin-right: 5px;"></component>
|
<component :is="menu.icon" style="width: 18px; margin-right: 5px;"></component>
|
||||||
<span>{{menu.title}}</span>
|
<span>{{ t(menu.titleKey) }}</span>
|
||||||
</template>
|
</template>
|
||||||
<el-menu-item v-for="(subItem,subIndex) in menu.child" :key="subIndex" :index="subItem.path" @click="openMenu(subItem.path)">
|
<el-menu-item v-for="(subItem,subIndex) in menu.child" :key="subIndex" :index="subItem.path" @click="openMenu(subItem.path)">
|
||||||
{{subItem.title}}
|
{{ t(subItem.titleKey) }}
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
@@ -40,8 +54,8 @@
|
|||||||
<el-main class="layout-main">
|
<el-main class="layout-main">
|
||||||
<div class="layout-tabs">
|
<div class="layout-tabs">
|
||||||
<el-tabs type="card" class="nav-tabs" v-model="store.state.activeTab" @tab-change="openMenu" @tab-remove="removeTab">
|
<el-tabs type="card" class="nav-tabs" v-model="store.state.activeTab" @tab-change="openMenu" @tab-remove="removeTab">
|
||||||
<el-tab-pane label="首页" name="/"></el-tab-pane>
|
<el-tab-pane :label="t('layout.tabs.home')" name="/"></el-tab-pane>
|
||||||
<el-tab-pane v-for="tab in store.state.tabs" :key="tab.name" :label="tab.title" :name="tab.path" closable></el-tab-pane>
|
<el-tab-pane v-for="tab in store.state.tabs" :key="tab.name" :label="t(tab.title)" :name="tab.path" closable></el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</div>
|
</div>
|
||||||
<div class="layout-content">
|
<div class="layout-content">
|
||||||
@@ -51,36 +65,48 @@
|
|||||||
</keep-alive>
|
</keep-alive>
|
||||||
</router-view>
|
</router-view>
|
||||||
</div>
|
</div>
|
||||||
<div class="layout-copy">2016-{{currentYear}} © colorfulsweet 上次更新:{{ version }}</div>
|
|
||||||
</el-main>
|
</el-main>
|
||||||
</el-container>
|
</el-container>
|
||||||
</el-container>
|
</el-container>
|
||||||
|
|
||||||
<el-dialog v-model="changePwdModal" title="修改密码" width="420px" @closed="resetPwdForm">
|
<el-dialog v-model="changePwdModal" :title="t('layout.passwordDialog.title')" width="420px" @closed="resetPwdForm">
|
||||||
<el-form ref="changePwdForm" :model="changePwdData" :rules="changePwdRules" :label-width="100">
|
<el-form ref="changePwdForm" :model="changePwdData" :rules="changePwdRules" :label-width="100">
|
||||||
<el-form-item label="旧密码" prop="oldPassword">
|
<el-form-item :label="t('layout.passwordDialog.oldPasswordLabel')" prop="oldPassword">
|
||||||
<el-input v-model="changePwdData.oldPassword" type="password" show-password />
|
<el-input v-model="changePwdData.oldPassword" type="password" show-password />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="新密码" prop="newPassword">
|
<el-form-item :label="t('layout.passwordDialog.newPasswordLabel')" prop="newPassword">
|
||||||
<el-input v-model="changePwdData.newPassword" type="password" show-password />
|
<el-input v-model="changePwdData.newPassword" type="password" show-password />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="确认密码" prop="confirmPassword">
|
<el-form-item :label="t('layout.passwordDialog.confirmPasswordLabel')" prop="confirmPassword">
|
||||||
<el-input v-model="changePwdData.confirmPassword" type="password" show-password />
|
<el-input v-model="changePwdData.confirmPassword" type="password" show-password />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="changePwdModal = false" plain>取消</el-button>
|
<el-button @click="changePwdModal = false" plain>{{ t('layout.passwordDialog.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="submitChangePassword" :loading="changePwdLoading" plain>确定</el-button>
|
<el-button type="primary" @click="submitChangePassword" :loading="changePwdLoading" plain>{{ t('layout.passwordDialog.confirm') }}</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="aboutModal" :title="t('layout.aboutDialog.title')" width="420px">
|
||||||
|
<el-descriptions :column="1" border>
|
||||||
|
<el-descriptions-item :label="t('layout.aboutDialog.frontendVersion')">
|
||||||
|
{{ version }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="t('layout.aboutDialog.backendVersion')">
|
||||||
|
{{ backendVersion }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, nextTick } from 'vue'
|
import { ref, reactive, computed, nextTick } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { setLocale, SUPPORTED_LOCALES, type SupportedLocale } from '@/i18n'
|
||||||
import allMenus from '../config/menu'
|
import allMenus from '../config/menu'
|
||||||
import type { MenuGroup } from '../config/menu'
|
import type { MenuGroup } from '../config/menu'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
@@ -91,14 +117,28 @@ import { hasPermission } from '@/utils/permission'
|
|||||||
const store = useStore()
|
const store = useStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
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(t('layout.aboutDialog.unknown'))
|
||||||
|
|
||||||
|
const languageOptions = [
|
||||||
|
{ value: 'zh-CN', label: '简体中文' },
|
||||||
|
{ value: 'zh-TW', label: '繁體中文' },
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
]
|
||||||
|
const currentLangLabel = computed(() => {
|
||||||
|
return languageOptions.find(l => l.value === locale.value)?.label || '简体中文'
|
||||||
|
})
|
||||||
|
function handleLangChange(lang: SupportedLocale) {
|
||||||
|
setLocale(lang)
|
||||||
|
}
|
||||||
|
|
||||||
const menus = computed((): MenuGroup[] => {
|
const menus = computed((): MenuGroup[] => {
|
||||||
return allMenus
|
return allMenus
|
||||||
@@ -142,23 +182,23 @@ const changePwdData = reactive({
|
|||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: ''
|
confirmPassword: ''
|
||||||
})
|
})
|
||||||
const changePwdRules = {
|
const changePwdRules = computed(() => ({
|
||||||
oldPassword: [
|
oldPassword: [
|
||||||
{ required: true, message: '请输入旧密码', trigger: 'blur' }
|
{ required: true, message: t('layout.passwordDialog.oldPasswordRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
newPassword: [
|
newPassword: [
|
||||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
{ required: true, message: t('layout.passwordDialog.newPasswordRequired'), trigger: 'blur' },
|
||||||
{ min: 8, max: 16, message: '密码长度8~16位', trigger: 'blur' },
|
{ min: 8, max: 16, message: t('layout.passwordDialog.passwordLength'), trigger: 'blur' },
|
||||||
{ pattern: /^(?![\d]+$)(?![a-zA-Z]+$)(?![-=+_.,]+$)[\da-zA-Z-=+_.,]{8,16}$/, message: '密码由字母、数字、特殊字符中的任意两种组成', trigger: 'blur' }
|
{ pattern: /^(?![\d]+$)(?![a-zA-Z]+$)(?![-=+_.,]+$)[\da-zA-Z-=+_.,]{8,16}$/, message: t('layout.passwordDialog.passwordComplexity'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
confirmPassword: [
|
confirmPassword: [
|
||||||
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
|
{ required: true, message: t('layout.passwordDialog.confirmPasswordRequired'), trigger: 'blur' },
|
||||||
{ validator: (_rule: object, value: string, callback: Function) => {
|
{ validator: (_rule: object, value: string, callback: Function) => {
|
||||||
value !== changePwdData.newPassword ? callback(new Error('两次输入的密码不一致')) : callback()
|
value !== changePwdData.newPassword ? callback(new Error(t('layout.passwordDialog.passwordMismatch'))) : callback()
|
||||||
}, trigger: 'blur'
|
}, trigger: 'blur'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}))
|
||||||
|
|
||||||
const realname = computed((): null | string => {
|
const realname = computed((): null | string => {
|
||||||
return store.state.loginInfo.userInfo
|
return store.state.loginInfo.userInfo
|
||||||
@@ -194,6 +234,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 +248,13 @@ function dropdownMenuCommand(command: string): void {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openAboutDialog(): Promise<void> {
|
||||||
|
aboutModal.value = true
|
||||||
|
backendVersion.value = t('layout.aboutDialog.fetching')
|
||||||
|
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
|
||||||
@@ -213,7 +263,7 @@ function submitChangePassword(): void {
|
|||||||
changePwdLoading.value = false
|
changePwdLoading.value = false
|
||||||
})
|
})
|
||||||
changePwdModal.value = false
|
changePwdModal.value = false
|
||||||
ElMessage.success('密码修改成功')
|
ElMessage.success(t('layout.passwordDialog.success'))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function resetPwdForm(): void {
|
function resetPwdForm(): void {
|
||||||
|
|||||||
@@ -5,91 +5,218 @@
|
|||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-icon">✦</div>
|
<div class="brand-icon">✦</div>
|
||||||
<h1>Blog Admin</h1>
|
<h1>Blog Admin</h1>
|
||||||
<p>简洁 · 高效 · 专注写作</p>
|
<p>{{ t('login.brandTagline') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-right">
|
<div class="card-right">
|
||||||
<h2 class="form-title">欢迎回来</h2>
|
<h2 class="form-title">{{ t('login.welcomeBack') }}</h2>
|
||||||
<p class="form-subtitle">请登录你的管理账号</p>
|
<p class="form-subtitle">{{ t('login.subtitle') }}</p>
|
||||||
<el-form ref="loginForm" :model="userInfo" :rules="ruleValidate" label-position="top">
|
<el-form ref="loginForm" :model="userInfo" :rules="ruleValidate" label-position="top">
|
||||||
<el-form-item label="用户名" prop="username">
|
<el-form-item :label="t('login.username')" prop="username">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="userInfo.username"
|
v-model="userInfo.username"
|
||||||
placeholder="请输入用户名"
|
:placeholder="t('login.usernamePlaceholder')"
|
||||||
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="t('login.password')" prop="password">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="userInfo.password"
|
v-model="userInfo.password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="请输入密码"
|
:placeholder="t('login.passwordPlaceholder')"
|
||||||
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>
|
||||||
登 录
|
{{ t('login.loginBtn') }}
|
||||||
</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="t('login.captchaTitle')"
|
||||||
|
:close-on-click-modal="!loading"
|
||||||
|
:close-on-press-escape="!loading"
|
||||||
|
:show-close="!loading"
|
||||||
|
@closed="handleCaptchaDialogClosed"
|
||||||
|
>
|
||||||
|
<p class="captcha-dialog-subtitle">{{ t('login.captchaSubtitle') }}</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 { computed, 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 { useI18n } from 'vue-i18n'
|
||||||
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 { t } = useI18n()
|
||||||
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
|
||||||
})
|
})
|
||||||
const ruleValidate = {
|
const ruleValidate = computed(() => ({
|
||||||
username: [
|
username: [
|
||||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
{ required: true, message: t('login.usernameRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
password: [
|
password: [
|
||||||
{ required: true, message: '请输入密码', trigger: 'blur' }
|
{ required: true, message: t('login.passwordRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
}
|
}))
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
// created
|
// created
|
||||||
store.commit('logout')
|
store.commit('logout')
|
||||||
store.commit('clearTabs')
|
store.commit('clearTabs')
|
||||||
|
const savedLocale = localStorage.getItem('locale')
|
||||||
|
const savedTheme = localStorage.getItem('theme_mode')
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
|
if (savedLocale) localStorage.setItem('locale', savedLocale)
|
||||||
|
if (savedTheme) localStorage.setItem('theme_mode', savedTheme)
|
||||||
|
|
||||||
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(t('login.captchaInitError'))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +354,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>
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="welcome-page">
|
<div class="welcome-page">
|
||||||
<div class="welcome-header">
|
<div class="welcome-header">
|
||||||
<h2>欢迎回来 👋</h2>
|
<h2>{{ t('welcome.heading') }} 👋</h2>
|
||||||
<p>选择下方快捷入口,开始管理你的博客</p>
|
<p>{{ t('welcome.subtitle') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-sections">
|
<div class="menu-sections">
|
||||||
<div class="menu-section" v-for="menu in menus" :key="menu.name">
|
<div class="menu-section" v-for="menu in menus" :key="menu.name">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<el-icon><component :is="menu.icon" /></el-icon>
|
<el-icon><component :is="menu.icon" /></el-icon>
|
||||||
<span>{{ menu.title }}</span>
|
<span>{{ t(menu.titleKey) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-items">
|
<div class="section-items">
|
||||||
<router-link
|
<router-link
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
:key="submenu.path"
|
:key="submenu.path"
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
>
|
>
|
||||||
<span class="item-label">{{ submenu.title }}</span>
|
<span class="item-label">{{ t(submenu.titleKey) }}</span>
|
||||||
<el-icon class="item-arrow"><ArrowRight /></el-icon>
|
<el-icon class="item-arrow"><ArrowRight /></el-icon>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,10 +27,13 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import allMenus from '../config/menu'
|
import allMenus from '../config/menu'
|
||||||
import type { MenuGroup } from '../config/menu'
|
import type { MenuGroup } from '../config/menu'
|
||||||
import { hasPermission } from '@/utils/permission'
|
import { hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const menus = computed((): MenuGroup[] => {
|
const menus = computed((): MenuGroup[] => {
|
||||||
return allMenus
|
return allMenus
|
||||||
.map(group => ({
|
.map(group => ({
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<el-form inline :model="search">
|
<el-form inline :model="search">
|
||||||
<el-form-item label="内容">
|
<el-form-item :label="t('api.hitokoto.contentLabel')">
|
||||||
<el-input v-model="search.hitokoto" />
|
<el-input v-model="search.hitokoto" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="类型">
|
<el-form-item :label="t('api.hitokoto.typeLabel')">
|
||||||
<el-select v-model="search.types" multiple collapse-tags>
|
<el-select v-model="search.types" multiple collapse-tags>
|
||||||
<el-option v-for="item in typeList" :key="item.value" :value="item.value" :label="item.label" />
|
<el-option v-for="item in typeList" :key="item.value" :value="item.value" :label="item.label" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="创建时间">
|
<el-form-item :label="t('api.hitokoto.createdAtLabel')">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="search.createdAt"
|
v-model="search.createdAt"
|
||||||
type="daterange"
|
type="daterange"
|
||||||
range-separator="至"
|
:range-separator="t('api.hitokoto.rangeSeparator')"
|
||||||
start-placeholder="开始日期"
|
:start-placeholder="t('api.hitokoto.startDate')"
|
||||||
end-placeholder="结束日期" />
|
:end-placeholder="t('api.hitokoto.endDate')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="btn-container">
|
<div class="btn-container">
|
||||||
<el-button type="primary" @click="addModal = true" v-permission="'hitokoto:save'" plain>添加</el-button>
|
<el-button type="primary" @click="addModal = true" v-permission="'hitokoto:save'" plain>{{ t('common.add') }}</el-button>
|
||||||
<el-button type="danger" @click="deleteAll" v-permission="'hitokoto:delete'" plain>删除</el-button>
|
<el-button type="danger" @click="deleteAll" v-permission="'hitokoto:delete'" plain>{{ t('api.hitokoto.deleteBtn') }}</el-button>
|
||||||
<div class="search-btn">
|
<div class="search-btn">
|
||||||
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
|
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
|
||||||
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
|
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table class="table-container" :data="hitokotoData" v-loading="loading" stripe @selection-change="dataSelect">
|
<el-table class="table-container" :data="hitokotoData" v-loading="loading" stripe @selection-change="dataSelect">
|
||||||
<el-table-column type="selection" width="55" />
|
<el-table-column type="selection" width="55" />
|
||||||
<el-table-column prop="type" label="类型" width="180">
|
<el-table-column prop="type" :label="t('api.hitokoto.tableType')" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ findTypeText(scope.row.type) }}
|
{{ findTypeText(scope.row.type) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="hitokoto" label="内容" />
|
<el-table-column prop="hitokoto" :label="t('api.hitokoto.tableContent')" />
|
||||||
<el-table-column prop="from" label="来自" width="180"/>
|
<el-table-column prop="from" :label="t('api.hitokoto.tableFrom')" width="180"/>
|
||||||
<el-table-column prop="creator" label="作者" width="180"/>
|
<el-table-column prop="creator" :label="t('api.hitokoto.tableCreator')" width="180"/>
|
||||||
<el-table-column prop="number" label="编号" width="70"/>
|
<el-table-column prop="number" :label="t('api.hitokoto.tableNumber')" width="70"/>
|
||||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
<el-table-column prop="createdAt" :label="t('api.hitokoto.createdAtLabel')" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.createdAt) }}
|
{{ datetimeFormat(scope.row.createdAt) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -53,12 +53,12 @@
|
|||||||
@current-change="pageChange">
|
@current-change="pageChange">
|
||||||
</el-pagination>
|
</el-pagination>
|
||||||
</div>
|
</div>
|
||||||
<el-dialog v-model="addModal" title="新增一言" >
|
<el-dialog v-model="addModal" :title="t('api.hitokoto.addTitle')" >
|
||||||
<hitokoto-add ref="addForm" :typeList="typeList" :formData="formData" />
|
<hitokoto-add ref="addForm" :typeList="typeList" :formData="formData" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="addModal = false" plain>取消</el-button>
|
<el-button @click="addModal = false" plain>{{ t('common.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="save" :loading="modalLoading" plain>确定</el-button>
|
<el-button type="primary" @click="save" :loading="modalLoading" plain>{{ t('common.confirm') }}</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -67,6 +67,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import HitokotoAdd from './HitokotoAdd.vue'
|
import HitokotoAdd from './HitokotoAdd.vue'
|
||||||
import { useBaseList } from '@/model/baselist'
|
import { useBaseList } from '@/model/baselist'
|
||||||
import { Page } from '@/model/common.dto'
|
import { Page } from '@/model/common.dto'
|
||||||
@@ -74,6 +75,8 @@ import HitokotoModel from '@/model/api/hitokoto'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
class HitokotoPage extends Page {
|
class HitokotoPage extends Page {
|
||||||
hitokoto?: string
|
hitokoto?: string
|
||||||
types?: string[]
|
types?: string[]
|
||||||
@@ -121,19 +124,19 @@ async function save() {
|
|||||||
const data = await http.post<any, any>('/hitokoto/save', formData)
|
const data = await http.post<any, any>('/hitokoto/save', formData)
|
||||||
modalLoading.value = false
|
modalLoading.value = false
|
||||||
addModal.value = false
|
addModal.value = false
|
||||||
ElMessage.success('保存成功')
|
ElMessage.success(t('common.saveSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
Object.keys(formData).forEach(key => delete formData[key])
|
Object.keys(formData).forEach(key => delete formData[key])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function deleteAll() {
|
function deleteAll() {
|
||||||
if (!selectedData.length) {
|
if (!selectedData.length) {
|
||||||
ElMessage.warning('请选择要删除的数据')
|
ElMessage.warning(t('common.selectToDelete'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ElMessageBox.confirm(`是否确认删除选中的${selectedData.length}条数据?`, '确认删除', {type: 'warning'}).then(async () => {
|
ElMessageBox.confirm(t('common.deleteSelectedConfirm', { count: selectedData.length }), t('common.deleteConfirm'), {type: 'warning'}).then(async () => {
|
||||||
await http.delete<any, any>('/hitokoto/delete', {params: {ids: selectedData}})
|
await http.delete<any, any>('/hitokoto/delete', {params: {ids: selectedData}})
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-form ref="hitokotoForm" :model="formData" :rules="ruleValidate" :label-width="80">
|
<el-form ref="hitokotoForm" :model="formData" :rules="ruleValidate" :label-width="80">
|
||||||
<el-form-item label="内容" prop="hitokoto">
|
<el-form-item :label="t('api.hitokoto.formContent')" prop="hitokoto">
|
||||||
<el-input v-model="formData.hitokoto" type="textarea" :rows="4"/>
|
<el-input v-model="formData.hitokoto" type="textarea" :rows="4"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="类型" prop="type">
|
<el-form-item :label="t('api.hitokoto.formType')" prop="type">
|
||||||
<el-select v-model="formData.type">
|
<el-select v-model="formData.type">
|
||||||
<el-option v-for="item in typeList" :value="item.value" :key="item.value" :label="item.label"></el-option>
|
<el-option v-for="item in typeList" :value="item.value" :key="item.value" :label="item.label"></el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="来自">
|
<el-form-item :label="t('api.hitokoto.formFrom')">
|
||||||
<el-input v-model="formData.from" />
|
<el-input v-model="formData.from" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="作者">
|
<el-form-item :label="t('api.hitokoto.formCreator')">
|
||||||
<el-input v-model="formData.creator" />
|
<el-input v-model="formData.creator" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { VForm } from '@/types'
|
import type { VForm } from '@/types'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
typeList: {label: string, value: string}[]
|
typeList: {label: string, value: string}[]
|
||||||
formData: {[propName: string]: string | null}
|
formData: {[propName: string]: string | null}
|
||||||
@@ -29,14 +32,14 @@ defineProps<{
|
|||||||
|
|
||||||
const hitokotoForm = ref<VForm>()
|
const hitokotoForm = ref<VForm>()
|
||||||
|
|
||||||
const ruleValidate = {
|
const ruleValidate = computed(() => ({
|
||||||
hitokoto: [
|
hitokoto: [
|
||||||
{ required: true, message: '请输入内容', trigger: 'blur' }
|
{ required: true, message: t('api.hitokoto.contentRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
type: [
|
type: [
|
||||||
{ required: true, message: '请选择类型', trigger: 'blur' }
|
{ required: true, message: t('api.hitokoto.typeRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
}
|
}))
|
||||||
|
|
||||||
defineExpose({ hitokotoForm })
|
defineExpose({ hitokotoForm })
|
||||||
</script>
|
</script>
|
||||||
@@ -1,53 +1,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<el-form inline :model="search">
|
<el-form inline :model="search">
|
||||||
<el-form-item label="名称/标题">
|
<el-form-item :label="t('api.music.searchNameTitle')">
|
||||||
<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="t('api.music.searchLibLabel')">
|
||||||
<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="t('api.music.searchExtLabel')">
|
||||||
<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>
|
||||||
<el-form-item label="唱片集">
|
<el-form-item :label="t('api.music.searchAlbumLabel')">
|
||||||
<el-input v-model="search.album" />
|
<el-input v-model="search.album" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="艺术家">
|
<el-form-item :label="t('api.music.searchArtistLabel')">
|
||||||
<el-input v-model="search.artist" />
|
<el-input v-model="search.artist" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="btn-container">
|
<div class="btn-container">
|
||||||
<el-button type="success" plain icon="VideoPlay" @click="playMusic">播放</el-button>
|
<el-button type="success" plain icon="VideoPlay" @click="playMusic">{{ t('api.music.playBtn') }}</el-button>
|
||||||
<el-button type="primary" icon="Upload" @click="openUploadModal" v-permission="'music:save'" plain>上传音乐</el-button>
|
<el-button type="primary" icon="Upload" @click="openUploadModal" v-permission="'music:save'" plain>{{ t('api.music.uploadBtn') }}</el-button>
|
||||||
<el-button type="warning" plain icon="Notebook" @click="libDrawerVisible = true" v-permission="'music:save'">歌单管理</el-button>
|
<el-button type="warning" plain icon="Notebook" @click="libDrawerVisible = true" v-permission="'music:save'">{{ t('api.music.libManageBtn') }}</el-button>
|
||||||
<div class="search-btn">
|
<div class="search-btn">
|
||||||
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
|
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
|
||||||
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
|
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table class="table-container" :data="musicData" v-loading="loading" stripe @selection-change="dataSelect">
|
<el-table class="table-container" :data="musicData" v-loading="loading" stripe @selection-change="dataSelect">
|
||||||
<el-table-column type="selection" width="55" />
|
<el-table-column type="selection" width="55" />
|
||||||
<el-table-column prop="name" label="名称" show-overflow-tooltip role=""/>
|
<el-table-column prop="name" :label="t('api.music.tableName')" show-overflow-tooltip role=""/>
|
||||||
<el-table-column prop="ext" label="类型" width="80" />
|
<el-table-column prop="ext" :label="t('api.music.tableType')" width="80" />
|
||||||
<el-table-column prop="size" label="文件大小" width="110">
|
<el-table-column prop="size" :label="t('api.music.tableSize')" width="110">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ prettyBytes(scope.row.size) }}
|
{{ prettyBytes(scope.row.size) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="time" label="时长" width="110">
|
<el-table-column prop="time" :label="t('api.music.tableDuration')" width="110">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ formatDuration(scope.row.time) }}
|
{{ formatDuration(scope.row.time) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="title" label="标题" show-overflow-tooltip />
|
<el-table-column prop="title" :label="t('api.music.tableTitle')" show-overflow-tooltip />
|
||||||
<el-table-column prop="album" label="唱片集" />
|
<el-table-column prop="album" :label="t('api.music.tableAlbum')" />
|
||||||
<el-table-column prop="artist" label="艺术家" />
|
<el-table-column prop="artist" :label="t('api.music.tableArtist')" />
|
||||||
<el-table-column prop="libId" label="所属歌单" >
|
<el-table-column prop="libId" :label="t('api.music.tableLib')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<template v-if="scope.row.isEditing && currentRow">
|
<template v-if="scope.row.isEditing && currentRow">
|
||||||
<el-select v-model="currentRow.libId">
|
<el-select v-model="currentRow.libId">
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="lyricId" label="歌词" width="80" >
|
<el-table-column prop="lyricId" :label="t('api.music.tableLyric')" width="80" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div style="width: 18px">
|
<div style="width: 18px">
|
||||||
<Check v-if="scope.row.lyricId" />
|
<Check v-if="scope.row.lyricId" />
|
||||||
@@ -70,11 +70,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="150" >
|
<el-table-column :label="t('common.operation')" width="150" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link icon="Document" @click="updateLyric(scope.row)" title="歌词" v-permission="'music:save'"></el-button>
|
<el-button link icon="Document" @click="updateLyric(scope.row)" :title="t('api.music.tableLyric')" v-permission="'music:save'"></el-button>
|
||||||
<el-button link icon="Download" @click="download(scope.row)" title="下载"></el-button>
|
<el-button link icon="Download" @click="download(scope.row)" :title="t('api.music.actionDownload')"></el-button>
|
||||||
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除" v-permission="'music:delete'"></el-button>
|
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" :title="t('common.delete')" v-permission="'music:delete'"></el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -88,29 +88,29 @@
|
|||||||
@current-change="pageChange">
|
@current-change="pageChange">
|
||||||
</el-pagination>
|
</el-pagination>
|
||||||
</div>
|
</div>
|
||||||
<el-dialog v-model="modifyLyricModal" title="编辑歌词" :width="600" >
|
<el-dialog v-model="modifyLyricModal" :title="t('api.music.editLyricTitle')" :width="600" >
|
||||||
<el-form v-loading="lyricLoading" ref="lyricForm" :model="lyricFormData" :rules="lyricRuleValidate" :label-width="120">
|
<el-form v-loading="lyricLoading" ref="lyricForm" :model="lyricFormData" :rules="lyricRuleValidate" :label-width="120">
|
||||||
<el-form-item label="网易云ID" prop="cloudId">
|
<el-form-item :label="t('api.music.formCloudId')" prop="cloudId">
|
||||||
<el-input v-model="lyricFormData.cloudId" />
|
<el-input v-model="lyricFormData.cloudId" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="名称" prop="name">
|
<el-form-item :label="t('api.music.formName')" prop="name">
|
||||||
<el-input v-model="lyricFormData.name" />
|
<el-input v-model="lyricFormData.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="歌词" prop="lyric">
|
<el-form-item :label="t('api.music.formLyric')" prop="lyric">
|
||||||
<el-input v-model="lyricFormData.lyric" type="textarea" :rows="4"/>
|
<el-input v-model="lyricFormData.lyric" type="textarea" :rows="4"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="modifyLyricModal = false" plain>取消</el-button>
|
<el-button @click="modifyLyricModal = false" plain>{{ t('common.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="saveLyric" :loading="modalLoading" plain>确定</el-button>
|
<el-button type="primary" @click="saveLyric" :loading="modalLoading" plain>{{ t('common.confirm') }}</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
<el-dialog v-model="uploadModal" title="上传音乐" :width="550" @closed="uploadModalClosed" >
|
<el-dialog v-model="uploadModal" :title="t('api.music.uploadTitle')" :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="t('api.music.formLib')" 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,70 +118,89 @@
|
|||||||
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>
|
{{ uploadDragParts.before }}<em>{{ uploadDragParts.after }}</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>{{ t('api.music.uploadTip') }}</span>
|
||||||
|
<el-tag v-for="ext in uploadExtList" :key="ext" size="small">
|
||||||
|
{{ ext }}<span v-if="ext === 'ncm'"> {{ t('api.music.autoDecode') }}</span>
|
||||||
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="uploadModal = false" plain>取消</el-button>
|
<el-button @click="uploadModal = false" plain>{{ t('common.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="uploadMusic" plain>开始上传</el-button>
|
<el-button type="primary" @click="uploadMusic" plain>{{ t('api.music.startUpload') }}</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
<el-drawer v-model="libDrawerVisible" title="歌单管理" size="900px">
|
<el-drawer v-model="libDrawerVisible" :title="t('api.music.libDrawerTitle')" size="900px">
|
||||||
<el-button type="primary" icon="Plus" @click="addLibRow" style="margin-bottom: 12px" plain>添加歌单</el-button>
|
<el-button type="primary" icon="Plus" @click="addLibRow" style="margin-bottom: 12px" plain>{{ t('api.music.addLibBtn') }}</el-button>
|
||||||
<el-table :data="libTableData" stripe>
|
<el-table :data="libTableData" stripe>
|
||||||
<el-table-column prop="name" label="名称">
|
<el-table-column prop="name" :label="t('api.music.tableName')">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-input v-if="scope.row._isNew" v-model="scope.row.name" placeholder="歌单名称" />
|
<el-input v-if="scope.row._isNew" v-model="scope.row.name" :placeholder="t('api.music.libNamePlaceholder')" />
|
||||||
<span v-else>{{ scope.row.name }}</span>
|
<span v-else>{{ scope.row.name }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="path" label="路径">
|
<el-table-column prop="path" :label="t('api.music.tablePath')">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-input v-if="scope.row._isNew" v-model="scope.row.path" placeholder="music/歌单名/" />
|
<el-input v-if="scope.row._isNew" v-model="scope.row.path" :placeholder="t('api.music.libPathPlaceholder')" />
|
||||||
<span v-else>{{ scope.row.path }}</span>
|
<span v-else>{{ scope.row.path }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="musicCount" label="歌曲数量" width="100" />
|
<el-table-column prop="musicCount" :label="t('api.music.tableMusicCount')" width="100" />
|
||||||
<el-table-column label="操作" width="160">
|
<el-table-column :label="t('common.operation')" width="160">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button v-if="scope.row._isNew" link type="primary" icon="Check" @click="saveLib(scope.row)" :loading="libSaving">保存</el-button>
|
<el-button v-if="scope.row._isNew" link type="primary" icon="Check" @click="saveLib(scope.row)" :loading="libSaving">{{ t('common.save') }}</el-button>
|
||||||
<el-button v-if="scope.row._isNew" link icon="Close" @click="cancelAddLib">取消</el-button>
|
<el-button v-if="scope.row._isNew" link icon="Close" @click="cancelAddLib">{{ t('common.cancel') }}</el-button>
|
||||||
<el-button v-if="!scope.row._isNew" link type="danger" icon="Delete" @click="removeLib(scope.row)">删除</el-button>
|
<el-button v-if="!scope.row._isNew" link type="danger" icon="Delete" @click="removeLib(scope.row)">{{ t('common.delete') }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</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="t('api.music.playDrawerTitle')" 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 { useI18n } from 'vue-i18n'
|
||||||
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 {
|
||||||
@@ -193,6 +212,8 @@ function formatDuration(seconds?: number): string {
|
|||||||
import type { VForm } from '@/types'
|
import type { VForm } from '@/types'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
class MusicPage extends Page {
|
class MusicPage extends Page {
|
||||||
exts: string[] = []
|
exts: string[] = []
|
||||||
title?: string
|
title?: string
|
||||||
@@ -213,7 +234,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,23 +247,41 @@ 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 lyricRuleValidate = {
|
const uploadRules = computed(() => ({
|
||||||
|
libId: [{ required: true, message: t('api.music.libRequired'), trigger: 'change' }]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const uploadExtList = computed(() => {
|
||||||
|
return Array.from(new Set([...exts.value, 'ncm']))
|
||||||
|
})
|
||||||
|
const uploadAccept = computed(() => {
|
||||||
|
return uploadExtList.value.map(ext => `.${ext}`).join(',')
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploadDragParts = computed(() => {
|
||||||
|
const text = t('api.music.uploadDragText')
|
||||||
|
const idx = text.indexOf('{em}')
|
||||||
|
if (idx === -1) return { before: text, after: '' }
|
||||||
|
return { before: text.slice(0, idx), after: text.slice(idx + 4) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const lyricRuleValidate = computed(() => ({
|
||||||
cloudId: [
|
cloudId: [
|
||||||
{ required: true, message: '请输入网易云ID', trigger: 'blur' }
|
{ required: true, message: t('api.music.cloudIdRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
name: [
|
name: [
|
||||||
{ required: true, message: '请输入名称', trigger: 'blur' }
|
{ required: true, message: t('api.music.nameRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
lyric: [
|
lyric: [
|
||||||
{ required: true, message: '请输入歌词正文', trigger: 'blur' }
|
{ required: true, message: t('api.music.lyricRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
}
|
}))
|
||||||
|
|
||||||
let selectedIds: string[] = []
|
let selectedIds: string[] = []
|
||||||
|
|
||||||
@@ -288,7 +328,7 @@ async function playMusic() {
|
|||||||
musicPlaying.value = true
|
musicPlaying.value = true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
ElMessage.error('获取播放列表失败')
|
ElMessage.error(t('api.music.getPlaylistFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function updateLib(row: MusicModel) {
|
function updateLib(row: MusicModel) {
|
||||||
@@ -303,9 +343,9 @@ function download(row: MusicModel) {
|
|||||||
link.click()
|
link.click()
|
||||||
}
|
}
|
||||||
function remove(row: MusicModel) {
|
function remove(row: MusicModel) {
|
||||||
ElMessageBox.confirm(`是否确认删除 ${row.name} ?`, '确认删除', {type: 'warning'}).then(async () => {
|
ElMessageBox.confirm(t('common.deleteConfirmMsg', { name: row.name }), t('common.deleteConfirm'), {type: 'warning'}).then(async () => {
|
||||||
await http.delete<{params: {id: string}}, any>('/music/delete', {params: {id: row._id}})
|
await http.delete<{params: {id: string}}, any>('/music/delete', {params: {id: row._id}})
|
||||||
ElMessage.success("删除成功")
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -331,7 +371,7 @@ async function saveLyric() {
|
|||||||
await http.post<MusicLyricModel, any>(`/music/lyric/save?musicId=${currentRow.value ? currentRow.value._id : ''}`, lyricFormData.value)
|
await http.post<MusicLyricModel, any>(`/music/lyric/save?musicId=${currentRow.value ? currentRow.value._id : ''}`, lyricFormData.value)
|
||||||
modalLoading.value = false
|
modalLoading.value = false
|
||||||
modifyLyricModal.value = false
|
modifyLyricModal.value = false
|
||||||
ElMessage.success("歌词保存成功")
|
ElMessage.success(t('api.music.lyricSaveSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
lyricFormData.value = {}
|
lyricFormData.value = {}
|
||||||
})
|
})
|
||||||
@@ -339,27 +379,26 @@ async function saveLyric() {
|
|||||||
async function saveMusicLib(row: MusicModel) {
|
async function saveMusicLib(row: MusicModel) {
|
||||||
if (!currentRow.value) return
|
if (!currentRow.value) return
|
||||||
await http.post<{id: string, libId: string}, any>('/music/lib/update', {id: currentRow.value._id, libId: currentRow.value.libId})
|
await http.post<{id: string, libId: string}, any>('/music/lib/update', {id: currentRow.value._id, libId: currentRow.value.libId})
|
||||||
ElMessage.success("歌单更新成功")
|
ElMessage.success(t('api.music.libUpdateSuccess'))
|
||||||
row.libId = currentRow.value.libId
|
row.libId = currentRow.value.libId
|
||||||
row.isEditing = false
|
row.isEditing = false
|
||||||
}
|
}
|
||||||
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(t('common.uploadSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning(response.message)
|
ElMessage.warning(response.message || t('common.uploadFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function uploadError(error: Error) {
|
function uploadError(error: Error) {
|
||||||
@@ -384,20 +423,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 => {
|
||||||
@@ -414,22 +472,22 @@ function cancelAddLib() {
|
|||||||
}
|
}
|
||||||
async function saveLib(row: MusicLibModel & { _isNew?: boolean }) {
|
async function saveLib(row: MusicLibModel & { _isNew?: boolean }) {
|
||||||
if (!row.name || !row.path) {
|
if (!row.name || !row.path) {
|
||||||
ElMessage.warning('请输入歌单名称和路径')
|
ElMessage.warning(t('api.music.libNamePathRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
libSaving.value = true
|
libSaving.value = true
|
||||||
try {
|
try {
|
||||||
await http.post<any, any>('/music/lib/add', { name: row.name, path: row.path })
|
await http.post<any, any>('/music/lib/add', { name: row.name, path: row.path })
|
||||||
ElMessage.success('歌单创建成功')
|
ElMessage.success(t('api.music.libCreateSuccess'))
|
||||||
await loadLibs()
|
await loadLibs()
|
||||||
} finally {
|
} finally {
|
||||||
libSaving.value = false
|
libSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function removeLib(row: MusicLibModel) {
|
function removeLib(row: MusicLibModel) {
|
||||||
ElMessageBox.confirm(`是否确认删除歌单「${row.name}」?`, '确认删除', { type: 'warning' }).then(async () => {
|
ElMessageBox.confirm(t('api.music.libDeleteConfirmMsg', { name: row.name }), t('common.deleteConfirm'), { type: 'warning' }).then(async () => {
|
||||||
await http.delete<any, any>('/music/lib/delete', { params: { id: row._id } })
|
await http.delete<any, any>('/music/lib/delete', { params: { id: row._id } })
|
||||||
ElMessage.success('歌单删除成功')
|
ElMessage.success(t('api.music.libDeleteSuccess'))
|
||||||
await loadLibs()
|
await loadLibs()
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +1,179 @@
|
|||||||
<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" />
|
<el-form inline :model="search" @submit.prevent>
|
||||||
</el-form-item>
|
<el-form-item :label="t('api.photoWall.searchFileName')">
|
||||||
<el-form-item label="宽度" >
|
<el-input v-model="search.name" />
|
||||||
<el-input-number v-model="search.widthMin" :min="0" controls-position="right" step-strictly>
|
</el-form-item>
|
||||||
<template #prefix>≥</template>
|
<el-form-item :label="t('api.photoWall.searchWidth')">
|
||||||
<template #suffix>px</template>
|
<el-input-number v-model="search.widthMin" :min="0" controls-position="right" step-strictly>
|
||||||
</el-input-number>
|
<template #prefix>≥</template>
|
||||||
<el-input-number v-model="search.widthMax" :min="0" controls-position="right" step-strictly>
|
<template #suffix>px</template>
|
||||||
<template #prefix>≤</template>
|
</el-input-number>
|
||||||
<template #suffix>px</template>
|
<el-input-number v-model="search.widthMax" :min="0" controls-position="right" step-strictly style="margin-left: 8px;">
|
||||||
</el-input-number>
|
<template #prefix>≤</template>
|
||||||
</el-form-item>
|
<template #suffix>px</template>
|
||||||
<el-form-item label="高度" >
|
</el-input-number>
|
||||||
<el-input-number v-model="search.heightMin" :min="0" controls-position="right" step-strictly>
|
</el-form-item>
|
||||||
<template #prefix>≥</template>
|
<el-form-item :label="t('api.photoWall.searchHeight')">
|
||||||
<template #suffix>px</template>
|
<el-input-number v-model="search.heightMin" :min="0" controls-position="right" step-strictly>
|
||||||
</el-input-number>
|
<template #prefix>≥</template>
|
||||||
<el-input-number v-model="search.heightMax" :min="0" controls-position="right" step-strictly>
|
<template #suffix>px</template>
|
||||||
<template #prefix>≤</template>
|
</el-input-number>
|
||||||
<template #suffix>px</template>
|
<el-input-number v-model="search.heightMax" :min="0" controls-position="right" step-strictly style="margin-left: 8px;">
|
||||||
</el-input-number>
|
<template #prefix>≤</template>
|
||||||
</el-form-item>
|
<template #suffix>px</template>
|
||||||
</el-form>
|
</el-input-number>
|
||||||
<div class="btn-container">
|
</el-form-item>
|
||||||
<el-upload
|
</el-form>
|
||||||
:action="`${apiBase}/photoWall/upload`"
|
|
||||||
accept="image/jpeg,image/png"
|
<!-- 操作栏 -->
|
||||||
name="image"
|
<div class="btn-container">
|
||||||
:headers="{token: store.state.loginInfo.token}"
|
<el-upload
|
||||||
:before-upload="beforeUpload"
|
:action="`${apiBase}/photoWall/upload`"
|
||||||
:on-success="uploadSuccess"
|
accept="image/jpeg,image/png"
|
||||||
:on-error="uploadError"
|
name="image"
|
||||||
auto-upload
|
:headers="{ token: store.state.loginInfo.token }"
|
||||||
:show-file-list="false"
|
:before-upload="beforeUpload"
|
||||||
style="display: inline-block;margin-right: 10px;">
|
:on-success="uploadSuccess"
|
||||||
<el-tooltip :content="`图片格式为${allowUploadExt.join('、')},文件大小不超过10MB。`">
|
:on-error="uploadError"
|
||||||
<el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'photoWall:save'" plain>上传图片</el-button>
|
auto-upload
|
||||||
</el-tooltip>
|
:show-file-list="false"
|
||||||
</el-upload>
|
style="margin-right: 10px;">
|
||||||
<el-button type="danger" @click="deleteAll" style="vertical-align: bottom" v-permission="'photoWall:delete'" plain>批量删除</el-button>
|
<el-tooltip :content="t('api.photoWall.uploadTooltip', { exts: allowUploadExt.join('\u3001') })">
|
||||||
<div class="search-btn">
|
<el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'photoWall:save'" plain>{{ t('api.photoWall.uploadBtn') }}</el-button>
|
||||||
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
|
</el-tooltip>
|
||||||
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
|
</el-upload>
|
||||||
|
<el-button
|
||||||
|
v-if="selected.size > 0"
|
||||||
|
type="danger"
|
||||||
|
icon="Delete"
|
||||||
|
plain
|
||||||
|
v-permission="'photoWall:delete'"
|
||||||
|
@click="deleteSelected">
|
||||||
|
{{ t('api.photoWall.deleteSelected', { count: selected.size }) }}
|
||||||
|
</el-button>
|
||||||
|
<div class="search-btn">
|
||||||
|
<el-button link type="primary" :disabled="!photos.length" @click="selectAll" v-if="!isAllSelected">{{ t('api.photoWall.selectAll') }}</el-button>
|
||||||
|
<el-button link type="primary" @click="clearSelect" v-else>{{ t('api.photoWall.deselectAll') }}</el-button>
|
||||||
|
<el-button link type="primary" :disabled="!photos.length" @click="invertSelect">{{ t('api.photoWall.invertSelect') }}</el-button>
|
||||||
|
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
|
||||||
|
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</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 }} x {{ item.height }}</p>
|
||||||
|
</div>
|
||||||
|
<el-button-group class="overlay-actions">
|
||||||
|
<el-button size="small" icon="View" plain @click.stop="preview(item)">{{ t('api.photoWall.previewBtn') }}</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
icon="Delete"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
v-permission="'photoWall:delete'"
|
||||||
|
@click.stop="deleteSingle(item)">
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</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" />
|
||||||
|
|
||||||
<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>
|
</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 { useI18n } from 'vue-i18n'
|
||||||
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 {
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// --- 常量 ---
|
||||||
|
const COLUMN_WIDTH = 220 // 每列目标宽度(px)
|
||||||
|
const COL_GAP = 14 // 列间距(px)
|
||||||
|
|
||||||
|
// --- 基础状态 ---
|
||||||
|
const store = useStore()
|
||||||
|
const apiBase = import.meta.env.VITE_APP_API_BASE
|
||||||
|
const rootEl = ref<HTMLElement | null>(null)
|
||||||
|
const photos = ref<PhotoWallModel[]>([])
|
||||||
|
const isUploading = ref(false)
|
||||||
|
const allowUploadExt = ['jpg', 'jpeg', 'png']
|
||||||
|
|
||||||
|
class GalleryPage extends Page {
|
||||||
name?: string
|
name?: string
|
||||||
widthMin!: number | null
|
widthMin?: number | null
|
||||||
widthMax!: number | null
|
widthMax?: number | null
|
||||||
heightMin!: number | null
|
heightMin?: number | null
|
||||||
heightMax!: number | null
|
heightMax?: number | null
|
||||||
|
constructor() { super(); this.limit = 50 }
|
||||||
reset() {
|
reset() {
|
||||||
super.reset()
|
super.reset()
|
||||||
|
this.limit = 50
|
||||||
this.name = undefined
|
this.name = undefined
|
||||||
this.widthMin = null
|
this.widthMin = null
|
||||||
this.widthMax = null
|
this.widthMax = null
|
||||||
@@ -103,73 +181,301 @@ class PhotoWallPage extends Page {
|
|||||||
this.heightMax = null
|
this.heightMax = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const { loading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange } = useBaseList(new GalleryPage())
|
||||||
|
|
||||||
const store = useStore()
|
const cdnBase = ref('')
|
||||||
const apiBase = import.meta.env.VITE_APP_API_BASE
|
const selected = ref<Set<string>>(new Set())
|
||||||
const { loading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange } = useBaseList(new PhotoWallPage())
|
const entered = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
const allowUploadExt = ['jpg', 'jpeg', 'png']
|
|
||||||
const photowallData = ref<PhotoWallModel[]>([])
|
|
||||||
const isUploading = ref(false)
|
|
||||||
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})
|
selected.value = new Set()
|
||||||
selectedData = []
|
entered.value = new Set()
|
||||||
loading.value = false
|
try {
|
||||||
total.value = data.total
|
if (!cdnBase.value) {
|
||||||
photowallData.value = data.list
|
cdnBase.value = await http.get('/common/config/picture_cdn')
|
||||||
|
}
|
||||||
|
const data = await http.get<any, any>('/photoWall/list', { params: search })
|
||||||
|
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(t('api.photoWall.deleteSingleConfirm', { name: item.name }), t('common.deleteConfirm'), { type: 'warning' })
|
||||||
|
await http.delete('/photoWall/delete', { params: { ids: [item._id] } })
|
||||||
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelected() {
|
||||||
|
const ids = [...selected.value]
|
||||||
|
await ElMessageBox.confirm(t('api.photoWall.deleteMultiConfirm', { count: ids.length }), t('common.deleteConfirm'), { type: 'warning' })
|
||||||
|
await http.delete('/photoWall/delete', { params: { ids } })
|
||||||
|
selected.value = new Set()
|
||||||
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
|
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(t('common.fileSizeExceeded'))
|
||||||
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(t('common.uploadSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning(response.message)
|
ElMessage.warning(response.message || t('common.uploadFailed'))
|
||||||
}
|
}
|
||||||
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>
|
||||||
|
|||||||
@@ -11,36 +11,36 @@
|
|||||||
: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="t('api.sourceImage.uploadTooltip', { exts: allowUploadExt.join('\u3001') })">
|
||||||
<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>{{ t('api.sourceImage.uploadBtn') }}</el-button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
<el-button type="danger" @click="deleteAll" style="vertical-align: bottom" v-permission="'sourceImage:delete'" plain>批量删除</el-button>
|
<el-button type="danger" @click="deleteAll" style="vertical-align: bottom" v-permission="'sourceImage:delete'" plain>{{ t('api.sourceImage.batchDeleteBtn') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table class="table-container" :data="sourceImageData" v-loading="loading" stripe @selection-change="dataSelect">
|
<el-table class="table-container" :data="sourceImageData" v-loading="loading" stripe @selection-change="dataSelect">
|
||||||
<el-table-column type="selection" width="55" />
|
<el-table-column type="selection" width="55" />
|
||||||
<el-table-column prop="hash" label="md5" width="300" />
|
<el-table-column prop="hash" label="md5" width="300" />
|
||||||
<el-table-column prop="size" label="文件大小" >
|
<el-table-column prop="size" :label="t('api.sourceImage.tableSize')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ prettyBytes(scope.row.size) }}
|
{{ prettyBytes(scope.row.size) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="mime" label="MIME" />
|
<el-table-column prop="mime" label="MIME" />
|
||||||
<el-table-column prop="label" label="标签" >
|
<el-table-column prop="label" :label="t('api.sourceImage.tableLabel')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag v-for="label in scope.row.label" :key="label" effect="plain">{{label}}</el-tag>
|
<el-tag v-for="label in scope.row.label" :key="label" effect="plain">{{label}}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createdAt" label="上传时间" >
|
<el-table-column prop="createdAt" :label="t('api.sourceImage.tableUploadTime')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.createdAt) }}
|
{{ datetimeFormat(scope.row.createdAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" >
|
<el-table-column :label="t('common.operation')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link icon="EditPen" type="primary" @click="modifyTags(scope.row)" title="修改标签" v-permission="'sourceImage:save'"></el-button>
|
<el-button link icon="EditPen" type="primary" @click="modifyTags(scope.row)" :title="t('api.sourceImage.modifyTagsBtn')" v-permission="'sourceImage:save'"></el-button>
|
||||||
<el-button link icon="View" @click="preview(scope.row)" title="预览"></el-button>
|
<el-button link icon="View" @click="preview(scope.row)" :title="t('api.sourceImage.previewBtn')"></el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -54,12 +54,12 @@
|
|||||||
@current-change="pageChange">
|
@current-change="pageChange">
|
||||||
</el-pagination>
|
</el-pagination>
|
||||||
</div>
|
</div>
|
||||||
<el-dialog v-model="modifyModal" title="修改标签" width="630px" @closed="loadData">
|
<el-dialog v-model="modifyModal" :title="t('api.sourceImage.modifyTagsTitle')" width="630px" @closed="loadData">
|
||||||
<el-transfer
|
<el-transfer
|
||||||
v-model="curModifyLabels"
|
v-model="curModifyLabels"
|
||||||
filterable
|
filterable
|
||||||
:render-content="renderFunc"
|
:render-content="renderFunc"
|
||||||
:titles="['可选标签', '已选标签']"
|
:titles="[t('api.sourceImage.availableLabels'), t('api.sourceImage.selectedLabels')]"
|
||||||
:format="{
|
:format="{
|
||||||
noChecked: '${total}',
|
noChecked: '${total}',
|
||||||
hasChecked: '${checked}/${total}',
|
hasChecked: '${checked}/${total}',
|
||||||
@@ -79,6 +79,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import prettyBytes from 'pretty-bytes'
|
import prettyBytes from 'pretty-bytes'
|
||||||
import { MsgResult, Page } from '@/model/common.dto'
|
import { MsgResult, Page } from '@/model/common.dto'
|
||||||
@@ -86,6 +87,8 @@ import { useBaseList } from '@/model/baselist'
|
|||||||
import { SourceImageModel } from '@/model/api/source-image'
|
import { SourceImageModel } from '@/model/api/source-image'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
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, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new Page())
|
const { loading, total, search, setLoadData, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new Page())
|
||||||
@@ -124,12 +127,12 @@ setLoadData(loadData)
|
|||||||
|
|
||||||
function deleteAll(): void {
|
function deleteAll(): void {
|
||||||
if (!selectedData.length) {
|
if (!selectedData.length) {
|
||||||
ElMessage.warning('请选择要删除的数据')
|
ElMessage.warning(t('common.selectToDelete'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ElMessageBox.confirm(`是否确认删除选中的${selectedData.length}条数据?`, '确认删除', {type: 'warning'}).then(async () => {
|
ElMessageBox.confirm(t('common.deleteSelectedConfirm', { count: selectedData.length }), t('common.deleteConfirm'), {type: 'warning'}).then(async () => {
|
||||||
await http.delete('/source-image/delete', {params: {ids: selectedData}})
|
await http.delete('/source-image/delete', {params: {ids: selectedData}})
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -138,7 +141,7 @@ function dataSelect(selection: SourceImageModel[]): void {
|
|||||||
}
|
}
|
||||||
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(t('common.fileSizeExceeded'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
isUploading.value = true
|
isUploading.value = true
|
||||||
@@ -146,10 +149,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(t('common.uploadSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning(response.message)
|
ElMessage.warning(response.message || t('common.uploadFailed'))
|
||||||
}
|
}
|
||||||
isUploading.value = false
|
isUploading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
|
||||||
<path fill="#FFF" d="M4 16c0-6.6 5.4-12 12-12s12 5.4 12 12c0 1.2-0.8 2-2 2s-2-0.8-2-2c0-4.4-3.6-8-8-8s-8 3.6-8 8 3.6 8 8 8c1.2 0 2 0.8 2 2s-0.8 2-2 2c-6.6 0-12-5.4-12-12z"></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 265 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
|
||||||
<path d="M26.667 5.333h-21.333c-0 0-0.001 0-0.001 0-1.472 0-2.666 1.194-2.666 2.666 0 0 0 0.001 0 0.001v-0 16c0 0 0 0.001 0 0.001 0 1.472 1.194 2.666 2.666 2.666 0 0 0.001 0 0.001 0h21.333c0 0 0.001 0 0.001 0 1.472 0 2.666-1.194 2.666-2.666 0-0 0-0.001 0-0.001v0-16c0-0 0-0.001 0-0.001 0-1.472-1.194-2.666-2.666-2.666-0 0-0.001 0-0.001 0h0zM5.333 16h5.333v2.667h-5.333v-2.667zM18.667 24h-13.333v-2.667h13.333v2.667zM26.667 24h-5.333v-2.667h5.333v2.667zM26.667 18.667h-13.333v-2.667h13.333v2.667z"></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 590 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="-5 0 32 32">
|
|
||||||
<path d="M20.8 14.4q0.704 0 1.152 0.48t0.448 1.12-0.48 1.12-1.12 0.48h-19.2q-0.64 0-1.12-0.48t-0.48-1.12 0.448-1.12 1.152-0.48h19.2zM1.6 11.2q-0.64 0-1.12-0.48t-0.48-1.12 0.448-1.12 1.152-0.48h19.2q0.704 0 1.152 0.48t0.448 1.12-0.48 1.12-1.12 0.48h-19.2zM20.8 20.8q0.704 0 1.152 0.48t0.448 1.12-0.48 1.12-1.12 0.48h-19.2q-0.64 0-1.12-0.48t-0.48-1.12 0.448-1.12 1.152-0.48h19.2z"></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 473 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
|
||||||
<path d="M2.667 7.027l1.707-1.693 22.293 22.293-1.693 1.707-4-4h-11.64v4l-5.333-5.333 5.333-5.333v4h8.973l-8.973-8.973v0.973h-2.667v-3.64l-4-4zM22.667 17.333h2.667v5.573l-2.667-2.667v-2.907zM22.667 6.667v-4l5.333 5.333-5.333 5.333v-4h-10.907l-2.667-2.667h13.573z"></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 357 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="-8 0 32 32">
|
|
||||||
<path stroke="#000" fill="#FFF" d="M14.080 4.8q2.88 0 2.88 2.048v18.24q0 2.112-2.88 2.112t-2.88-2.112v-18.24q0-2.048 2.88-2.048zM2.88 4.8q2.88 0 2.88 2.048v18.24q0 2.112-2.88 2.112t-2.88-2.112v-18.24q0-2.048 2.88-2.048z"></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 315 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="-8 0 32 32">
|
|
||||||
<path stroke="#000" fill="#FFF" d="M15.552 15.168q0.448 0.32 0.448 0.832 0 0.448-0.448 0.768l-13.696 8.512q-0.768 0.512-1.312 0.192t-0.544-1.28v-16.448q0-0.96 0.544-1.28t1.312 0.192z"></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 278 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
|
||||||
<path d="M9.333 9.333h13.333v4l5.333-5.333-5.333-5.333v4h-16v8h2.667v-5.333zM22.667 22.667h-13.333v-4l-5.333 5.333 5.333 5.333v-4h16v-8h-2.667v5.333z"></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 240 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
|
||||||
<path d="M9.333 9.333h13.333v4l5.333-5.333-5.333-5.333v4h-16v8h2.667v-5.333zM22.667 22.667h-13.333v-4l-5.333 5.333 5.333 5.333v-4h16v-8h-2.667v5.333zM17.333 20v-8h-1.333l-2.667 1.333v1.333h2v5.333h2z"></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 294 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
|
||||||
<path d="M22.667 4l7 6-7 6 7 6-7 6v-4h-3.653l-3.76-3.76 2.827-2.827 2.587 2.587h2v-8h-2l-12 12h-6v-4h4.347l12-12h3.653v-4zM2.667 8h6l3.76 3.76-2.827 2.827-2.587-2.587h-4.347v-4z"></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 272 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
|
||||||
<path d="M25.468 6.947c-0.326-0.172-0.724-0.151-1.030 0.057l-6.438 4.38v-3.553c0-0.371-0.205-0.71-0.532-0.884-0.326-0.172-0.724-0.151-1.030 0.057l-12 8.164c-0.274 0.186-0.438 0.496-0.438 0.827s0.164 0.641 0.438 0.827l12 8.168c0.169 0.115 0.365 0.174 0.562 0.174 0.16 0 0.321-0.038 0.468-0.116 0.327-0.173 0.532-0.514 0.532-0.884v-3.556l6.438 4.382c0.169 0.115 0.365 0.174 0.562 0.174 0.16 0 0.321-0.038 0.468-0.116 0.327-0.173 0.532-0.514 0.532-0.884v-16.333c0-0.371-0.205-0.71-0.532-0.884z"></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 585 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
|
||||||
<path d="M13.728 6.272v19.456q0 0.448-0.352 0.8t-0.8 0.32-0.8-0.32l-5.952-5.952h-4.672q-0.48 0-0.8-0.352t-0.352-0.8v-6.848q0-0.48 0.352-0.8t0.8-0.352h4.672l5.952-5.952q0.32-0.32 0.8-0.32t0.8 0.32 0.352 0.8zM20.576 16q0 1.344-0.768 2.528t-2.016 1.664q-0.16 0.096-0.448 0.096-0.448 0-0.8-0.32t-0.32-0.832q0-0.384 0.192-0.64t0.544-0.448 0.608-0.384 0.512-0.64 0.192-1.024-0.192-1.024-0.512-0.64-0.608-0.384-0.544-0.448-0.192-0.64q0-0.48 0.32-0.832t0.8-0.32q0.288 0 0.448 0.096 1.248 0.48 2.016 1.664t0.768 2.528z"></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 604 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
|
||||||
<path d="M13.728 6.272v19.456q0 0.448-0.352 0.8t-0.8 0.32-0.8-0.32l-5.952-5.952h-4.672q-0.48 0-0.8-0.352t-0.352-0.8v-6.848q0-0.48 0.352-0.8t0.8-0.352h4.672l5.952-5.952q0.32-0.32 0.8-0.32t0.8 0.32 0.352 0.8z"></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 301 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
|
||||||
<path d="M13.728 6.272v19.456q0 0.448-0.352 0.8t-0.8 0.32-0.8-0.32l-5.952-5.952h-4.672q-0.48 0-0.8-0.352t-0.352-0.8v-6.848q0-0.48 0.352-0.8t0.8-0.352h4.672l5.952-5.952q0.32-0.32 0.8-0.32t0.8 0.32 0.352 0.8zM20.576 16q0 1.344-0.768 2.528t-2.016 1.664q-0.16 0.096-0.448 0.096-0.448 0-0.8-0.32t-0.32-0.832q0-0.384 0.192-0.64t0.544-0.448 0.608-0.384 0.512-0.64 0.192-1.024-0.192-1.024-0.512-0.64-0.608-0.384-0.544-0.448-0.192-0.64q0-0.48 0.32-0.832t0.8-0.32q0.288 0 0.448 0.096 1.248 0.48 2.016 1.664t0.768 2.528zM25.152 16q0 2.72-1.536 5.056t-4 3.36q-0.256 0.096-0.448 0.096-0.48 0-0.832-0.352t-0.32-0.8q0-0.704 0.672-1.056 1.024-0.512 1.376-0.8 1.312-0.96 2.048-2.4t0.736-3.104-0.736-3.104-2.048-2.4q-0.352-0.288-1.376-0.8-0.672-0.352-0.672-1.056 0-0.448 0.32-0.8t0.8-0.352q0.224 0 0.48 0.096 2.496 1.056 4 3.36t1.536 5.056zM29.728 16q0 4.096-2.272 7.552t-6.048 5.056q-0.224 0.096-0.448 0.096-0.48 0-0.832-0.352t-0.32-0.8q0-0.64 0.704-1.056 0.128-0.064 0.384-0.192t0.416-0.192q0.8-0.448 1.44-0.896 2.208-1.632 3.456-4.064t1.216-5.152-1.216-5.152-3.456-4.064q-0.64-0.448-1.44-0.896-0.128-0.096-0.416-0.192t-0.384-0.192q-0.704-0.416-0.704-1.056 0-0.448 0.32-0.8t0.832-0.352q0.224 0 0.448 0.096 3.776 1.632 6.048 5.056t2.272 7.552z"></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,153 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="aplayer-bar-wrap"
|
|
||||||
@mousedown="onThumbMouseDown"
|
|
||||||
ref="barWrap"
|
|
||||||
>
|
|
||||||
<div class="aplayer-bar">
|
|
||||||
<div
|
|
||||||
class="aplayer-loaded"
|
|
||||||
:style="{width: `${loadProgress * 100}%`}">
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="aplayer-played"
|
|
||||||
:style="{width: `${playProgress * 100}%`, background: theme}"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
ref="thumb"
|
|
||||||
@mouseover="thumbHovered = true"
|
|
||||||
@mouseout="thumbHovered = false"
|
|
||||||
class="aplayer-thumb"
|
|
||||||
:style="{borderColor: theme, backgroundColor: thumbHovered ? theme : '#fff'}"
|
|
||||||
>
|
|
||||||
<span class="aplayer-loading-icon" :style="{backgroundColor: theme }">
|
|
||||||
<player-icon type="loading"/>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { getElementViewLeft } from '../utils'
|
|
||||||
import PlayerIcon from './aplayer-icon.vue'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
loadProgress: number
|
|
||||||
playProgress: number
|
|
||||||
theme?: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits(['dragbegin', 'dragging', 'dragend'])
|
|
||||||
|
|
||||||
const thumbHovered = ref(false)
|
|
||||||
const barWrap = ref<unknown>(null)
|
|
||||||
|
|
||||||
const onThumbMouseDown = (e: MouseEvent) => {
|
|
||||||
const barWidth = (<HTMLElement>barWrap.value).clientWidth
|
|
||||||
let percentage = (e.clientX - getElementViewLeft(<HTMLElement>barWrap.value)) / barWidth
|
|
||||||
percentage = percentage > 0 ? percentage : 0
|
|
||||||
percentage = percentage < 1 ? percentage : 1
|
|
||||||
emit('dragbegin', percentage)
|
|
||||||
document.addEventListener('mousemove', onDocumentMouseMove)
|
|
||||||
document.addEventListener('mouseup', onDocumentMouseUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDocumentMouseMove = (e: MouseEvent) => {
|
|
||||||
const barWidth = (<HTMLElement>barWrap.value).clientWidth
|
|
||||||
let percentage = (e.clientX - getElementViewLeft(<HTMLElement>barWrap.value)) / barWidth
|
|
||||||
percentage = percentage > 0 ? percentage : 0
|
|
||||||
percentage = percentage < 1 ? percentage : 1
|
|
||||||
emit('dragging', percentage)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDocumentMouseUp = (e: MouseEvent) => {
|
|
||||||
document.removeEventListener('mouseup', onDocumentMouseUp)
|
|
||||||
document.removeEventListener('mousemove', onDocumentMouseMove)
|
|
||||||
const barWidth = (<HTMLElement>barWrap.value).clientWidth
|
|
||||||
let percentage = (e.clientX - getElementViewLeft(<HTMLElement>barWrap.value)) / barWidth
|
|
||||||
percentage = percentage > 0 ? percentage : 0
|
|
||||||
percentage = percentage < 1 ? percentage : 1
|
|
||||||
emit('dragend', percentage)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.aplayer-bar-wrap {
|
|
||||||
margin: 0 0 0 5px;
|
|
||||||
padding: 4px 0;
|
|
||||||
cursor: pointer;
|
|
||||||
flex: 1;
|
|
||||||
.aplayer-bar {
|
|
||||||
position: relative;
|
|
||||||
height: 2px;
|
|
||||||
width: 100%;
|
|
||||||
background: #cdcdcd;
|
|
||||||
.aplayer-loaded {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: #aaa;
|
|
||||||
height: 2px;
|
|
||||||
transition: all 0.5s ease;
|
|
||||||
will-change: width;
|
|
||||||
}
|
|
||||||
.aplayer-played {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
height: 2px;
|
|
||||||
transition: background-color .3s;
|
|
||||||
will-change: width;
|
|
||||||
.aplayer-thumb {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 5px;
|
|
||||||
margin-top: -5px;
|
|
||||||
margin-right: -10px;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border: 1px solid;
|
|
||||||
transform: scale(.8);
|
|
||||||
will-change: transform;
|
|
||||||
transition: transform 300ms, background-color .3s, border-color .3s;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
&:hover {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
overflow: hidden;
|
|
||||||
.aplayer-loading-icon {
|
|
||||||
display: none;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
.icon-svg {
|
|
||||||
position: absolute;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.aplayer-loading {
|
|
||||||
.aplayer-bar-wrap .aplayer-bar .aplayer-thumb .aplayer-loading-icon {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.aplayer-info .aplayer-controller .aplayer-bar-wrap .aplayer-bar .aplayer-played .aplayer-thumb {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0)
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aplayer-volume-wrap">
|
|
||||||
<icon-button
|
|
||||||
:class="`aplayer-icon-${volumeIcon}`"
|
|
||||||
:icon="volumeIcon"
|
|
||||||
@click="emit('togglemute')"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="aplayer-volume-bar-wrap"
|
|
||||||
@mousedown="onBarMouseDown"
|
|
||||||
>
|
|
||||||
<div class="aplayer-volume-bar" ref="bar">
|
|
||||||
<div
|
|
||||||
class="aplayer-volume"
|
|
||||||
:style="{
|
|
||||||
height: muted ? 0 : `${Math.trunc(volume * 100)}%`,
|
|
||||||
background: theme
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import IconButton from './aplayer-iconbutton.vue'
|
|
||||||
import {getElementViewTop} from '../utils'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
volume: number
|
|
||||||
muted: boolean
|
|
||||||
theme: string
|
|
||||||
}>()
|
|
||||||
const emit = defineEmits(['setvolume', '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,195 @@
|
|||||||
|
/**
|
||||||
|
* 音频可视化 - 粒子系统与圆形频谱绘制
|
||||||
|
*
|
||||||
|
* 负责唱片周围的圆形音频频谱条绘制, 以及随节奏从唱片边缘飘散的粒子效果。
|
||||||
|
* 频谱条根据音频频率数据径向排列在唱片外圈, 粒子根据低频/高频能量动态生成。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 单个粒子的状态描述 */
|
||||||
|
export interface Particle {
|
||||||
|
x: number // 当前 x 坐标
|
||||||
|
y: number // 当前 y 坐标
|
||||||
|
vx: number // x 方向速度
|
||||||
|
vy: number // y 方向速度
|
||||||
|
life: number // 已存活帧数
|
||||||
|
maxLife: number // 最大存活帧数
|
||||||
|
size: number // 粒子半径
|
||||||
|
r: number // 颜色 R 分量
|
||||||
|
g: number // 颜色 G 分量
|
||||||
|
b: number // 颜色 B 分量
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 粒子数量上限, 防止性能问题 */
|
||||||
|
const MAX_PARTICLES = 200
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在唱片边缘生成新粒子
|
||||||
|
*
|
||||||
|
* 粒子从唱片圆周的随机角度向外发射, 数量由低频能量决定, 速度由高频能量加成。
|
||||||
|
* 颜色在蓝紫色调范围内随机偏移。
|
||||||
|
*
|
||||||
|
* @param particles - 粒子数组(会被直接修改)
|
||||||
|
* @param centerX - 唱片圆心 x 坐标
|
||||||
|
* @param centerY - 唱片圆心 y 坐标
|
||||||
|
* @param discRadius - 唱片半径(像素)
|
||||||
|
* @param energy - 低频能量(0~1), 决定生成数量和粒子大小
|
||||||
|
* @param highEnergy - 高频能量(0~1), 决定发射速度和颜色偏移
|
||||||
|
*/
|
||||||
|
export function spawnParticles(particles: Particle[], centerX: number, centerY: number, discRadius: number, energy: number, highEnergy: number) {
|
||||||
|
const count = Math.floor(energy * 4)
|
||||||
|
for (let i = 0; i < count && particles.length < MAX_PARTICLES; i++) {
|
||||||
|
const angle = Math.random() * Math.PI * 2
|
||||||
|
const speed = 0.5 + energy * 2 + highEnergy * 1.5
|
||||||
|
const hue = Math.random()
|
||||||
|
particles.push({
|
||||||
|
x: centerX + Math.cos(angle) * (discRadius + 4),
|
||||||
|
y: centerY + Math.sin(angle) * (discRadius + 4),
|
||||||
|
vx: Math.cos(angle) * speed * (0.6 + Math.random() * 0.8),
|
||||||
|
vy: Math.sin(angle) * speed * (0.6 + Math.random() * 0.8),
|
||||||
|
life: 1,
|
||||||
|
maxLife: 40 + Math.random() * 40,
|
||||||
|
size: 1.5 + energy * 2 + Math.random() * 1.5,
|
||||||
|
r: Math.floor(100 + hue * 155),
|
||||||
|
g: Math.floor(180 - hue * 100 + highEnergy * 60),
|
||||||
|
b: Math.floor(220 + hue * 35),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新粒子物理状态并绘制到 Canvas
|
||||||
|
*
|
||||||
|
* 每帧对所有粒子: 移动位置、衰减速度、递增生命周期。
|
||||||
|
* 生命结束的粒子被移除, 存活的粒子按透明度渐变绘制, 较大粒子额外绘制发光光圈。
|
||||||
|
*
|
||||||
|
* @param ctx - Canvas 2D 渲染上下文
|
||||||
|
* @param particles - 粒子数组(会被直接修改, 移除死亡粒子)
|
||||||
|
*/
|
||||||
|
export function updateAndDrawParticles(ctx: CanvasRenderingContext2D, particles: Particle[]) {
|
||||||
|
for (let i = particles.length - 1; i >= 0; i--) {
|
||||||
|
const p = particles[i]
|
||||||
|
p.x += p.vx
|
||||||
|
p.y += p.vy
|
||||||
|
p.vx *= 0.98
|
||||||
|
p.vy *= 0.98
|
||||||
|
p.life += 1
|
||||||
|
|
||||||
|
const progress = p.life / p.maxLife
|
||||||
|
if (progress >= 1) {
|
||||||
|
particles.splice(i, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const alpha = 1 - progress
|
||||||
|
const size = p.size * (1 - progress * 0.5)
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(p.x, p.y, size, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = `rgba(${p.r}, ${p.g}, ${p.b}, ${alpha * 0.7})`
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
// 发光效果: 在粒子外围画一个更大的半透明圆
|
||||||
|
if (size > 1.5) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(p.x, p.y, size * 2, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = `rgba(${p.r}, ${p.g}, ${p.b}, ${alpha * 0.15})`
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** drawCircularSpectrum 的入参 */
|
||||||
|
export interface SpectrumParams {
|
||||||
|
ctx: CanvasRenderingContext2D
|
||||||
|
width: number // Canvas 宽度
|
||||||
|
height: number // Canvas 高度
|
||||||
|
dataArray: Uint8Array<ArrayBuffer> // Web Audio API 频率数据
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 频率能量分析结果 */
|
||||||
|
export interface EnergyResult {
|
||||||
|
lowEnergy: number // 低频段能量(0~1), 对应鼓点/贝斯
|
||||||
|
highEnergy: number // 高频段能量(0~1), 对应人声/镲片
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从频率数据中计算低频和高频的平均能量
|
||||||
|
*
|
||||||
|
* 将频率数据分为低频段(前25%)和高频段(后40%), 分别求归一化均值。
|
||||||
|
* 用于驱动粒子系统的生成参数。
|
||||||
|
*
|
||||||
|
* @param dataArray - FFT 频率数据(0~255)
|
||||||
|
* @returns 低频和高频的归一化能量值
|
||||||
|
*/
|
||||||
|
function computeFrequencyEnergy(dataArray: Uint8Array<ArrayBuffer>): EnergyResult {
|
||||||
|
const barCount = dataArray.length
|
||||||
|
let lowSum = 0, highSum = 0
|
||||||
|
const lowEnd = Math.floor(barCount * 0.25)
|
||||||
|
const highStart = Math.floor(barCount * 0.6)
|
||||||
|
for (let i = 0; i < lowEnd; i++) lowSum += dataArray[i]
|
||||||
|
for (let i = highStart; i < barCount; i++) highSum += dataArray[i]
|
||||||
|
return {
|
||||||
|
lowEnergy: lowSum / (lowEnd * 255),
|
||||||
|
highEnergy: highSum / ((barCount - highStart) * 255),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制圆形音频频谱
|
||||||
|
*
|
||||||
|
* 以 Canvas 中心为圆心, 将频率数据的每个频段绘制为一条从唱片边缘向外辐射的线段。
|
||||||
|
* 线段长度与频率振幅成正比, 颜色沿频率轴从蓝色渐变到紫粉色。
|
||||||
|
* 绘制完成后自动计算并返回频率能量, 供粒子系统使用。
|
||||||
|
*
|
||||||
|
* @param params - 包含 Canvas 上下文、尺寸和频率数据
|
||||||
|
* @returns 低频/高频能量分析结果
|
||||||
|
*/
|
||||||
|
export function drawCircularSpectrum(params: SpectrumParams): EnergyResult {
|
||||||
|
const { ctx, width, height, dataArray } = params
|
||||||
|
const centerX = width / 2
|
||||||
|
const centerY = height / 2
|
||||||
|
const discRadius = Math.min(width, height) * 0.30
|
||||||
|
const barCount = dataArray.length
|
||||||
|
const maxBarLength = Math.min(width, height) * 0.18
|
||||||
|
|
||||||
|
for (let i = 0; i < barCount; i++) {
|
||||||
|
const value = dataArray[i]
|
||||||
|
const barLength = (value / 255) * maxBarLength
|
||||||
|
if (barLength < 1) continue
|
||||||
|
const angle = (i / barCount) * Math.PI * 2 - Math.PI / 2
|
||||||
|
|
||||||
|
const startX = centerX + Math.cos(angle) * (discRadius + 2)
|
||||||
|
const startY = centerY + Math.sin(angle) * (discRadius + 2)
|
||||||
|
const endX = centerX + Math.cos(angle) * (discRadius + 2 + barLength)
|
||||||
|
const endY = centerY + Math.sin(angle) * (discRadius + 2 + barLength)
|
||||||
|
|
||||||
|
const ratio = i / barCount
|
||||||
|
const r = Math.floor(100 + ratio * 155)
|
||||||
|
const g = Math.floor(180 - ratio * 100)
|
||||||
|
const b = Math.floor(230 + ratio * 25)
|
||||||
|
const alpha = 0.4 + (value / 255) * 0.6
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(startX, startY)
|
||||||
|
ctx.lineTo(endX, endY)
|
||||||
|
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||||
|
ctx.lineWidth = Math.max(1.5, (Math.PI * 2 * discRadius) / barCount * 0.7)
|
||||||
|
ctx.lineCap = 'round'
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
return computeFrequencyEnergy(dataArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将秒数格式化为 MM:SS 格式
|
||||||
|
*
|
||||||
|
* @param sec - 秒数
|
||||||
|
* @returns 格式化后的时间字符串, 如 "03:25"
|
||||||
|
*/
|
||||||
|
export function formatTime(sec: number) {
|
||||||
|
if (isNaN(sec)) return '00:00'
|
||||||
|
const m = Math.floor(sec / 60)
|
||||||
|
const s = Math.floor(sec % 60)
|
||||||
|
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
@@ -1,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>
|
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<el-button type="primary" @click="startCaptcha" plain>{{ t('debug.captcha.startBtn') }}</el-button>
|
||||||
|
<el-button @click="reloadCaptcha" :disabled="!captchaActive">{{ t('debug.captcha.refreshBtn') }}</el-button>
|
||||||
|
<el-button @click="destroyCaptcha" :disabled="!captchaActive" plain>{{ t('debug.captcha.destroyBtn') }}</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sandbox-grid">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>{{ t('debug.captcha.typeCardTitle') }}</template>
|
||||||
|
|
||||||
|
<p class="type-hint">
|
||||||
|
{{ t('debug.captcha.typeHint') }}
|
||||||
|
</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">{{ t('debug.captcha.restoreRandom') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="preview-card" shadow="never">
|
||||||
|
<template #header>{{ t('debug.captcha.previewCardTitle') }}</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 { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import CaptchaPanel from '@/components/CaptchaPanel.vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
type CaptchaType = 'SLIDER' | 'ROTATE' | 'CONCAT' | 'WORD_IMAGE_CLICK'
|
||||||
|
|
||||||
|
type CaptchaPanelRef = {
|
||||||
|
destroy: () => void
|
||||||
|
init: () => Promise<void>
|
||||||
|
reload: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const captchaTypeOptions = computed<Array<{ label: string, value: CaptchaType }>>(() => [
|
||||||
|
{ label: t('debug.captcha.typeSlider'), value: 'SLIDER' },
|
||||||
|
{ label: t('debug.captcha.typeRotate'), value: 'ROTATE' },
|
||||||
|
{ label: t('debug.captcha.typeConcat'), value: 'CONCAT' },
|
||||||
|
{ label: t('debug.captcha.typeWordClick'), 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>
|
||||||
@@ -1,40 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<el-form inline :model="search">
|
<el-form inline :model="search">
|
||||||
<el-form-item label="标题">
|
<el-form-item :label="t('system.article.formTitle')">
|
||||||
<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="t('common.createdAt')">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="search.createDate"
|
v-model="search.createDate"
|
||||||
type="daterange"
|
type="daterange"
|
||||||
range-separator="至"
|
:range-separator="t('system.article.rangeSeparator')"
|
||||||
start-placeholder="开始日期"
|
:start-placeholder="t('system.article.startDate')"
|
||||||
end-placeholder="结束日期" />
|
:end-placeholder="t('system.article.endDate')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="分类">
|
<el-form-item :label="t('system.article.formCategory')">
|
||||||
<el-select v-model="search.category" filterable clearable>
|
<el-select v-model="search.category" filterable clearable>
|
||||||
<el-option v-for="item in categories" :key="item" :value="item" :label="item" />
|
<el-option v-for="item in categories" :key="item" :value="item" :label="item" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="标签">
|
<el-form-item :label="t('system.article.formTag')">
|
||||||
<el-select v-model="search.tag" filterable clearable>
|
<el-select v-model="search.tag" filterable clearable>
|
||||||
<el-option v-for="item in tags" :key="item" :value="item" :label="item" />
|
<el-option v-for="item in tags" :key="item" :value="item" :label="item" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="已分词">
|
<el-form-item :label="t('system.article.formIsSplited')">
|
||||||
<el-select v-model="search.isSplited" >
|
<el-select v-model="search.isSplited" >
|
||||||
<el-option :value="true" label="是" />
|
<el-option :value="true" :label="t('common.yes')" />
|
||||||
<el-option :value="false" label="否" />
|
<el-option :value="false" :label="t('common.no')" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="btn-container">
|
<div class="btn-container">
|
||||||
<el-button type="primary" @click="splitWord" style="vertical-align: bottom" v-permission="'article:edit'" plain>分词处理</el-button>
|
<el-button type="primary" @click="splitWord" style="vertical-align: bottom" v-permission="'article:edit'" plain>{{ t('system.article.splitWordBtn') }}</el-button>
|
||||||
<el-button @click="pullArticles" style="vertical-align: bottom" v-permission="'article:edit'" plain>拉取文章</el-button>
|
<el-button @click="pullArticles" style="vertical-align: bottom" v-permission="'article:edit'" plain>{{ t('system.article.pullArticlesBtn') }}</el-button>
|
||||||
<div class="search-btn">
|
<div class="search-btn">
|
||||||
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
|
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
|
||||||
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
|
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-row style="flex: 1; overflow: hidden;">
|
<el-row style="flex: 1; overflow: hidden;">
|
||||||
@@ -52,29 +52,29 @@
|
|||||||
<el-col :span="20" style="height: 100%; display: flex; flex-direction: column;">
|
<el-col :span="20" style="height: 100%; display: flex; flex-direction: column;">
|
||||||
<el-table class="table-container" :data="articleData" v-loading="loading" stripe @selection-change="dataSelect" height="100%">
|
<el-table class="table-container" :data="articleData" v-loading="loading" stripe @selection-change="dataSelect" height="100%">
|
||||||
<el-table-column type="selection" width="55" />
|
<el-table-column type="selection" width="55" />
|
||||||
<el-table-column prop="title" label="标题" />
|
<el-table-column prop="title" :label="t('system.article.tableTitle')" />
|
||||||
<el-table-column prop="path" label="路径" >
|
<el-table-column prop="path" :label="t('system.article.tablePath')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ scope.row.path.join('/') }}
|
{{ scope.row.path.join('/') }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="categories" label="分类" width="150" >
|
<el-table-column prop="categories" :label="t('system.article.tableCategory')" width="150" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ typeof scope.row.categories === 'string' ? scope.row.categories : scope.row.categories?.join(',') }}
|
{{ typeof scope.row.categories === 'string' ? scope.row.categories : scope.row.categories?.join(t('system.article.comma')) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="tags" label="标签" width="180" >
|
<el-table-column prop="tags" :label="t('system.article.tableTag')" width="180" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ typeof scope.row.tags === 'string' ? scope.row.tags : scope.row.tags?.join(',') }}
|
{{ typeof scope.row.tags === 'string' ? scope.row.tags : scope.row.tags?.join(t('system.article.comma')) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="contentLen" label="正文长度" width="100" />
|
<el-table-column prop="contentLen" :label="t('system.article.tableContentLen')" width="100" />
|
||||||
<el-table-column prop="createDate" label="创建时间" width="180" >
|
<el-table-column prop="createDate" :label="t('common.createdAt')" width="180" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.createDate) }}
|
{{ datetimeFormat(scope.row.createDate) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="tags" label="是否已分词" width="120" >
|
<el-table-column prop="tags" :label="t('system.article.tableIsSplited')" width="120" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div style="width: 18px">
|
<div style="width: 18px">
|
||||||
<Check v-if="scope.row.isSplited" />
|
<Check v-if="scope.row.isSplited" />
|
||||||
@@ -108,6 +108,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import hyperdown from 'hyperdown'
|
import hyperdown from 'hyperdown'
|
||||||
import { ArticleModel, TreeNodeData, TreeNodeSource } from '@/model/system/article'
|
import { ArticleModel, TreeNodeData, TreeNodeSource } from '@/model/system/article'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
@@ -139,6 +140,7 @@ class ArticlePage extends Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const { t } = useI18n()
|
||||||
const { loading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new ArticlePage())
|
const { loading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new ArticlePage())
|
||||||
|
|
||||||
const articleData = ref<ArticleModel[]>([])
|
const articleData = ref<ArticleModel[]>([])
|
||||||
@@ -175,18 +177,18 @@ setLoadData(loadData)
|
|||||||
|
|
||||||
function splitWord() {
|
function splitWord() {
|
||||||
if (!selectedData.length) {
|
if (!selectedData.length) {
|
||||||
ElMessage.warning('请选择要执行分词的文章')
|
ElMessage.warning(t('system.article.selectToSplit'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ElMessageBox.confirm(`是否确认对选中的${selectedData.length}篇文章执行分词处理?`, '操作确认', {type: 'info'}).then(async () => {
|
ElMessageBox.confirm(t('system.article.splitConfirmMsg', {count: selectedData.length}), t('system.article.splitConfirmTitle'), {type: 'info'}).then(async () => {
|
||||||
const successNum = await http.put<{_ids: string[]}, any>('/article/splitWord', {_ids: selectedData})
|
const successNum = await http.put<{_ids: string[]}, any>('/article/splitWord', {_ids: selectedData})
|
||||||
ElMessage.success(`${successNum}篇文章分词处理成功`)
|
ElMessage.success(t('system.article.splitSuccess', {count: successNum}))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function pullArticles() {
|
function pullArticles() {
|
||||||
ElMessageBox.confirm('确认拉取全部文章?', '操作确认', {type: 'info'}).then(async () => {
|
ElMessageBox.confirm(t('system.article.pullConfirmMsg'), t('system.article.pullConfirmTitle'), {type: 'info'}).then(async () => {
|
||||||
const { updateCount, createCount } = await http.put<never, any>('/article/pull')
|
const { updateCount, createCount } = await http.put<never, any>('/article/pull')
|
||||||
ElMessage.success(`拉取文章完成,更新 ${updateCount} 篇,创建 ${createCount} 篇`)
|
ElMessage.success(t('system.article.pullSuccess', {updateCount, createCount}))
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<el-icon><Document /></el-icon>
|
<el-icon><Document /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<div class="stat-label">文章总数</div>
|
<div class="stat-label">{{ t('system.statistics.totalArticles') }}</div>
|
||||||
<div class="stat-value">{{ totalArticles }}</div>
|
<div class="stat-value">{{ totalArticles }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<el-icon><FolderOpened /></el-icon>
|
<el-icon><FolderOpened /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<div class="stat-label">分类数量</div>
|
<div class="stat-label">{{ t('system.statistics.totalCategories') }}</div>
|
||||||
<div class="stat-value">{{ totalCategories }}</div>
|
<div class="stat-value">{{ totalCategories }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<el-icon><Calendar /></el-icon>
|
<el-icon><Calendar /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<div class="stat-label">最早发布</div>
|
<div class="stat-label">{{ t('system.statistics.earliestPublish') }}</div>
|
||||||
<div class="stat-value">{{ earliestDate }}</div>
|
<div class="stat-value">{{ earliestDate }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<el-icon><TrendCharts /></el-icon>
|
<el-icon><TrendCharts /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<div class="stat-label">高频词汇</div>
|
<div class="stat-label">{{ t('system.statistics.topKeywords') }}</div>
|
||||||
<div class="stat-value">{{ topKeyword }}</div>
|
<div class="stat-value">{{ topKeyword }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,9 +47,9 @@
|
|||||||
<div class="chart-header">
|
<div class="chart-header">
|
||||||
<div class="chart-title">
|
<div class="chart-title">
|
||||||
<el-icon><PieChart /></el-icon>
|
<el-icon><PieChart /></el-icon>
|
||||||
<span>文章分类分布</span>
|
<span>{{ t('system.statistics.categoryDistribution') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-subtitle">各类别文章数量占比</div>
|
<div class="chart-subtitle">{{ t('system.statistics.categoryDistributionSub') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="categoriesChart" v-loading="categoriesChartLoading" class="chart-body"></div>
|
<div ref="categoriesChart" v-loading="categoriesChartLoading" class="chart-body"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,9 +59,9 @@
|
|||||||
<div class="chart-header">
|
<div class="chart-header">
|
||||||
<div class="chart-title">
|
<div class="chart-title">
|
||||||
<el-icon><TrendCharts /></el-icon>
|
<el-icon><TrendCharts /></el-icon>
|
||||||
<span>文章发布趋势</span>
|
<span>{{ t('system.statistics.publishTrend') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-subtitle">各时间段文章发布数量变化</div>
|
<div class="chart-subtitle">{{ t('system.statistics.publishTrendSub') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="publishDatesChart" v-loading="publishDatesChartLoading" class="chart-body"></div>
|
<div ref="publishDatesChart" v-loading="publishDatesChartLoading" class="chart-body"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,9 +71,9 @@
|
|||||||
<div class="chart-header">
|
<div class="chart-header">
|
||||||
<div class="chart-title">
|
<div class="chart-title">
|
||||||
<el-icon><Histogram /></el-icon>
|
<el-icon><Histogram /></el-icon>
|
||||||
<span>年度高频词汇分析</span>
|
<span>{{ t('system.statistics.yearlyKeywords') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-subtitle">基于文章分词结果的年度关键词统计</div>
|
<div class="chart-subtitle">{{ t('system.statistics.yearlyKeywordsSub') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="timelineWordsChart" v-loading="timelineWordsChartLoading" class="chart-body timeline-chart-body"></div>
|
<div ref="timelineWordsChart" v-loading="timelineWordsChartLoading" class="chart-body timeline-chart-body"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,10 +83,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
import { Document, FolderOpened, Calendar, TrendCharts, PieChart, Histogram } from '@element-plus/icons-vue'
|
import { Document, FolderOpened, Calendar, TrendCharts, PieChart, Histogram } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const categoriesChart = ref<HTMLElement>()
|
const categoriesChart = ref<HTMLElement>()
|
||||||
const publishDatesChart = ref<HTMLElement>()
|
const publishDatesChart = ref<HTMLElement>()
|
||||||
const timelineWordsChart = ref<HTMLElement>()
|
const timelineWordsChart = ref<HTMLElement>()
|
||||||
@@ -104,7 +107,7 @@ const topKeyword = ref('-')
|
|||||||
const categoriesChartOption: any = {
|
const categoriesChartOption: any = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
formatter: "{b}: {c}篇 ({d}%)"
|
formatter: (params: any) => `${params.name}: ${params.value}${t('system.statistics.articlesUnit')} (${params.percent}%)`
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
type: 'scroll',
|
type: 'scroll',
|
||||||
@@ -117,7 +120,7 @@ const categoriesChartOption: any = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
series: {
|
series: {
|
||||||
name: '类别',
|
name: t('system.statistics.categoryName'),
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: ['45%', '75%'],
|
radius: ['45%', '75%'],
|
||||||
center: ['35%', '50%'],
|
center: ['35%', '50%'],
|
||||||
@@ -169,7 +172,7 @@ const publishDatesChartOption: any = {
|
|||||||
containLabel: true
|
containLabel: true
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
name: '发布时间',
|
name: t('system.statistics.publishTime'),
|
||||||
nameTextStyle: {
|
nameTextStyle: {
|
||||||
color: '#909399'
|
color: '#909399'
|
||||||
},
|
},
|
||||||
@@ -186,7 +189,7 @@ const publishDatesChartOption: any = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
name: '文章数量',
|
name: t('system.statistics.articleCount'),
|
||||||
nameTextStyle: {
|
nameTextStyle: {
|
||||||
color: '#909399'
|
color: '#909399'
|
||||||
},
|
},
|
||||||
@@ -221,7 +224,7 @@ const publishDatesChartOption: any = {
|
|||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
series: {
|
series: {
|
||||||
name: '文章数量',
|
name: t('system.statistics.articleCount'),
|
||||||
type: 'line',
|
type: 'line',
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'circle',
|
symbol: 'circle',
|
||||||
@@ -297,7 +300,7 @@ const timelineWordsChartOption: any = {
|
|||||||
right: 40
|
right: 40
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
name: '高频词汇',
|
name: t('system.statistics.keywordName'),
|
||||||
nameTextStyle: {
|
nameTextStyle: {
|
||||||
color: '#909399'
|
color: '#909399'
|
||||||
},
|
},
|
||||||
@@ -314,7 +317,7 @@ const timelineWordsChartOption: any = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
name: '出现次数',
|
name: t('system.statistics.occurrenceCount'),
|
||||||
nameTextStyle: {
|
nameTextStyle: {
|
||||||
color: '#909399'
|
color: '#909399'
|
||||||
},
|
},
|
||||||
@@ -405,7 +408,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
timelineWordsChartOption.baseOption.timeline.data = timelineData.timelineWords.map((item: any) => item._id)
|
timelineWordsChartOption.baseOption.timeline.data = timelineData.timelineWords.map((item: any) => item._id)
|
||||||
timelineWordsChartOption.options = timelineData.timelineWords.map((item: any) => ({
|
timelineWordsChartOption.options = timelineData.timelineWords.map((item: any) => ({
|
||||||
title: {text: `${item._id}年发布的文章 - 高频词汇TOP10`},
|
title: {text: t('system.statistics.yearlyTitle', {year: item._id})},
|
||||||
xAxis: {data: item.keys.map((keyItem: any) => keyItem.key)},
|
xAxis: {data: item.keys.map((keyItem: any) => keyItem.key)},
|
||||||
series: {data: item.keys.map((keyItem: any) => keyItem.total)}
|
series: {data: item.keys.map((keyItem: any) => keyItem.total)}
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-form inline :model="search" @submit.prevent>
|
<el-form inline :model="search" @submit.prevent>
|
||||||
<el-form-item label="配置项">
|
<el-form-item :label="t('system.config.searchLabel')">
|
||||||
<el-input placeholder="名称/描述" v-model="search.name" />
|
<el-input :placeholder="t('system.config.searchPlaceholder')" v-model="search.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="btn-container">
|
<div class="btn-container">
|
||||||
<el-button type="primary" @click="add" v-permission="'config:save'" plain>添加</el-button>
|
<el-button type="primary" @click="add" v-permission="'config:save'" plain>{{ t('common.add') }}</el-button>
|
||||||
<div class="search-btn">
|
<div class="search-btn">
|
||||||
<el-button type="primary" @click="loadData" icon="Search" plain>搜索</el-button>
|
<el-button type="primary" @click="loadData" icon="Search" plain>{{ t('common.search') }}</el-button>
|
||||||
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
|
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table class="table-container" :data="systemConfigData" v-loading="loading" stripe>
|
<el-table class="table-container" :data="systemConfigData" v-loading="loading" stripe>
|
||||||
@@ -18,27 +18,27 @@
|
|||||||
<pre style="margin:0 10px;">{{ JSON.stringify(scope.row.value, null, ' ') }}</pre>
|
<pre style="margin:0 10px;">{{ JSON.stringify(scope.row.value, null, ' ') }}</pre>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="name" label="配置项名称" />
|
<el-table-column prop="name" :label="t('system.config.tableName')" />
|
||||||
<el-table-column prop="description" label="配置项描述" />
|
<el-table-column prop="description" :label="t('system.config.tableDescription')" />
|
||||||
<el-table-column prop="isPublic" label="是否公开" >
|
<el-table-column prop="isPublic" :label="t('system.config.tableIsPublic')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ scope.row.isPublic ? '是' : '否' }}
|
{{ scope.row.isPublic ? t('common.yes') : t('common.no') }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createdAt" label="创建时间" >
|
<el-table-column prop="createdAt" :label="t('common.createdAt')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.createdAt) }}
|
{{ datetimeFormat(scope.row.createdAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="updatedAt" label="更新时间" >
|
<el-table-column prop="updatedAt" :label="t('common.updatedAt')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.updatedAt) }}
|
{{ datetimeFormat(scope.row.updatedAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" >
|
<el-table-column :label="t('common.operation')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link icon="Edit" @click="update(scope.row)" title="修改" v-permission="'config:save'"></el-button>
|
<el-button link icon="Edit" @click="update(scope.row)" :title="t('common.edit')" v-permission="'config:save'"></el-button>
|
||||||
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除" v-permission="'config:delete'"></el-button>
|
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" :title="t('common.delete')" v-permission="'config:delete'"></el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -46,8 +46,8 @@
|
|||||||
<system-config-add ref="addForm" :formData="formData" />
|
<system-config-add ref="addForm" :formData="formData" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="addModal = false" plain>取消</el-button>
|
<el-button @click="addModal = false" plain>{{ t('common.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="save" :loading="modalLoading" plain>确定</el-button>
|
<el-button type="primary" @click="save" :loading="modalLoading" plain>{{ t('common.confirm') }}</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
@@ -63,6 +64,7 @@ import { SystemConfigModel } from '@/model/system/system-config'
|
|||||||
|
|
||||||
const modalLoading = ref(false)
|
const modalLoading = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const { t } = useI18n()
|
||||||
const search = ref<{name?: string}>({})
|
const search = ref<{name?: string}>({})
|
||||||
const systemConfigData = ref<SystemConfigModel[]>([])
|
const systemConfigData = ref<SystemConfigModel[]>([])
|
||||||
const addModal = ref(false)
|
const addModal = ref(false)
|
||||||
@@ -94,7 +96,7 @@ function add() {
|
|||||||
description: '',
|
description: '',
|
||||||
isPublic: false
|
isPublic: false
|
||||||
}
|
}
|
||||||
modalTitle.value = '新增配置项'
|
modalTitle.value = t('system.config.addTitle')
|
||||||
addModal.value = true
|
addModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +104,7 @@ function update(row: SystemConfigModel) {
|
|||||||
const data = Object.assign({}, row)
|
const data = Object.assign({}, row)
|
||||||
data.value = JSON.stringify(data.value, null, ' ')
|
data.value = JSON.stringify(data.value, null, ' ')
|
||||||
formData.value = data
|
formData.value = data
|
||||||
modalTitle.value = '修改配置项'
|
modalTitle.value = t('system.config.editTitle')
|
||||||
addModal.value = true
|
addModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,15 +115,15 @@ async function save() {
|
|||||||
await http.post<SystemConfigModel, any>('/system/config/save', formData.value)
|
await http.post<SystemConfigModel, any>('/system/config/save', formData.value)
|
||||||
modalLoading.value = false
|
modalLoading.value = false
|
||||||
addModal.value = false
|
addModal.value = false
|
||||||
ElMessage.success("保存成功")
|
ElMessage.success(t('common.saveSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(row: SystemConfigModel) {
|
function remove(row: SystemConfigModel) {
|
||||||
ElMessageBox.confirm(`是否确认删除 ${row.name} 配置项?`, '确认删除', {type: 'warning'}).then(async () => {
|
ElMessageBox.confirm(t('system.config.deleteConfirmMsg', {name: row.name}), t('common.deleteConfirm'), {type: 'warning'}).then(async () => {
|
||||||
await http.delete<{params: {id: string}}, any>('/system/config/delete', {params: {id: row._id}})
|
await http.delete<{params: {id: string}}, any>('/system/config/delete', {params: {id: row._id}})
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-form ref="configForm" :model="formData" :rules="ruleValidate" :label-width="80">
|
<el-form ref="configForm" :model="formData" :rules="ruleValidate" :label-width="80">
|
||||||
<el-form-item label="名称" prop="name">
|
<el-form-item :label="t('system.config.formName')" prop="name">
|
||||||
<el-input v-model="formData.name" />
|
<el-input v-model="formData.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="值" prop="value">
|
<el-form-item :label="t('system.config.formValue')" prop="value">
|
||||||
<el-input v-model="formData.value" type="textarea" placeholder="必须符合JSON字符串格式" :rows="4"/>
|
<el-input v-model="formData.value" type="textarea" :placeholder="t('system.config.formValuePlaceholder')" :rows="4"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="描述">
|
<el-form-item :label="t('system.config.formDescription')">
|
||||||
<el-input v-model="formData.description" />
|
<el-input v-model="formData.description" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="公开">
|
<el-form-item :label="t('system.config.formIsPublic')">
|
||||||
<el-switch
|
<el-switch
|
||||||
v-model="formData.isPublic"
|
v-model="formData.isPublic"
|
||||||
active-text="是"
|
:active-text="t('common.yes')"
|
||||||
inactive-text="否" />
|
:inactive-text="t('common.no')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
import { SystemConfigModel } from '@/model/system/system-config'
|
import { SystemConfigModel } from '@/model/system/system-config'
|
||||||
import { VForm } from '@/types'
|
import { VForm } from '@/types'
|
||||||
@@ -29,15 +30,17 @@ const props = defineProps<{
|
|||||||
formData: SystemConfigModel
|
formData: SystemConfigModel
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const configForm = ref<VForm>()
|
const configForm = ref<VForm>()
|
||||||
|
|
||||||
const ruleValidate = computed(() => ({
|
const ruleValidate = computed(() => ({
|
||||||
name: [
|
name: [
|
||||||
{ required: true, message: '请输入配置项名称', trigger: 'blur' },
|
{ required: true, message: t('system.config.nameRequired'), trigger: 'blur' },
|
||||||
{ validator: (rule: object, value: string, callback: Function) => {
|
{ validator: (rule: object, value: string, callback: Function) => {
|
||||||
http.get<any, any>('/system/config/exists', {params: {name: value, id: props.formData._id}}).then(data => {
|
http.get<any, any>('/system/config/exists', {params: {name: value, id: props.formData._id}}).then(data => {
|
||||||
if(data.exists) {
|
if(data.exists) {
|
||||||
callback(new Error('配置项名称已存在'))
|
callback(new Error(t('system.config.nameExists')))
|
||||||
} else {
|
} else {
|
||||||
callback()
|
callback()
|
||||||
}
|
}
|
||||||
@@ -46,13 +49,13 @@ const ruleValidate = computed(() => ({
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
value: [
|
value: [
|
||||||
{ required: true, message: '请输入配置项值', trigger: 'blur' },
|
{ required: true, message: t('system.config.valueRequired'), trigger: 'blur' },
|
||||||
{ validator: (rule: object, value: string, callback: Function) => {
|
{ validator: (rule: object, value: string, callback: Function) => {
|
||||||
try {
|
try {
|
||||||
JSON.parse(value)
|
JSON.parse(value)
|
||||||
callback()
|
callback()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
callback(new Error('值不符合JSON字符串格式'))
|
callback(new Error(t('system.config.valueInvalidJson')))
|
||||||
}
|
}
|
||||||
}, trigger: 'blur'
|
}, trigger: 'blur'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<el-form inline :model="search" @submit.prevent>
|
<el-form inline :model="search" @submit.prevent>
|
||||||
<el-form-item label="角色名称/描述">
|
<el-form-item :label="t('system.role.searchLabel')">
|
||||||
<el-input v-model="search.name" />
|
<el-input v-model="search.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="btn-container">
|
<div class="btn-container">
|
||||||
<el-button type="primary" @click="add" v-permission="'role:save'" plain>添加</el-button>
|
<el-button type="primary" @click="add" v-permission="'role:save'" plain>{{ t('common.add') }}</el-button>
|
||||||
<div class="search-btn">
|
<div class="search-btn">
|
||||||
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
|
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
|
||||||
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
|
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table class="table-container" :data="systemRoleData" v-loading="loading" stripe>
|
<el-table class="table-container" :data="systemRoleData" v-loading="loading" stripe>
|
||||||
<el-table-column prop="name" label="角色名称" />
|
<el-table-column prop="name" :label="t('system.role.tableName')" />
|
||||||
<el-table-column prop="description" label="描述" />
|
<el-table-column prop="description" :label="t('system.role.tableDescription')" />
|
||||||
<el-table-column prop="createdAt" label="创建时间" >
|
<el-table-column prop="createdAt" :label="t('common.createdAt')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.createdAt) }}
|
{{ datetimeFormat(scope.row.createdAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="updatedAt" label="更新时间" >
|
<el-table-column prop="updatedAt" :label="t('common.updatedAt')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.updatedAt) }}
|
{{ datetimeFormat(scope.row.updatedAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" >
|
<el-table-column :label="t('common.operation')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link icon="Edit" @click="update(scope.row)" title="修改" v-permission="'role:save'"></el-button>
|
<el-button link icon="Edit" @click="update(scope.row)" :title="t('common.edit')" v-permission="'role:save'"></el-button>
|
||||||
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除" v-permission="'role:delete'"></el-button>
|
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" :title="t('common.delete')" v-permission="'role:delete'"></el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -44,13 +44,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-dialog v-model="addModal" :title="modalTitle" >
|
<el-dialog v-model="addModal" :title="modalTitle" >
|
||||||
<el-form ref="roleForm" :model="formData" :rules="ruleValidate" :label-width="120">
|
<el-form ref="roleForm" :model="formData" :rules="ruleValidate" :label-width="120">
|
||||||
<el-form-item label="角色名称" prop="name">
|
<el-form-item :label="t('system.role.formName')" prop="name">
|
||||||
<el-input v-model="formData.name" />
|
<el-input v-model="formData.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="描述">
|
<el-form-item :label="t('system.role.formDescription')">
|
||||||
<el-input v-model="formData.description" />
|
<el-input v-model="formData.description" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="权限" prop="permissions">
|
<el-form-item :label="t('system.role.formPermission')" prop="permissions">
|
||||||
<el-tree
|
<el-tree
|
||||||
ref="permTree"
|
ref="permTree"
|
||||||
:data="permissionTreeData"
|
:data="permissionTreeData"
|
||||||
@@ -65,8 +65,8 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="addModal = false" plain>取消</el-button>
|
<el-button @click="addModal = false" plain>{{ t('common.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="save" :loading="modalLoading" plain>确定</el-button>
|
<el-button type="primary" @click="save" :loading="modalLoading" plain>{{ t('common.confirm') }}</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -74,8 +74,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, nextTick } from 'vue'
|
import { ref, reactive, computed, nextTick } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useBaseList } from '@/model/baselist'
|
import { useBaseList } from '@/model/baselist'
|
||||||
import { Page } from '@/model/common.dto'
|
import { Page } from '@/model/common.dto'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
@@ -84,6 +85,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
import type { VForm } from '@/types'
|
import type { VForm } from '@/types'
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
class SystemRolePage extends Page {
|
class SystemRolePage extends Page {
|
||||||
name?: string
|
name?: string
|
||||||
@@ -95,21 +97,21 @@ class SystemRolePage extends Page {
|
|||||||
|
|
||||||
const { loading, modalLoading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new SystemRolePage())
|
const { loading, modalLoading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new SystemRolePage())
|
||||||
|
|
||||||
const ruleValidate = {
|
const ruleValidate = computed(() => ({
|
||||||
name: [
|
name: [
|
||||||
{ required: true, message: '请输入角色名称', trigger: 'blur' }
|
{ required: true, message: t('system.role.nameRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
permissions: [
|
permissions: [
|
||||||
{ validator: (rule: any, value: any, callback: any) => {
|
{ validator: (rule: any, value: any, callback: any) => {
|
||||||
const permissions = getPermissionsToSave()
|
const permissions = getPermissionsToSave()
|
||||||
if (permissions.length === 0) {
|
if (permissions.length === 0) {
|
||||||
callback(new Error('至少需要勾选一项权限'))
|
callback(new Error(t('system.role.permissionRequired')))
|
||||||
} else {
|
} else {
|
||||||
callback()
|
callback()
|
||||||
}
|
}
|
||||||
}, trigger: 'change' }
|
}, trigger: 'change' }
|
||||||
]
|
]
|
||||||
}
|
}))
|
||||||
const systemRoleData = ref<SystemRoleModel[]>([])
|
const systemRoleData = ref<SystemRoleModel[]>([])
|
||||||
const addModal = ref(false)
|
const addModal = ref(false)
|
||||||
const modalTitle = ref<string | null>(null)
|
const modalTitle = ref<string | null>(null)
|
||||||
@@ -143,7 +145,7 @@ function add() {
|
|||||||
description: null,
|
description: null,
|
||||||
permissions: []
|
permissions: []
|
||||||
})
|
})
|
||||||
modalTitle.value = '新增角色'
|
modalTitle.value = t('system.role.addTitle')
|
||||||
addModal.value = true
|
addModal.value = true
|
||||||
clearValidate()
|
clearValidate()
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -269,16 +271,16 @@ function update(row: SystemRoleModel) {
|
|||||||
description: row.description,
|
description: row.description,
|
||||||
permissions: [...row.permissions]
|
permissions: [...row.permissions]
|
||||||
})
|
})
|
||||||
modalTitle.value = '修改角色'
|
modalTitle.value = t('system.role.editTitle')
|
||||||
addModal.value = true
|
addModal.value = true
|
||||||
clearValidate()
|
clearValidate()
|
||||||
setTreeCheckedByPermissions(row.permissions)
|
setTreeCheckedByPermissions(row.permissions)
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(row: SystemRoleModel) {
|
function remove(row: SystemRoleModel) {
|
||||||
ElMessageBox.confirm(`是否确认删除 ${row.name} 角色?`, '确认删除', {type: 'warning'}).then(async () => {
|
ElMessageBox.confirm(t('system.role.deleteConfirmMsg', {name: row.name}), t('common.deleteConfirm'), {type: 'warning'}).then(async () => {
|
||||||
await http.delete<{params: {id: string}}, any>('/system/role/delete', {params: {id: row._id}})
|
await http.delete<{params: {id: string}}, any>('/system/role/delete', {params: {id: row._id}})
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -291,7 +293,7 @@ async function save() {
|
|||||||
await http.post<SystemRoleModel, any>('/system/role/save', saveData)
|
await http.post<SystemRoleModel, any>('/system/role/save', saveData)
|
||||||
modalLoading.value = false
|
modalLoading.value = false
|
||||||
addModal.value = false
|
addModal.value = false
|
||||||
ElMessage.success("保存成功")
|
ElMessage.success(t('common.saveSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<el-form inline :model="search" @submit.prevent>
|
<el-form inline :model="search" @submit.prevent>
|
||||||
<el-form-item label="用户名/昵称">
|
<el-form-item :label="t('system.user.searchLabel')">
|
||||||
<el-input v-model="search.username" />
|
<el-input v-model="search.username" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="btn-container">
|
<div class="btn-container">
|
||||||
<el-button type="primary" @click="add" v-permission="'user:save'" plain>添加</el-button>
|
<el-button type="primary" @click="add" v-permission="'user:save'" plain>{{ t('common.add') }}</el-button>
|
||||||
<div class="search-btn">
|
<div class="search-btn">
|
||||||
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
|
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
|
||||||
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
|
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table class="table-container" :data="systemUserData" v-loading="loading" stripe>
|
<el-table class="table-container" :data="systemUserData" v-loading="loading" stripe>
|
||||||
<el-table-column prop="username" label="用户名" />
|
<el-table-column prop="username" :label="t('system.user.tableUsername')" />
|
||||||
<el-table-column prop="realname" label="昵称" />
|
<el-table-column prop="realname" :label="t('system.user.tableRealname')" />
|
||||||
<el-table-column prop="createdAt" label="创建时间" >
|
<el-table-column prop="createdAt" :label="t('common.createdAt')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.createdAt) }}
|
{{ datetimeFormat(scope.row.createdAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="updatedAt" label="更新时间" >
|
<el-table-column prop="updatedAt" :label="t('common.updatedAt')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.updatedAt) }}
|
{{ datetimeFormat(scope.row.updatedAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" >
|
<el-table-column :label="t('common.operation')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link icon="Edit" @click="update(scope.row)" title="修改" v-permission="'user:save'"></el-button>
|
<el-button link icon="Edit" @click="update(scope.row)" :title="t('common.edit')" v-permission="'user:save'"></el-button>
|
||||||
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除" v-permission="'user:delete'"></el-button>
|
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" :title="t('common.delete')" v-permission="'user:delete'"></el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -44,16 +44,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-dialog v-model="addModal" :title="modalTitle" >
|
<el-dialog v-model="addModal" :title="modalTitle" >
|
||||||
<el-form ref="userForm" :model="formData" :rules="ruleValidate" :label-width="120">
|
<el-form ref="userForm" :model="formData" :rules="ruleValidate" :label-width="120">
|
||||||
<el-form-item label="用户名" prop="username">
|
<el-form-item :label="t('system.user.formUsername')" prop="username">
|
||||||
<el-input v-model="formData.username" />
|
<el-input v-model="formData.username" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="密码" prop="password">
|
<el-form-item :label="t('system.user.formPassword')" prop="password">
|
||||||
<el-input v-model="formData.password" type="password" />
|
<el-input v-model="formData.password" type="password" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="昵称" prop="realname">
|
<el-form-item :label="t('system.user.formRealname')" prop="realname">
|
||||||
<el-input v-model="formData.realname" />
|
<el-input v-model="formData.realname" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="角色" prop="roleIds">
|
<el-form-item :label="t('system.user.formRole')" prop="roleIds">
|
||||||
<el-select v-model="formData.roleIds" multiple >
|
<el-select v-model="formData.roleIds" multiple >
|
||||||
<el-option v-for="role in roles" :key="role._id" :value="role._id" :label="role.name"></el-option>
|
<el-option v-for="role in roles" :key="role._id" :value="role._id" :label="role.name"></el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -61,8 +61,8 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="addModal = false" plain>取消</el-button>
|
<el-button @click="addModal = false" plain>{{ t('common.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="save" :loading="modalLoading" plain>确定</el-button>
|
<el-button type="primary" @click="save" :loading="modalLoading" plain>{{ t('common.confirm') }}</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, nextTick } from 'vue'
|
import { ref, reactive, computed, nextTick } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useBaseList } from '@/model/baselist'
|
import { useBaseList } from '@/model/baselist'
|
||||||
import { Page } from '@/model/common.dto'
|
import { Page } from '@/model/common.dto'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
@@ -80,6 +81,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
import { VForm } from '@/types'
|
import { VForm } from '@/types'
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
class SystemUserPage extends Page {
|
class SystemUserPage extends Page {
|
||||||
username?: string
|
username?: string
|
||||||
@@ -106,17 +108,17 @@ const userForm = ref<VForm>()
|
|||||||
|
|
||||||
const ruleValidate = computed(() => ({
|
const ruleValidate = computed(() => ({
|
||||||
username: [
|
username: [
|
||||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
{ required: true, message: t('system.user.usernameRequired'), trigger: 'blur' },
|
||||||
{ validator: async (rule: object, value: string, callback: Function) => {
|
{ validator: async (rule: object, value: string, callback: Function) => {
|
||||||
const res = await http.get<any, any>('/system/user/exists', {params: {username: value, id: formData._id}})
|
const res = await http.get<any, any>('/system/user/exists', {params: {username: value, id: formData._id}})
|
||||||
res.exists ? callback(new Error('用户名已存在')) : callback()
|
res.exists ? callback(new Error(t('system.user.usernameExists'))) : callback()
|
||||||
}, trigger: 'blur'
|
}, trigger: 'blur'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
password: [
|
password: [
|
||||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
{ required: true, message: t('system.user.passwordRequired'), trigger: 'blur' },
|
||||||
{ min: 8, max: 16, message: '密码长度8~16位', trigger: 'blur' },
|
{ min: 8, max: 16, message: t('system.user.passwordLength'), trigger: 'blur' },
|
||||||
{ pattern: /^(?![\d]+$)(?![a-zA-Z]+$)(?![-=+_.,]+$)[\da-zA-Z-=+_.,]{8,16}$/, message: '密码由字母、数字、特殊字符中的任意两种组成', trigger: 'blur' }
|
{ pattern: /^(?![\d]+$)(?![a-zA-Z]+$)(?![-=+_.,]+$)[\da-zA-Z-=+_.,]{8,16}$/, message: t('system.user.passwordComplexity'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -137,7 +139,7 @@ function add() {
|
|||||||
realname: null,
|
realname: null,
|
||||||
roleIds: []
|
roleIds: []
|
||||||
})
|
})
|
||||||
modalTitle.value = '新增用户'
|
modalTitle.value = t('system.user.addTitle')
|
||||||
addModal.value = true
|
addModal.value = true
|
||||||
clearValidate()
|
clearValidate()
|
||||||
}
|
}
|
||||||
@@ -149,7 +151,7 @@ function update(row: SystemUserModel) {
|
|||||||
realname: row.realname,
|
realname: row.realname,
|
||||||
roleIds: row.roleIds
|
roleIds: row.roleIds
|
||||||
})
|
})
|
||||||
modalTitle.value = '修改用户'
|
modalTitle.value = t('system.user.editTitle')
|
||||||
addModal.value = true
|
addModal.value = true
|
||||||
clearValidate()
|
clearValidate()
|
||||||
}
|
}
|
||||||
@@ -161,15 +163,15 @@ async function save() {
|
|||||||
await http.post<SystemUserModel, any>('/system/user/save', formData)
|
await http.post<SystemUserModel, any>('/system/user/save', formData)
|
||||||
modalLoading.value = false
|
modalLoading.value = false
|
||||||
addModal.value = false
|
addModal.value = false
|
||||||
ElMessage.success("保存成功")
|
ElMessage.success(t('common.saveSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(row: SystemUserModel) {
|
function remove(row: SystemUserModel) {
|
||||||
ElMessageBox.confirm(`是否确认删除 ${row.username} 用户?`, '确认删除', {type: 'warning'}).then(async () => {
|
ElMessageBox.confirm(t('system.user.deleteConfirmMsg', {name: row.username}), t('common.deleteConfirm'), {type: 'warning'}).then(async () => {
|
||||||
await http.delete<{params: {id: string}}, any>('/system/user/delete', {params: {id: row._id}})
|
await http.delete<{params: {id: string}}, any>('/system/user/delete', {params: {id: row._id}})
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,3 +10,9 @@ interface ImportMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare const __APP_VERSION__: string
|
declare const __APP_VERSION__: string
|
||||||
|
|
||||||
|
declare module 'vue-router' {
|
||||||
|
interface RouteMeta {
|
||||||
|
titleKey?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -59,6 +54,36 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@floating-ui/core" "^1.0.5"
|
"@floating-ui/core" "^1.0.5"
|
||||||
|
|
||||||
|
"@intlify/core-base@11.4.4":
|
||||||
|
version "11.4.4"
|
||||||
|
resolved "http://192.168.102.20:28080/repository/npm/@intlify/core-base/-/core-base-11.4.4.tgz"
|
||||||
|
integrity sha512-w/vItlylrAmhebkIbVl5YY8XMCtj8Mb2g70ttxktMYuf5AuRahgEHL2iLgLIsZBIbTSgs4hkUo7ucCL0uTJvOg==
|
||||||
|
dependencies:
|
||||||
|
"@intlify/devtools-types" "11.4.4"
|
||||||
|
"@intlify/message-compiler" "11.4.4"
|
||||||
|
"@intlify/shared" "11.4.4"
|
||||||
|
|
||||||
|
"@intlify/devtools-types@11.4.4":
|
||||||
|
version "11.4.4"
|
||||||
|
resolved "http://192.168.102.20:28080/repository/npm/@intlify/devtools-types/-/devtools-types-11.4.4.tgz"
|
||||||
|
integrity sha512-PcBLmGmDQsTSVV911P8upzpcLJO1CNVYi/IH6bGnLR2nA+0L963+kXN1ZrisTEnbtw2ewN6HMMSldqzjronA0Q==
|
||||||
|
dependencies:
|
||||||
|
"@intlify/core-base" "11.4.4"
|
||||||
|
"@intlify/shared" "11.4.4"
|
||||||
|
|
||||||
|
"@intlify/message-compiler@11.4.4":
|
||||||
|
version "11.4.4"
|
||||||
|
resolved "http://192.168.102.20:28080/repository/npm/@intlify/message-compiler/-/message-compiler-11.4.4.tgz"
|
||||||
|
integrity sha512-vn0OAV9pYkJlPPmgnsSm5eAG3mL0+9C/oaded2JY9jmxBbhmUXT3TcAUY8WRgLY9Hte7lkUJKpXrVlYjMXBD2w==
|
||||||
|
dependencies:
|
||||||
|
"@intlify/shared" "11.4.4"
|
||||||
|
source-map-js "^1.0.2"
|
||||||
|
|
||||||
|
"@intlify/shared@11.4.4":
|
||||||
|
version "11.4.4"
|
||||||
|
resolved "http://192.168.102.20:28080/repository/npm/@intlify/shared/-/shared-11.4.4.tgz"
|
||||||
|
integrity sha512-QRUCHqda1U6aR14FR0vvXD4+4gj6+fm0AhAozvSuRCw0fCvrmCugWpgiR4xH2NI6s8am6N9p5OhirplsX8ZS3g==
|
||||||
|
|
||||||
"@jridgewell/gen-mapping@^0.3.5":
|
"@jridgewell/gen-mapping@^0.3.5":
|
||||||
version "0.3.13"
|
version "0.3.13"
|
||||||
resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz"
|
resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz"
|
||||||
@@ -114,11 +139,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-win32-x64-msvc@1.0.1":
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz"
|
||||||
|
integrity sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==
|
||||||
|
|
||||||
|
"@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 +168,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 +185,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 +197,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"
|
||||||
@@ -214,7 +246,7 @@
|
|||||||
"@vue/compiler-dom" "3.5.30"
|
"@vue/compiler-dom" "3.5.30"
|
||||||
"@vue/shared" "3.5.30"
|
"@vue/shared" "3.5.30"
|
||||||
|
|
||||||
"@vue/devtools-api@^6.0.0-beta.11", "@vue/devtools-api@^6.6.4":
|
"@vue/devtools-api@^6.0.0-beta.11", "@vue/devtools-api@^6.5.0", "@vue/devtools-api@^6.6.4":
|
||||||
version "6.6.4"
|
version "6.6.4"
|
||||||
resolved "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz"
|
resolved "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz"
|
||||||
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
|
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
|
||||||
@@ -284,6 +316,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 +342,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 +439,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 +451,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 +532,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 +560,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 +572,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"
|
||||||
@@ -637,6 +658,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 +724,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 +740,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-win32-x64-msvc@1.32.0:
|
||||||
|
version "1.32.0"
|
||||||
|
resolved "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz"
|
||||||
|
integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==
|
||||||
|
|
||||||
|
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 +922,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 +941,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 +991,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 +1022,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"
|
||||||
@@ -1001,7 +1049,7 @@ semver@^5.6.0:
|
|||||||
resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz"
|
resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz"
|
||||||
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
|
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
|
||||||
|
|
||||||
source-map-js@^1.2.1:
|
source-map-js@^1.0.2, source-map-js@^1.2.1:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
|
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
|
||||||
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
|
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
|
||||||
@@ -1031,7 +1079,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 +1089,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,17 +1179,29 @@ 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"
|
||||||
|
|
||||||
|
vue-i18n@^11.4.4:
|
||||||
|
version "11.4.4"
|
||||||
|
resolved "http://192.168.102.20:28080/repository/npm/vue-i18n/-/vue-i18n-11.4.4.tgz"
|
||||||
|
integrity sha512-gIbXVSFQV4jcSJxfwdZ5zSZmZ+12CnX0K3vBkRSd6Zn+HSzCp+QwUgPwpD/uN0oKNKI9RzlUXPKVedEuMgNG0A==
|
||||||
|
dependencies:
|
||||||
|
"@intlify/core-base" "11.4.4"
|
||||||
|
"@intlify/devtools-types" "11.4.4"
|
||||||
|
"@intlify/shared" "11.4.4"
|
||||||
|
"@vue/devtools-api" "^6.5.0"
|
||||||
|
|
||||||
vue-router@^4.6.4:
|
vue-router@^4.6.4:
|
||||||
version "4.6.4"
|
version "4.6.4"
|
||||||
resolved "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz"
|
resolved "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz"
|
||||||
@@ -1141,7 +1209,7 @@ vue-router@^4.6.4:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@vue/devtools-api" "^6.6.4"
|
"@vue/devtools-api" "^6.6.4"
|
||||||
|
|
||||||
vue@^3.2.0, vue@^3.2.25, vue@^3.3.0, vue@^3.5.0, vue@^3.5.13, vue@^3.5.30, "vue@2 || 3", vue@3.5.30:
|
vue@^3.0.0, vue@^3.2.0, vue@^3.2.25, vue@^3.3.0, vue@^3.5.0, vue@^3.5.13, vue@^3.5.30, "vue@2 || 3", vue@3.5.30:
|
||||||
version "3.5.30"
|
version "3.5.30"
|
||||||
resolved "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz"
|
resolved "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz"
|
||||||
integrity sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==
|
integrity sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==
|
||||||
|
|||||||