Compare commits

...
40 Commits
Author SHA1 Message Date
sookie 5fd79a7b5a 调整顶部布局 2026-06-02 10:21:20 +08:00
sookie 0ecb6010e2 照片墙加回搜索条件 2026-06-02 10:15:57 +08:00
sookie 4305eb3fe6 feat: 接入 vue-i18n 实现全站国际化
引入 vue-i18n,支持简体中文、繁体中文、英文三种语言。
提取所有页面硬编码中文为国际化词条,Header 右上角新增语言切换下拉菜单。
语言偏好存储于 localStorage,首次访问根据 navigator.language 自动检测。
同步切换 Element Plus 组件语言,校验规则改为 computed 保证切换后实时更新。
2026-06-02 09:52:48 +08:00
sookie 263f8ef8d3 歌曲库补全支持上传的文件类型 2026-05-28 23:00:56 +08:00
sookie 6ccbec1f82 补充tooltip 2026-05-18 01:08:34 +08:00
sookie 5b2d25fe78 照片墙重写改造 2026-05-17 23:48:14 +08:00
sookie 8fc5ef572c 升级vite版本 2026-05-17 22:01:15 +08:00
sookie 10b19a3311 粒子特效 2026-05-16 23:40:20 +08:00
sookie 4533a754c9 播放器资源销毁逻辑优化 2026-05-16 23:18:28 +08:00
sookie 6d2e522818 音乐播放器重构 2026-05-16 22:51:43 +08:00
sookie 25e80d73ae 验证码接口适配调整 2026-05-16 16:03:54 +08:00
sookie f9b0b05c7e 去掉bgUrl的设定 2026-05-16 14:18:33 +08:00
sookie 0aef7d0ad5 图片拼接类型验证码, 两块图片之间添加一个细线, 遮盖切分的像素误差 2026-05-16 14:07:35 +08:00
sookie e581ced7e8 验证码组件封装 2026-05-16 13:21:55 +08:00
sookie 78bed71168 验证码调试页面布局精简 2026-05-16 12:52:40 +08:00
sookie 02e12f6e3d 验证码控件部分样式调整 2026-05-16 12:42:42 +08:00
sookie 662b22576d refactor: simplify captcha selector naming
Convert captcha vendor templates and DOM selectors from id-based tianai naming to class-based captcha naming.

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

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

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

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 01:10:58 +08:00
sookie c144830fd7 el-alert换成tooltip 2026-04-03 11:02:11 +08:00
sookie 55877c5e92 修改照片墙搜索框, 换用input-number 2026-04-03 10:53:50 +08:00
sookie df11fa47a5 所有大按钮都加上plain 2026-04-03 10:41:29 +08:00
sookie 11f52789eb 删除发布博客的功能 2026-04-03 10:27:50 +08:00
sookie ca4c17042d 首页菜单按权限过滤 2026-04-02 20:47:29 +08:00
sookie a4f7db415e 权限系统重构:
- 角色模型改为 permissions 字段,移除 methods/includeUri/excludeUri
- 角色管理页面支持权限树选择,保存时只传最上级权限
- /common/verifyToken 返回 permissions,存入 vuex
- 新增 v-permission 指令,所有操作按钮和菜单均按权限控制显示
- 菜单按 list 权限过滤
- 各业务页面按钮加权限指令
- 角色管理列表只显示名称/描述/时间,不显示权限列,权限必选
2026-04-02 20:38:14 +08:00
87 changed files with 7439 additions and 17589 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
VUE_APP_PROXY_TARGET=https://www.colorfulsweet.site
VUE_APP_API_BASE=/api/v2
VITE_APP_PROXY_TARGET=https://www.colorfulsweet.site
VITE_APP_API_BASE=/api/v2
+9 -1
View File
@@ -7,13 +7,19 @@ export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
CaptchaPanel: typeof import('./src/components/CaptchaPanel.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckboxButton: typeof import('element-plus/es')['ElCheckboxButton']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
@@ -25,6 +31,7 @@ declare module '@vue/runtime-core' {
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
@@ -39,6 +46,7 @@ declare module '@vue/runtime-core' {
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTransfer: typeof import('element-plus/es')['ElTransfer']
ElTree: typeof import('element-plus/es')['ElTree']
ElUpload: typeof import('element-plus/es')['ElUpload']
+2 -1
View File
@@ -8,5 +8,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>
+1000 -9817
View File
File diff suppressed because it is too large Load Diff
+8 -9
View File
@@ -2,8 +2,9 @@
"name": "blog-admin-web",
"version": "0.1.0",
"scripts": {
"dev": "vue-cli-service serve",
"build": "vue-cli-service build"
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
@@ -15,20 +16,18 @@
"moment": "^2.30.1",
"pretty-bytes": "^5.6.0",
"vue": "^3.5.30",
"vue-i18n": "^11.4.4",
"vue-router": "^4.6.4",
"vuex": "^4.1.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-plugin-typescript": "~5.0.0",
"@vue/cli-plugin-vuex": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"@types/node": "^25.6.0",
"@vitejs/plugin-vue": "^6.0.7",
"less": "^4.6.4",
"less-loader": "^8.0.0",
"typescript": "^5.4.0",
"unplugin-auto-import": "^0.12.1",
"unplugin-vue-components": "^0.22.12"
"unplugin-vue-components": "^0.22.12",
"vite": "^8.0.13"
},
"browserslist": [
"> 1%",
+15 -6
View File
@@ -1,15 +1,24 @@
<template>
<div id="app" >
<el-config-provider :locale="locale" >
<router-view ></router-view>
</el-config-provider>
</div>
<el-config-provider :locale="epLocale" >
<router-view ></router-view>
</el-config-provider>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
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>
<style lang="less">
@import url('./static/common.less');
+116
View File
@@ -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>
+36 -13
View File
@@ -1,24 +1,47 @@
export default [
export interface MenuItem {
titleKey: string
path: string
permission: string
}
export interface MenuGroup {
name: string
titleKey: string
icon: string
child: MenuItem[]
}
const menus: MenuGroup[] = [
{
name: 'system',
title: '系统管理',
titleKey: 'menu.system.title',
icon: 'Operation',
child: [
{ title: '系统配置', path: '/system/config' },
{ title: '用户管理', path: '/system/user' },
{ title: '角色管理', path: '/system/role' },
{ title: '博客文章', path: '/system/article' },
{ title: '分析统计', path: '/system/statistics' }
{ titleKey: 'menu.system.config', path: '/system/config', permission: 'config:list' },
{ titleKey: 'menu.system.user', path: '/system/user', permission: 'user:list' },
{ titleKey: 'menu.system.role', path: '/system/role', permission: 'role:list' },
{ titleKey: 'menu.system.article', path: '/system/article', permission: 'article:list' },
{ titleKey: 'menu.system.statistics', path: '/system/statistics', permission: 'article:list' }
]
},{
name: 'api',
title: 'API数据',
titleKey: 'menu.api.title',
icon: 'Histogram',
child: [
{ title: '一言', path: '/api/hitokoto' },
{ title: '照片墙', path: '/api/photoWall' },
{ title: '图片资源库', path: '/api/sourceImage' },
{ title: '歌曲库', path: '/api/music' }
{ titleKey: 'menu.api.hitokoto', path: '/api/hitokoto', permission: 'hitokoto:list' },
{ titleKey: 'menu.api.photoWall', path: '/api/photoWall', permission: 'photoWall:list' },
{ titleKey: 'menu.api.sourceImage', path: '/api/sourceImage', permission: 'sourceImage: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' }
]
}
]
]
export default menus
+49
View File
@@ -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
+346
View File
@@ -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}',
},
}
+346
View File
@@ -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}',
},
}
+346
View File
@@ -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}',
},
}
+7 -4
View File
@@ -10,11 +10,12 @@ import 'element-plus/theme-chalk/el-message-box.css'
import 'element-plus/theme-chalk/el-image-viewer.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { permissionDirective } from '@/utils/permission'
import i18n from '@/i18n'
// 全局路由导航前置守卫
router.beforeEach(function (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) {
if (to.meta?.title) {
store.commit('addTab', { title: to.meta.title, path: to.path, name: to.name })
if (to.meta?.titleKey) {
store.commit('addTab', { title: to.meta.titleKey, path: to.path, name: to.name })
}
store.state.activeTab = to.path
if(filterExclude.indexOf(to.path) !== -1 || store.state.loginInfo.token) {
@@ -29,7 +30,9 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(router)
app.use(i18n)
.use(router)
.use(store)
.directive('loading', ElLoading.directive)
.directive('permission', permissionDirective)
.mount('#app')
+1
View File
@@ -34,6 +34,7 @@ export interface MusicPlayerItem {
src: string
pic: string
lrc?: string
lyricId?: string
}
export interface StatType {
+9 -3
View File
@@ -2,9 +2,15 @@ export interface SystemRoleModel {
_id: string | null
name: string | null // 角色名称
description: string | null // 描述
methods: string[] // 允许的请求类型(优先级3)
includeUri: string[] // 包含的URI(优先级2)
excludeUri: string[] // 排除的URI(优先级1)
permissions: string[] // 权限标识列表
createdAt?: Date // 创建时间
updatedAt?: Date // 更新时间
}
export interface PermissionTreeNode {
code: string
name: string
description: string
leaf: boolean
children: PermissionTreeNode[]
}
+11 -9
View File
@@ -8,16 +8,18 @@ const routes: Array<RouteRecordRaw> = [
{ path: '/login', name: 'Login', component: Login },
{ path: '/', name: 'Home', component: Home, children: [
{ path: '/', name: 'Welcome', component: Welcome },
{ path: '/system/user', name: 'SystemUser', component: () => import( /* webpackChunkName: "system" */ '@/views/system/SystemUser.vue'), meta: { title: '用户管理' } },
{ path: '/system/role', name: 'SystemRole', component: () => import( /* webpackChunkName: "system" */ '@/views/system/SystemRole.vue'), meta: { title: '角色管理' } },
{ path: '/system/config', name: 'SystemConfig', component: () => import( /* webpackChunkName: "system" */ '@/views/system/SystemConfig.vue'), meta: { title: '系统配置' } },
{ path: '/system/article', name: 'Article', component: () => import( /* webpackChunkName: "system" */ '@/views/system/Article.vue'), meta: { title: '博客文章' } },
{ path: '/system/statistics', name: 'Statistics', component: () => import( /* webpackChunkName: "system" */ '@/views/system/Statistics.vue'), meta: { title: '分析统计' } },
{ path: '/system/user', name: 'SystemUser', component: () => import('@/views/system/SystemUser.vue'), meta: { titleKey: 'route.systemUser' } },
{ 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: { titleKey: 'route.systemConfig' } },
{ 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: { titleKey: 'route.statistics' } },
{ path: '/api/music', name: 'Music', component: () => import( /* webpackChunkName: "api" */ '@/views/api/Music.vue'), meta: { title: '歌曲库' } },
{ path: '/api/hitokoto', name: 'Hitokoto', component: () => import( /* webpackChunkName: "api" */ '@/views/api/Hitokoto.vue'), meta: { title: '一言' } },
{ path: '/api/photoWall', name: 'PhotoWall', component: () => import( /* webpackChunkName: "api" */ '@/views/api/PhotoWall.vue'), meta: { title: '照片墙' } },
{ path: '/api/sourceImage', name: 'SourceImage', component: () => import( /* webpackChunkName: "api" */ '@/views/api/SourceImage.vue'), meta: { title: '图片资源库' } },
{ path: '/api/music', name: 'Music', component: () => import('@/views/api/Music.vue'), meta: { titleKey: 'route.music' } },
{ 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: { titleKey: 'route.photoWall' } },
{ 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' } },
]}
]
+187 -11
View File
@@ -11,6 +11,7 @@ html, body, #app, .layout {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
div.main-title {
@@ -21,11 +22,7 @@ html, body, #app, .layout {
line-height: 60px;
}
.nav-btns-right {
display: inline-block;
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
display: flex;
}
}
@@ -83,12 +80,6 @@ html, body, #app, .layout {
flex-basis: 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
> .layout-copy {
text-align: center;
padding-bottom: 12px;
color: var(--el-text-color-placeholder);
font-size: var(--el-font-size-extra-small);
}
/* ===== 页面布局(用于带分页的表格页面)===== */
.page-wrapper {
@@ -215,6 +206,191 @@ body .el-dialog {
overflow: auto;
}
}
/* ===== Markdown 预览 ===== */
.article-preview-drawer {
> .el-drawer__header {
border-bottom: 1px solid var(--el-border-color-lighter);
padding: 14px 20px;
margin-bottom: 0;
background: var(--el-fill-color-light);
.el-drawer__title {
font-size: var(--el-font-size-base);
font-weight: var(--el-font-weight-primary);
color: var(--el-text-color-primary);
}
}
> .el-drawer__body {
padding: 24px 32px;
}
}
.markdown-body {
font-size: 15px;
line-height: 1.8;
color: var(--el-text-color-primary);
word-break: break-word;
// 首个元素去掉顶部外边距
> :first-child { margin-top: 0 !important; }
// ── 标题 ───────────────────────────────────
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.35;
margin: 1.4em 0 0.6em;
color: var(--el-text-color-primary);
}
h1 {
font-size: 1.8em;
padding-bottom: 0.4em;
border-bottom: 2px solid var(--el-border-color);
}
h2 {
font-size: 1.45em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--el-border-color-lighter);
}
h3 { font-size: 1.2em; }
h4 { font-size: 1.05em; }
h5 { font-size: 0.95em; }
h6 {
font-size: 0.9em;
color: var(--el-text-color-secondary);
}
// ── 段落 ───────────────────────────────────
p { margin: 0.85em 0; }
// ── 链接 ───────────────────────────────────
a {
color: var(--el-color-primary);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.2s, color 0.2s;
&:hover {
color: var(--el-color-primary-light-3);
border-bottom-color: var(--el-color-primary-light-3);
}
}
// ── 强调 ───────────────────────────────────
strong { font-weight: 600; }
em { font-style: italic; }
del { color: var(--el-text-color-secondary); text-decoration: line-through; }
// ── 内联代码 ───────────────────────────────
:not(pre) > code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.85em;
padding: 0.18em 0.45em;
border-radius: 4px;
background: var(--el-fill-color-dark);
color: var(--el-color-danger);
border: 1px solid var(--el-border-color-lighter);
}
// ── 代码块(亮色:浅灰背景;暗色:深背景) ───
pre {
background: #f6f8fa;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 1em 1.2em;
overflow-x: auto;
margin: 1em 0;
code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.875em;
background: none;
border: none;
padding: 0;
color: #24292f;
line-height: 1.65;
}
}
// ── 引用块 ─────────────────────────────────
blockquote {
margin: 1em 0;
padding: 0.6em 1.1em;
border-left: 4px solid var(--el-color-primary-light-5);
background: var(--el-color-primary-light-9);
border-radius: 0 6px 6px 0;
color: var(--el-text-color-secondary);
> :first-child { margin-top: 0; }
> :last-child { margin-bottom: 0; }
}
// ── 列表 ───────────────────────────────────
ul, ol {
margin: 0.7em 0;
padding-left: 1.8em;
}
li {
margin: 0.3em 0;
line-height: 1.75;
> ul, > ol { margin: 0.2em 0; }
}
ul > li { list-style-type: disc; }
ul > li > ul > li { list-style-type: circle; }
ol > li { list-style-type: decimal; }
// ── 水平线 ─────────────────────────────────
hr {
border: none;
border-top: 1px solid var(--el-border-color);
margin: 1.6em 0;
}
// ── 图片 ───────────────────────────────────
img {
max-width: 100%;
height: auto;
border-radius: 6px;
display: block;
margin: 1em auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
// ── 表格 ───────────────────────────────────
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 0.9em;
border: 1px solid var(--el-border-color);
border-radius: 6px;
overflow: hidden;
th, td {
padding: 0.6em 1em;
border: 1px solid var(--el-border-color-lighter);
text-align: left;
vertical-align: top;
}
th {
background: var(--el-fill-color-light);
font-weight: 600;
color: var(--el-text-color-regular);
}
tr:nth-child(even) td { background: var(--el-fill-color-lighter); }
tr:hover td { background: var(--el-color-primary-light-9); }
}
}
// 暗色模式:代码块使用深色背景
html.dark .markdown-body {
pre {
background: #161b22;
border-color: rgba(255, 255, 255, 0.08);
code { color: #e6edf3; }
}
img { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4); }
}
/* ========================================== */
.el-tabs--card.nav-tabs {
> .el-tabs__header {
margin-bottom: 0;
+7 -2
View File
@@ -8,7 +8,8 @@ class Store {
state: StateType = {
loginInfo: {
userInfo: null,
token: localStorage.getItem('login_token')
token: localStorage.getItem('login_token'),
permissions: []
},
tabs: [],
activeTab: null,
@@ -21,12 +22,15 @@ class Store {
* @param {Object} state
* @param {UserInfo} data 登录数据
*/
login(state: StateType, data: {token?: string, userInfo: UserInfo}): void {
login(state: StateType, data: {token?: string, userInfo: UserInfo, permissions?: string[]}): void {
if (data.token) {
localStorage.setItem('login_token', data.token)
state.loginInfo.token = data.token
}
state.loginInfo.userInfo = data.userInfo
if (data.permissions) {
state.loginInfo.permissions = data.permissions
}
},
/**
* 注销
@@ -36,6 +40,7 @@ class Store {
localStorage.removeItem('login_token')
state.loginInfo.token = null
state.loginInfo.userInfo = null
state.loginInfo.permissions = []
},
/**
* 添加导航页签
+1
View File
@@ -15,6 +15,7 @@ export interface StateType {
loginInfo: { // 登录信息
userInfo: null | UserInfo
token: null | string
permissions: string[] // 用户权限列表
}
tabs: TabItem[] // 导航页签
activeTab: string | null // 当前激活的页签
+7 -15
View File
@@ -2,16 +2,16 @@ import axios from 'axios'
import { ElMessage } from 'element-plus'
import store from '@/store'
import { router } from '@/router'
import i18n from '@/i18n'
const http = axios.create({
baseURL: process.env.VUE_APP_API_BASE,
baseURL: import.meta.env.VITE_APP_API_BASE,
timeout: 10000,
paramsSerializer: {
indexes: null
}
})
// 添加请求拦截器
http.interceptors.request.use(config => {
const token = store.state.loginInfo.token
if (token !== null) {
@@ -19,35 +19,27 @@ http.interceptors.request.use(config => {
}
return config
}, err => {
ElMessage.error('请求超时,请稍后再试')
ElMessage.error(i18n.global.t('http.timeout'))
return Promise.reject(err)
})
http.interceptors.response.use(res => {
const responseBody = res.data
// 统一响应格式处理
switch (responseBody.code) {
case 0:
// 成功,直接返回数据
return responseBody.data
case -1:
// 失败,显示错误信息
ElMessage.error(responseBody.message || '请求失败')
return Promise.reject(new Error(responseBody.message || '请求失败'))
ElMessage.error(responseBody.message || i18n.global.t('http.failed'))
return Promise.reject(new Error(responseBody.message || i18n.global.t('http.failed')))
default:
// 其他情况,兼容没有包装格式的响应
return res.data
}
}, err => {
if (err.response?.status >= 500) {
ElMessage.error('服务器内部错误')
ElMessage.error(i18n.global.t('http.serverError'))
} else if (err.response?.status >= 400) {
const message = err.response.data?.message
if (typeof message === 'string') {
ElMessage.error(message)
} else if (Array.isArray(message)) {
ElMessage.error(message.join('<br/>'))
}
message && ElMessage.error(message)
if (err.response.status === 403) {
router.push('/login')
}
+37
View File
@@ -0,0 +1,37 @@
import type { Directive } from 'vue'
import store from '@/store'
/**
* 检查用户是否具备指定权限
* 支持上下级关系:如果用户拥有 'user' 权限,则 'user:list' 也视为拥有
*/
export function hasPermission(permission: string): boolean {
const permissions: string[] = store.state.loginInfo.permissions
if (!permissions || !permissions.length) return false
// 直接匹配
if (permissions.includes(permission)) return true
// 上级权限匹配:如果权限是 'user:list',检查用户是否拥有 'user'
const colonIndex = permission.indexOf(':')
if (colonIndex !== -1) {
const parent = permission.substring(0, colonIndex)
if (permissions.includes(parent)) return true
}
return false
}
/**
* v-permission 自定义指令
* 用法:v-permission="'user:save'" 或 v-permission="['user:save', 'user:delete']"
* 当传入数组时,满足其中任一权限即可显示
*/
export const permissionDirective: Directive<HTMLElement, string | string[]> = {
mounted(el, binding) {
const value = binding.value
if (!value) return
const permissions = Array.isArray(value) ? value : [value]
const hasPerm = permissions.some(p => hasPermission(p))
if (!hasPerm) {
el.parentNode?.removeChild(el)
}
}
}
+174
View File
@@ -0,0 +1,174 @@
import "./captcha.less";
import Slider from "./slider/slider";
import Rotate from "./rotate/rotate";
import Concat from "./concat/concat";
import Disable from "./disable/disable";
import ImageClick from "./image_click/image_click";
import WordImageClick from "./word_image_click/word_image_click";
import { CaptchaConfig, wrapConfig, wrapStyle } from "./config/config";
import { clearAllPreventDefault } from "./common/common";
const template = `
<div class="__captcha-parent">
<div class="captcha-box">
<div class="captcha-loading loading"></div>
</div>
<!-- 底部 -->
<div class="slider-bottom">
<div class="refresh-btn"></div>
<div class="close-btn"></div>
</div>
</div>
`;
function createCaptchaByType(type, tac) {
const box = tac.config.domBindEl.find(".captcha-box");
const styleConfig = tac.style;
switch (type) {
case "SLIDER":
return new Slider(box, styleConfig);
case "ROTATE":
return new Rotate(box, styleConfig);
case "CONCAT":
return new Concat(box, styleConfig);
case "IMAGE_CLICK":
return new ImageClick(box, styleConfig);
case "WORD_IMAGE_CLICK":
return new WordImageClick(box, styleConfig);
case "DISABLED":
return new Disable(box, styleConfig);
default:
return null;
}
}
class TianAiCaptcha {
constructor(config, style) {
this.config = wrapConfig(config);
if (this.config.btnRefreshFun) {
this.btnRefreshFun = this.config.btnRefreshFun;
}
if (this.config.btnCloseFun) {
this.btnCloseFun = this.config.btnCloseFun;
}
this.style = wrapStyle(style);
}
init() {
this.destroyWindow();
this.config.domBindEl.append(template);
this.domTemplate = this.config.domBindEl.find(".__captcha-parent");
clearAllPreventDefault(this.domTemplate);
// 绑定按钮事件
this.config.domBindEl
.find(".refresh-btn")
.click((el) => {
this.btnRefreshFun(el, this);
});
this.config.domBindEl
.find(".close-btn")
.click((el) => {
this.btnCloseFun(el, this);
});
// 加载验证码
this.reloadCaptcha();
return this;
}
btnRefreshFun(el, tac) {
tac.reloadCaptcha();
}
btnCloseFun(el, tac) {
tac.destroyWindow();
}
reloadCaptcha() {
this.showLoading();
this.destroyCaptcha(() => {
this.createCaptcha();
});
}
showLoading() {
this.config.domBindEl
.find(".captcha-loading")
.css("display", "block");
}
closeLoading() {
this.config.domBindEl
.find(".captcha-loading")
.css("display", "none");
}
destroyWindow() {
if (this.C) {
this.C.destroy();
this.C = undefined;
}
if (this.domTemplate) {
this.domTemplate.remove();
}
}
openCaptcha() {
setTimeout(() => {
this.C.el.css("transform", "translateX(0)");
}, 10);
}
createCaptcha() {
this.config.requestCaptchaData().then((data) => {
this.closeLoading();
if (!data.code) {
throw new Error("[TAC] 后台验证码接口数据错误!!!");
}
let captchaType = data.code === 200 ? data.data?.type : "DISABLED";
const captcha = createCaptchaByType(captchaType, this);
if (captcha == null) {
throw new Error("[TAC] 未知的验证码类型[" + captchaType + "]");
}
captcha.init(data, (d, c) => {
// 验证
const currentCaptchaData = c.currentCaptchaData;
const data = {
bgImageWidth: currentCaptchaData.bgImageWidth,
bgImageHeight: currentCaptchaData.bgImageHeight,
templateImageWidth: currentCaptchaData.templateImageWidth,
templateImageHeight: currentCaptchaData.templateImageHeight,
startTime: currentCaptchaData.startTime.getTime(),
stopTime: currentCaptchaData.stopTime.getTime(),
trackList: currentCaptchaData.trackList,
};
if (c.type === "ROTATE_DEGREE" || c.type === "ROTATE") {
data.bgImageWidth = c.currentCaptchaData.end;
}
if (currentCaptchaData.data) {
data.data = currentCaptchaData.data;
}
// 清空
const id = c.currentCaptchaData.currentCaptchaId;
c.currentCaptchaData = undefined;
if (this.config.onDataReady) {
Promise.resolve(this.config.onDataReady({ id, data }, c, this)).catch((error) => {
console.error("[TAC] onDataReady callback failed", error);
});
return;
}
// 调用验证接口
this.config.validCaptcha(id, data, c, this);
});
this.C = captcha;
this.openCaptcha();
});
}
destroyCaptcha(callback) {
if (this.C) {
this.C.el.css("transform", "translateX(300px)");
setTimeout(() => {
this.C.destroy();
callback && callback();
}, 500);
} else {
callback();
}
}
}
export { TianAiCaptcha, CaptchaConfig };
+98
View File
@@ -0,0 +1,98 @@
.__captcha-parent {
box-shadow: 0 0 11px 0 #999999;
width: 316px;
overflow: hidden;
position: relative;
z-index: 997;
box-sizing: border-box;
border-radius: 5px;
padding: 8px;
.captcha-box {
height: 260px;
width: 100%;
position: relative;
overflow: hidden;
.loading {
width: 120px;
height: 20px;
mask: linear-gradient(90deg, #000 70%, #0000 0) 0/20%;
background: linear-gradient(#f7b645 0 0) 0 / 0% no-repeat #dddddd6b;
animation: cartoon 1s infinite steps(6);
margin: 120px auto;
@keyframes cartoon {
100% {
background-size: 120%;
}
}
}
.captcha {
transform-style: preserve-3d;
will-change: transform;
transition-duration: 0.45s;
transform: translateX(-100%);
}
}
.slider-bottom {
text-align: right;
margin-top: 8px;
display: flex;
justify-content: flex-end;
gap: 10px;
.close-btn, .refresh-btn {
width: 20px;
height: 20px;
background-repeat: no-repeat;
cursor: pointer;
}
.close-btn {
background-image: url("../images/icon.png");
background-position: 0 -14px;
}
.refresh-btn {
background-image: url("../images/icon.png");
background-position: 0 -167px;
}
}
.slider-move-shadow {
animation: move-shadow 2s infinite;
height: 100%;
width: 5px;
background-color: #fff;
position: absolute;
top: 0;
left: 0;
filter: opacity(0.5);
box-shadow: 1px 1px 1px #fff;
border-radius: 50%;
}
.captcha-slider-move-track-mask {
border-width: 1px;
border-style: solid;
border-color: #00f4ab;
width: 0;
height: 32px;
background-color: #a9ffe5;
opacity: 0.5;
position: absolute;
top: -1px;
left: -1px;
border-radius: 5px;
}
@keyframes move-shadow {
from {
left: 0;
}
to {
left: 289px;
}
}
@import "./common/common.less";
@import "./slider/slider.less";
@import "./rotate/rotate.less";
@import "./concat/concat.less";
@import "./disable/disable.less";
@import "./image_click/image_click.less";
}
+462
View File
@@ -0,0 +1,462 @@
/** 是否打印日志 */
var isPrintLog = false;
function printLog(params) {
if (isPrintLog) {
console.log(JSON.stringify(params));
}
}
/**
* 清除默认事件
* @param event event
*/
function clearPreventDefault(event) {
if (event.preventDefault) {
event.preventDefault();
}
}
/**
* 阻止某div默认事件
* @param dom
*/
function clearAllPreventDefault(dom) {
Dom(dom).each((el) => {
// 手机端
el.addEventListener("touchmove", clearPreventDefault, { passive: false });
// pc端
el.addEventListener("mousemove", clearPreventDefault, { passive: false });
});
}
/**
* 获取当前坐标
* @param event 事件
* @returns {{x: number, y: number}}
*/
function getCurrentCoordinate(event) {
if (event.pageX !== null && event.pageX !== undefined) {
return {
x: Math.round(event.pageX),
y: Math.round(event.pageY),
};
}
let targetTouches;
if (event.changedTouches) {
// 抬起事件
targetTouches = event.changedTouches;
} else if (event.targetTouches) {
// pc 按下事件
targetTouches = event.targetTouches;
} else if (event.originalEvent && event.originalEvent.targetTouches) {
// 鼠标触摸事件
targetTouches = event.originalEvent.targetTouches;
}
if (targetTouches[0].pageX !== null && targetTouches[0].pageX !== undefined) {
return {
x: Math.round(targetTouches[0].pageX),
y: Math.round(targetTouches[0].pageY),
};
}
return {
x: Math.round(targetTouches[0].clientX),
y: Math.round(targetTouches[0].clientY),
};
}
function down(currentCaptcha, event) {
// debugger
const coordinate = getCurrentCoordinate(event);
let startX = coordinate.x;
let startY = coordinate.y;
currentCaptcha.currentCaptchaData.startX = startX;
currentCaptcha.currentCaptchaData.startY = startY;
const trackList = currentCaptcha.currentCaptchaData.trackList;
currentCaptcha.currentCaptchaData.startTime = new Date();
const startTime = currentCaptcha.currentCaptchaData.startTime;
trackList.push({
x: coordinate.x,
y: coordinate.y,
type: "down",
t: new Date().getTime() - startTime.getTime(),
});
printLog(["start", startX, startY]);
currentCaptcha.__m__ = move.bind(null, currentCaptcha);
currentCaptcha.__u__ = up.bind(null, currentCaptcha);
// pc
window.addEventListener("mousemove", currentCaptcha.__m__);
window.addEventListener("mouseup", currentCaptcha.__u__);
// 手机端
window.addEventListener("touchmove", currentCaptcha.__m__, false);
window.addEventListener("touchend", currentCaptcha.__u__, false);
if (currentCaptcha && currentCaptcha.doDown) {
currentCaptcha.doDown(event, currentCaptcha);
}
}
function move(currentCaptcha, event) {
if (event.touches && event.touches.length > 0) {
event = event.touches[0];
}
// debugger
const coordinate = getCurrentCoordinate(event);
let pageX = coordinate.x;
let pageY = coordinate.y;
const startX = currentCaptcha.currentCaptchaData.startX;
const startY = currentCaptcha.currentCaptchaData.startY;
const startTime = currentCaptcha.currentCaptchaData.startTime;
const end = currentCaptcha.currentCaptchaData.end;
const bgImageWidth = currentCaptcha.currentCaptchaData.bgImageWidth;
const trackList = currentCaptcha.currentCaptchaData.trackList;
let moveX = pageX - startX;
let moveY = pageY - startY;
const track = {
x: coordinate.x,
y: coordinate.y,
type: "move",
t: new Date().getTime() - startTime.getTime(),
};
trackList.push(track);
if (moveX < 0) {
moveX = 0;
} else if (moveX > end) {
moveX = end;
}
currentCaptcha.currentCaptchaData.moveX = moveX;
currentCaptcha.currentCaptchaData.moveY = moveY;
if (currentCaptcha.doMove) {
currentCaptcha.doMove(event, currentCaptcha);
}
printLog(["move", track]);
}
function destroyEvent(currentCaptcha) {
if (currentCaptcha) {
if (currentCaptcha.__m__) {
window.removeEventListener("mousemove", currentCaptcha.__m__);
window.removeEventListener("touchmove", currentCaptcha.__m__);
}
if (currentCaptcha.__u__) {
window.removeEventListener("mouseup", currentCaptcha.__u__);
window.removeEventListener("touchend", currentCaptcha.__u__);
}
}
}
function up(currentCaptcha, event) {
destroyEvent(currentCaptcha);
const coordinate = getCurrentCoordinate(event);
currentCaptcha.currentCaptchaData.stopTime = new Date();
const startTime = currentCaptcha.currentCaptchaData.startTime;
const trackList = currentCaptcha.currentCaptchaData.trackList;
const track = {
x: coordinate.x,
y: coordinate.y,
type: "up",
t: new Date().getTime() - startTime.getTime(),
};
trackList.push(track);
printLog(["up", track]);
printLog(["tracks", trackList]);
if (currentCaptcha.doUp) {
currentCaptcha.doUp(event, currentCaptcha);
}
currentCaptcha.endCallback(currentCaptcha.currentCaptchaData, currentCaptcha);
}
function initConfig(
bgImageWidth,
bgImageHeight,
templateImageWidth,
templateImageHeight,
end,
) {
// bugfix 图片宽高可能会有小数情况,强转一下整数
const currentCaptchaConfig = {
startTime: new Date(),
trackList: [],
movePercent: 0,
clickCount: 0,
bgImageWidth: Math.round(bgImageWidth),
bgImageHeight: Math.round(bgImageHeight),
templateImageWidth: Math.round(templateImageWidth),
templateImageHeight: Math.round(templateImageHeight),
end: end,
};
printLog(["init", currentCaptchaConfig]);
return currentCaptchaConfig;
}
function closeTips(el, callback) {
const tipEl = Dom(el).find(".captcha-tips");
tipEl.removeClass("captcha-tips-on");
// tipEl.removeClass("captcha-tips-success")
// tipEl.removeClass("captcha-tips-error")
// 延时
if (callback) {
setTimeout(callback, 0.35);
}
}
function showTips(el, msg, type, callback) {
const tipEl = Dom(el).find(".captcha-tips");
tipEl.text(msg);
if (type === 1) {
// 成功
tipEl.removeClass("captcha-tips-error");
tipEl.addClass("captcha-tips-success");
} else {
// 失败
tipEl.removeClass("captcha-tips-success");
tipEl.addClass("captcha-tips-error");
}
tipEl.addClass("captcha-tips-on");
// 延时
setTimeout(callback, 1000);
}
class CommonCaptcha {
showTips(msg, type, callback) {
showTips(this.el, msg, type, callback);
}
closeTips(msg, callback) {
closeTips(this.el, msg, callback);
}
}
function Dom(domStr, dom) {
return new DomEl(domStr, dom);
}
class DomEl {
constructor(domStr, dom) {
if (dom instanceof HTMLElement) {
this.dom = dom;
this.domStr = domStr;
return;
}
if (domStr instanceof DomEl) {
this.dom = domStr.dom;
this.domStr = domStr.domStr;
} else if (typeof domStr === "string") {
this.dom = document.querySelector(domStr);
this.domStr = domStr;
} else if (domStr instanceof HTMLElement) {
this.dom = domStr;
this.domStr = domStr.nodeName;
} else {
throw new Error("不支持的类型");
}
}
each(callback) {
this.getTarget().querySelectorAll("*").forEach(callback);
}
removeClass(className) {
let element = this.getTarget();
if (element.classList) {
// 使用 classList API 移除类
element.classList.remove(className);
} else {
// 兼容旧版本浏览器
const currentClass = element.className;
const regex = new RegExp("(?:^|\\s)" + className + "(?!\\S)", "g");
element.className = currentClass.replace(regex, "");
}
return this;
}
addClass(className) {
const element = this.getTarget();
if (element.classList) {
// 使用 classList API 添加类
element.classList.add(className);
} else {
// 兼容旧版本浏览器
let currentClass = element.className;
if (currentClass.indexOf(className) === -1) {
element.className = currentClass + " " + className;
}
}
return this;
}
find(str) {
const el = this.getTarget().querySelector(str);
if (el) {
return new DomEl(str, el);
}
return null;
}
children(selector) {
const childNodes = this.getTarget().childNodes;
for (let i = 0; i < childNodes.length; i++) {
if (childNodes[i].nodeType === 1 && childNodes[i].matches(selector)) {
return new DomEl(selector, childNodes[i]);
}
}
return null;
}
remove() {
this.getTarget().remove();
return null;
}
css(property, value) {
if (typeof property === "string" && typeof value === "string") {
// 设置单个属性
this.getTarget().style[property] = value;
} else if (typeof property === "object") {
// 设置多个属性
for (var prop in property) {
if (property.hasOwnProperty(prop)) {
this.getTarget().style[prop] = property[prop];
}
}
} else if (typeof property === "string" && typeof value === "undefined") {
// 获取单个属性
return window.getComputedStyle(element)[property];
}
}
attr(attributeName, value) {
if (value === undefined) {
// 如果未提供值,则返回属性的当前值
return this.getTarget().getAttribute(attributeName);
} else {
// 如果提供了值,则设置属性的值
this.getTarget().setAttribute(attributeName, value);
}
return this;
}
text(str) {
this.getTarget().innerText = str;
return this;
}
html(str) {
this.getTarget().innerHtml = str;
return this;
}
is(dom) {
if (dom && typeof dom === "object" && typeof dom.nodeType !== "undefined") {
return this.dom === dom;
}
if (dom instanceof DomEl) {
return this.dom === dom.dom;
}
}
append(content) {
if (typeof content === "string") {
this.getTarget().insertAdjacentHTML("beforeend", content);
} else if (content instanceof HTMLElement) {
this.getTarget().appendChild(content);
} else {
throw new Error("Invalid content type");
}
return this;
}
click(fun) {
this.on("click", fun);
return this;
}
mousedown(fun) {
this.on("mousedown", fun);
return this;
}
touchstart(fun) {
this.on("touchstart", fun);
return this;
}
on(eventType, fun) {
this.getTarget().addEventListener(eventType, fun, { passive: true });
return this;
}
width() {
return this.getTarget().offsetWidth;
}
height() {
return this.getTarget().offsetHeight;
}
getTarget() {
if (this.dom) {
return this.dom;
}
throw new Error("dom不存在: [" + this.domStr + "]");
}
}
function http(options) {
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open(options.method || "GET", options.url);
// 设置请求头
if (options.headers) {
for (const header in options.headers) {
if (options.headers.hasOwnProperty(header)) {
xhr.setRequestHeader(header, options.headers[header]);
}
}
}
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status >= 200 && xhr.status <= 500) {
const contentType = xhr.getResponseHeader("Content-Type");
if (contentType && contentType.indexOf("application/json") !== -1) {
resolve(JSON.parse(xhr.responseText));
} else {
resolve(xhr.responseText);
}
} else {
reject(new Error("Request failed with status: " + xhr.status));
}
}
};
xhr.onerror = function () {
reject(new Error("Network Error"));
};
xhr.send(options.data);
});
}
function isEmptyObject(obj) {
for (var key in obj) {
if (obj.hasOwnProperty(key)) {
return false; // 对象不为空
}
}
return true; // 对象为空
}
export {
isEmptyObject,
http,
Dom,
DomEl,
CommonCaptcha,
clearAllPreventDefault,
down,
move,
up,
initConfig,
showTips,
closeTips,
destroyEvent,
};
+73
View File
@@ -0,0 +1,73 @@
.captcha {
text-align: left;
box-sizing: content-box;
width: 100%;
height: 260px;
z-index: 999;
.slider-bottom .logo {
height: 30px;
}
.slider-bottom {
height: 19px;
width: 100%;
}
.content {
.captcha-tips {
height: 25px;
width: 100%;
position: absolute;
bottom: -25px;
left: 0;
z-index: 999;
font-size: 15px;
line-height: 25px;
color: #fff;
text-align: center;
transition: bottom 0.3s ease-in-out;
}
.captcha-tips.captcha-tips-error {
background-color: #ff5d39;
}
.captcha-tips.captcha-tips-success {
background-color: #39c522;
}
.captcha-tips.captcha-tips-on {
bottom: 0;
}
.captcha-loading {
z-index: 9999;
background-color: #f5f5f5;
text-align: center;
height: 100%;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
align-items: center;
img {
display: block;
width: 45px;
height: 45px;
}
}
}
.captcha-slider-bg-canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border-radius: 5px;
}
.captcha-slider-bg-div {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
.captcha-slider-bg-div-slice {
position: absolute;
}
}
}
+137
View File
@@ -0,0 +1,137 @@
import {
Dom,
CommonCaptcha,
clearAllPreventDefault,
down,
initConfig,
destroyEvent,
} from "../common/common.js";
const TYPE = "CONCAT";
function getTemplate() {
return `
<div class="captcha captcha-slider captcha-concat">
<div class="slider-tip">
<span class="captcha-slider-move-track-font" >拖动滑块完成拼图</span>
</div>
<div class="content">
<div class="captcha-slider-concat-img-div"></div>
<div class="captcha-slider-concat-bg-img"></div>
<div class="captcha-tips"></div>
</div>
<div class="slider-move">
<div class="slider-move-track">
<div class="captcha-slider-move-track-mask"></div>
<div class="slider-move-shadow"></div>
</div>
<div class="slider-move-btn captcha-slider-move-btn">
</div>
</div>
</div>
`;
}
class Concat extends CommonCaptcha {
constructor(divId, styleConfig) {
super();
this.boxEl = Dom(divId);
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate());
this.el = this.boxEl.find(".captcha");
this.loadStyle();
// 按钮绑定事件
this.el
.find(".captcha-slider-move-btn")
.mousedown(down.bind(null, this));
this.el
.find(".captcha-slider-move-btn")
.touchstart(down.bind(null, this));
clearAllPreventDefault(this.el);
// 绑定全局
window.currentCaptcha = this;
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy() {
destroyEvent();
const existsCaptchaEl = this.boxEl.children(".captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
}
doMove() {
const moveX = this.currentCaptchaData.moveX;
this.el
.find(".captcha-slider-move-btn")
.css("transform", "translate(" + moveX + "px, 0px)");
this.el
.find(".captcha-slider-concat-img-div")
.css("background-position-x", moveX + "px");
this.el
.find(".captcha-slider-move-track-mask")
.css("width", moveX + "px");
}
loadStyle() {
let sliderImg = "";
let moveTrackMaskBorderColor = "#00f4ab";
let moveTrackMaskBgColor = "#a9ffe5";
const styleConfig = this.styleConfig;
if (styleConfig) {
sliderImg = styleConfig.btnUrl;
moveTrackMaskBgColor = styleConfig.moveTrackMaskBgColor;
moveTrackMaskBorderColor = styleConfig.moveTrackMaskBorderColor;
}
this.el
.find(".slider-move .slider-move-btn")
.css("background-image", "url(" + sliderImg + ")");
// this.el.find(".captcha-slider-move-track-font").text(title);
this.el
.find(".captcha-slider-move-track-mask")
.css("border-color", moveTrackMaskBorderColor);
this.el
.find(".captcha-slider-move-track-mask")
.css("background-color", moveTrackMaskBgColor);
}
loadCaptchaForData(that, data) {
const bgImg = that.el.find(".captcha-slider-concat-bg-img");
const sliderImg = that.el.find(".captcha-slider-concat-img-div");
bgImg.css("background-image", "url(" + data.data.backgroundImage + ")");
sliderImg.css("background-image", "url(" + data.data.backgroundImage + ")");
sliderImg.css("background-position", "0px 0px");
var backgroundImageHeight = data.data.backgroundImageHeight;
var height =
((backgroundImageHeight - data.data.data.randomY) /
backgroundImageHeight) *
180;
sliderImg.css("height", height + "px");
that.currentCaptchaData = initConfig(
bgImg.width(),
bgImg.height(),
sliderImg.width(),
sliderImg.height(),
300 - 63 + 5,
);
that.currentCaptchaData.currentCaptchaId = data.data.id;
}
}
export default Concat;
+29
View File
@@ -0,0 +1,29 @@
.captcha.captcha-concat {
.captcha-slider-concat-img-div {
background-size: 100% 180px;
position: absolute;
transform: translate(0px, 0px);
z-index: 1;
width: 100%;
&::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: rgba(255, 255, 255, 0.92);
box-shadow:
0 0 6px 3px rgb(255 255 255 / 60%),
0 1px 4px rgb(0 0 0 / 90%);
pointer-events: none;
}
}
.captcha-slider-concat-bg-img {
width: 100%;
height: 100%;
position: absolute;
transform: translate(0px, 0px);
background-size: 100% 180px;
}
}
+206
View File
@@ -0,0 +1,206 @@
import StyleConfig from "./styleConfig";
import { Dom, http } from "../common/common";
class CaptchaConfig {
constructor(args) {
if (!args.bindEl) {
throw new Error("[TAC] 必须配置 [bindEl]用于将验证码绑定到该元素上");
}
if (!args.requestCaptchaDataUrl) {
throw new Error("[TAC] 必须配置 [requestCaptchaDataUrl]请求验证码接口");
}
if (!args.validCaptchaUrl && !args.onDataReady) {
throw new Error("[TAC] 必须配置 [validCaptchaUrl]验证验证码接口");
}
this.bindEl = args.bindEl;
this.domBindEl = Dom(args.bindEl);
this.requestCaptchaDataUrl = args.requestCaptchaDataUrl;
this.types = Array.isArray(args.types) ? args.types.filter(Boolean) : [];
this.validCaptchaUrl = args.validCaptchaUrl;
if (args.validSuccess) {
this.validSuccess = args.validSuccess;
}
if (args.validFail) {
this.validFail = args.validFail;
}
if (args.onDataReady) {
this.onDataReady = args.onDataReady;
}
if (args.requestHeaders) {
this.requestHeaders = args.requestHeaders;
} else {
this.requestHeaders = {};
}
if (args.btnCloseFun) {
this.btnCloseFun = args.btnCloseFun;
}
if (args.btnRefreshFun) {
this.btnRefreshFun = args.btnRefreshFun;
}
this.requestChain = [];
// 时间戳转换
this.timeToTimestamp = args.timeToTimestamp || true;
this.insertRequestChain(0, {
preRequest(type, param, c, tac) {
if (this.timeToTimestamp && param.data) {
for (let key in param.data) {
// 将date全部转换为时间戳
if (param.data[key] instanceof Date) {
param.data[key] = param.data[key].getTime();
}
}
}
return true;
},
});
}
addRequestChain(fun) {
this.requestChain.push(fun);
}
insertRequestChain(index, chain) {
this.requestChain.splice(index, 0, chain);
}
removeRequestChain(index) {
this.requestChain.splice(index, 1);
}
requestCaptchaData() {
const requestParam = {};
requestParam.headers = this.requestHeaders || {};
requestParam.data = { types: this.types };
// 设置默认值
requestParam.headers["Content-Type"] = "application/json;charset=UTF-8";
requestParam.method = "POST";
requestParam.url = this.requestCaptchaDataUrl;
// 请求前装载参数
this._preRequest("requestCaptchaData", requestParam);
// 发送请求
const request = this.doSendRequest(requestParam);
// 返回结果
return request.then((res) => {
// 装返回结果
this._postRequest("requestCaptchaData", requestParam, res);
// 返回结果
return res;
});
}
doSendRequest(requestParam) {
// 如果content-type是json,那么data就是json字符串, 这里直接匹配所有header是否包含application/json
if (requestParam.headers) {
for (const key in requestParam.headers) {
if (requestParam.headers[key].indexOf("application/json") > -1) {
if (typeof requestParam.data !== "string") {
requestParam.data = JSON.stringify(requestParam.data);
}
break;
}
}
}
return http(requestParam).then((res) => {
try {
return JSON.parse(res);
} catch (e) {
return res;
}
});
}
_preRequest(type, requestParam, c, tac) {
for (let i = 0; i < this.requestChain.length; i++) {
const r = this.requestChain[i];
if (r.preRequest) {
if (!r.preRequest(type, requestParam, this, c, tac)) {
break;
}
}
}
}
_postRequest(type, requestParam, res, c, tac) {
for (let i = 0; i < this.requestChain.length; i++) {
const r = this.requestChain[i];
// 判断r是否存圩postRequest方法
if (r.postRequest) {
if (!r.postRequest(type, requestParam, res, this, c, tac)) {
break;
}
}
}
}
validCaptcha(currentCaptchaId, data, c, tac) {
const sendParam = {
id: currentCaptchaId,
data: data,
};
let requestParam = {};
requestParam.headers = this.requestHeaders || {};
requestParam.data = sendParam;
requestParam.headers["Content-Type"] = "application/json;charset=UTF-8";
requestParam.method = "POST";
requestParam.url = this.validCaptchaUrl;
this._preRequest("validCaptcha", requestParam, c, tac);
const request = this.doSendRequest(requestParam);
return request
.then((res) => {
this._postRequest("validCaptcha", requestParam, res, c, tac);
return res;
})
.then((res) => {
if (res.code == 200) {
const useTimes = (data.stopTime - data.startTime) / 1000;
c.showTips(`验证成功,耗时${useTimes}`, 1, () =>
this.validSuccess(res, c, tac),
);
} else {
let tipMsg = "验证失败,请重新尝试!";
if (res.code) {
if (res.code != 4001) {
tipMsg = "验证码被黑洞吸走了!";
}
}
c.showTips(tipMsg, 0, () => this.validFail(res, c, tac));
}
})
.catch((e) => {
let tipMsg = c.styleConfig.i18n.tips_error;
if (e.code && e.code != 200) {
if (e.code != 4001) {
tipMsg = c.styleConfig.i18n.tips_4001;
}
c.showTips(tipMsg, 0, () => this.validFail(e, c, tac));
return;
}
c.showTips(tipMsg, 0, () => this.validFail(e, c, tac));
});
}
validSuccess(res, c, tac) {
console.log(
"验证码校验成功, 请重写 [config.validSuccess] 方法, 用于自定义逻辑处理",
);
window.currentCaptchaRes = res;
tac.destroyWindow();
}
validFail(res, c, tac) {
tac.reloadCaptcha();
}
}
function wrapConfig(config) {
if (config instanceof CaptchaConfig) {
return config;
}
return new CaptchaConfig(config);
}
function wrapStyle(style) {
let margeStyle = { ...StyleConfig, ...style };
margeStyle.i18n = { ...StyleConfig.i18n, ...style?.i18n };
return margeStyle;
}
const captchaRequestChains = {};
export { CaptchaConfig, wrapConfig, wrapStyle };
+23
View File
@@ -0,0 +1,23 @@
export default {
// 按钮图片
btnUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIwAAABkCAYAAABU19jRAAAJcUlEQVR4nO2d63MT1xmHf9rV6mr5fgNMuSW+ENsY8N0EE2BMhinJNB8y/dD2Qz/0v+gMf0w/JHTKNJAhICwbsA02TpNAHEMgQIwNBSEb8F2rvXTeY1kjYyA+TmVJmfeZ8YiRWa9299E57/mdI63Dtm3E+RjAKTDMaj4F8AU9uyzMCQBn+EQxb+EjAF+RMH8AcJrPFLMGvCSMzWeKWSN/I2GiAFx8xpi1oPBZYiTQWRhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGCiefrtShGwZiup74+4qqwu12Z/W7lIVJEfN6FDfv3sPXfYOIRRfpm1UQKC7EkQ+PYFtRcdZKw8KkiLsPJ/CfgSFcH7yOxWhU7MSluYQoR44fxdaCoqyUhoVJEfZ8FN99c1N0Sx6PR+zEMAz0XAgBNtB14hi25OXDkWXHxUVvinA4ln6ScTqdsGwbvRd7EPwyiEcvXyDbvpyHhUkRaq4fe/c3wEWSWFZiJySNYZroCYYQPHsBY1OTWSWNevLkyb/TYwa8lt8UAb8ftluDW9UwPj4hDs0Rb3JUVRXd09j9nwELKKgoR4HXlw2Hb3INkyK8mob9NdUwLROq4sCVKwMrdqRpGkzTFN0TaWR2HcKu0rKMr2lYmBTi1jS01dUt7UBx4PKlfvHP5JaGuqseIY0DjmOHsKukNKOPiYVJMU5VRXt9PSwboO+fvHJ5QEiiKEvlIz3S86HuHiiqAhw9iJ0lpRnb0rAwG4CqKHh/Tz0UhwOWaWGg/5oofEkmJLU4wfPdQia765CQJhNHJCzMBkEtSVtdLRw2YNo2hgaGEDMMMWpahrwJBUMUCkM9djgjE2EWZgOhFqW5rlbMKdm2heHBYUT1mCiAEW9pKKfpPh8Sj5mYCLMwG4zLqWJfTZWQgL5S++uhYURjBrR4S0MtUSYnwixMGvBoGvZUV4quh0S4Pjgsaho1XtOIcM8wxJCb+qmu33dljDS/CWEeTb/E/Pw89EUdebkBVBQWrnnbWVjQoMAtsT9asGDQhf8VUbnX5UJ9VaVoZahVuXZ1cMXoiaSJxWIiEab/dPj4UXFczjRrk/VJ70/hp/jhuxF89o9TGP1+FH6fD9OxGHw5Pnicb34/PJ2dweitu7hwLojvb47A9rhQmJeXGLm8iQeP/4uRH27h88/+iZhhYs40UFZQsK7XrqkqigvyYbk18VrHH74+EX74YAzRqI66mupE15UmzKwW5kEkgtFvRxA8ex7hJ2HMzczgzu0f8fjxExRt2YzcgB9udfUJjuo6Tv/7HE6f+pe4GHd//AkwLRhuDeXFRW+U5v7EI4yMjKI3GMLt0Tt4cO8BAoEcWJoTZYXrl6asqBC6U0GOy42HY+MrZi1JmoWFRZQW5sNyuVBeUpxOabJ7aiASjiB4/iKmnj+H5loaacwvLOL2jRF4AjnY8dc/I/DKbTdoSHvr8SO8DD/DzPSMWHrg1JwYvHZdpK2NVZWU26/aF3VDTyLP0N/bh4mJR3C7XZiZnRVdht/nx7u7tsOzzg5qORFWHAocigO9vX2Jronwej24cXMEbq8XrfW169rH/4usnq02o1FEo9FEE47luN22sTAzC0OPrd7ItnHn9h0MDg3D6/WKbZdHJqYRg26ar92XDgvD39zA2Ng4VKdTbEf7mpmeRX/fAPRfeRch+luNNTXICeSu+h3ti7okUzdgp3luO6uFUTUN9lLmnniOCkdKVnML8uB0r76rD72Di4qL4NI0IUnydpZlw/WmGsY00bRvDzZvKhfFKLAU9VOG8v7BdijW+i8kLX649yyMz0+fwVQksur3NILyejzw5efCoaT3kmW1MN68AMq2bBIXXtd18WMZBt6r242DBzvgda3uWhQ4xNzOkeNdohZYXFjA4vwCfD4/Sio2i9bjdeSoGirKylFYXirykehiFHpUR2FJCbZu+x1yXlMrrQWSZWwygv6Ll3DxXBCX+66u6I7o2DRFRWtbM1o62xNdb7rI7lGSqqBs+zZMTj4XLYY/x49t7+zABx8eReWO7ciLL41ctZmqoqRiE/x+P6amp5FbkI9jx7tw+GgncqmbesPuPAEfduzcgenZOTg0FaWby/GXP/0RdZXvrOvlkyzjzyfR81UIoQs9IpRJniqglszt0tDc1oS9bc2o37lTLMhKI2bW35HtRXQRs3MLmH/xUrzzVb8HJQUFyHX/crJCQ+JwOALFqaKspGjNRWtkbg5zc7PQXC5szl/f6Ig6MFqiSavuqHCmumuFLIYBt+ZEY0sTGtua0VBTJQK/NKPzLfzSQEKWL4NiiG5a1gpZzPhMdnNrE/a3N2NPVaUI+jIAnacGNhiShdbx9pzrFgunSA4tqeUQRbuqoLW9BQ0tjSINzhBZBCzMBvPzVAS950KiG6KWJVkWGnXRELrjQBtqG/eioTqzZAELs3FQy3Iv/BR9wUtiUtGOr+tNhoptGt1V7atD4+4aEehlGizMBnH/WRj9wcuiG7LjI7Vllm8d3nnoAKoaakXq+0tzWumChUkxdlyWge4rYt0uzRMpSck01SzUDR3s7MC7e2pFRqSmOZx7GyxMCrESLcsldAd7oCgrEx6xrldRRM1SvbceHfV1K0K7TISFSREx28L41KRIcGmdruOVz82KBFd1oqWjBe/tb0ArLd3McFnAwqSOiclJ9JwP4fLFXtEtJXdDywluU2uTGDpTgZupNcur8GerU8R0eBJDV6+LRVbJLYdIcF2aSHD3tzaL9b20zjdbYGFShB0z4HY6V9QtFNLRXFATxf2U4FZXZkLcLwULkyJoaUXMNMV6HbyS4O6jicQMS3DXCguTInJKC9HU0YoPOg8k1uy0t7eivnmfSHB9WSgLwZOPKcKwLcT0GL69cxe3b46KoK6+ZS92V2zNyAR3jfBsdaox6LPSpiVyf/rEo/rq11JlFzxbnWoomEMW5CtrhWsYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgr6qGx6b4/BZBXUwnzCl4xZI5844g3MCQBn+Kwxb+EjAGcdST3SxwBO8RljXsOnAL4AgP8BXnVIgIvemwsAAAAASUVORK5CYII=",
// 移动边框背景颜色
moveTrackMaskBgColor: "#89d2ff",
// 移动边框颜色
moveTrackMaskBorderColor: "#0298f8",
// 文字提示
i18n: {
tips_success: "验证成功,耗时%s秒",
tips_error: "验证失败,请重新尝试!",
slider_title: "拖动滑块完成拼图",
concat_title: "拖动滑块完成拼图",
image_click_title: "请依次点击:",
rotate_title: "拖动滑块完成拼图",
// TITLE 大小
slider_title_size: "15px",
concat_title_size: "15px",
image_click_title_size: "20px",
rotate_title_size: "15px",
},
};
+57
View File
@@ -0,0 +1,57 @@
const TYPE = "DISABLE";
function getTemplate(styleConfig) {
return `
<div class="captcha captcha-disable">
<div class="slider-tip">
<span class="captcha-slider-move-track-font" style="font-size: ${styleConfig.i18n.disable_title_size}">${styleConfig.i18n.disable_title}</span>
</div>
<div class="content">
<div class="bg-img-div">
<!-- <svg width="100" height="100" viewBox="0 0 100 100">-->
<!-- <polygon points="50,10 90,90 10,90" fill="none" stroke="#FF9900" stroke-width="4"/>-->
<!-- <path d="M50 35V65 M50 75V75" stroke="#FF9900" stroke-width="4" stroke-linecap="round"/>-->
<!-- </svg>-->
<span class="content-span"></span>
</div>
</div>
</div>
`;
}
class Disable {
constructor(boxEl, styleConfig) {
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find(".captcha");
// 绑定全局
// window.currentCaptcha = this;
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy() {
const existsCaptchaEl = this.boxEl.find(".captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
}
loadCaptchaForData(that, data) {
const msg = data.msg || data.message || "接口异常";
that.el.find(".content-span").text(msg);
}
}
export default Disable;
+25
View File
@@ -0,0 +1,25 @@
.captcha.captcha-disable {
z-index: 999;
position: absolute;
left: 0;
top: 0;
.content {
width: 100%;
height: 180px;
position: relative;
overflow: hidden;
.bg-img-div {
background-image: url("../../images/dun.jpeg");
width: 100%;
height: 100%;
overflow: hidden;
.content-span {
color: #fff;
overflow: hidden;
margin-top: 132px;
display: block;
text-align: center;
}
}
}
}
@@ -0,0 +1,131 @@
import {
CommonCaptcha,
move,
initConfig,
destroyEvent,
} from "../common/common.js";
/**
* 滑动验证码
*/
const TYPE = "IMAGE_CLICK";
function getTemplate(styleConfig) {
return `
<div class="captcha captcha-slider captcha-word-click">
<div class="click-tip">
<span class="captcha-click-track-font" style="font-size: ${styleConfig.i18n.image_click_title_size}">${styleConfig.i18n.image_click_title}</span>
<img src="" class="captcha-tip-img tip-img">
</div>
<div class="content">
<div class="bg-img-div">
<img class="captcha-slider-bg-img" src="" alt/>
<canvas class="captcha-slider-bg-canvas"></canvas>
<div class="bg-img-click-mask"></div>
</div>
<div class="captcha-tips"></div>
</div>
<div class="click-confirm-btn">确定</div>
</div>
`;
}
class ImageClick extends CommonCaptcha {
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find(".captcha");
// 绑定全局
// window.currentCaptcha = this;
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
const moveFun = move.bind(null, this);
// 绑定事件
this.el.find(".bg-img-click-mask").click((event) => {
if (event.target.className === "click-span") {
return;
}
this.currentCaptchaData.clickCount++;
const trackList = this.currentCaptchaData.trackList;
if (this.currentCaptchaData.clickCount === 1) {
this.currentCaptchaData.startTime = new Date();
// move 轨迹
window.addEventListener("mousemove", moveFun);
this.currentCaptchaData.startX = event.offsetX;
this.currentCaptchaData.startY = event.offsetY;
}
const startTime = this.currentCaptchaData.startTime;
trackList.push({
x: Math.round(event.offsetX),
y: Math.round(event.offsetY),
type: "click",
t: new Date().getTime() - startTime.getTime(),
});
const left = event.offsetX - 10;
const top = event.offsetY - 10;
this.el
.find(".bg-img-click-mask")
.append(
"<span class='click-span' style='left:" +
left +
"px;top: " +
top +
"px'>" +
this.currentCaptchaData.clickCount +
"</span>",
);
// if (this.currentCaptchaData.clickCount === 4) {
// // 校验
// this.currentCaptchaData.stopTime = new Date();
// window.removeEventListener("mousemove", move);
// this.endCallback(this.currentCaptchaData,this);
// }
});
this.el.find(".click-confirm-btn").click(() => {
if (this.currentCaptchaData.clickCount > 0) {
// 校验
this.currentCaptchaData.stopTime = new Date();
window.removeEventListener("mousemove", moveFun);
this.endCallback(this.currentCaptchaData, this);
}
});
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy() {
const existsCaptchaEl = this.boxEl.children(".captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData(that, data) {
const bgImg = that.el.find(".captcha-slider-bg-img");
const tipImg = that.el.find(".captcha-tip-img");
bgImg.on("load", () => {
that.currentCaptchaData = initConfig(
bgImg.width(),
bgImg.height(),
tipImg.width(),
tipImg.height(),
);
that.currentCaptchaData.currentCaptchaId = data.data.id;
});
bgImg.attr("src", data.data.backgroundImage);
tipImg.attr("src", data.data.templateImage);
}
}
export default ImageClick;
@@ -0,0 +1,66 @@
.captcha.captcha-word-click {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
.tip-img {
height: 35px;
position: absolute;
right: 15px;
}
.captcha-click-track-font {
font-size: 18px;
display: inline-block;
height: 40px;
line-height: 40px;
position: absolute;
}
}
.slider-bottom {
position: relative;
top: 6px;
}
.content {
.bg-img-click-mask {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
.click-span {
position: absolute;
left: 0;
top: 0;
border-radius: 50px;
background-color: #409eff;
width: 20px;
height: 20px;
text-align: center;
line-height: 20px;
color: #fff;
border: 2px solid #fff;
box-sizing: content-box;
}
}
}
.click-confirm-btn {
width: 100%;
height: 35px;
border-radius: 4px;
background-image: linear-gradient(
173deg,
hsl(38.09deg 91% 57.89%) 0%,
hsl(38.09deg 89.38% 71.74%) 100%
);
font-size: 15px;
text-align: center;
box-sizing: border-box;
line-height: 35px;
color: #fff;
margin-top: 3px;
}
.click-confirm-btn:hover {
cursor: pointer;
}
}
+134
View File
@@ -0,0 +1,134 @@
import {
CommonCaptcha,
down,
initConfig,
destroyEvent,
} from "../common/common.js";
/**
* 滑动验证码
*/
const TYPE = "ROTATE";
function getTemplate(styleConfig) {
return `
<div class="captcha captcha-slider captcha-rotate">
<div class="slider-tip">
<span class="captcha-slider-move-track-font" style="font-size: ${styleConfig.i18n.rotate_title_size}">${styleConfig.i18n.rotate_title}</span>
</div>
<div class="content">
<div class="bg-img-div">
<img class="captcha-slider-bg-img" src="" alt/>
<canvas class="captcha-slider-bg-canvas"></canvas>
</div>
<div class="rotate-img-div captcha-slider-img-div">
<img class="captcha-slider-move-img" src="" alt/>
</div>
<div class="captcha-tips"></div>
</div>
<div class="slider-move">
<div class="slider-move-track">
<div class="captcha-slider-move-track-mask"></div>
<div class="slider-move-shadow"></div>
</div>
<div class="slider-move-btn captcha-slider-move-btn">
</div>
</div>
</div>
`;
}
class Rotate extends CommonCaptcha {
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find(".captcha");
this.loadStyle();
// 按钮绑定事件
this.el
.find(".captcha-slider-move-btn")
.mousedown(down.bind(null, this));
this.el
.find(".captcha-slider-move-btn")
.touchstart(down.bind(null, this));
// 绑定全局
// window.currentCaptcha = this;
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy() {
const existsCaptchaEl = this.boxEl.children(".captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
doMove() {
const moveX = this.currentCaptchaData.moveX;
this.el
.find(".captcha-slider-move-btn")
.css("transform", "translate(" + moveX + "px, 0px)");
this.el
.find(".captcha-slider-move-img")
.css(
"transform",
"rotate(" + moveX / (this.currentCaptchaData.end / 360) + "deg)",
);
this.el
.find(".captcha-slider-move-track-mask")
.css("width", moveX + "px");
}
loadStyle() {
let sliderImg = "";
let moveTrackMaskBorderColor = "#00f4ab";
let moveTrackMaskBgColor = "#a9ffe5";
const styleConfig = this.styleConfig;
if (styleConfig) {
sliderImg = styleConfig.btnUrl;
moveTrackMaskBgColor = styleConfig.moveTrackMaskBgColor;
moveTrackMaskBorderColor = styleConfig.moveTrackMaskBorderColor;
}
this.el
.find(".slider-move .slider-move-btn")
.css("background-image", "url(" + sliderImg + ")");
// this.el.find(".captcha-slider-move-track-font").text(title);
this.el
.find(".captcha-slider-move-track-mask")
.css("border-color", moveTrackMaskBorderColor);
this.el
.find(".captcha-slider-move-track-mask")
.css("background-color", moveTrackMaskBgColor);
}
loadCaptchaForData(that, data) {
const bgImg = that.el.find(".captcha-slider-bg-img");
const sliderImg = that.el.find(".captcha-slider-move-img");
bgImg.attr("src", data.data.backgroundImage);
sliderImg.attr("src", data.data.templateImage);
bgImg.on("load", () => {
that.currentCaptchaData = initConfig(
bgImg.width(),
bgImg.height(),
sliderImg.width(),
sliderImg.height(),
300 - 63 + 5,
);
that.currentCaptchaData.currentCaptchaId = data.data.id;
});
}
}
export default Rotate;
+11
View File
@@ -0,0 +1,11 @@
.captcha.captcha-rotate {
.rotate-img-div {
height: 100%;
text-align: center;
img {
height: 100%;
transform: rotate(0deg);
display: inline-block;
}
}
}
+142
View File
@@ -0,0 +1,142 @@
import {
CommonCaptcha,
closeTips,
down,
initConfig,
showTips,
destroyEvent,
} from "../common/common.js";
/**
* 滑动验证码
*/
const TYPE = "SLIDER";
function getTemplate(styleConfig) {
return `
<div class="captcha captcha-slider">
<div class="slider-tip">
<span class="captcha-slider-move-track-font" style="font-size: ${styleConfig.i18n.slider_title_size}">${styleConfig.i18n.slider_title}</span>
</div>
<div class="content">
<div class="bg-img-div">
<img class="captcha-slider-bg-img" src="" alt/>
<canvas class="captcha-slider-bg-canvas"></canvas>
<div class="captcha-slider-bg-div"></div>
</div>
<div class="slider-img-div captcha-slider-img-div">
<img class="captcha-slider-move-img" src="" alt/>
</div>
<div class="captcha-tips"></div>
</div>
<div class="slider-move">
<div class="slider-move-track">
<div class="captcha-slider-move-track-mask"></div>
<div class="slider-move-shadow"></div>
</div>
<div class="slider-move-btn captcha-slider-move-btn">
</div>
</div>
</div>
`;
}
class Slider extends CommonCaptcha {
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find(".captcha");
this.loadStyle();
// 按钮绑定事件
this.el
.find(".captcha-slider-move-btn")
.mousedown(down.bind(null, this));
this.el
.find(".captcha-slider-move-btn")
.touchstart(down.bind(null, this));
// 绑定全局
// window.currentCaptcha = this;
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
showTips(msg, type, callback) {
showTips(this.el, msg, type, callback);
}
closeTips(callback) {
closeTips(this.el, callback);
}
destroy() {
const existsCaptchaEl = this.boxEl.children(".captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
doMove() {
const moveX = this.currentCaptchaData.moveX;
this.el
.find(".captcha-slider-move-btn")
.css("transform", "translate(" + moveX + "px, 0px)");
this.el
.find(".captcha-slider-img-div")
.css("transform", "translate(" + moveX + "px, 0px)");
this.el
.find(".captcha-slider-move-track-mask")
.css("width", moveX + "px");
}
loadStyle() {
let sliderImg = "";
let moveTrackMaskBorderColor = "#00f4ab";
let moveTrackMaskBgColor = "#a9ffe5";
const styleConfig = this.styleConfig;
if (styleConfig) {
sliderImg = styleConfig.btnUrl;
moveTrackMaskBgColor = styleConfig.moveTrackMaskBgColor;
moveTrackMaskBorderColor = styleConfig.moveTrackMaskBorderColor;
}
this.el
.find(".slider-move .slider-move-btn")
.css("background-image", "url(" + sliderImg + ")");
// this.el.find(".captcha-slider-move-track-font").text(title);
this.el
.find(".captcha-slider-move-track-mask")
.css("border-color", moveTrackMaskBorderColor);
this.el
.find(".captcha-slider-move-track-mask")
.css("background-color", moveTrackMaskBgColor);
}
loadCaptchaForData(that, data) {
const bgImg = that.el.find(".captcha-slider-bg-img");
const sliderImg = that.el.find(".captcha-slider-move-img");
bgImg.attr("src", data.data.backgroundImage);
sliderImg.attr("src", data.data.templateImage);
bgImg.on("load", () => {
that.currentCaptchaData = initConfig(
bgImg.width(),
bgImg.height(),
sliderImg.width(),
sliderImg.height(),
300 - 63 + 5,
);
that.currentCaptchaData.currentCaptchaId = data.data.id;
});
}
}
export default Slider;
+87
View File
@@ -0,0 +1,87 @@
.captcha.captcha-slider {
z-index: 999;
position: absolute;
left: 0;
top: 0;
.content {
width: 100%;
height: 180px;
position: relative;
overflow: hidden;
}
.bg-img-div {
width: 100%;
height: 100%;
position: absolute;
transform: translate(0px, 0px);
img {
height: 100%;
width: 100%;
}
}
.slider-img-div {
height: 100%;
position: absolute;
left: 0;
transform: translate(0px, 0px);
.captcha-slider-move-img {
height: 100%;
}
}
.slider-move {
height: 34px;
width: 100%;
margin: 11px 0;
position: relative;
}
.slider-move-track {
position: relative;
height: 32px;
line-height: 32px;
text-align: center;
background: #f5f5f5;
color: #999;
transition: 0s;
font-size: 14px;
box-sizing: content-box;
border: 1px solid #f5f5f5;
border-radius: 4px;
}
.refresh-btn,
.close-btn {
display: inline-block;
}
.slider-move {
line-height: 38px;
font-size: 14px;
text-align: center;
white-space: nowrap;
color: #88949d;
-moz-user-select: none;
-webkit-user-select: none;
user-select: none;
filter: opacity(0.8);
}
.slider-move .slider-move-btn {
transform: translate(0px, 0px);
position: absolute;
top: -6px;
left: 0;
width: 63px;
height: 45px;
background-color: #fff;
background-repeat: no-repeat;
background-size: contain;
border-radius: 5px;
}
.slider-tip {
margin-bottom: 5px;
font-weight: bold;
font-size: 15px;
line-height: normal;
}
.slider-move-btn:hover {
cursor: pointer;
}
user-select: none;
}
@@ -0,0 +1,13 @@
import ImageClick from "../image_click/image_click";
/**
* 滑动验证码
*/
const TYPE = "WORD_IMAGE_CLICK";
class WordImageClick extends ImageClick {
constructor(divId, styleConfig) {
super(divId, styleConfig);
this.type = TYPE;
}
}
export default WordImageClick;
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+43
View File
@@ -0,0 +1,43 @@
import { CaptchaConfig, TianAiCaptcha } from "./captcha/captcha.js"
export type TianAiCaptchaBindTarget = string | HTMLElement
export interface TianAiCaptchaResult {
id: string
data: Record<string, any>
}
export interface TianAiCaptchaStyle {
btnUrl?: string
moveTrackMaskBgColor?: string
moveTrackMaskBorderColor?: string
i18n?: Record<string, string>
}
export interface TianAiCaptchaConfig {
bindEl: TianAiCaptchaBindTarget
requestCaptchaDataUrl: string
types?: string[]
validCaptchaUrl?: string
timeToTimestamp?: boolean
validSuccess?: (res: any, captcha: any, tac: TianAiCaptchaInstance) => void
validFail?: (res: any, captcha: any, tac: TianAiCaptchaInstance) => void
onDataReady?: (result: TianAiCaptchaResult, captcha: any, tac: TianAiCaptchaInstance) => void | Promise<void>
btnRefreshFun?: (el: Event, tac: TianAiCaptchaInstance) => void
btnCloseFun?: (el: Event, tac: TianAiCaptchaInstance) => void
}
export interface TianAiCaptchaInstance {
init: () => TianAiCaptchaInstance
reloadCaptcha: () => void
destroyWindow: () => void
}
export function createTianAiCaptcha(
config: TianAiCaptchaConfig,
style?: TianAiCaptchaStyle,
): TianAiCaptchaInstance {
return new TianAiCaptcha(config, style) as TianAiCaptchaInstance
}
export { CaptchaConfig, TianAiCaptcha }
+93 -31
View File
@@ -1,23 +1,37 @@
<template>
<el-container class="layout">
<el-header class="layout-header">
<div class="main-title">博客管理后台</div>
<div class="main-title">{{ t('layout.header.title') }}</div>
<div class="nav-btns-right">
<el-button link @click="toggleDarkMode" style="color: #fff; font-size: 18px; margin-right: 12px;">
<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>
<Moon v-if="!isDark" style="width: 18px" />
<Sunny v-else style="width: 18px" />
</el-button>
<el-dropdown @command="dropdownMenuCommand">
<el-button link type="primary">
<el-button link type="primary" plain>
<UserFilled style="width: 18px" />
{{ realname }}
<ArrowDown style="width: 18px"/>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="home">返回首页</el-dropdown-item>
<el-dropdown-item command="changePassword">修改密码</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出</el-dropdown-item>
<el-dropdown-item command="home">{{ t('layout.header.backHome') }}</el-dropdown-item>
<el-dropdown-item command="changePassword">{{ t('layout.header.changePassword') }}</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>
</template>
</el-dropdown>
@@ -29,10 +43,10 @@
<el-sub-menu v-for="menu in menus" :key="menu.name" :index="menu.name">
<template #title>
<component :is="menu.icon" style="width: 18px; margin-right: 5px;"></component>
<span>{{menu.title}}</span>
<span>{{ t(menu.titleKey) }}</span>
</template>
<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-sub-menu>
</el-menu>
@@ -40,8 +54,8 @@
<el-main class="layout-main">
<div class="layout-tabs">
<el-tabs type="card" class="nav-tabs" v-model="store.state.activeTab" @tab-change="openMenu" @tab-remove="removeTab">
<el-tab-pane label="首页" name="/"></el-tab-pane>
<el-tab-pane v-for="tab in store.state.tabs" :key="tab.name" :label="tab.title" :name="tab.path" closable></el-tab-pane>
<el-tab-pane :label="t('layout.tabs.home')" name="/"></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>
</div>
<div class="layout-content">
@@ -51,52 +65,90 @@
</keep-alive>
</router-view>
</div>
<div class="layout-copy">2016-{{currentYear}} &copy; colorfulsweet 上次更新{{ version }}</div>
</el-main>
</el-container>
</el-container>
<el-dialog v-model="changePwdModal" title="修改密码" width="420px" @closed="resetPwdForm">
<el-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-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-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-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-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="changePwdModal = false">取消</el-button>
<el-button type="primary" @click="submitChangePassword" :loading="changePwdLoading">确定</el-button>
<el-button @click="changePwdModal = false" plain>{{ t('layout.passwordDialog.cancel') }}</el-button>
<el-button type="primary" @click="submitChangePassword" :loading="changePwdLoading" plain>{{ t('layout.passwordDialog.confirm') }}</el-button>
</span>
</template>
</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>
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { useStore } from 'vuex'
import { useRouter, useRoute } from 'vue-router'
import menus from '../config/menu'
import { useI18n } from 'vue-i18n'
import { setLocale, SUPPORTED_LOCALES, type SupportedLocale } from '@/i18n'
import allMenus from '../config/menu'
import type { MenuGroup } from '../config/menu'
import http from '@/utils/http'
import { ElMessage } from 'element-plus'
import { VForm } from '@/types'
import { hasPermission } from '@/utils/permission'
const store = useStore()
const router = useRouter()
const route = useRoute()
const { t, locale } = useI18n()
const version = process.env.VERSION
const currentYear = new Date().getFullYear()
const version = __APP_VERSION__
const defaultActiveMenuKey = ref<string | null>(null)
const openMenuNames = ref<string[]>([])
const mainViewReady = ref(false)
const isDark = ref(false)
const aboutModal = ref(false)
const backendVersion = ref(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[] => {
return allMenus
.map(group => ({
...group,
child: group.child.filter(item => hasPermission(item.permission))
}))
.filter(group => group.child.length > 0)
})
function applyDarkMode(dark: boolean) {
isDark.value = dark
document.documentElement.classList.toggle('dark', dark)
@@ -130,23 +182,23 @@ const changePwdData = reactive({
newPassword: '',
confirmPassword: ''
})
const changePwdRules = {
const changePwdRules = computed(() => ({
oldPassword: [
{ required: true, message: '请输入旧密码', trigger: 'blur' }
{ required: true, message: t('layout.passwordDialog.oldPasswordRequired'), trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 8, max: 16, message: '密码长度8~16位', trigger: 'blur' },
{ pattern: /^(?![\d]+$)(?![a-zA-Z]+$)(?![-=+_.,]+$)[\da-zA-Z-=+_.,]{8,16}$/, message: '密码由字母、数字、特殊字符中的任意两种组成', trigger: 'blur' }
{ required: true, message: t('layout.passwordDialog.newPasswordRequired'), trigger: 'blur' },
{ min: 8, max: 16, message: t('layout.passwordDialog.passwordLength'), trigger: 'blur' },
{ pattern: /^(?![\d]+$)(?![a-zA-Z]+$)(?![-=+_.,]+$)[\da-zA-Z-=+_.,]{8,16}$/, message: t('layout.passwordDialog.passwordComplexity'), trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{ required: true, message: t('layout.passwordDialog.confirmPasswordRequired'), trigger: 'blur' },
{ 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'
}
]
}
}))
const realname = computed((): null | string => {
return store.state.loginInfo.userInfo
@@ -170,7 +222,7 @@ if (!store.state.loginInfo.token) {
router.push('/login')
} else {
http.post<{token: string}, any>('/common/verifyToken', {token: store.state.loginInfo.token}).then(data => {
store.commit('login', {userInfo: data.userInfo})
store.commit('login', {userInfo: data.userInfo, permissions: data.permissions})
mainViewReady.value = true
}).catch(() => {
router.push('/login')
@@ -182,6 +234,9 @@ function dropdownMenuCommand(command: string): void {
case 'home':
router.push('/')
break
case 'about':
openAboutDialog()
break
case 'changePassword':
changePwdModal.value = true
break
@@ -193,6 +248,13 @@ function dropdownMenuCommand(command: string): void {
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 {
changePwdForm.value?.validate(async (valid: boolean) => {
if (!valid) return
@@ -201,7 +263,7 @@ function submitChangePassword(): void {
changePwdLoading.value = false
})
changePwdModal.value = false
ElMessage.success('密码修改成功')
ElMessage.success(t('layout.passwordDialog.success'))
})
}
function resetPwdForm(): void {
@@ -220,4 +282,4 @@ function removeTab(path: string): void {
openMenu(tabs[tabs.length - 1]?.path || '/')
}
}
</script>
</script>
+163 -25
View File
@@ -5,91 +5,218 @@
<div class="brand">
<div class="brand-icon"></div>
<h1>Blog Admin</h1>
<p>简洁 · 高效 · 专注写作</p>
<p>{{ t('login.brandTagline') }}</p>
</div>
</div>
<div class="card-right">
<h2 class="form-title">欢迎回来</h2>
<p class="form-subtitle">请登录你的管理账号</p>
<h2 class="form-title">{{ t('login.welcomeBack') }}</h2>
<p class="form-subtitle">{{ t('login.subtitle') }}</p>
<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
v-model="userInfo.username"
placeholder="请输入用户名"
:placeholder="t('login.usernamePlaceholder')"
prefix-icon="User"
size="large"
@keyup.enter="login"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-form-item :label="t('login.password')" prop="password">
<el-input
v-model="userInfo.password"
type="password"
placeholder="请输入密码"
:placeholder="t('login.passwordPlaceholder')"
prefix-icon="Lock"
size="large"
show-password
@keyup.enter="login"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-button class="login-btn" type="primary" size="large" :loading="loading" @click="login">
<el-button class="login-btn" type="primary" size="large" :loading="loading" @click="handleLogin" plain>
{{ t('login.loginBtn') }}
</el-button>
</el-form>
</div>
</div>
<el-dialog
v-model="captchaDialogVisible"
class="captcha-dialog"
width="460px"
align-center
destroy-on-close
append-to-body
:title="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>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, reactive, ref } from 'vue'
import { useStore } from 'vuex'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import type { VForm } from '../types'
import CaptchaPanel from '@/components/CaptchaPanel.vue'
import http from '@/utils/http'
import type { TianAiCaptchaResult } from '@/vendor/tianai-captcha'
type UserInfo = {
type LoginFormState = {
username: string | null,
password: string | null
}
type LoginRequest = LoginFormState & {
captchaId?: string,
captchaData?: Record<string, any>
}
type LoginConfigResponse = {
captcha?: {
enabled?: boolean
}
}
type CaptchaPanelRef = {
destroy: () => void
init: () => Promise<void>
reload: () => void
}
const store = useStore()
const router = useRouter()
const { t } = useI18n()
const loginForm = ref<VForm>()
const captchaRef = ref<CaptchaPanelRef | null>(null)
const captchaDialogVisible = ref(false)
const captchaErrorPattern = /验证码|captcha/i
const userInfo: UserInfo = reactive({
const userInfo: LoginFormState = reactive({
username: null,
password: null
})
const ruleValidate = {
const ruleValidate = computed(() => ({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
{ required: true, message: t('login.usernameRequired'), trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' }
{ required: true, message: t('login.passwordRequired'), trigger: 'blur' }
],
}
}))
const loading = ref(false)
// created
store.commit('logout')
store.commit('clearTabs')
const savedLocale = localStorage.getItem('locale')
const savedTheme = localStorage.getItem('theme_mode')
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) => {
if (!valid) return
if (!valid || loading.value) return
loading.value = true
const data = await http.post<UserInfo, any>('/common/login', userInfo).finally(() => {
try {
const loginConfig = await http.get<any, LoginConfigResponse>('/common/loginConfig', {
params: {
username: userInfo.username,
}
})
if (loginConfig.captcha?.enabled) {
loading.value = false
await openCaptchaDialog()
return
}
await submitLogin(userInfo)
} catch {
loading.value = false
})
if (data.token) {
store.commit('login', data)
router.push('/')
}
})
}
async function handleCaptchaSolved(result: TianAiCaptchaResult) {
if (loading.value) return
loading.value = true
try {
await submitLogin({
...userInfo,
captchaId: result.id,
captchaData: result.data,
})
} catch (error) {
if (isCaptchaError(error)) {
captchaRef.value?.reload()
} else {
closeCaptchaDialog()
}
} finally {
loading.value = false
}
}
async function submitLogin(payload: LoginRequest) {
const data = await http.post<LoginRequest, any>('/common/login', payload)
closeCaptchaDialog(false)
if (data.token) {
store.commit('login', data)
router.push('/')
}
}
function closeCaptchaDialog(resetLoading = true) {
captchaDialogVisible.value = false
captchaRef.value?.destroy()
if (resetLoading) {
loading.value = false
}
}
function handleCaptchaDialogClosed() {
captchaRef.value?.destroy()
}
function handleCaptchaInitError() {
captchaDialogVisible.value = false
ElMessage.error(t('login.captchaInitError'))
}
function isCaptchaError(error: unknown) {
if (error instanceof Error) {
return captchaErrorPattern.test(error.message)
}
return false
}
onBeforeUnmount(() => captchaRef.value?.destroy())
</script>
<style lang="less" scoped>
.login-page {
@@ -227,4 +354,15 @@ async function login() {
border-radius: 8px;
}
}
.captcha-dialog-subtitle {
margin: 0 0 16px;
color: var(--el-text-color-secondary);
font-size: 14px;
}
.captcha-box {
width: fit-content;
margin: 0 auto;
}
</style>
+20 -5
View File
@@ -1,14 +1,14 @@
<template>
<div class="welcome-page">
<div class="welcome-header">
<h2>欢迎回来 👋</h2>
<p>选择下方快捷入口开始管理你的博客</p>
<h2>{{ t('welcome.heading') }} 👋</h2>
<p>{{ t('welcome.subtitle') }}</p>
</div>
<div class="menu-sections">
<div class="menu-section" v-for="menu in menus" :key="menu.name">
<div class="section-title">
<el-icon><component :is="menu.icon" /></el-icon>
<span>{{ menu.title }}</span>
<span>{{ t(menu.titleKey) }}</span>
</div>
<div class="section-items">
<router-link
@@ -17,7 +17,7 @@
:key="submenu.path"
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>
</router-link>
</div>
@@ -26,7 +26,22 @@
</div>
</template>
<script setup lang="ts">
import menus from '../config/menu'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import allMenus from '../config/menu'
import type { MenuGroup } from '../config/menu'
import { hasPermission } from '@/utils/permission'
const { t } = useI18n()
const menus = computed((): MenuGroup[] => {
return allMenus
.map(group => ({
...group,
child: group.child.filter(item => hasPermission(item.permission))
}))
.filter(group => group.child.length > 0)
})
</script>
<style lang="less" scoped>
.welcome-page {
+27 -24
View File
@@ -1,50 +1,50 @@
<template>
<div class="page-wrapper">
<el-form inline :model="search">
<el-form-item label="内容">
<el-form-item :label="t('api.hitokoto.contentLabel')">
<el-input v-model="search.hitokoto" />
</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-option v-for="item in typeList" :key="item.value" :value="item.value" :label="item.label" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-form-item :label="t('api.hitokoto.createdAtLabel')">
<el-date-picker
v-model="search.createdAt"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期" />
:range-separator="t('api.hitokoto.rangeSeparator')"
:start-placeholder="t('api.hitokoto.startDate')"
:end-placeholder="t('api.hitokoto.endDate')" />
</el-form-item>
</el-form>
<div class="btn-container">
<el-button type="primary" @click="addModal = true">添加</el-button>
<el-button type="danger" @click="deleteAll">删除</el-button>
<el-button type="primary" @click="addModal = true" v-permission="'hitokoto:save'" plain>{{ t('common.add') }}</el-button>
<el-button type="danger" @click="deleteAll" v-permission="'hitokoto:delete'" plain>{{ t('api.hitokoto.deleteBtn') }}</el-button>
<div class="search-btn">
<el-button type="primary" @click="loadDataBase(true)" icon="Search">搜索</el-button>
<el-button @click="reset" icon="RefreshLeft">重置</el-button>
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
</div>
</div>
<el-table class="table-container" :data="hitokotoData" v-loading="loading" stripe @selection-change="dataSelect">
<el-table-column type="selection" width="55" />
<el-table-column prop="type" label="类型" width="180">
<el-table-column prop="type" :label="t('api.hitokoto.tableType')" width="180">
<template #default="scope">
{{ findTypeText(scope.row.type) }}
</template>
</el-table-column>
<el-table-column prop="hitokoto" label="内容" />
<el-table-column prop="from" label="来自" width="180"/>
<el-table-column prop="creator" label="作者" width="180"/>
<el-table-column prop="number" label="编号" width="70"/>
<el-table-column prop="createdAt" label="创建时间" width="180">
<el-table-column prop="hitokoto" :label="t('api.hitokoto.tableContent')" />
<el-table-column prop="from" :label="t('api.hitokoto.tableFrom')" width="180"/>
<el-table-column prop="creator" :label="t('api.hitokoto.tableCreator')" width="180"/>
<el-table-column prop="number" :label="t('api.hitokoto.tableNumber')" width="70"/>
<el-table-column prop="createdAt" :label="t('api.hitokoto.createdAtLabel')" width="180">
<template #default="scope">
{{ datetimeFormat(scope.row.createdAt) }}
</template>
</el-table-column>
</el-table>
<div class="page-container">
<el-pagination background
<el-pagination background
:page-sizes="store.state.pageSizeOpts"
:layout="store.state.pageLayout"
:current-page="search.pageNum"
@@ -53,12 +53,12 @@
@current-change="pageChange">
</el-pagination>
</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" />
<template #footer>
<span class="dialog-footer">
<el-button @click="addModal = false">取消</el-button>
<el-button type="primary" @click="save" :loading="modalLoading">确定</el-button>
<el-button @click="addModal = false" plain>{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="save" :loading="modalLoading" plain>{{ t('common.confirm') }}</el-button>
</span>
</template>
</el-dialog>
@@ -67,6 +67,7 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useStore } from 'vuex'
import { useI18n } from 'vue-i18n'
import HitokotoAdd from './HitokotoAdd.vue'
import { useBaseList } from '@/model/baselist'
import { Page } from '@/model/common.dto'
@@ -74,6 +75,8 @@ import HitokotoModel from '@/model/api/hitokoto'
import { ElMessage, ElMessageBox } from 'element-plus'
import http from '@/utils/http'
const { t } = useI18n()
class HitokotoPage extends Page {
hitokoto?: string
types?: string[]
@@ -121,19 +124,19 @@ async function save() {
const data = await http.post<any, any>('/hitokoto/save', formData)
modalLoading.value = false
addModal.value = false
ElMessage.success('保存成功')
ElMessage.success(t('common.saveSuccess'))
loadData()
Object.keys(formData).forEach(key => delete formData[key])
})
}
function deleteAll() {
if (!selectedData.length) {
ElMessage.warning('请选择要删除的数据')
ElMessage.warning(t('common.selectToDelete'))
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}})
ElMessage.success('删除成功')
ElMessage.success(t('common.deleteSuccess'))
loadData()
}).catch(() => {})
}
+13 -10
View File
@@ -1,27 +1,30 @@
<template>
<div>
<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-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-option v-for="item in typeList" :value="item.value" :key="item.value" :label="item.label"></el-option>
</el-select>
</el-form-item>
<el-form-item label="来自">
<el-form-item :label="t('api.hitokoto.formFrom')">
<el-input v-model="formData.from" />
</el-form-item>
<el-form-item label="作者">
<el-form-item :label="t('api.hitokoto.formCreator')">
<el-input v-model="formData.creator" />
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { VForm } from '@/types'
const { t } = useI18n()
defineProps<{
typeList: {label: string, value: string}[]
formData: {[propName: string]: string | null}
@@ -29,14 +32,14 @@ defineProps<{
const hitokotoForm = ref<VForm>()
const ruleValidate = {
const ruleValidate = computed(() => ({
hitokoto: [
{ required: true, message: '请输入内容', trigger: 'blur' }
{ required: true, message: t('api.hitokoto.contentRequired'), trigger: 'blur' }
],
type: [
{ required: true, message: '请选择类型', trigger: 'blur' }
{ required: true, message: t('api.hitokoto.typeRequired'), trigger: 'blur' }
],
}
}))
defineExpose({ hitokotoForm })
</script>
</script>
+163 -100
View File
@@ -1,53 +1,53 @@
<template>
<div class="page-wrapper">
<el-form inline :model="search">
<el-form-item label="名称/标题">
<el-form-item :label="t('api.music.searchNameTitle')">
<el-input v-model="search.title" />
</el-form-item>
<el-form-item label="所属歌单">
<el-select v-model="search.libIds" multiple collapse-tags>
<el-form-item :label="t('api.music.searchLibLabel')">
<el-select v-model="search.libIds" multiple collapse-tags clearable>
<el-option v-for="musicLib in musicLibs" :key="musicLib._id" :value="musicLib._id" :label="musicLib.name" />
</el-select>
</el-form-item>
<el-form-item label="文件类型">
<el-select v-model="search.exts" multiple >
<el-form-item :label="t('api.music.searchExtLabel')">
<el-select v-model="search.exts" multiple collapse-tags clearable>
<el-option v-for="ext in exts" :key="ext" :value="ext" :label="ext" />
</el-select>
</el-form-item>
<el-form-item label="唱片集">
<el-form-item :label="t('api.music.searchAlbumLabel')">
<el-input v-model="search.album" />
</el-form-item>
<el-form-item label="艺术家">
<el-form-item :label="t('api.music.searchArtistLabel')">
<el-input v-model="search.artist" />
</el-form-item>
</el-form>
<div class="btn-container">
<el-button type="success" plain icon="VideoPlay" @click="playMusic">播放</el-button>
<el-button type="primary" icon="Upload" @click="openUploadModal">上传音乐</el-button>
<el-button type="warning" plain icon="Notebook" @click="libDrawerVisible = true">歌单管理</el-button>
<el-button type="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>{{ t('api.music.uploadBtn') }}</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">
<el-button type="primary" @click="loadDataBase(true)" icon="Search">搜索</el-button>
<el-button @click="reset" icon="RefreshLeft">重置</el-button>
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
</div>
</div>
<el-table class="table-container" :data="musicData" v-loading="loading" stripe @selection-change="dataSelect">
<el-table-column type="selection" width="55" />
<el-table-column prop="name" label="名称" show-overflow-tooltip role=""/>
<el-table-column prop="ext" label="类型" width="80" />
<el-table-column prop="size" label="文件大小" width="110">
<el-table-column prop="name" :label="t('api.music.tableName')" show-overflow-tooltip role=""/>
<el-table-column prop="ext" :label="t('api.music.tableType')" width="80" />
<el-table-column prop="size" :label="t('api.music.tableSize')" width="110">
<template #default="scope">
{{ prettyBytes(scope.row.size) }}
</template>
</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">
{{ formatDuration(scope.row.time) }}
</template>
</el-table-column>
<el-table-column prop="title" label="标题" show-overflow-tooltip />
<el-table-column prop="album" label="唱片集" />
<el-table-column prop="artist" label="艺术家" />
<el-table-column prop="libId" label="所属歌单" >
<el-table-column prop="title" :label="t('api.music.tableTitle')" show-overflow-tooltip />
<el-table-column prop="album" :label="t('api.music.tableAlbum')" />
<el-table-column prop="artist" :label="t('api.music.tableArtist')" />
<el-table-column prop="libId" :label="t('api.music.tableLib')" >
<template #default="scope">
<template v-if="scope.row.isEditing && currentRow">
<el-select v-model="currentRow.libId">
@@ -62,7 +62,7 @@
</template>
</template>
</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">
<div style="width: 18px">
<Check v-if="scope.row.lyricId" />
@@ -70,16 +70,16 @@
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="150" >
<el-table-column :label="t('common.operation')" width="150" >
<template #default="scope">
<el-button link icon="Document" @click="updateLyric(scope.row)" title="歌词"></el-button>
<el-button link icon="Download" @click="download(scope.row)" title="下载"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除"></el-button>
<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="t('api.music.actionDownload')"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" :title="t('common.delete')" v-permission="'music:delete'"></el-button>
</template>
</el-table-column>
</el-table>
<div class="page-container">
<el-pagination background
<el-pagination background
:page-sizes="store.state.pageSizeOpts"
:layout="store.state.pageLayout"
:current-page="search.pageNum"
@@ -88,29 +88,29 @@
@current-change="pageChange">
</el-pagination>
</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-item label="网易云ID" prop="cloudId">
<el-form-item :label="t('api.music.formCloudId')" prop="cloudId">
<el-input v-model="lyricFormData.cloudId" />
</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-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-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="modifyLyricModal = false">取消</el-button>
<el-button type="primary" @click="saveLyric" :loading="modalLoading">确定</el-button>
<el-button @click="modifyLyricModal = false" plain>{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="saveLyric" :loading="modalLoading" plain>{{ t('common.confirm') }}</el-button>
</span>
</template>
</el-dialog>
<el-dialog v-model="uploadModal" title="上传音乐" :width="550" @closed="uploadModalClosed" >
<el-form :label-width="80">
<el-form-item label="歌单">
<el-select v-model="libIdSelected">
<el-dialog v-model="uploadModal" :title="t('api.music.uploadTitle')" :width="550" @closed="uploadModalClosed" >
<el-form ref="uploadForm" :model="uploadFormData" :rules="uploadRules" :label-width="80">
<el-form-item :label="t('api.music.formLib')" prop="libId">
<el-select v-model="uploadFormData.libId">
<el-option v-for="musicLib in musicLibs" :key="musicLib._id" :value="musicLib._id" :label="musicLib.name" />
</el-select>
</el-form-item>
@@ -118,70 +118,89 @@
ref="musicUpload"
:action="`${apiBase}/music/upload`"
name="file"
accept=".mp3,.flac"
:accept="uploadAccept"
:headers="{token: store.state.loginInfo.token}"
:on-success="uploadSuccess"
:on-error="uploadError"
:on-error="uploadError"
:auto-upload="false"
multiple
drag
:data="{libId: libIdSelected}">
:data="{libId: uploadFormData.libId}">
<div class="el-upload__text">
拖拽到此处或<em>点击上传</em>
{{ uploadDragParts.before }}<em>{{ uploadDragParts.after }}</em>
</div>
<template #tip>
<div class="el-upload__tip">
可选择 mp3/flac 文件
<div class="el-upload__tip" style="display: flex; align-items: center; gap: 5px;">
<span>{{ 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>
</template>
</el-upload>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="uploadModal = false">取消</el-button>
<el-button type="primary" @click="uploadMusic" >开始上传</el-button>
<el-button @click="uploadModal = false" plain>{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="uploadMusic" plain>{{ t('api.music.startUpload') }}</el-button>
</span>
</template>
</el-dialog>
<el-drawer v-model="libDrawerVisible" title="歌单管理" size="900px">
<el-button type="primary" icon="Plus" @click="addLibRow" style="margin-bottom: 12px">添加歌单</el-button>
<el-drawer v-model="libDrawerVisible" :title="t('api.music.libDrawerTitle')" size="900px">
<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-column prop="name" label="名称">
<el-table-column prop="name" :label="t('api.music.tableName')">
<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>
</template>
</el-table-column>
<el-table-column prop="path" label="路径">
<el-table-column prop="path" :label="t('api.music.tablePath')">
<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>
</template>
</el-table-column>
<el-table-column prop="musicCount" label="歌曲数量" width="100" />
<el-table-column label="操作" width="160">
<el-table-column prop="musicCount" :label="t('api.music.tableMusicCount')" width="100" />
<el-table-column :label="t('common.operation')" width="160">
<template #default="scope">
<el-button v-if="scope.row._isNew" link type="primary" icon="Check" @click="saveLib(scope.row)" :loading="libSaving">保存</el-button>
<el-button v-if="scope.row._isNew" link icon="Close" @click="cancelAddLib">取消</el-button>
<el-button v-if="!scope.row._isNew" link type="danger" icon="Delete" @click="removeLib(scope.row)">删除</el-button>
<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">{{ t('common.cancel') }}</el-button>
<el-button v-if="!scope.row._isNew" link type="danger" icon="Delete" @click="removeLib(scope.row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
<el-drawer v-model="musicPlaying" :close-on-click-modal="false" size="40%" title="播放音乐">
<a-player v-if="musicPlaying" ref="player" autoplay showLrc :list="musicList" v-model:music="currentMusic" @play="musicPlay"/>
<el-drawer v-model="musicPlaying" :close-on-click-modal="false" size="1200px" :title="t('api.music.playDrawerTitle')" class="music-drawer">
<music-player-panel
v-if="musicPlaying && currentMusic"
ref="player"
autoplay
:music="currentMusic"
:list="musicList"
@update:music="currentMusic = $event"
@play="musicPlay"
@toggle="player?.toggle()"
@prev="switchPrev"
@next="switchNext"
@toggle-shuffle="player && (player.shuffle = !player.shuffle)"
@next-mode="cycleRepeatMode"
@toggle-mute="togglePlayerMute"
@select-song="onSelectSong"
/>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch, computed } from 'vue'
import { useStore } from 'vuex'
import { useI18n } from 'vue-i18n'
import { useBaseList } from '@/model/baselist'
import { MsgResult, Page } from '@/model/common.dto'
import { UploadInstance, ElMessage, ElMessageBox } from 'element-plus'
import { MusicModel, MusicLibModel, MusicLyricModel, MusicPlayerItem } from '@/model/api/music'
import APlayer from './aplayer/vue-aplayer.vue'
import MusicPlayerPanel from './aplayer/music-player-panel.vue'
import prettyBytes from 'pretty-bytes'
// 格式化秒为 mm:ss
function formatDuration(seconds?: number): string {
@@ -193,6 +212,8 @@ function formatDuration(seconds?: number): string {
import type { VForm } from '@/types'
import http from '@/utils/http'
const { t } = useI18n()
class MusicPage extends Page {
exts: string[] = []
title?: string
@@ -210,10 +231,11 @@ class MusicPage extends Page {
}
const store = useStore()
const apiBase = process.env.VUE_APP_API_BASE as string
const apiBase = import.meta.env.VITE_APP_API_BASE
const { loading, modalLoading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange } = useBaseList(new MusicPage())
const currentRow = ref<MusicModel | null>(null)
const libIdSelected = ref<string | null>(null)
const uploadFormData = ref<{ libId: string | null }>({ libId: null })
const uploadForm = ref<VForm>()
const exts = ref<string[]>([])
const musicLibs = ref<MusicLibModel[]>([])
const musicData = ref<MusicModel[]>([])
@@ -225,26 +247,51 @@ const musicList = ref<MusicPlayerItem[]>([])
const currentMusic = ref<MusicPlayerItem>()
const lyricForm = ref<VForm>()
const musicUpload = ref<UploadInstance>()
const player = ref<any>()
const player = ref<InstanceType<typeof MusicPlayerPanel>>()
const lyricLoading = ref(false)
const libDrawerVisible = ref(false)
const libTableData = ref<(MusicLibModel & { _isNew?: boolean })[]>([])
const libSaving = ref(false)
const 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: [
{ required: true, message: '请输入网易云ID', trigger: 'blur' }
{ required: true, message: t('api.music.cloudIdRequired'), trigger: 'blur' }
],
name: [
{ required: true, message: '请输入名称', trigger: 'blur' }
{ required: true, message: t('api.music.nameRequired'), trigger: 'blur' }
],
lyric: [
{ required: true, message: '请输入歌词正文', trigger: 'blur' }
{ required: true, message: t('api.music.lyricRequired'), trigger: 'blur' }
],
}
}))
let selectedIds: string[] = []
watch(() => currentMusic.value, async (newMusic) => {
if (newMusic && !newMusic.lrc) {
const { lyric } = await http.get<any, any>('/music/lyric/get', {params: {lyricId: newMusic.lyricId}})
newMusic.lrc = lyric
}
})
async function loadData() {
loading.value = true
const data = await http.get<MusicPage, any>('/music/list', {params: search})
@@ -273,9 +320,7 @@ async function playMusic() {
album: item.album,
src: `${apiBase}/common/music/load/${item._id}`,
pic: `${apiBase}/common/music/album/${item._id}`,
}
if (item.lyricId) {
musicItem.lrc = `${apiBase}/common/music/lyric/${item.lyricId}`
lyricId: item.lyricId,
}
return musicItem
})
@@ -283,7 +328,7 @@ async function playMusic() {
musicPlaying.value = true
} catch (err) {
console.error(err)
ElMessage.error('获取播放列表失败')
ElMessage.error(t('api.music.getPlaylistFailed'))
}
}
function updateLib(row: MusicModel) {
@@ -298,9 +343,9 @@ function download(row: MusicModel) {
link.click()
}
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}})
ElMessage.success("删除成功")
ElMessage.success(t('common.deleteSuccess'))
loadData()
}).catch(() => {})
}
@@ -326,7 +371,7 @@ async function saveLyric() {
await http.post<MusicLyricModel, any>(`/music/lyric/save?musicId=${currentRow.value ? currentRow.value._id : ''}`, lyricFormData.value)
modalLoading.value = false
modifyLyricModal.value = false
ElMessage.success("歌词保存成功")
ElMessage.success(t('api.music.lyricSaveSuccess'))
loadData()
lyricFormData.value = {}
})
@@ -334,27 +379,26 @@ async function saveLyric() {
async function saveMusicLib(row: MusicModel) {
if (!currentRow.value) return
await http.post<{id: string, libId: string}, any>('/music/lib/update', {id: currentRow.value._id, libId: currentRow.value.libId})
ElMessage.success("歌单更新成功")
ElMessage.success(t('api.music.libUpdateSuccess'))
row.libId = currentRow.value.libId
row.isEditing = false
}
function openUploadModal() {
uploadModal.value = true
libIdSelected.value = null
uploadFormData.value = { libId: null }
}
async function uploadMusic() {
if (!libIdSelected.value) {
ElMessage.warning('请选择歌单')
return
}
musicUpload.value?.submit()
uploadForm.value?.validate((valid: boolean) => {
if (!valid) return
musicUpload.value?.submit()
})
}
function uploadSuccess(response: MsgResult) {
if (response.code === 0) {
ElMessage.success(response.message)
ElMessage.success(t('common.uploadSuccess'))
loadData()
} else {
ElMessage.warning(response.message)
ElMessage.warning(response.message || t('common.uploadFailed'))
}
}
function uploadError(error: Error) {
@@ -373,26 +417,45 @@ function musicPlay() {
artwork: [{src: location.origin + currentMusic.value.pic}]
})
navigator.mediaSession.setActionHandler('play', () => {
player.value.play()
void player.value?.play()
})
navigator.mediaSession.setActionHandler('pause', () => {
player.value.pause()
player.value?.pause()
})
navigator.mediaSession.setActionHandler('previoustrack', () => {
if (currentId === 0) {
player.value.switch(musicList.value.length - 1)
} else {
player.value.switch(currentId - 1)
}
switchPrev()
})
navigator.mediaSession.setActionHandler('nexttrack', () => {
if (currentId === musicList.value.length - 1) {
player.value.switch(0)
} else {
player.value.switch(currentId + 1)
}
switchNext()
})
}
function switchPrev() {
if (!currentMusic.value) return
const idx = musicList.value.indexOf(currentMusic.value)
const prevIdx = idx <= 0 ? musicList.value.length - 1 : idx - 1
player.value?.switch(prevIdx)
}
function switchNext() {
if (!currentMusic.value) return
const idx = musicList.value.indexOf(currentMusic.value)
const nextIdx = idx >= musicList.value.length - 1 ? 0 : idx + 1
player.value?.switch(nextIdx)
}
function cycleRepeatMode() {
if (!player.value) return
if (player.value.repeatMode === 'no_repeat') player.value.repeatMode = 'repeat_all'
else if (player.value.repeatMode === 'repeat_all') player.value.repeatMode = 'repeat_one'
else player.value.repeatMode = 'no_repeat'
}
function togglePlayerMute() {
if (!player.value?.audio) return
const audio = player.value.audio as unknown as HTMLAudioElement
audio.muted = !audio.muted
}
function onSelectSong(song: MusicPlayerItem) {
const idx = musicList.value.indexOf(song)
if (idx >= 0) player.value?.switch(idx)
}
function loadLibs() {
return http.get<never, any>('/music/lib/list').then(data => {
@@ -409,22 +472,22 @@ function cancelAddLib() {
}
async function saveLib(row: MusicLibModel & { _isNew?: boolean }) {
if (!row.name || !row.path) {
ElMessage.warning('请输入歌单名称和路径')
ElMessage.warning(t('api.music.libNamePathRequired'))
return
}
libSaving.value = true
try {
await http.post<any, any>('/music/lib/add', { name: row.name, path: row.path })
ElMessage.success('歌单创建成功')
ElMessage.success(t('api.music.libCreateSuccess'))
await loadLibs()
} finally {
libSaving.value = false
}
}
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 } })
ElMessage.success('歌单删除成功')
ElMessage.success(t('api.music.libDeleteSuccess'))
await loadLibs()
}).catch(() => {})
}
@@ -436,4 +499,4 @@ loadLibs().then(() => {
http.get<never, any>('/music/listExts').then(data => {
exts.value = data
})
</script>
</script>
+430 -126
View File
@@ -1,103 +1,179 @@
<template>
<div class="page-wrapper">
<el-alert type="info" show-icon :closable="false" style="margin-bottom: 10px" >
<template #title>上传要求</template>
图片格式为{{allowUploadExt.join('、')}}文件大小不超过10MB
</el-alert>
<el-form inline :model="search">
<el-form-item label="文件名">
<el-input v-model="search.name" />
</el-form-item>
<el-form-item label="宽度" >
<el-input v-model="search.widthMin" type="number" :min="0" >
<template #prepend></template>
</el-input>
</el-form-item>
<el-form-item>
<el-input v-model="search.widthMax" type="number" :min="0" >
<template #prepend></template>
</el-input>
</el-form-item>
<el-form-item label="高度" >
<el-input v-model="search.heightMin" type="number" :min="0" >
<template #prepend></template>
</el-input>
</el-form-item>
<el-form-item>
<el-input v-model="search.heightMax" type="number" :min="0" >
<template #prepend></template>
</el-input>
</el-form-item>
</el-form>
<div class="btn-container">
<el-upload
:action="`${apiBase}/photoWall/upload`"
accept="image/jpeg,image/png"
name="image"
:headers="{token: store.state.loginInfo.token}"
:before-upload="beforeUpload"
:on-success="uploadSuccess"
:on-error="uploadError"
auto-upload
:show-file-list="false"
style="display: inline-block;margin-right: 10px;">
<el-button type="primary" icon="Upload" :loading="isUploading">上传图片</el-button>
</el-upload>
<el-button type="danger" @click="deleteAll" style="vertical-align: bottom">批量删除</el-button>
<div class="search-btn">
<el-button type="primary" @click="loadDataBase(true)" icon="Search">搜索</el-button>
<el-button @click="reset" icon="RefreshLeft">重置</el-button>
<div class="page-wrapper" ref="rootEl">
<!-- 搜索栏 -->
<el-form inline :model="search" @submit.prevent>
<el-form-item :label="t('api.photoWall.searchFileName')">
<el-input v-model="search.name" />
</el-form-item>
<el-form-item :label="t('api.photoWall.searchWidth')">
<el-input-number v-model="search.widthMin" :min="0" controls-position="right" step-strictly>
<template #prefix></template>
<template #suffix>px</template>
</el-input-number>
<el-input-number v-model="search.widthMax" :min="0" controls-position="right" step-strictly style="margin-left: 8px;">
<template #prefix></template>
<template #suffix>px</template>
</el-input-number>
</el-form-item>
<el-form-item :label="t('api.photoWall.searchHeight')">
<el-input-number v-model="search.heightMin" :min="0" controls-position="right" step-strictly>
<template #prefix></template>
<template #suffix>px</template>
</el-input-number>
<el-input-number v-model="search.heightMax" :min="0" controls-position="right" step-strictly style="margin-left: 8px;">
<template #prefix></template>
<template #suffix>px</template>
</el-input-number>
</el-form-item>
</el-form>
<!-- 操作栏 -->
<div class="btn-container">
<el-upload
:action="`${apiBase}/photoWall/upload`"
accept="image/jpeg,image/png"
name="image"
:headers="{ token: store.state.loginInfo.token }"
:before-upload="beforeUpload"
:on-success="uploadSuccess"
:on-error="uploadError"
auto-upload
:show-file-list="false"
style="margin-right: 10px;">
<el-tooltip :content="t('api.photoWall.uploadTooltip', { exts: allowUploadExt.join('\u3001') })">
<el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'photoWall:save'" plain>{{ t('api.photoWall.uploadBtn') }}</el-button>
</el-tooltip>
</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 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" />
</div>
<el-table class="table-container" :data="photowallData" v-loading="loading" stripe @selection-change="dataSelect">
<el-table-column type="selection" width="55" />
<el-table-column prop="name" label="文件名" />
<el-table-column prop="md5" label="md5" width="300" />
<el-table-column prop="thumbnail" label="缩略图" />
<el-table-column prop="width" label="宽度" width="70" />
<el-table-column prop="height" label="高度" width="70" />
<el-table-column label="操作" width="100" >
<template #default="scope">
<el-button link icon="View" plain @click="preview(scope.row)" title="预览"></el-button>
</template>
</el-table-column>
</el-table>
<div class="page-container">
<el-pagination background
:page-sizes="store.state.pageSizeOpts"
:layout="store.state.pageLayout"
:current-page="search.pageNum"
:total="total"
@size-change="pageSizeChange"
@current-change="pageChange">
</el-pagination>
</div>
<el-image-viewer
v-if="previewVisible"
:url-list="previewUrlList"
:initial-index="previewIndex"
@close="previewVisible = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useStore } from 'vuex'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { MsgResult, Page } from '@/model/common.dto'
import type { MsgResult } from '@/model/common.dto'
import { Page } from '@/model/common.dto'
import { useBaseList } from '@/model/baselist'
import PhotoWallModel from '@/model/api/photowall'
import type PhotoWallModel from '@/model/api/photowall'
import http from '@/utils/http'
class PhotoWallPage extends Page {
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
widthMin!: number | null
widthMax!: number | null
heightMin!: number | null
heightMax!: number | null
widthMin?: number | null
widthMax?: number | null
heightMin?: number | null
heightMax?: number | null
constructor() { super(); this.limit = 50 }
reset() {
super.reset()
this.limit = 50
this.name = undefined
this.widthMin = null
this.widthMax = null
@@ -105,73 +181,301 @@ class PhotoWallPage extends Page {
this.heightMax = null
}
}
const { loading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange } = useBaseList(new GalleryPage())
const store = useStore()
const apiBase = process.env.VUE_APP_API_BASE as string
const { loading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange } = useBaseList(new PhotoWallPage())
const allowUploadExt = ['jpg', 'jpeg', 'png']
const photowallData = ref<PhotoWallModel[]>([])
const isUploading = ref(false)
const cdnBase = ref('')
const selected = ref<Set<string>>(new Set())
const entered = ref<Set<string>>(new Set())
const previewVisible = ref(false)
const previewUrlList = ref<string[]>([])
const previewUrls = ref<string[]>([])
const previewIndex = ref(0)
let selectedData: string[] = []
// --- 瀑布流分列 ---
const colCount = ref(4)
function recalcCols() {
if (!rootEl.value) return
const w = rootEl.value.clientWidth - 32
colCount.value = Math.max(1, Math.floor((w + COL_GAP) / (COLUMN_WIDTH + COL_GAP)))
}
const columns = computed<PhotoWallModel[][]>(() => {
const cols: PhotoWallModel[][] = Array.from({ length: colCount.value }, () => [])
photos.value.forEach((item, i) => {
cols[i % colCount.value].push(item)
})
return cols
})
// --- 数据加载 ---
async function loadData() {
loading.value = true
const data = await http.get<PhotoWallPage, any>('/photoWall/list', {params: search})
selectedData = []
loading.value = false
total.value = data.total
photowallData.value = data.list
selected.value = new Set()
entered.value = new Set()
try {
if (!cdnBase.value) {
cdnBase.value = await http.get('/common/config/picture_cdn')
}
const data = await http.get<any, any>('/photoWall/list', { params: 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)
function deleteAll() {
if (!selectedData || !selectedData.length) {
ElMessage.warning('请选择要删除的数据')
return
}
ElMessageBox.confirm(`是否确认删除选中的${selectedData.length}条数据?`, '确认删除', {type: 'warning'}).then(async () => {
await http.delete('/photoWall/delete', {params: {ids: selectedData}})
ElMessage.success('删除成功')
loadData()
}).catch(() => {})
// --- 选中 ---
function toggleSelect(item: PhotoWallModel) {
const s = new Set(selected.value)
s.has(item._id) ? s.delete(item._id) : s.add(item._id)
selected.value = s
}
function dataSelect(selection: PhotoWallModel[]) {
selectedData = selection.map(item => item._id)
function selectAll() {
selected.value = new Set(photos.value.map(p => p._id))
}
function invertSelect() {
const s = new Set<string>()
photos.value.forEach(p => {
if (!selected.value.has(p._id)) s.add(p._id)
})
selected.value = s
}
function clearSelect() {
selected.value = new Set()
}
const isAllSelected = computed(() => photos.value.length && selected.value.size === photos.value.length)
// --- 预览 ---
function preview(item: PhotoWallModel) {
previewIndex.value = photos.value.findIndex(p => p._id === item._id) || 0
previewVisible.value = true
}
// --- 删除 ---
async function deleteSingle(item: PhotoWallModel) {
await ElMessageBox.confirm(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 {
if (file.size > 10 << 20) {
ElMessage.warning('文件大小超过10MB')
ElMessage.warning(t('common.fileSizeExceeded'))
return false
}
isUploading.value = true
return true
}
function uploadSuccess(response: MsgResult) {
isUploading.value = false
if (response.code === 0) {
ElMessage.success(response.message)
ElMessage.success(t('common.uploadSuccess'))
loadData()
} else {
ElMessage.warning(response.message)
ElMessage.warning(response.message || t('common.uploadFailed'))
}
isUploading.value = false
}
function uploadError(error: Error) {
isUploading.value = false
ElMessage.error(error.message)
}
async function preview(row: PhotoWallModel) {
const pictureCdn = await http.get('/common/config/picture_cdn')
const index = photowallData.value.findIndex(item => item._id === row._id)
previewUrlList.value = photowallData.value.map(item => `${pictureCdn}/${item.name}`)
previewIndex.value = index >= 0 ? index : 0
previewVisible.value = true
// --- 响应式列数 ---
let resizeObserver: ResizeObserver | null = null
onMounted(async () => {
recalcCols()
resizeObserver = new ResizeObserver(() => recalcCols())
if (rootEl.value) resizeObserver.observe(rootEl.value)
await loadData()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
</script>
<style scoped lang="less">
// --- 可滚动瀑布流区域 ---
.masonry-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px;
}
// created
loadData()
</script>
.masonry-scene {
display: flex;
gap: 14px;
align-items: flex-start;
}
.masonry-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
// --- 卡片外壳(选中描边) ---
.card-wrap {
opacity: 0;
transform: translateY(20px);
border-radius: 10px;
outline: 2px solid transparent;
outline-offset: 2px;
transition:
opacity 0.35s ease,
transform 0.35s ease,
outline-color 0.2s ease;
cursor: pointer;
&.is-entered {
opacity: 1;
transform: translateY(0);
}
&.is-selected {
outline-color: var(--el-color-primary);
.check-badge {
opacity: 1;
transform: scale(1);
}
}
&:hover .card-overlay {
opacity: 1;
transform: translateY(0);
}
&:hover .overlay-info {
opacity: 1;
transform: translateY(0);
}
&:hover .overlay-actions {
opacity: 1;
transform: translateY(0);
}
}
// --- 卡片内容 ---
.card-inner {
position: relative;
width: 100%;
min-height: 80px;
border-radius: 10px;
overflow: hidden;
background: var(--el-fill-color-light);
}
.card-img {
display: block;
width: 100%;
height: auto;
border-radius: 10px;
transition: transform 0.4s ease;
.card-wrap:hover & {
transform: scale(1.04);
}
}
// --- 选中角标 ---
.check-badge {
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--el-color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
opacity: 0;
transform: scale(0.5);
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
// --- Hover 浮层 ---
.card-overlay {
position: absolute;
inset: 0;
border-radius: 10px;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.82) 0%,
rgba(0, 0, 0, 0.4) 55%,
rgba(0, 0, 0, 0.08) 100%
);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 12px;
box-sizing: border-box;
opacity: 0;
transform: translateY(6px);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.overlay-info {
opacity: 0;
transform: translateY(8px);
transition:
opacity 0.3s ease 0.04s,
transform 0.3s ease 0.04s;
.overlay-name {
margin: 0 0 3px;
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
word-break: break-all;
display: -webkit-box;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.overlay-size {
margin: 0 0 10px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.5px;
}
}
.overlay-actions {
display: flex;
justify-content: center;
opacity: 0;
transform: translateY(8px);
transition:
opacity 0.3s ease 0.08s,
transform 0.3s ease 0.08s;
}
</style>
+27 -26
View File
@@ -1,10 +1,6 @@
<template>
<div class="page-wrapper">
<el-alert type="info" show-icon :closable="false" >
<template #title>上传要求</template>
图片格式为{{allowUploadExt.join('、')}}文件大小不超过10MB
</el-alert>
<div class="btn-container" style="margin-top:10px;">
<div class="btn-container">
<el-upload
:action="`${apiBase}/source-image/upload`"
accept="image/jpeg,image/png,image/svg+xml,image/x-icon"
@@ -12,42 +8,44 @@
:headers="{token: store.state.loginInfo.token}"
:before-upload="beforeUpload"
:on-success="uploadSuccess"
:on-error="uploadError"
:on-error="uploadError"
auto-upload
:show-file-list="false"
style="display: inline-block;margin-right: 10px;">
<el-button type="primary" icon="Upload" :loading="isUploading">上传图片</el-button>
style="margin-right: 10px;">
<el-tooltip :content="t('api.sourceImage.uploadTooltip', { exts: allowUploadExt.join('\u3001') })">
<el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'sourceImage:save'" plain>{{ t('api.sourceImage.uploadBtn') }}</el-button>
</el-tooltip>
</el-upload>
<el-button type="danger" @click="deleteAll" style="vertical-align: bottom">批量删除</el-button>
<el-button type="danger" @click="deleteAll" style="vertical-align: bottom" v-permission="'sourceImage:delete'" plain>{{ t('api.sourceImage.batchDeleteBtn') }}</el-button>
</div>
<el-table class="table-container" :data="sourceImageData" v-loading="loading" stripe @selection-change="dataSelect">
<el-table-column type="selection" width="55" />
<el-table-column prop="hash" label="md5" width="300" />
<el-table-column prop="size" label="文件大小" >
<el-table-column prop="size" :label="t('api.sourceImage.tableSize')" >
<template #default="scope">
{{ prettyBytes(scope.row.size) }}
</template>
</el-table-column>
<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">
<el-tag v-for="label in scope.row.label" :key="label" effect="plain">{{label}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="上传时间" >
<el-table-column prop="createdAt" :label="t('api.sourceImage.tableUploadTime')" >
<template #default="scope">
{{ datetimeFormat(scope.row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" >
<el-table-column :label="t('common.operation')" >
<template #default="scope">
<el-button link icon="EditPen" type="primary" plain @click="modifyTags(scope.row)" title="修改标签"></el-button>
<el-button link icon="View" plain @click="preview(scope.row)" title="预览"></el-button>
<el-button link icon="EditPen" type="primary" @click="modifyTags(scope.row)" :title="t('api.sourceImage.modifyTagsBtn')" v-permission="'sourceImage:save'"></el-button>
<el-button link icon="View" @click="preview(scope.row)" :title="t('api.sourceImage.previewBtn')"></el-button>
</template>
</el-table-column>
</el-table>
<div class="page-container">
<el-pagination background
<el-pagination background
:page-sizes="store.state.pageSizeOpts"
:layout="store.state.pageLayout"
:current-page="search.pageNum"
@@ -56,12 +54,12 @@
@current-change="pageChange">
</el-pagination>
</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
v-model="curModifyLabels"
filterable
:render-content="renderFunc"
:titles="['可选标签', '已选标签']"
:titles="[t('api.sourceImage.availableLabels'), t('api.sourceImage.selectedLabels')]"
:format="{
noChecked: '${total}',
hasChecked: '${checked}/${total}',
@@ -81,6 +79,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useStore } from 'vuex'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import prettyBytes from 'pretty-bytes'
import { MsgResult, Page } from '@/model/common.dto'
@@ -88,8 +87,10 @@ import { useBaseList } from '@/model/baselist'
import { SourceImageModel } from '@/model/api/source-image'
import http from '@/utils/http'
const { t } = useI18n()
const store = useStore()
const apiBase = process.env.VUE_APP_API_BASE as string
const apiBase = import.meta.env.VITE_APP_API_BASE
const { loading, total, search, setLoadData, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new Page())
const allowUploadExt = ['jpg', 'jpeg', 'png', 'svg', 'ico']
@@ -126,12 +127,12 @@ setLoadData(loadData)
function deleteAll(): void {
if (!selectedData.length) {
ElMessage.warning('请选择要删除的数据')
ElMessage.warning(t('common.selectToDelete'))
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}})
ElMessage.success('删除成功')
ElMessage.success(t('common.deleteSuccess'))
loadData()
}).catch(() => {})
}
@@ -140,7 +141,7 @@ function dataSelect(selection: SourceImageModel[]): void {
}
function beforeUpload(file: File): boolean {
if (file.size > 10 << 20) {
ElMessage.warning('文件大小超过10MB')
ElMessage.warning(t('common.fileSizeExceeded'))
return false
}
isUploading.value = true
@@ -148,10 +149,10 @@ function beforeUpload(file: File): boolean {
}
function uploadSuccess(response: MsgResult): void {
if (response.code === 0) {
ElMessage.success(response.message)
ElMessage.success(t('common.uploadSuccess'))
loadData()
} else {
ElMessage.warning(response.message)
ElMessage.warning(response.message || t('common.uploadFailed'))
}
isUploading.value = false
}
@@ -182,4 +183,4 @@ http.get<never, any>('/common/config/image_label').then(data => {
labelList.value.push(...data)
loadData()
})
</script>
</script>
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
<path fill="#FFF" d="M4 16c0-6.6 5.4-12 12-12s12 5.4 12 12c0 1.2-0.8 2-2 2s-2-0.8-2-2c0-4.4-3.6-8-8-8s-8 3.6-8 8 3.6 8 8 8c1.2 0 2 0.8 2 2s-0.8 2-2 2c-6.6 0-12-5.4-12-12z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 265 B

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

Before

Width:  |  Height:  |  Size: 590 B

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

Before

Width:  |  Height:  |  Size: 473 B

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

Before

Width:  |  Height:  |  Size: 357 B

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

Before

Width:  |  Height:  |  Size: 315 B

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

Before

Width:  |  Height:  |  Size: 278 B

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

Before

Width:  |  Height:  |  Size: 240 B

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

Before

Width:  |  Height:  |  Size: 294 B

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

Before

Width:  |  Height:  |  Size: 272 B

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

Before

Width:  |  Height:  |  Size: 585 B

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

Before

Width:  |  Height:  |  Size: 604 B

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

Before

Width:  |  Height:  |  Size: 301 B

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,153 +0,0 @@
<template>
<div
class="aplayer-bar-wrap"
@mousedown="onThumbMouseDown"
ref="barWrap"
>
<div class="aplayer-bar">
<div
class="aplayer-loaded"
:style="{width: `${loadProgress * 100}%`}">
</div>
<div
class="aplayer-played"
:style="{width: `${playProgress * 100}%`, background: theme}"
>
<span
ref="thumb"
@mouseover="thumbHovered = true"
@mouseout="thumbHovered = false"
class="aplayer-thumb"
:style="{borderColor: theme, backgroundColor: thumbHovered ? theme : '#fff'}"
>
<span class="aplayer-loading-icon" :style="{backgroundColor: theme }">
<player-icon type="loading"/>
</span>
</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { getElementViewLeft } from '../utils'
import PlayerIcon from './aplayer-icon.vue'
defineProps<{
loadProgress: number
playProgress: number
theme?: string
}>()
const emit = defineEmits(['dragbegin', 'dragging', 'dragend'])
const thumbHovered = ref(false)
const barWrap = ref<unknown>(null)
const onThumbMouseDown = (e: MouseEvent) => {
const barWidth = (<HTMLElement>barWrap.value).clientWidth
let percentage = (e.clientX - getElementViewLeft(<HTMLElement>barWrap.value)) / barWidth
percentage = percentage > 0 ? percentage : 0
percentage = percentage < 1 ? percentage : 1
emit('dragbegin', percentage)
document.addEventListener('mousemove', onDocumentMouseMove)
document.addEventListener('mouseup', onDocumentMouseUp)
}
const onDocumentMouseMove = (e: MouseEvent) => {
const barWidth = (<HTMLElement>barWrap.value).clientWidth
let percentage = (e.clientX - getElementViewLeft(<HTMLElement>barWrap.value)) / barWidth
percentage = percentage > 0 ? percentage : 0
percentage = percentage < 1 ? percentage : 1
emit('dragging', percentage)
}
const onDocumentMouseUp = (e: MouseEvent) => {
document.removeEventListener('mouseup', onDocumentMouseUp)
document.removeEventListener('mousemove', onDocumentMouseMove)
const barWidth = (<HTMLElement>barWrap.value).clientWidth
let percentage = (e.clientX - getElementViewLeft(<HTMLElement>barWrap.value)) / barWidth
percentage = percentage > 0 ? percentage : 0
percentage = percentage < 1 ? percentage : 1
emit('dragend', percentage)
}
</script>
<style lang="less" scoped>
.aplayer-bar-wrap {
margin: 0 0 0 5px;
padding: 4px 0;
cursor: pointer;
flex: 1;
.aplayer-bar {
position: relative;
height: 2px;
width: 100%;
background: #cdcdcd;
.aplayer-loaded {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background: #aaa;
height: 2px;
transition: all 0.5s ease;
will-change: width;
}
.aplayer-played {
position: absolute;
left: 0;
top: 0;
bottom: 0;
height: 2px;
transition: background-color .3s;
will-change: width;
.aplayer-thumb {
position: absolute;
top: 0;
right: 5px;
margin-top: -5px;
margin-right: -10px;
width: 10px;
height: 10px;
border: 1px solid;
transform: scale(.8);
will-change: transform;
transition: transform 300ms, background-color .3s, border-color .3s;
border-radius: 50%;
background: #fff;
cursor: pointer;
&:hover {
transform: scale(1);
}
overflow: hidden;
.aplayer-loading-icon {
display: none;
width: 100%;
height: 100%;
.icon-svg {
position: absolute;
animation: spin 1s linear infinite;
}
}
}
}
}
}
.aplayer-loading {
.aplayer-bar-wrap .aplayer-bar .aplayer-thumb .aplayer-loading-icon {
display: block;
}
.aplayer-info .aplayer-controller .aplayer-bar-wrap .aplayer-bar .aplayer-played .aplayer-thumb {
transform: scale(1);
}
}
@keyframes spin {
0% {
transform: rotate(0)
}
100% {
transform: rotate(360deg)
}
}
</style>
@@ -1,117 +0,0 @@
<template>
<div class="aplayer-volume-wrap">
<icon-button
:class="`aplayer-icon-${volumeIcon}`"
:icon="volumeIcon"
@click="$emit('togglemute')"
/>
<div
class="aplayer-volume-bar-wrap"
@mousedown="onBarMouseDown"
>
<div class="aplayer-volume-bar" ref="bar">
<div
class="aplayer-volume"
:style="{
height: muted ? 0 : `${Math.trunc(volume * 100)}%`,
background: theme
}"
>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import IconButton from './aplayer-iconbutton.vue'
import {getElementViewTop} from '../utils'
const props = defineProps<{
volume: number
muted: boolean
theme: string
}>()
const emit = defineEmits(['setvolume'])
const barHeight = 40
const volumeIcon = computed(() => {
if (props.muted || props.volume <= 0) return 'volume_off'
if (props.volume >= 1) return 'volume_up'
return 'volume_down'
})
const bar = ref<unknown>(null)
const onBarMouseDown = () => {
document.addEventListener('mousemove', onDocumentMouseMove)
document.addEventListener('mouseup', onDocumentMouseUp)
}
const onDocumentMouseMove = (e: MouseEvent) => {
let percentage = (barHeight - e.clientY + getElementViewTop(<HTMLElement>bar.value)) / barHeight
percentage = percentage > 0 ? percentage : 0
percentage = percentage < 1 ? percentage : 1
emit('setvolume', percentage)
}
const onDocumentMouseUp = (e: MouseEvent) => {
document.removeEventListener('mouseup', onDocumentMouseUp)
document.removeEventListener('mousemove', onDocumentMouseMove)
let percentage = (barHeight - e.clientY + getElementViewTop(<HTMLElement>bar.value)) / barHeight
percentage = percentage > 0 ? percentage : 0
percentage = percentage < 1 ? percentage : 1
emit('setvolume', percentage)
}
</script>
<style lang="less" scoped>
.aplayer-volume-wrap {
position: relative;
cursor: pointer;
z-index: 0;
&:hover .aplayer-volume-bar-wrap {
display: block;
}
.aplayer-volume-bar-wrap {
display: none;
position: absolute;
bottom: 15px;
left: -4px;
right: -4px;
height: 40px;
z-index: -1;
transition: all .2s ease;
&::after {
content: '';
position: absolute;
bottom: -16px;
left: 0;
right: 0;
height: 62px;
background-color: #fff;
box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.07), 0 0 5px 0 rgba(0, 0, 0, 0.1);
}
.aplayer-volume-bar {
position: absolute;
bottom: 0;
left: 11px;
width: 5px;
height: 40px;
background: #aaa;
border-radius: 2.5px;
overflow: hidden;
z-index: 1;
.aplayer-volume {
position: absolute;
bottom: 0;
left: 0;
right: 0;
transition: height 0.1s ease, background-color .3s;
will-change: height;
}
}
}
}
</style>
@@ -1,129 +0,0 @@
<template>
<div class="aplayer-controller">
<v-progress
:loadProgress="loadProgress"
:playProgress="playProgress"
:theme="theme"
@dragbegin="val => $emit('dragbegin', val)"
@dragend="val => $emit('dragend', val)"
@dragging="val => $emit('dragging', val)"
/>
<div class="aplayer-time">
<div class="aplayer-time-inner">
- <span class="aplayer-ptime">{{secondToTime(stat.playedTime)}}</span> / <span
class="aplayer-dtime">{{secondToTime(stat.duration)}}</span>
</div>
<volume
v-if="!isMobile"
:volume="volume"
:theme="theme"
:muted="muted"
@togglemute="$emit('togglemute')"
@setvolume="(v: number) => $emit('setvolume', v)"
/>
<icon-button
class="aplayer-icon-mode"
icon="shuffle"
:class="{ 'inactive': !shuffle }"
@click="$emit('toggleshuffle')"
/>
<icon-button
class="aplayer-icon-mode"
:icon="repeat === 'repeat_one' ? 'repeat_one' : 'repeat_all'"
:class="{ 'inactive': repeat === 'no_repeat'}"
@click="$emit('nextmode')"
/>
<icon-button
class="aplayer-icon-menu"
icon="menu"
:class="{ 'inactive': !showList }"
@click="$emit('togglelist')"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import IconButton from './aplayer-iconbutton.vue'
import VProgress from './aplayer-controller-progress.vue'
import Volume from './aplayer-controller-volume.vue'
import { StatType } from '@/model/api/music'
const props = defineProps<{
shuffle: boolean
repeat: string
stat: StatType
theme: string
volume: number
muted: boolean
showList: boolean
isMobile: boolean
}>()
const loadProgress = computed(() => {
if (props.stat.duration === 0) return 0
return props.stat.loadedTime / props.stat.duration
})
const playProgress = computed(() => {
if (props.stat.duration === 0) return 0
return props.stat.playedTime / props.stat.duration
})
const secondToTime = (second: number) => {
if (isNaN(second)) {
return '00:00'
}
const pad0 = (num: number) => {
return num < 10 ? '0' + num : '' + num
}
const min = Math.trunc(second / 60)
const sec = Math.trunc(second - min * 60)
const hours = Math.trunc(min / 60)
const minAdjust = Math.trunc((second / 60) - (60 * Math.trunc((second / 60) / 60)))
return second >= 3600 ? pad0(hours) + ':' + pad0(minAdjust) + ':' + pad0(sec) : pad0(min) + ':' + pad0(sec)
}
</script>
<style lang="less" scoped>
.aplayer-controller {
display: flex;
align-items: center;
position: relative;
.aplayer-time {
display: flex;
align-items: center;
position: relative;
height: 17px;
color: #999;
font-size: 11px;
padding-left: 7px;
.aplayer-volume-wrap {
margin-left: 4px;
margin-right: 4px;
}
.aplayer-icon {
cursor: pointer;
transition: all 0.2s ease;
margin-left: 4px;
&.inactive {
opacity: .3;
}
&.aplayer-icon-menu {
display: none;
}
}
.aplayer-volume-wrap + .aplayer-icon {
margin-left: 0;
}
&.aplayer-time-narrow {
.aplayer-icon-mode {
display: none;
}
.aplayer-icon-menu {
display: none;
}
}
}
}
</style>
@@ -1,24 +0,0 @@
<template>
<img class="icon-svg" :src="svg" :style="style" />
</template>
<script lang="ts" setup>
import { computed } from 'vue'
const props = defineProps<{type: string}>()
const requireAssets = require.context('../assets', false, /\.svg$/)
const SVGs = requireAssets.keys().reduce((svgs: {[propName:string]: string}, path: string) => {
const svgFile = requireAssets(path)
const fileNameMatch = path.match(/^.*\/(.+?)\.svg$/)
if (fileNameMatch) {
svgs[fileNameMatch[1]] = svgFile
}
return svgs
}, {})
const svg = computed(() => SVGs[props.type])
const style = computed(() => {
if (props.type === 'next') {
return { transform: 'rotate(180deg)' }
}
})
</script>
@@ -1,33 +0,0 @@
<template>
<button
type="button"
class="aplayer-icon"
>
<player-icon :type="icon"/>
</button>
</template>
<script lang="ts" setup>
import PlayerIcon from './aplayer-icon.vue'
defineProps<{icon: string}>()
</script>
<style lang="less" scoped>
.aplayer-icon {
width: 15px;
height: 15px;
border: none;
background-color: transparent;
outline: none;
cursor: pointer;
opacity: .8;
vertical-align: middle;
padding: 0;
font-size: 12px;
margin: 0;
display: inline;
&:hover {
opacity: 1;
}
}
</style>
@@ -1,135 +0,0 @@
<template>
<transition name="slide-v">
<div
class="aplayer-list"
:style="listHeightStyle"
ref="list"
v-show="show"
>
<ol ref="ol" :style="listHeightStyle">
<li
v-for="(aMusic, index) of musicList"
:key="index"
:class="{'aplayer-list-light': aMusic === currentMusic}"
@click="$emit('selectsong', aMusic)"
>
<span class="aplayer-list-cur" :style="{background: theme}"></span>
<span class="aplayer-list-index">{{ index + 1}}</span>
<span class="aplayer-list-title">{{ aMusic.title || 'Untitled' }}</span>
<span class="aplayer-list-author">{{ aMusic.artist || 'Unknown' }}</span>
</li>
</ol>
</div>
</transition>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { MusicPlayerItem } from '@/model/api/music'
const props = defineProps<{
show: boolean
currentMusic: MusicPlayerItem
musicList: MusicPlayerItem[]
theme?: string
listMaxHeight?: string
}>()
const listHeightStyle = computed(() => {
return {
height: `${33 * props.musicList.length - 1}px`,
maxHeight: props.listMaxHeight || ''
}
})
</script>
<style lang="less" scoped>
.aplayer-list {
overflow: hidden;
&.slide-v-enter-active,
&.slide-v-leave-active {
transition: height 500ms ease;
will-change: height;
}
&.slide-v-enter,
&.slide-v-leave-to {
height: 0 !important;
}
ol {
list-style-type: none;
margin: 0;
padding: 0;
overflow-y: auto;
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-track {
background-color: #f9f9f9;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: #eee;
}
&::-webkit-scrollbar-thumb:hover {
background-color: #ccc;
}
&:hover {
li.aplayer-list-light:not(:hover) {
background-color: inherit;
transition: inherit;
}
}
&:not(:hover) {
li.aplayer-list-light {
transition: background-color .6s ease;
}
}
li {
position: relative;
height: 32px;
line-height: 32px;
padding: 0 15px;
font-size: 12px;
border-top: 1px solid #e9e9e9;
cursor: pointer;
transition: all 0.2s ease;
overflow: hidden;
margin: 0;
text-align: start;
display: flex;
&:first-child {
border-top: none;
}
&:hover {
background: #efefef;
}
&.aplayer-list-light {
background: #efefef;
.aplayer-list-cur {
display: inline-block;
}
}
.aplayer-list-cur {
display: none;
width: 3px;
height: 22px;
position: absolute;
left: 0;
top: 5px;
transition: background-color .3s;
}
.aplayer-list-index {
color: #666;
margin-right: 12px;
}
.aplayer-list-title {
flex-grow: 1;
}
.aplayer-list-author {
flex-shrink: 0;
color: #666;
float: right;
}
}
}
}
</style>
@@ -1,120 +0,0 @@
<template>
<div class="aplayer-lrc">
<div
class="aplayer-lrc-contents"
:style="transformStyle"
>
<p
v-for="(line, index) of lrcLines"
:key="index"
:class="{ 'aplayer-lrc-current': index === currentLineIndex }"
>
{{ line[1] }}
</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue'
import { parseLrc } from '../utils'
import { StatType } from '@/model/api/music'
const props = defineProps<{
lrcSource: string
playStat: StatType
}>()
const displayLrc = ref('')
const currentLineIndex = ref(0)
const lrcLines = computed(() => parseLrc(displayLrc.value))
const transformStyle = computed(() => {
return {
transform: `translateY(${-currentLineIndex.value * 16}px)`,
webkitTransform: `translateY(${-currentLineIndex.value * 16}px)`,
}
})
watch(() => props.lrcSource, (lrcSource: string) => {
currentLineIndex.value = 0
if (/^https?:\/\//.test(lrcSource)) {
fetch(lrcSource)
.then(response => response.text())
.then(lrc => displayLrc.value = lrc)
} else {
displayLrc.value = lrcSource
}
}, { immediate: true })
watch(() => props.playStat.playedTime, (playedTime: number) => {
for (let i = 0; i < lrcLines.value.length; i++) {
const line = lrcLines.value[i]
const nextLine = lrcLines.value[i + 1]
if (playedTime >= line[0] && (!nextLine || playedTime < nextLine[0])) {
currentLineIndex.value = i
}
}
})
</script>
<style lang="less" scoped>
@import "../less/variables";
.aplayer-lrc {
position: relative;
height: @lrc-height;
text-align: center;
overflow: hidden;
margin-bottom: 7px;
&:before {
position: absolute;
top: 0;
z-index: 1;
display: block;
overflow: hidden;
width: 100%;
height: 10%;
content: ' ';
background: -moz-linear-gradient(top, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
background: -webkit-linear-gradient(top, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
background: linear-gradient(to bottom, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#00ffffff', GradientType=0);
}
&:after {
position: absolute;
bottom: 0;
z-index: 1;
display: block;
overflow: hidden;
width: 100%;
height: 33%;
content: ' ';
background: -moz-linear-gradient(top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%);
background: -webkit-linear-gradient(top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%);
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ffffff', endColorstr='#ccffffff', GradientType=0);
}
p {
font-size: 12px;
color: #666;
line-height: 16px;
height: 16px;
padding: 0;
margin: 0;
transition: all 0.5s ease-out;
opacity: 0.4;
overflow: hidden;
&.aplayer-lrc-current {
opacity: 1;
overflow: visible;
height: initial;
}
}
.aplayer-lrc-contents {
width: 100%;
transition: all 0.5s ease-out;
user-select: text;
cursor: default;
}
}
</style>
@@ -1,134 +0,0 @@
<template>
<div
class="aplayer-pic"
:style="{backgroundColor: theme}"
@mousedown="onDragBegin"
@click="onClick"
>
<img v-if="pic" ref="thumbnailPic" :src="pic" @load="imgLoad" />
<div class="aplayer-button" :class="playing ? 'aplayer-pause' : 'aplayer-play'">
<icon-button
:icon="playing ? 'pause' : 'play'"
:class="playing ? 'aplayer-icon-pause' : 'aplayer-icon-play'"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import IconButton from './aplayer-iconbutton.vue'
import { adaptingThemeColor } from '../utils'
const props = withDefaults(defineProps<{
pic?: string
theme: string
playing: boolean
enableDrag: boolean
}>(), {
playing: false,
enableDrag: false
})
const emit = defineEmits(['dragbegin', 'dragging', 'dragend', 'toggleplay', 'adaptingTheme'])
const hasMovedSinceMouseDown = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const thumbnailPic = ref<unknown>(null)
const onDragBegin = (e: MouseEvent) => {
if (props.enableDrag) {
hasMovedSinceMouseDown.value = false
emit('dragbegin')
dragStartX.value = e.clientX
dragStartY.value = e.clientY
document.addEventListener('mousemove', onDocumentMouseMove)
document.addEventListener('mouseup', onDocumentMouseUp)
}
}
const onDocumentMouseMove = (e: MouseEvent) => {
hasMovedSinceMouseDown.value = true
emit('dragging', {offsetLeft: e.clientX - dragStartX.value, offsetTop: e.clientY - dragStartY.value})
}
const onDocumentMouseUp = () => {
document.removeEventListener('mouseup', onDocumentMouseUp)
document.removeEventListener('mousemove', onDocumentMouseMove)
emit('dragend')
}
const onClick = () => {
if (!hasMovedSinceMouseDown.value) {
emit('toggleplay')
}
}
const imgLoad = () => {
const color = adaptingThemeColor(<HTMLImageElement>thumbnailPic.value)
emit('adaptingTheme', `rgb(${color.r},${color.g},${color.b})`)
}
watch(() => props.pic, (pic?: string) => {
if (!pic) emit('adaptingTheme', null)
}, { immediate: true })
</script>
<style lang="less" scoped>
@import "../less/variables";
.aplayer-float {
.aplayer-pic:active {
cursor: move;
}
}
.aplayer-pic {
flex-shrink: 0;
position: relative;
height: @aplayer-height;
width: @aplayer-height;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
.aplayer-button {
opacity: 1;
}
}
.aplayer-button {
position: absolute;
border-radius: 50%;
opacity: 0.8;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
background: rgba(0, 0, 0, 0.2);
transition: all 0.1s ease;
}
.aplayer-play {
width: 26px;
height: 26px;
border: 2px solid #fff;
bottom: 50%;
right: 50%;
margin: 0 -15px -15px 0;
.aplayer-icon-play {
position: absolute;
top: 3px;
left: 4px;
height: 20px;
width: 20px;
}
}
.aplayer-pause {
width: 16px;
height: 16px;
border: 2px solid #fff;
bottom: 4px;
right: 4px;
.aplayer-icon-pause {
position: absolute;
top: 2px;
left: 2px;
height: 12px;
width: 12px;
}
}
img {
width: 100%;
height: 100%;
}
}
</style>
@@ -1,3 +0,0 @@
@aplayer-height: 66px;
@lrc-height: 30px;
@aplayer-height-lrc: @aplayer-height + @lrc-height - 6;
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
/**
* 音频可视化 - 粒子系统与圆形频谱绘制
*
* 负责唱片周围的圆形音频频谱条绘制, 以及随节奏从唱片边缘飘散的粒子效果。
* 频谱条根据音频频率数据径向排列在唱片外圈, 粒子根据低频/高频能量动态生成。
*/
/** 单个粒子的状态描述 */
export interface Particle {
x: number // 当前 x 坐标
y: number // 当前 y 坐标
vx: number // x 方向速度
vy: number // y 方向速度
life: number // 已存活帧数
maxLife: number // 最大存活帧数
size: number // 粒子半径
r: number // 颜色 R 分量
g: number // 颜色 G 分量
b: number // 颜色 B 分量
}
/** 粒子数量上限, 防止性能问题 */
const MAX_PARTICLES = 200
/**
* 在唱片边缘生成新粒子
*
* 粒子从唱片圆周的随机角度向外发射, 数量由低频能量决定, 速度由高频能量加成。
* 颜色在蓝紫色调范围内随机偏移。
*
* @param particles - 粒子数组(会被直接修改)
* @param centerX - 唱片圆心 x 坐标
* @param centerY - 唱片圆心 y 坐标
* @param discRadius - 唱片半径(像素)
* @param energy - 低频能量(0~1), 决定生成数量和粒子大小
* @param highEnergy - 高频能量(0~1), 决定发射速度和颜色偏移
*/
export function spawnParticles(particles: Particle[], centerX: number, centerY: number, discRadius: number, energy: number, highEnergy: number) {
const count = Math.floor(energy * 4)
for (let i = 0; i < count && particles.length < MAX_PARTICLES; i++) {
const angle = Math.random() * Math.PI * 2
const speed = 0.5 + energy * 2 + highEnergy * 1.5
const hue = Math.random()
particles.push({
x: centerX + Math.cos(angle) * (discRadius + 4),
y: centerY + Math.sin(angle) * (discRadius + 4),
vx: Math.cos(angle) * speed * (0.6 + Math.random() * 0.8),
vy: Math.sin(angle) * speed * (0.6 + Math.random() * 0.8),
life: 1,
maxLife: 40 + Math.random() * 40,
size: 1.5 + energy * 2 + Math.random() * 1.5,
r: Math.floor(100 + hue * 155),
g: Math.floor(180 - hue * 100 + highEnergy * 60),
b: Math.floor(220 + hue * 35),
})
}
}
/**
* 更新粒子物理状态并绘制到 Canvas
*
* 每帧对所有粒子: 移动位置、衰减速度、递增生命周期。
* 生命结束的粒子被移除, 存活的粒子按透明度渐变绘制, 较大粒子额外绘制发光光圈。
*
* @param ctx - Canvas 2D 渲染上下文
* @param particles - 粒子数组(会被直接修改, 移除死亡粒子)
*/
export function updateAndDrawParticles(ctx: CanvasRenderingContext2D, particles: Particle[]) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i]
p.x += p.vx
p.y += p.vy
p.vx *= 0.98
p.vy *= 0.98
p.life += 1
const progress = p.life / p.maxLife
if (progress >= 1) {
particles.splice(i, 1)
continue
}
const alpha = 1 - progress
const size = p.size * (1 - progress * 0.5)
ctx.beginPath()
ctx.arc(p.x, p.y, size, 0, Math.PI * 2)
ctx.fillStyle = `rgba(${p.r}, ${p.g}, ${p.b}, ${alpha * 0.7})`
ctx.fill()
// 发光效果: 在粒子外围画一个更大的半透明圆
if (size > 1.5) {
ctx.beginPath()
ctx.arc(p.x, p.y, size * 2, 0, Math.PI * 2)
ctx.fillStyle = `rgba(${p.r}, ${p.g}, ${p.b}, ${alpha * 0.15})`
ctx.fill()
}
}
}
/** drawCircularSpectrum 的入参 */
export interface SpectrumParams {
ctx: CanvasRenderingContext2D
width: number // Canvas 宽度
height: number // Canvas 高度
dataArray: Uint8Array<ArrayBuffer> // Web Audio API 频率数据
}
/** 频率能量分析结果 */
export interface EnergyResult {
lowEnergy: number // 低频段能量(0~1), 对应鼓点/贝斯
highEnergy: number // 高频段能量(0~1), 对应人声/镲片
}
/**
* 从频率数据中计算低频和高频的平均能量
*
* 将频率数据分为低频段(前25%)和高频段(后40%), 分别求归一化均值。
* 用于驱动粒子系统的生成参数。
*
* @param dataArray - FFT 频率数据(0~255)
* @returns 低频和高频的归一化能量值
*/
function computeFrequencyEnergy(dataArray: Uint8Array<ArrayBuffer>): EnergyResult {
const barCount = dataArray.length
let lowSum = 0, highSum = 0
const lowEnd = Math.floor(barCount * 0.25)
const highStart = Math.floor(barCount * 0.6)
for (let i = 0; i < lowEnd; i++) lowSum += dataArray[i]
for (let i = highStart; i < barCount; i++) highSum += dataArray[i]
return {
lowEnergy: lowSum / (lowEnd * 255),
highEnergy: highSum / ((barCount - highStart) * 255),
}
}
/**
* 绘制圆形音频频谱
*
* 以 Canvas 中心为圆心, 将频率数据的每个频段绘制为一条从唱片边缘向外辐射的线段。
* 线段长度与频率振幅成正比, 颜色沿频率轴从蓝色渐变到紫粉色。
* 绘制完成后自动计算并返回频率能量, 供粒子系统使用。
*
* @param params - 包含 Canvas 上下文、尺寸和频率数据
* @returns 低频/高频能量分析结果
*/
export function drawCircularSpectrum(params: SpectrumParams): EnergyResult {
const { ctx, width, height, dataArray } = params
const centerX = width / 2
const centerY = height / 2
const discRadius = Math.min(width, height) * 0.30
const barCount = dataArray.length
const maxBarLength = Math.min(width, height) * 0.18
for (let i = 0; i < barCount; i++) {
const value = dataArray[i]
const barLength = (value / 255) * maxBarLength
if (barLength < 1) continue
const angle = (i / barCount) * Math.PI * 2 - Math.PI / 2
const startX = centerX + Math.cos(angle) * (discRadius + 2)
const startY = centerY + Math.sin(angle) * (discRadius + 2)
const endX = centerX + Math.cos(angle) * (discRadius + 2 + barLength)
const endY = centerY + Math.sin(angle) * (discRadius + 2 + barLength)
const ratio = i / barCount
const r = Math.floor(100 + ratio * 155)
const g = Math.floor(180 - ratio * 100)
const b = Math.floor(230 + ratio * 25)
const alpha = 0.4 + (value / 255) * 0.6
ctx.beginPath()
ctx.moveTo(startX, startY)
ctx.lineTo(endX, endY)
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`
ctx.lineWidth = Math.max(1.5, (Math.PI * 2 * discRadius) / barCount * 0.7)
ctx.lineCap = 'round'
ctx.stroke()
}
return computeFrequencyEnergy(dataArray)
}
/**
* 将秒数格式化为 MM:SS 格式
*
* @param sec - 秒数
* @returns 格式化后的时间字符串, 如 "03:25"
*/
export function formatTime(sec: number) {
if (isNaN(sec)) return '00:00'
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
-859
View File
@@ -1,859 +0,0 @@
<template>
<div
class="aplayer"
:class="{
'aplayer-narrow': mini,
'aplayer-withlist' : !mini && list.length > 0,
'aplayer-withlrc': !mini && (!!$slots.display || showLrc),
'aplayer-float': isFloatMode,
'aplayer-loading': isPlaying && isLoading
}"
:style="floatStyleObj"
>
<div class="aplayer-body">
<thumbnail
:pic="currentMusic.pic"
:playing="isPlaying"
:enable-drag="isFloatMode"
:theme="currentMusic.theme || theme"
@toggleplay="toggle"
@dragbegin="onDragBegin"
@dragging="onDragAround"
@adaptingTheme="setSelfAdaptingTheme"
/>
<div class="aplayer-info" v-show="!mini">
<div class="aplayer-music">
<span class="aplayer-title">{{ currentMusic.title || 'Untitled' }}</span>
<span class="aplayer-author">{{ currentMusic.artist || currentMusic.author || 'Unknown' }}</span>
</div>
<slot name="display" :current-music="currentMusic" :play-stat="playStat">
<lyrics :lrc-source="currentMusic.lrc" :play-stat="playStat" v-if="showLrc && currentMusic.lrc" />
</slot>
<controls
:shuffle="shouldShuffle"
:repeat="repeatMode"
:stat="playStat"
:volume="audioVolume"
:muted="isAudioMuted"
:theme="currentTheme"
:showList="showList"
:isMobile="isMobile"
@toggleshuffle="shouldShuffle = !shouldShuffle"
@togglelist="showList = !showList"
@togglemute="toggleMute"
@setvolume="setAudioVolume"
@dragbegin="onProgressDragBegin"
@dragend="onProgressDragEnd"
@dragging="onProgressDragging"
@nextmode="setNextMode"
/>
</div>
</div>
<audio ref="audio"></audio>
<music-list
:show="showList && !mini"
:current-music="currentMusic"
:music-list="list"
:play-index="playIndex"
:listMaxHeight="listMaxHeight"
:theme="currentTheme"
@selectsong="onSelectSong"
/>
</div>
</template>
<script>
import Thumbnail from './components/aplayer-thumbnail.vue'
import MusicList from './components/aplayer-list.vue'
import Controls from './components/aplayer-controller.vue'
import Lyrics from './components/aplayer-lrc.vue'
import { warn } from './utils'
// mutex playing instance
let activeMutex = null
const REPEAT = {
NONE: 'none',
MUSIC: 'music',
LIST: 'list',
NO_REPEAT: 'no_repeat',
REPEAT_ONE: 'repeat_one',
REPEAT_ALL: 'repeat_all',
};
const VueAPlayer = {
name: 'APlayer',
disableVersionBadge: false,
components: {
Thumbnail,
Controls,
MusicList,
Lyrics,
},
props: {
music: {
type: Object,
required: true,
validator (song) {
return song.src
},
},
list: {
type: Array,
default () {
return []
},
},
mini: {
type: Boolean,
default: false,
},
showLrc: {
type: Boolean,
default: false,
},
mutex: {
type: Boolean,
default: true,
},
theme: {
type: String,
default: '#41b883',
},
listMaxHeight: String,
/**
* @since 1.4.1
* Fold playlist initially
*/
listFolded: {
type: Boolean,
default: false,
},
/**
* @since 1.2.0 Float mode
*/
float: {
type: Boolean,
default: false,
},
// Audio attributes as props
// since 1.4.0
// autoplay muted preload volume
// autoplay is not observable
/**
* @since 1.4.0
* not observable
*/
autoplay: {
type: Boolean,
default: false,
},
/**
* @since 1.4.0
* observable, sync
*/
muted: {
type: Boolean,
default: false,
},
/**
* @since 1.4.0
* observable
*/
preload: {
type: String,
default: 'none'
},
/**
* @since 1.4.0
* observable, sync
*/
volume: {
type: Number,
default: 0.5,
validator (value) {
return value >= 0 && value <= 1
},
},
// play order control
// since 1.5.0
/**
* @since 1.5.0
* @see https://support.apple.com/en-us/HT207230
* twoWay
*/
shuffle: {
type: Boolean,
default: false,
},
/**
* @since 1.5.0
* @see https://support.apple.com/en-us/HT207230
* twoWay
*/
repeat: {
type: String,
default: REPEAT.NO_REPEAT,
}
},
data () {
return {
internalMusic: this.music,
isPlaying: false,
isSeeking: false,
wasPlayingBeforeSeeking: false,
isMobile: /mobile/i.test(window.navigator.userAgent),
playStat: {
duration: 0,
loadedTime: 0,
playedTime: 0,
},
showList: !this.listFolded,
// handle Promise returned from audio.play()
// @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play
audioPlayPromise: Promise.resolve(),
rejectPlayPromise: Promise.reject(),
// @since 1.2.0 float mode
floatOriginX: 0,
floatOriginY: 0,
floatOffsetLeft: 0,
floatOffsetTop: 0,
// @since 1.3.0 self adapting theme
selfAdaptingTheme: null,
// @since 1.4.0
// sync muted, volume
internalMuted: this.muted,
internalVolume: this.volume,
// @since 1.4.1
// Loading indicator
isLoading: false,
// @since 1.5.1
// sync shuffle, repeat
internalShuffle: this.shuffle,
internalRepeat: this.repeat,
// for shuffling
shuffledList: [],
hls: null
}
},
computed: {
// alias for $refs.audio
audio () {
return this.$refs.audio
},
// sync music
currentMusic: {
get () {
return this.internalMusic
},
set (val) {
this.$emit('update:music', val)
this.internalMusic = val
},
},
// props wrappers
currentTheme () {
return this.selfAdaptingTheme || this.currentMusic.theme || this.theme
},
isFloatMode () {
return this.float && !this.isMobile
},
shouldAutoplay () {
if (this.isMobile) return false
return this.autoplay
},
// useful
floatStyleObj () {
// transform: translate(floatOffsetLeft, floatOffsetY)
return {
transform: `translate(${this.floatOffsetLeft}px, ${this.floatOffsetTop}px)`,
webkitTransform: `translate(${this.floatOffsetLeft}px, ${this.floatOffsetTop}px)`,
}
},
currentPicStyleObj () {
if (this.currentMusic && this.currentMusic.pic) {
return {
backgroundImage: `url(${this.currentMusic.pic})`,
}
}
return {}
},
loadProgress () {
if (this.playStat.duration === 0) return 0
return this.playStat.loadedTime / this.playStat.duration
},
playProgress () {
if (this.playStat.duration === 0) return 0
return this.playStat.playedTime / this.playStat.duration
},
playIndex: {
get () {
return this.shuffledList.indexOf(this.currentMusic)
},
set (val) {
this.currentMusic = this.shuffledList[val % this.shuffledList.length]
},
},
// since 1.4.0
// sync muted, volume
isAudioMuted: {
get () {
return this.internalMuted
},
set (val) {
this.$emit('update:muted', val)
this.internalMuted = val
},
},
audioVolume: {
get () {
return this.internalVolume
},
set (val) {
this.$emit('update:volume', val)
this.internalVolume = val
},
},
// since 1.5.0
// sync shuffle, repeat
shouldShuffle: {
get () {
return this.internalShuffle
},
set (val) {
this.$emit('update:shuffle', val)
this.internalShuffle = val
},
},
repeatMode: {
get () {
switch (this.internalRepeat) {
case REPEAT.NONE:
case REPEAT.NO_REPEAT:
return REPEAT.NO_REPEAT
case REPEAT.MUSIC:
case REPEAT.REPEAT_ONE:
return REPEAT.REPEAT_ONE
default:
return REPEAT.REPEAT_ALL
}
},
set (val) {
this.$emit('update:repeat', val)
this.internalRepeat = val
},
},
},
methods: {
// Float mode
onDragBegin () {
this.floatOriginX = this.floatOffsetLeft
this.floatOriginY = this.floatOffsetTop
},
onDragAround ({ offsetLeft, offsetTop }) {
this.floatOffsetLeft = this.floatOriginX + offsetLeft
this.floatOffsetTop = this.floatOriginY + offsetTop
},
// functions
setNextMode () {
if (this.repeatMode === REPEAT.REPEAT_ALL) {
this.repeatMode = REPEAT.REPEAT_ONE
} else if (this.repeatMode === REPEAT.REPEAT_ONE) {
this.repeatMode = REPEAT.NO_REPEAT
} else {
this.repeatMode = REPEAT.REPEAT_ALL
}
},
thenPlay () {
this.$nextTick(() => {
this.play()
})
},
toggle () {
if (!this.audio.paused) {
this.pause()
} else {
this.play()
}
},
play () {
if (this.mutex) {
if (activeMutex && activeMutex !== this) {
activeMutex.pause()
}
activeMutex = this
}
// handle .play() Promise
const audioPlayPromise = this.audio.play()
if (audioPlayPromise) {
return this.audioPlayPromise = new Promise((resolve, reject) => {
// rejectPlayPromise is to force reject audioPlayPromise if it's still pending when pause() is called
this.rejectPlayPromise = reject
audioPlayPromise.then((res) => {
this.rejectPlayPromise = null
resolve(res)
}).catch(warn)
})
}
},
pause () {
this.audioPlayPromise
.then(() => {
this.audio.pause()
})
// Avoid force rejection throws Uncaught
.catch(() => {
this.audio.pause()
})
// audioPlayPromise is still pending
if (this.rejectPlayPromise) {
// force reject playPromise
this.rejectPlayPromise()
this.rejectPlayPromise = null
}
},
switch(index) {
this.playIndex = index
this.thenPlay()
},
// progress bar
onProgressDragBegin (val) {
this.wasPlayingBeforeSeeking = this.isPlaying
this.pause()
this.isSeeking = true
// handle load failures
if (!isNaN(this.audio.duration)) {
this.audio.currentTime = this.audio.duration * val
}
},
onProgressDragging (val) {
if (isNaN(this.audio.duration)) {
this.playStat.playedTime = 0
} else {
this.audio.currentTime = this.audio.duration * val
}
},
onProgressDragEnd (val) {
this.isSeeking = false
if (this.wasPlayingBeforeSeeking) {
this.thenPlay()
}
},
// volume
toggleMute () {
this.setAudioMuted(!this.audio.muted)
},
setAudioMuted (val) {
this.audio.muted = val
},
setAudioVolume (val) {
this.audio.volume = val
if (val > 0) {
this.setAudioMuted(false)
}
},
// playlist
getShuffledList () {
if (!this.list.length) {
return [this.internalMusic]
}
let unshuffled = [...this.list]
if (!this.internalShuffle || unshuffled.length <= 1) {
return unshuffled
}
let indexOfCurrentMusic = unshuffled.indexOf(this.internalMusic)
if (unshuffled.length === 2 && indexOfCurrentMusic !== -1) {
if (indexOfCurrentMusic === 0) {
return unshuffled
} else {
return [this.internalMusic, unshuffled[0]]
}
}
// shuffle list
// @see https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array
for (let i = unshuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
const tmp = unshuffled[i]
unshuffled[i] = unshuffled[j]
unshuffled[j] = tmp
}
// take currentMusic to first
if (indexOfCurrentMusic !== -1) {
indexOfCurrentMusic = unshuffled.indexOf(this.internalMusic)
if (indexOfCurrentMusic !== 0) {
[unshuffled[0], unshuffled[indexOfCurrentMusic]] = [unshuffled[indexOfCurrentMusic], unshuffled[0]]
}
}
return unshuffled
},
onSelectSong (song) {
if (this.currentMusic === song) {
this.toggle()
} else {
this.currentMusic = song
this.thenPlay()
}
},
// event handlers
// for keeping up with audio states
onAudioPlay () {
this.isPlaying = true
},
onAudioPause () {
this.isPlaying = false
},
onAudioWaiting () {
this.isLoading = true
},
onAudioCanplay () {
this.isLoading = false
},
onAudioDurationChange () {
if (this.audio.duration !== 1) {
this.playStat.duration = this.audio.duration
}
},
onAudioProgress () {
if (this.audio.buffered.length) {
this.playStat.loadedTime = this.audio.buffered.end(this.audio.buffered.length - 1)
} else {
this.playStat.loadedTime = 0
}
},
onAudioTimeUpdate () {
this.playStat.playedTime = this.audio.currentTime
},
onAudioSeeking () {
this.playStat.playedTime = this.audio.currentTime
},
onAudioSeeked () {
this.playStat.playedTime = this.audio.currentTime
},
onAudioVolumeChange () {
this.audioVolume = this.audio.volume
this.isAudioMuted = this.audio.muted
},
onAudioEnded () {
// determine next song according to shuffle and repeat
if (this.repeatMode === REPEAT.REPEAT_ALL) {
if (this.shouldShuffle && this.playIndex === this.shuffledList.length - 1) {
this.shuffledList = this.getShuffledList()
}
this.playIndex++
this.thenPlay()
} else if (this.repeatMode === REPEAT.REPEAT_ONE) {
this.thenPlay()
} else {
this.playIndex++
if (this.playIndex !== 0) {
this.thenPlay()
} else if (this.shuffledList.length === 1) {
this.audio.currentTime = 0
}
}
},
initAudio () {
// since 1.4.0 Audio attributes as props
this.audio.muted = this.muted
this.audio.preload = this.preload
this.audio.volume = this.volume
// since 1.4.0 Emit as many native audio events
// @see https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Media_events
const mediaEvents = [
'abort',
'canplay', 'canplaythrough',
'durationchange',
'emptied', 'encrypted', 'ended', 'error',
'interruptbegin', 'interruptend',
'loadeddata', 'loadedmetadata', 'loadstart',
'mozaudioavailable',
'pause', 'play', 'playing', 'progress',
'ratechange',
'seeked', 'seeking', 'stalled', 'suspend',
'timeupdate',
'volumechange',
'waiting',
]
mediaEvents.forEach(event => {
this.audio.addEventListener(event, e => this.$emit(event, e))
})
// event handlers
// they don't emit native media events
this.audio.addEventListener('play', this.onAudioPlay)
this.audio.addEventListener('pause', this.onAudioPause)
this.audio.addEventListener('abort', this.onAudioPause)
this.audio.addEventListener('waiting', this.onAudioWaiting)
this.audio.addEventListener('canplay', this.onAudioCanplay)
this.audio.addEventListener('progress', this.onAudioProgress)
this.audio.addEventListener('durationchange', this.onAudioDurationChange)
this.audio.addEventListener('seeking', this.onAudioSeeking)
this.audio.addEventListener('seeked', this.onAudioSeeked)
this.audio.addEventListener('timeupdate', this.onAudioTimeUpdate)
this.audio.addEventListener('volumechange', this.onAudioVolumeChange)
this.audio.addEventListener('ended', this.onAudioEnded)
if (this.currentMusic) {
this.audio.src = this.currentMusic.src || this.currentMusic.url
}
},
setSelfAdaptingTheme (color) {
this.selfAdaptingTheme = color
}
},
watch: {
music (music) {
this.internalMusic = music
},
currentMusic: {
handler (music) {
const src = music.src || music.url
// HLS support
if (/\.m3u8(?=(#|\?|$))/.test(src)) {
if (this.audio.canPlayType('application/x-mpegURL') || this.audio.canPlayType('application/vnd.apple.mpegURL')) {
this.audio.src = src
} else {
try {
const Hls = require('hls.js')
if (Hls.isSupported()) {
if (!this.hls) {
this.hls = new Hls()
}
this.hls.loadSource(src)
this.hls.attachMedia(this.audio)
} else {
warn('HLS is not supported on your browser')
this.audio.src = src
}
} catch (e) {
warn('hls.js is required to support m3u8')
this.audio.src = src
}
}
} else {
this.audio.src = src
}
// self-adapting theme color
},
},
// since 1.4.0
// observe muted, preload, volume
isAudioMuted (val) {
this.audio.muted = val
},
preload (val) {
this.audio.preload = val
},
audioVolume (val) {
this.audio.volume = val
},
// sync muted, volume
muted (val) {
this.internalMuted = val
},
volume (val) {
this.internalVolume = val
},
// sync shuffle, repeat
shuffle (val) {
this.internalShuffle = val
},
repeat (val) {
this.internalRepeat = val
},
},
created () {
this.shuffledList = this.getShuffledList()
},
mounted () {
this.initAudio()
if (this.autoplay) this.play()
},
beforeDestroy () {
if (activeMutex === this) {
activeMutex = null
}
if (this.hls) {
this.hls.destroy()
}
},
}
export default VueAPlayer
</script>
<style lang="less" scoped>
@import "./less/variables";
.aplayer {
font-family: Arial, Helvetica, sans-serif;
color: #000;
background-color: #fff;
margin: 5px;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.07), 0 1px 5px 0 rgba(0, 0, 0, 0.1);
border-radius: 2px;
overflow: hidden;
user-select: none;
line-height: initial;
* {
box-sizing: content-box;
}
.aplayer-lrc-content {
display: none;
}
.aplayer-body {
display: flex;
position: relative;
.aplayer-info {
flex-grow: 1;
display: flex;
flex-direction: column;
text-align: start;
padding: 14px 7px 0 10px;
height: @aplayer-height;
box-sizing: border-box;
overflow: hidden;
.aplayer-music {
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-left: 5px;
user-select: text;
cursor: default;
padding-bottom: 2px;
.aplayer-title {
font-size: 14px;
}
.aplayer-author {
font-size: 12px;
color: #666;
margin-left: 10px;
}
}
.aplayer-lrc {
z-index: 0;
}
}
}
// Mini mode
&.aplayer-narrow {
width: @aplayer-height;
}
&.aplayer-withlrc {
.aplayer-body {
.aplayer-pic {
height: @aplayer-height-lrc;
width: @aplayer-height-lrc;
}
.aplayer-info {
height: @aplayer-height-lrc;
}
.aplayer-info {
padding: 10px 7px 0 7px;
}
}
}
&.aplayer-withlist {
.aplayer-body {
.aplayer-info {
border-bottom: 1px solid #e9e9e9;
}
.aplayer-controller .aplayer-time .aplayer-icon.aplayer-icon-menu {
display: block;
}
}
}
/* floating player on top */
position: relative;
&.aplayer-float {
z-index: 1;
}
}
@keyframes aplayer-roll {
0% {
left: 0
}
100% {
left: -100%
}
}
</style>
+153
View File
@@ -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>
+42 -64
View File
@@ -1,53 +1,40 @@
<template>
<div class="page-wrapper">
<el-form inline :model="search">
<el-form-item label="标题">
<el-form-item :label="t('system.article.formTitle')">
<el-input v-model="search.title" />
</el-form-item>
<el-form-item label="创建时间">
<el-form-item :label="t('common.createdAt')">
<el-date-picker
v-model="search.createDate"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期" />
:range-separator="t('system.article.rangeSeparator')"
:start-placeholder="t('system.article.startDate')"
:end-placeholder="t('system.article.endDate')" />
</el-form-item>
<el-form-item label="分类">
<el-form-item :label="t('system.article.formCategory')">
<el-select v-model="search.category" filterable clearable>
<el-option v-for="item in categories" :key="item" :value="item" :label="item" />
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-form-item :label="t('system.article.formTag')">
<el-select v-model="search.tag" filterable clearable>
<el-option v-for="item in tags" :key="item" :value="item" :label="item" />
</el-select>
</el-form-item>
<el-form-item label="已分词">
<el-form-item :label="t('system.article.formIsSplited')">
<el-select v-model="search.isSplited" >
<el-option :value="true" label="是" />
<el-option :value="false" label="否" />
<el-option :value="true" :label="t('common.yes')" />
<el-option :value="false" :label="t('common.no')" />
</el-select>
</el-form-item>
</el-form>
<div class="btn-container">
<el-button type="primary" @click="splitWord" style="vertical-align: bottom">分词处理</el-button>
<el-button @click="pullArticles" style="vertical-align: bottom">拉取文章</el-button>
<el-upload
:action="`${apiBase}/system/deployBlog`"
accept="application/zip"
name="blogZip"
:headers="{token: store.state.loginInfo.token}"
:before-upload="beforeUpload"
:on-success="uploadSuccess"
:on-error="uploadError"
auto-upload
:show-file-list="false"
style="display: inline-block;margin-left: 10px;">
<el-button type="primary" icon="Upload" :loading="isUploading">发布博客</el-button>
</el-upload>
<el-button type="primary" @click="splitWord" style="vertical-align: bottom" v-permission="'article:edit'" plain>{{ t('system.article.splitWordBtn') }}</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">
<el-button type="primary" @click="loadDataBase(true)" icon="Search">搜索</el-button>
<el-button @click="reset" icon="RefreshLeft">重置</el-button>
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
</div>
</div>
<el-row style="flex: 1; overflow: hidden;">
@@ -65,29 +52,29 @@
<el-col :span="20" style="height: 100%; display: flex; flex-direction: column;">
<el-table class="table-container" :data="articleData" v-loading="loading" stripe @selection-change="dataSelect" height="100%">
<el-table-column type="selection" width="55" />
<el-table-column prop="title" label="标题" />
<el-table-column prop="path" label="路径" >
<el-table-column prop="title" :label="t('system.article.tableTitle')" />
<el-table-column prop="path" :label="t('system.article.tablePath')" >
<template #default="scope">
{{ scope.row.path.join('/') }}
</template>
</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">
{{ 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>
</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">
{{ 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>
</el-table-column>
<el-table-column prop="contentLen" label="正文长度" width="100" />
<el-table-column prop="createDate" label="创建时间" width="180" >
<el-table-column prop="contentLen" :label="t('system.article.tableContentLen')" width="100" />
<el-table-column prop="createDate" :label="t('common.createdAt')" width="180" >
<template #default="scope">
{{ datetimeFormat(scope.row.createDate) }}
</template>
</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">
<div style="width: 18px">
<Check v-if="scope.row.isSplited" />
@@ -112,22 +99,30 @@
v-model="markdownPreview.show"
:title="markdownPreview.title"
direction="rtl"
class="custom-drawer">
<div v-html="markdownPreview.content"></div>
size="50%"
class="custom-drawer article-preview-drawer">
<div class="markdown-body" v-html="markdownPreview.content"></div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useStore } from 'vuex'
import { useI18n } from 'vue-i18n'
import hyperdown from 'hyperdown'
import { ArticleModel, TreeNodeData, TreeNodeSource } from '@/model/system/article'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Node } from 'element-plus/lib/components/tree/src/model/node'
import { useBaseList } from '@/model/baselist'
import { Page, MsgResult } from '@/model/common.dto'
import { Page } from '@/model/common.dto'
import http from '@/utils/http'
type ArticleTreeNode = {
level: number
data: Pick<TreeNodeData, 'name'>
}
type TreeDataResolver = (data: TreeNodeData[]) => void
class ArticlePage extends Page {
title?: string
createDate?: [Date, Date]
@@ -145,7 +140,7 @@ class ArticlePage extends Page {
}
const store = useStore()
const apiBase = process.env.VUE_APP_API_BASE as string
const { t } = useI18n()
const { loading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new ArticlePage())
const articleData = ref<ArticleModel[]>([])
@@ -160,7 +155,6 @@ const markdownPreview = reactive<{
title: null,
content: null
})
const isUploading = ref(false)
let selectedData: string[] = []
@@ -183,47 +177,31 @@ setLoadData(loadData)
function splitWord() {
if (!selectedData.length) {
ElMessage.warning('请选择要执行分词的文章')
ElMessage.warning(t('system.article.selectToSplit'))
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})
ElMessage.success(`${successNum}篇文章分词处理成功`)
ElMessage.success(t('system.article.splitSuccess', {count: successNum}))
})
}
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')
ElMessage.success(`拉取文章完成,更新 ${updateCount} 篇,创建 ${createCount}`)
ElMessage.success(t('system.article.pullSuccess', {updateCount, createCount}))
loadData()
})
}
function dataSelect(selection: ArticleModel[]) {
selectedData = selection.map(item => item._id)
}
function beforeUpload(file: File): boolean {
isUploading.value = true
return true
}
function uploadSuccess(response: MsgResult) {
if (response.code === 0) {
ElMessage.success(response.message)
} else {
ElMessage.warning(response.message)
}
isUploading.value = false
}
function uploadError(error: Error) {
isUploading.value = false
ElMessage.error(error.message)
}
const treeProps = {
label: 'name',
children: 'children',
isLeaf: 'isLeaf',
}
async function loadTreeData(node: Node, resolve: Function) {
async function loadTreeData(node: ArticleTreeNode, resolve: TreeDataResolver) {
const childItems: TreeNodeSource[] = await http.get('/article/tree', {params: {deep: node.level, parent: node.data.name}})
resolve(childItems.map((childItem): TreeNodeData => {
const treeNode: TreeNodeData = {
+21 -18
View File
@@ -7,7 +7,7 @@
<el-icon><Document /></el-icon>
</div>
<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>
</div>
@@ -16,7 +16,7 @@
<el-icon><FolderOpened /></el-icon>
</div>
<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>
</div>
@@ -25,7 +25,7 @@
<el-icon><Calendar /></el-icon>
</div>
<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>
</div>
@@ -34,7 +34,7 @@
<el-icon><TrendCharts /></el-icon>
</div>
<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>
</div>
@@ -47,9 +47,9 @@
<div class="chart-header">
<div class="chart-title">
<el-icon><PieChart /></el-icon>
<span>文章分类分布</span>
<span>{{ t('system.statistics.categoryDistribution') }}</span>
</div>
<div class="chart-subtitle">各类别文章数量占比</div>
<div class="chart-subtitle">{{ t('system.statistics.categoryDistributionSub') }}</div>
</div>
<div ref="categoriesChart" v-loading="categoriesChartLoading" class="chart-body"></div>
</div>
@@ -59,9 +59,9 @@
<div class="chart-header">
<div class="chart-title">
<el-icon><TrendCharts /></el-icon>
<span>文章发布趋势</span>
<span>{{ t('system.statistics.publishTrend') }}</span>
</div>
<div class="chart-subtitle">各时间段文章发布数量变化</div>
<div class="chart-subtitle">{{ t('system.statistics.publishTrendSub') }}</div>
</div>
<div ref="publishDatesChart" v-loading="publishDatesChartLoading" class="chart-body"></div>
</div>
@@ -71,9 +71,9 @@
<div class="chart-header">
<div class="chart-title">
<el-icon><Histogram /></el-icon>
<span>年度高频词汇分析</span>
<span>{{ t('system.statistics.yearlyKeywords') }}</span>
</div>
<div class="chart-subtitle">基于文章分词结果的年度关键词统计</div>
<div class="chart-subtitle">{{ t('system.statistics.yearlyKeywordsSub') }}</div>
</div>
<div ref="timelineWordsChart" v-loading="timelineWordsChartLoading" class="chart-body timeline-chart-body"></div>
</div>
@@ -83,10 +83,13 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import * as echarts from 'echarts'
import http from '@/utils/http'
import { Document, FolderOpened, Calendar, TrendCharts, PieChart, Histogram } from '@element-plus/icons-vue'
const { t } = useI18n()
const categoriesChart = ref<HTMLElement>()
const publishDatesChart = ref<HTMLElement>()
const timelineWordsChart = ref<HTMLElement>()
@@ -104,7 +107,7 @@ const topKeyword = ref('-')
const categoriesChartOption: any = {
tooltip: {
trigger: 'item',
formatter: "{b}: {c}篇 ({d}%)"
formatter: (params: any) => `${params.name}: ${params.value}${t('system.statistics.articlesUnit')} (${params.percent}%)`
},
legend: {
type: 'scroll',
@@ -117,7 +120,7 @@ const categoriesChartOption: any = {
}
},
series: {
name: '类别',
name: t('system.statistics.categoryName'),
type: 'pie',
radius: ['45%', '75%'],
center: ['35%', '50%'],
@@ -169,7 +172,7 @@ const publishDatesChartOption: any = {
containLabel: true
},
xAxis: {
name: '发布时间',
name: t('system.statistics.publishTime'),
nameTextStyle: {
color: '#909399'
},
@@ -186,7 +189,7 @@ const publishDatesChartOption: any = {
}
},
yAxis: {
name: '文章数量',
name: t('system.statistics.articleCount'),
nameTextStyle: {
color: '#909399'
},
@@ -221,7 +224,7 @@ const publishDatesChartOption: any = {
}
}],
series: {
name: '文章数量',
name: t('system.statistics.articleCount'),
type: 'line',
smooth: true,
symbol: 'circle',
@@ -297,7 +300,7 @@ const timelineWordsChartOption: any = {
right: 40
},
xAxis: {
name: '高频词汇',
name: t('system.statistics.keywordName'),
nameTextStyle: {
color: '#909399'
},
@@ -314,7 +317,7 @@ const timelineWordsChartOption: any = {
}
},
yAxis: {
name: '出现次数',
name: t('system.statistics.occurrenceCount'),
nameTextStyle: {
color: '#909399'
},
@@ -405,7 +408,7 @@ onMounted(async () => {
timelineWordsChartOption.baseOption.timeline.data = timelineData.timelineWords.map((item: any) => item._id)
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)},
series: {data: item.keys.map((keyItem: any) => keyItem.total)}
}))
+23 -21
View File
@@ -1,15 +1,15 @@
<template>
<div>
<el-form inline :model="search" @submit.prevent>
<el-form-item label="配置项">
<el-input placeholder="名称/描述" v-model="search.name" />
<el-form-item :label="t('system.config.searchLabel')">
<el-input :placeholder="t('system.config.searchPlaceholder')" v-model="search.name" />
</el-form-item>
</el-form>
<div class="btn-container">
<el-button type="primary" @click="add">添加</el-button>
<el-button type="primary" @click="add" v-permission="'config:save'" plain>{{ t('common.add') }}</el-button>
<div class="search-btn">
<el-button type="primary" @click="loadData" icon="Search">搜索</el-button>
<el-button @click="reset" icon="RefreshLeft">重置</el-button>
<el-button type="primary" @click="loadData" icon="Search" plain>{{ t('common.search') }}</el-button>
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
</div>
</div>
<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>
</template>
</el-table-column>
<el-table-column prop="name" label="配置项名称" />
<el-table-column prop="description" label="配置项描述" />
<el-table-column prop="isPublic" label="是否公开" >
<el-table-column prop="name" :label="t('system.config.tableName')" />
<el-table-column prop="description" :label="t('system.config.tableDescription')" />
<el-table-column prop="isPublic" :label="t('system.config.tableIsPublic')" >
<template #default="scope">
{{ scope.row.isPublic ? '是' : '否' }}
{{ scope.row.isPublic ? t('common.yes') : t('common.no') }}
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" >
<el-table-column prop="createdAt" :label="t('common.createdAt')" >
<template #default="scope">
{{ datetimeFormat(scope.row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" >
<el-table-column prop="updatedAt" :label="t('common.updatedAt')" >
<template #default="scope">
{{ datetimeFormat(scope.row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" >
<el-table-column :label="t('common.operation')" >
<template #default="scope">
<el-button link icon="Edit" @click="update(scope.row)" title="修改"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除"></el-button>
<el-button link icon="Edit" @click="update(scope.row)" :title="t('common.edit')" v-permission="'config:save'"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" :title="t('common.delete')" v-permission="'config:delete'"></el-button>
</template>
</el-table-column>
</el-table>
@@ -46,8 +46,8 @@
<system-config-add ref="addForm" :formData="formData" />
<template #footer>
<span class="dialog-footer">
<el-button @click="addModal = false">取消</el-button>
<el-button type="primary" @click="save" :loading="modalLoading">确定</el-button>
<el-button @click="addModal = false" plain>{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="save" :loading="modalLoading" plain>{{ t('common.confirm') }}</el-button>
</span>
</template>
</el-dialog>
@@ -55,6 +55,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import moment from 'moment'
import http from '@/utils/http'
@@ -63,6 +64,7 @@ import { SystemConfigModel } from '@/model/system/system-config'
const modalLoading = ref(false)
const loading = ref(false)
const { t } = useI18n()
const search = ref<{name?: string}>({})
const systemConfigData = ref<SystemConfigModel[]>([])
const addModal = ref(false)
@@ -94,7 +96,7 @@ function add() {
description: '',
isPublic: false
}
modalTitle.value = '新增配置项'
modalTitle.value = t('system.config.addTitle')
addModal.value = true
}
@@ -102,7 +104,7 @@ function update(row: SystemConfigModel) {
const data = Object.assign({}, row)
data.value = JSON.stringify(data.value, null, ' ')
formData.value = data
modalTitle.value = '修改配置项'
modalTitle.value = t('system.config.editTitle')
addModal.value = true
}
@@ -113,15 +115,15 @@ async function save() {
await http.post<SystemConfigModel, any>('/system/config/save', formData.value)
modalLoading.value = false
addModal.value = false
ElMessage.success("保存成功")
ElMessage.success(t('common.saveSuccess'))
loadData()
})
}
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}})
ElMessage.success('删除成功')
ElMessage.success(t('common.deleteSuccess'))
loadData()
})
}
+14 -11
View File
@@ -1,26 +1,27 @@
<template>
<div>
<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-form-item>
<el-form-item label="值" prop="value">
<el-input v-model="formData.value" type="textarea" placeholder="必须符合JSON字符串格式" :rows="4"/>
<el-form-item :label="t('system.config.formValue')" prop="value">
<el-input v-model="formData.value" type="textarea" :placeholder="t('system.config.formValuePlaceholder')" :rows="4"/>
</el-form-item>
<el-form-item label="描述">
<el-form-item :label="t('system.config.formDescription')">
<el-input v-model="formData.description" />
</el-form-item>
<el-form-item label="公开">
<el-form-item :label="t('system.config.formIsPublic')">
<el-switch
v-model="formData.isPublic"
active-text=""
inactive-text="" />
:active-text="t('common.yes')"
:inactive-text="t('common.no')" />
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import http from '@/utils/http'
import { SystemConfigModel } from '@/model/system/system-config'
import { VForm } from '@/types'
@@ -29,15 +30,17 @@ const props = defineProps<{
formData: SystemConfigModel
}>()
const { t } = useI18n()
const configForm = ref<VForm>()
const ruleValidate = computed(() => ({
name: [
{ required: true, message: '请输入配置项名称', trigger: 'blur' },
{ required: true, message: t('system.config.nameRequired'), trigger: 'blur' },
{ validator: (rule: object, value: string, callback: Function) => {
http.get<any, any>('/system/config/exists', {params: {name: value, id: props.formData._id}}).then(data => {
if(data.exists) {
callback(new Error('配置项名称已存在'))
callback(new Error(t('system.config.nameExists')))
} else {
callback()
}
@@ -46,13 +49,13 @@ const ruleValidate = computed(() => ({
}
],
value: [
{ required: true, message: '请输入配置项值', trigger: 'blur' },
{ required: true, message: t('system.config.valueRequired'), trigger: 'blur' },
{ validator: (rule: object, value: string, callback: Function) => {
try {
JSON.parse(value)
callback()
} catch (e) {
callback(new Error('值不符合JSON字符串格式'))
callback(new Error(t('system.config.valueInvalidJson')))
}
}, trigger: 'blur'
}
+172 -82
View File
@@ -1,38 +1,34 @@
<template>
<div class="page-wrapper">
<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-form-item>
</el-form>
<div class="btn-container">
<el-button type="primary" @click="add">添加</el-button>
<el-button type="primary" @click="add" v-permission="'role:save'" plain>{{ t('common.add') }}</el-button>
<div class="search-btn">
<el-button type="primary" @click="loadDataBase(true)" icon="Search">搜索</el-button>
<el-button @click="reset" icon="RefreshLeft">重置</el-button>
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
</div>
</div>
<el-table class="table-container" :data="systemRoleData" v-loading="loading" stripe>
<el-table-column prop="name" label="角色名称" />
<el-table-column prop="methods" label="允许的请求类型" >
<template #default="scope">
<el-tag type="info" v-for="method in scope.row.methods" :key="method">{{method}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" >
<el-table-column prop="name" :label="t('system.role.tableName')" />
<el-table-column prop="description" :label="t('system.role.tableDescription')" />
<el-table-column prop="createdAt" :label="t('common.createdAt')" >
<template #default="scope">
{{ datetimeFormat(scope.row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" >
<el-table-column prop="updatedAt" :label="t('common.updatedAt')" >
<template #default="scope">
{{ datetimeFormat(scope.row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" >
<el-table-column :label="t('common.operation')" >
<template #default="scope">
<el-button link icon="Edit" @click="update(scope.row)" title="修改"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除"></el-button>
<el-button link icon="Edit" @click="update(scope.row)" :title="t('common.edit')" v-permission="'role:save'"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" :title="t('common.delete')" v-permission="'role:delete'"></el-button>
</template>
</el-table-column>
</el-table>
@@ -48,41 +44,29 @@
</div>
<el-dialog v-model="addModal" :title="modalTitle" >
<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-form-item>
<el-form-item label="描述">
<el-form-item :label="t('system.role.formDescription')">
<el-input v-model="formData.description" />
</el-form-item>
<el-form-item label="允许的请求类型">
<el-select v-model="formData.methods" multiple collapse-tags>
<el-option value="GET">GET</el-option>
<el-option value="POST">POST</el-option>
<el-option value="PUT">PUT</el-option>
<el-option value="DELETE">DELETE</el-option>
</el-select>
</el-form-item>
<el-form-item label="允许的URI">
<el-input v-model="uri.include">
<template #append>
<el-button icon="Plus" @click="addUri('includeUri', uri.include)"></el-button>
</template>
</el-input>
<el-tag v-for="uri in formData.includeUri" :key="uri" closable @close="removeUri('includeUri', uri)">{{uri}}</el-tag>
</el-form-item>
<el-form-item label="禁止的URI">
<el-input v-model="uri.exclude">
<template #append>
<el-button icon="Plus" @click="addUri('excludeUri', uri.exclude)"></el-button>
</template>
</el-input>
<el-tag v-for="uri in formData.excludeUri" :key="uri" closable @close="removeUri('excludeUri', uri)">{{uri}}</el-tag>
<el-form-item :label="t('system.role.formPermission')" prop="permissions">
<el-tree
ref="permTree"
:data="permissionTreeData"
:props="treeProps"
show-checkbox
node-key="code"
:default-checked-keys="formData.permissions"
:check-strictly="true"
@check="handlePermCheckWithValidate"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="addModal = false">取消</el-button>
<el-button type="primary" @click="save" :loading="modalLoading">确定</el-button>
<el-button @click="addModal = false" plain>{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="save" :loading="modalLoading" plain>{{ t('common.confirm') }}</el-button>
</span>
</template>
</el-dialog>
@@ -90,16 +74,18 @@
</template>
<script setup lang="ts">
import { ref, reactive, nextTick } from 'vue'
import { ref, reactive, computed, nextTick } from 'vue'
import { useStore } from 'vuex'
import { useI18n } from 'vue-i18n'
import { useBaseList } from '@/model/baselist'
import { Page } from '@/model/common.dto'
import http from '@/utils/http'
import { SystemRoleModel } from '@/model/system/system-role'
import { SystemRoleModel, PermissionTreeNode } from '@/model/system/system-role'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VForm } from '@/types'
import type { VForm } from '@/types'
const store = useStore()
const { t } = useI18n()
class SystemRolePage extends Page {
name?: string
@@ -111,30 +97,37 @@ class SystemRolePage extends Page {
const { loading, modalLoading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new SystemRolePage())
const ruleValidate = {
const ruleValidate = computed(() => ({
name: [
{ required: true, message: '请输入角色名称', trigger: 'blur' }
{ required: true, message: t('system.role.nameRequired'), trigger: 'blur' }
],
}
permissions: [
{ validator: (rule: any, value: any, callback: any) => {
const permissions = getPermissionsToSave()
if (permissions.length === 0) {
callback(new Error(t('system.role.permissionRequired')))
} else {
callback()
}
}, trigger: 'change' }
]
}))
const systemRoleData = ref<SystemRoleModel[]>([])
const addModal = ref(false)
const modalTitle = ref<string | null>(null)
const uri = reactive<{
include: string | null,
exclude: string | null
}>({
include: null,
exclude: null
})
const permissionTreeData = ref<PermissionTreeNode[]>([])
const treeProps = {
children: 'children',
label: 'name'
}
const formData = reactive<SystemRoleModel>({
_id: null,
name: null,
description: null,
methods: [],
includeUri: [],
excludeUri: []
permissions: []
})
const roleForm = ref<VForm>()
const permTree = ref<any>()
async function loadData() {
loading.value = true
@@ -146,55 +139,148 @@ async function loadData() {
setLoadData(loadData)
function add() {
uri.include = null
uri.exclude = null
Object.assign(formData, {
_id: null,
name: null,
description: null,
methods: [],
includeUri: [],
excludeUri: []
permissions: []
})
modalTitle.value = '新增角色'
modalTitle.value = t('system.role.addTitle')
addModal.value = true
clearValidate()
nextTick(() => {
permTree.value?.setCheckedKeys([])
})
}
function addUri(fieldName: 'includeUri' | 'excludeUri', uriValue: string | null) {
if(!uriValue) return
if(formData[fieldName].indexOf(uriValue) === -1) {
formData[fieldName].push(uriValue)
/**
* 处理权限树勾选:
* - 勾选父节点时,自动勾选所有子节点
* - 取消父节点时,自动取消所有子节点
* - 所有子节点都被勾选时,自动勾选父节点并取消子节点勾选(只保留父code)
*/
function handlePermCheck(nodeData: PermissionTreeNode, checkState: { checkedKeys: string[], halfCheckedKeys: string[] }) {
const tree = permTree.value
if (!tree) return
const node = tree.getNode(nodeData.code)
const isChecked = node.checked
if (!nodeData.leaf) {
// 父节点操作:联动所有子节点
const childCodes = nodeData.children.map((c: PermissionTreeNode) => c.code)
if (isChecked) {
// 勾选父节点 → 勾选所有子节点
childCodes.forEach((code: string) => tree.setChecked(code, true, false))
} else {
// 取消父节点 → 取消所有子节点
childCodes.forEach((code: string) => tree.setChecked(code, false, false))
}
} else {
// 子节点操作:检查是否所有兄弟节点都被勾选
const parentNode = findParentNode(permissionTreeData.value, nodeData.code)
if (parentNode) {
const allChildCodes = parentNode.children.map(c => c.code)
const allChecked = allChildCodes.every(code => tree.getNode(code)?.checked)
if (allChecked) {
// 所有子节点勾选 → 勾选父节点
tree.setChecked(parentNode.code, true, false)
} else {
// 不是全部 → 取消父节点
tree.setChecked(parentNode.code, false, false)
}
}
}
}
function removeUri(fieldName: 'includeUri' | 'excludeUri', uriValue: string) {
let index = formData[fieldName].indexOf(uriValue)
if(index !== -1) {
formData[fieldName].splice(index, 1)
function handlePermCheckWithValidate(nodeData: PermissionTreeNode, checkState: { checkedKeys: string[], halfCheckedKeys: string[] }) {
handlePermCheck(nodeData, checkState)
roleForm.value?.validateField('permissions')
}
function findParentNode(nodes: PermissionTreeNode[], childCode: string): PermissionTreeNode | null {
for (const node of nodes) {
if (node.children.some(c => c.code === childCode)) {
return node
}
const found = findParentNode(node.children, childCode)
if (found) return found
}
return null
}
/**
* 从树中获取要保存的 permissions
* 如果父节点被勾选,只传父节点code;否则传各个被勾选的子节点code
*/
function getPermissionsToSave(): string[] {
const tree = permTree.value
if (!tree) return []
const result: string[] = []
for (const group of permissionTreeData.value) {
const parentNode = tree.getNode(group.code)
if (parentNode?.checked) {
result.push(group.code)
} else {
for (const child of group.children) {
const childNode = tree.getNode(child.code)
if (childNode?.checked) {
result.push(child.code)
}
}
}
}
return result
}
/**
* 编辑角色时,根据已有 permissions 回显树的勾选状态
* 如果有父级code(如'user'),需要勾选父节点及所有子节点
*/
function setTreeCheckedByPermissions(permissions: string[]) {
nextTick(() => {
const tree = permTree.value
if (!tree) return
tree.setCheckedKeys([])
for (const perm of permissions) {
// 查找是否是父节点
const parentGroup = permissionTreeData.value.find(g => g.code === perm)
if (parentGroup) {
// 是父节点:勾选自身和所有子节点
tree.setChecked(perm, true, false)
parentGroup.children.forEach(c => tree.setChecked(c.code, true, false))
} else {
tree.setChecked(perm, true, false)
}
}
// 再次检查:如果某个父级的所有子节点都勾选了,勾选父级
for (const group of permissionTreeData.value) {
if (!permissions.includes(group.code)) {
const allChildChecked = group.children.length > 0 && group.children.every(c => tree.getNode(c.code)?.checked)
if (allChildChecked) {
tree.setChecked(group.code, true, false)
}
}
}
})
}
function update(row: SystemRoleModel) {
uri.include = null
uri.exclude = null
Object.assign(formData, {
_id: row._id,
name: row.name,
description: row.description,
methods: row.methods,
includeUri: row.includeUri,
excludeUri: row.excludeUri
permissions: [...row.permissions]
})
modalTitle.value = '修改角色'
modalTitle.value = t('system.role.editTitle')
addModal.value = true
clearValidate()
setTreeCheckedByPermissions(row.permissions)
}
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}})
ElMessage.success('删除成功')
ElMessage.success(t('common.deleteSuccess'))
loadData()
})
}
@@ -203,10 +289,11 @@ async function save() {
roleForm.value?.validate(async (valid: boolean) => {
if (!valid) return
modalLoading.value = true
await http.post<SystemRoleModel, any>('/system/role/save', formData)
const saveData = { ...formData, permissions: getPermissionsToSave() }
await http.post<SystemRoleModel, any>('/system/role/save', saveData)
modalLoading.value = false
addModal.value = false
ElMessage.success("保存成功")
ElMessage.success(t('common.saveSuccess'))
loadData()
})
}
@@ -218,4 +305,7 @@ function clearValidate() {
}
loadData()
http.get<never, any>('/system/role/permissionTree').then(data => {
permissionTreeData.value = data
})
</script>
+29 -27
View File
@@ -1,34 +1,34 @@
<template>
<div class="page-wrapper">
<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-form-item>
</el-form>
<div class="btn-container">
<el-button type="primary" @click="add">添加</el-button>
<el-button type="primary" @click="add" v-permission="'user:save'" plain>{{ t('common.add') }}</el-button>
<div class="search-btn">
<el-button type="primary" @click="loadDataBase(true)" icon="Search">搜索</el-button>
<el-button @click="reset" icon="RefreshLeft">重置</el-button>
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
</div>
</div>
<el-table class="table-container" :data="systemUserData" v-loading="loading" stripe>
<el-table-column prop="username" label="用户名" />
<el-table-column prop="realname" label="昵称" />
<el-table-column prop="createdAt" label="创建时间" >
<el-table-column prop="username" :label="t('system.user.tableUsername')" />
<el-table-column prop="realname" :label="t('system.user.tableRealname')" />
<el-table-column prop="createdAt" :label="t('common.createdAt')" >
<template #default="scope">
{{ datetimeFormat(scope.row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" >
<el-table-column prop="updatedAt" :label="t('common.updatedAt')" >
<template #default="scope">
{{ datetimeFormat(scope.row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" >
<el-table-column :label="t('common.operation')" >
<template #default="scope">
<el-button link icon="Edit" @click="update(scope.row)" title="修改"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除"></el-button>
<el-button link icon="Edit" @click="update(scope.row)" :title="t('common.edit')" v-permission="'user:save'"></el-button>
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" :title="t('common.delete')" v-permission="'user:delete'"></el-button>
</template>
</el-table-column>
</el-table>
@@ -44,16 +44,16 @@
</div>
<el-dialog v-model="addModal" :title="modalTitle" >
<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-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-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-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-option v-for="role in roles" :key="role._id" :value="role._id" :label="role.name"></el-option>
</el-select>
@@ -61,8 +61,8 @@
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="addModal = false">取消</el-button>
<el-button type="primary" @click="save" :loading="modalLoading">确定</el-button>
<el-button @click="addModal = false" plain>{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="save" :loading="modalLoading" plain>{{ t('common.confirm') }}</el-button>
</span>
</template>
</el-dialog>
@@ -71,6 +71,7 @@
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { useStore } from 'vuex'
import { useI18n } from 'vue-i18n'
import { useBaseList } from '@/model/baselist'
import { Page } from '@/model/common.dto'
import http from '@/utils/http'
@@ -80,6 +81,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { VForm } from '@/types'
const store = useStore()
const { t } = useI18n()
class SystemUserPage extends Page {
username?: string
@@ -106,17 +108,17 @@ const userForm = ref<VForm>()
const ruleValidate = computed(() => ({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ required: true, message: t('system.user.usernameRequired'), trigger: 'blur' },
{ validator: async (rule: object, value: string, callback: Function) => {
const res = await http.get<any, any>('/system/user/exists', {params: {username: value, id: formData._id}})
res.exists ? callback(new Error('用户名已存在')) : callback()
res.exists ? callback(new Error(t('system.user.usernameExists'))) : callback()
}, trigger: 'blur'
}
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 16, message: '密码长度8~16位', trigger: 'blur' },
{ pattern: /^(?![\d]+$)(?![a-zA-Z]+$)(?![-=+_.,]+$)[\da-zA-Z-=+_.,]{8,16}$/, message: '密码由字母、数字、特殊字符中的任意两种组成', trigger: 'blur' }
{ required: true, message: t('system.user.passwordRequired'), trigger: 'blur' },
{ min: 8, max: 16, message: t('system.user.passwordLength'), 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,
roleIds: []
})
modalTitle.value = '新增用户'
modalTitle.value = t('system.user.addTitle')
addModal.value = true
clearValidate()
}
@@ -149,7 +151,7 @@ function update(row: SystemUserModel) {
realname: row.realname,
roleIds: row.roleIds
})
modalTitle.value = '修改用户'
modalTitle.value = t('system.user.editTitle')
addModal.value = true
clearValidate()
}
@@ -161,15 +163,15 @@ async function save() {
await http.post<SystemUserModel, any>('/system/user/save', formData)
modalLoading.value = false
addModal.value = false
ElMessage.success("保存成功")
ElMessage.success(t('common.saveSuccess'))
loadData()
})
}
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}})
ElMessage.success('删除成功')
ElMessage.success(t('common.deleteSuccess'))
loadData()
})
}
+18
View File
@@ -0,0 +1,18 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_API_BASE: string
readonly VITE_APP_PROXY_TARGET?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare const __APP_VERSION__: string
declare module 'vue-router' {
interface RouteMeta {
titleKey?: string
}
}
+3 -3
View File
@@ -12,11 +12,11 @@
"esModuleInterop": true,
"experimentalDecorators": true,
"lib": ["esnext", "dom"],
"types": ["node"],
"allowJs": true,
"baseUrl": "./",
"paths": {
"@/*": ["src/*"],
"vuex": ["node_modules/vuex/types/index.d.ts"]
"@/*": ["./src/*"],
"vuex": ["./node_modules/vuex/types/index.d.ts"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
+86
View File
@@ -0,0 +1,86 @@
import { execSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { defineConfig, loadEnv } from 'vite'
const srcDir = fileURLToPath(new URL('./src', import.meta.url))
function resolveCommitInfo() {
try {
return execSync('git show -s --format=%cs%h', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
} catch {
return 'unknown'
}
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
base: './',
plugins: [
vue(),
AutoImport({
dts: './auto-imports.d.ts',
resolvers: [ElementPlusResolver()]
}),
Components({
dts: './components.d.ts',
resolvers: [ElementPlusResolver()]
})
],
resolve: {
alias: {
'@': srcDir
}
},
define: {
__APP_VERSION__: JSON.stringify(resolveCommitInfo())
},
server: {
port: 8080,
proxy: {
'/api': {
target: env.VITE_APP_PROXY_TARGET,
changeOrigin: true
}
}
},
build: {
sourcemap: false,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('/node_modules/vue/') || id.includes('/node_modules/vue-router/') || id.includes('/node_modules/vuex/')) {
return 'vue'
}
if (id.includes('/node_modules/element-plus/') || id.includes('/node_modules/@element-plus/')) {
return 'element-plus'
}
if (id.includes('/node_modules/echarts/')) {
return 'echarts'
}
if (id.includes('/node_modules/hls.js/')) {
return 'media'
}
if (id.includes('/node_modules/')) {
return 'vendor'
}
if (id.includes('/src/views/system/')) {
return 'system'
}
if (id.includes('/src/views/api/')) {
return 'api'
}
if (id.includes('/src/views/debug/')) {
return 'debug'
}
}
}
}
}
}
})
-45
View File
@@ -1,45 +0,0 @@
const path = require('path')
const AutoImport = require('unplugin-auto-import/webpack')
const Components = require('unplugin-vue-components/webpack')
const { ElementPlusResolver } = require('unplugin-vue-components/resolvers')
const { defineConfig } = require('@vue/cli-service')
const { DefinePlugin } = require('webpack')
const { execSync } = require('child_process')
const commitInfo = execSync('git show -s --format=%cs%h').toString().trim()
module.exports = defineConfig({
publicPath: './',
transpileDependencies: true,
productionSourceMap: false,
configureWebpack: {
resolve: {
alias: { '@': path.resolve(__dirname, './src') }
},
plugins: [
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
new DefinePlugin({
'process.env.VERSION': `'${commitInfo}'`
})
]
},
chainWebpack: config => {
// fork-ts-checker-webpack-plugin v6 与 TypeScript 5 不兼容(无法覆写只读的 performance.mark
// 类型检查改由 tsc --noEmit 承担
config.plugins.delete('fork-ts-checker')
},
devServer: {
port: 8080,
proxy: {
'^/api': {
target: process.env.VUE_APP_PROXY_TARGET,
changeOrigin: true
}
}
}
})
+292 -5332
View File
File diff suppressed because it is too large Load Diff