feat: 接入 vue-i18n 实现全站国际化
引入 vue-i18n,支持简体中文、繁体中文、英文三种语言。 提取所有页面硬编码中文为国际化词条,Header 右上角新增语言切换下拉菜单。 语言偏好存储于 localStorage,首次访问根据 navigator.language 自动检测。 同步切换 Element Plus 组件语言,校验规则改为 computed 保证切换后实时更新。
This commit is contained in:
parent
263f8ef8d3
commit
4305eb3fe6
1
components.d.ts
vendored
1
components.d.ts
vendored
@ -31,7 +31,6 @@ declare module '@vue/runtime-core' {
|
|||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
|
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
|
||||||
ElMain: typeof import('element-plus/es')['ElMain']
|
ElMain: typeof import('element-plus/es')['ElMain']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
|
|||||||
83
package-lock.json
generated
83
package-lock.json
generated
@ -17,6 +17,7 @@
|
|||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"pretty-bytes": "^5.6.0",
|
"pretty-bytes": "^5.6.0",
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30",
|
||||||
|
"vue-i18n": "^11.4.4",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
"vuex": "^4.1.0"
|
"vuex": "^4.1.0"
|
||||||
},
|
},
|
||||||
@ -153,6 +154,67 @@
|
|||||||
"@floating-ui/core": "^1.0.5"
|
"@floating-ui/core": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@intlify/core-base": {
|
||||||
|
"version": "11.4.4",
|
||||||
|
"resolved": "http://192.168.102.20:28080/repository/npm/@intlify/core-base/-/core-base-11.4.4.tgz",
|
||||||
|
"integrity": "sha512-w/vItlylrAmhebkIbVl5YY8XMCtj8Mb2g70ttxktMYuf5AuRahgEHL2iLgLIsZBIbTSgs4hkUo7ucCL0uTJvOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/devtools-types": "11.4.4",
|
||||||
|
"@intlify/message-compiler": "11.4.4",
|
||||||
|
"@intlify/shared": "11.4.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/devtools-types": {
|
||||||
|
"version": "11.4.4",
|
||||||
|
"resolved": "http://192.168.102.20:28080/repository/npm/@intlify/devtools-types/-/devtools-types-11.4.4.tgz",
|
||||||
|
"integrity": "sha512-PcBLmGmDQsTSVV911P8upzpcLJO1CNVYi/IH6bGnLR2nA+0L963+kXN1ZrisTEnbtw2ewN6HMMSldqzjronA0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/core-base": "11.4.4",
|
||||||
|
"@intlify/shared": "11.4.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/message-compiler": {
|
||||||
|
"version": "11.4.4",
|
||||||
|
"resolved": "http://192.168.102.20:28080/repository/npm/@intlify/message-compiler/-/message-compiler-11.4.4.tgz",
|
||||||
|
"integrity": "sha512-vn0OAV9pYkJlPPmgnsSm5eAG3mL0+9C/oaded2JY9jmxBbhmUXT3TcAUY8WRgLY9Hte7lkUJKpXrVlYjMXBD2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/shared": "11.4.4",
|
||||||
|
"source-map-js": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/shared": {
|
||||||
|
"version": "11.4.4",
|
||||||
|
"resolved": "http://192.168.102.20:28080/repository/npm/@intlify/shared/-/shared-11.4.4.tgz",
|
||||||
|
"integrity": "sha512-QRUCHqda1U6aR14FR0vvXD4+4gj6+fm0AhAozvSuRCw0fCvrmCugWpgiR4xH2NI6s8am6N9p5OhirplsX8ZS3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@ -2737,6 +2799,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-i18n": {
|
||||||
|
"version": "11.4.4",
|
||||||
|
"resolved": "http://192.168.102.20:28080/repository/npm/vue-i18n/-/vue-i18n-11.4.4.tgz",
|
||||||
|
"integrity": "sha512-gIbXVSFQV4jcSJxfwdZ5zSZmZ+12CnX0K3vBkRSd6Zn+HSzCp+QwUgPwpD/uN0oKNKI9RzlUXPKVedEuMgNG0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/core-base": "11.4.4",
|
||||||
|
"@intlify/devtools-types": "11.4.4",
|
||||||
|
"@intlify/shared": "11.4.4",
|
||||||
|
"@vue/devtools-api": "^6.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "4.6.4",
|
"version": "4.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"pretty-bytes": "^5.6.0",
|
"pretty-bytes": "^5.6.0",
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30",
|
||||||
|
"vue-i18n": "^11.4.4",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
"vuex": "^4.1.0"
|
"vuex": "^4.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
15
src/App.vue
15
src/App.vue
@ -1,13 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-config-provider :locale="locale" >
|
<el-config-provider :locale="epLocale" >
|
||||||
<router-view ></router-view>
|
<router-view ></router-view>
|
||||||
</el-config-provider>
|
</el-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import zhTw from 'element-plus/es/locale/lang/zh-tw'
|
||||||
|
import en from 'element-plus/es/locale/lang/en'
|
||||||
|
|
||||||
const locale = zhCn
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
const epLocaleMap: Record<string, any> = {
|
||||||
|
'zh-CN': zhCn,
|
||||||
|
'zh-TW': zhTw,
|
||||||
|
'en': en,
|
||||||
|
}
|
||||||
|
const epLocale = computed(() => epLocaleMap[locale.value] || zhCn)
|
||||||
</script>
|
</script>
|
||||||
<style lang="less">
|
<style lang="less">
|
||||||
@import url('./static/common.less');
|
@import url('./static/common.less');
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, onBeforeUnmount, ref } from 'vue'
|
import { nextTick, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import i18n from '@/i18n'
|
||||||
import {
|
import {
|
||||||
createTianAiCaptcha,
|
createTianAiCaptcha,
|
||||||
type TianAiCaptchaConfig,
|
type TianAiCaptchaConfig,
|
||||||
@ -43,7 +44,7 @@ async function init() {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
if (!captchaBoxRef.value) {
|
if (!captchaBoxRef.value) {
|
||||||
emit('init-error', new Error('验证码容器初始化失败'))
|
emit('init-error', new Error(i18n.global.t('captcha.initFailed')))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,7 +72,7 @@ async function init() {
|
|||||||
config.onDataReady = result => emit('data-ready', result)
|
config.onDataReady = result => emit('data-ready', result)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
emit('init-error', new Error(`未知的验证码模式: ${props.mode}`))
|
emit('init-error', new Error(i18n.global.t('captcha.unknownMode', { mode: props.mode })))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
title: string
|
titleKey: string
|
||||||
path: string
|
path: string
|
||||||
permission: string // 需要的权限标识(list类型)
|
permission: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MenuGroup {
|
export interface MenuGroup {
|
||||||
name: string
|
name: string
|
||||||
title: string
|
titleKey: string
|
||||||
icon: string
|
icon: string
|
||||||
child: MenuItem[]
|
child: MenuItem[]
|
||||||
}
|
}
|
||||||
@ -14,32 +14,32 @@ export interface MenuGroup {
|
|||||||
const menus: MenuGroup[] = [
|
const menus: MenuGroup[] = [
|
||||||
{
|
{
|
||||||
name: 'system',
|
name: 'system',
|
||||||
title: '系统管理',
|
titleKey: 'menu.system.title',
|
||||||
icon: 'Operation',
|
icon: 'Operation',
|
||||||
child: [
|
child: [
|
||||||
{ title: '系统配置', path: '/system/config', permission: 'config:list' },
|
{ titleKey: 'menu.system.config', path: '/system/config', permission: 'config:list' },
|
||||||
{ title: '用户管理', path: '/system/user', permission: 'user:list' },
|
{ titleKey: 'menu.system.user', path: '/system/user', permission: 'user:list' },
|
||||||
{ title: '角色管理', path: '/system/role', permission: 'role:list' },
|
{ titleKey: 'menu.system.role', path: '/system/role', permission: 'role:list' },
|
||||||
{ title: '博客文章', path: '/system/article', permission: 'article:list' },
|
{ titleKey: 'menu.system.article', path: '/system/article', permission: 'article:list' },
|
||||||
{ title: '分析统计', path: '/system/statistics', permission: 'article:list' }
|
{ titleKey: 'menu.system.statistics', path: '/system/statistics', permission: 'article:list' }
|
||||||
]
|
]
|
||||||
},{
|
},{
|
||||||
name: 'api',
|
name: 'api',
|
||||||
title: 'API数据',
|
titleKey: 'menu.api.title',
|
||||||
icon: 'Histogram',
|
icon: 'Histogram',
|
||||||
child: [
|
child: [
|
||||||
{ title: '一言', path: '/api/hitokoto', permission: 'hitokoto:list' },
|
{ titleKey: 'menu.api.hitokoto', path: '/api/hitokoto', permission: 'hitokoto:list' },
|
||||||
{ title: '照片墙', path: '/api/photoWall', permission: 'photoWall:list' },
|
{ titleKey: 'menu.api.photoWall', path: '/api/photoWall', permission: 'photoWall:list' },
|
||||||
{ title: '图片资源库', path: '/api/sourceImage', permission: 'sourceImage:list' },
|
{ titleKey: 'menu.api.sourceImage', path: '/api/sourceImage', permission: 'sourceImage:list' },
|
||||||
{ title: '歌曲库', path: '/api/music', permission: 'music:list' }
|
{ titleKey: 'menu.api.music', path: '/api/music', permission: 'music:list' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'debug',
|
name: 'debug',
|
||||||
title: '调试工具',
|
titleKey: 'menu.debug.title',
|
||||||
icon: 'Tools',
|
icon: 'Tools',
|
||||||
child: [
|
child: [
|
||||||
{ title: '图形验证码', path: '/debug/captcha', permission: 'captcha:list' }
|
{ titleKey: 'menu.debug.captcha', path: '/debug/captcha', permission: 'captcha:list' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
49
src/i18n/index.ts
Normal file
49
src/i18n/index.ts
Normal 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
|
||||||
343
src/i18n/locales/en.ts
Normal file
343
src/i18n/locales/en.ts
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
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: {
|
||||||
|
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}',
|
||||||
|
},
|
||||||
|
}
|
||||||
343
src/i18n/locales/zh-CN.ts
Normal file
343
src/i18n/locales/zh-CN.ts
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
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: {
|
||||||
|
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}',
|
||||||
|
},
|
||||||
|
}
|
||||||
343
src/i18n/locales/zh-TW.ts
Normal file
343
src/i18n/locales/zh-TW.ts
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
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: {
|
||||||
|
uploadTooltip: '圖片格式為{exts},檔案大小不超過10MB。',
|
||||||
|
uploadBtn: '上傳圖片',
|
||||||
|
deleteSelected: '刪除選中 ({count})',
|
||||||
|
selectAll: '全選',
|
||||||
|
deselectAll: '取消全選',
|
||||||
|
invertSelect: '反選',
|
||||||
|
previewBtn: '預覽',
|
||||||
|
deleteSingleConfirm: '確認刪除「{name}」?',
|
||||||
|
deleteMultiConfirm: '確認刪除選中的 {count} 張圖片?',
|
||||||
|
},
|
||||||
|
sourceImage: {
|
||||||
|
uploadTooltip: '圖片格式為{exts},檔案大小不超過10MB。',
|
||||||
|
uploadBtn: '上傳圖片',
|
||||||
|
batchDeleteBtn: '批次刪除',
|
||||||
|
tableSize: '檔案大小',
|
||||||
|
tableLabel: '標籤',
|
||||||
|
tableUploadTime: '上傳時間',
|
||||||
|
modifyTagsTitle: '修改標籤',
|
||||||
|
modifyTagsBtn: '修改標籤',
|
||||||
|
previewBtn: '預覽',
|
||||||
|
availableLabels: '可選標籤',
|
||||||
|
selectedLabels: '已選標籤',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
captcha: {
|
||||||
|
startBtn: '開始驗證',
|
||||||
|
refreshBtn: '重新整理',
|
||||||
|
destroyBtn: '銷毀',
|
||||||
|
typeCardTitle: '驗證碼類型',
|
||||||
|
typeHint: '可同時勾選多種類型;不選時將按隨機類型請求。',
|
||||||
|
restoreRandom: '恢復隨機',
|
||||||
|
previewCardTitle: '驗證碼預覽',
|
||||||
|
typeSlider: '滑塊拼圖',
|
||||||
|
typeRotate: '旋轉',
|
||||||
|
typeConcat: '拼接',
|
||||||
|
typeWordClick: '漢字點選',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
captcha: {
|
||||||
|
initFailed: '驗證碼容器初始化失敗',
|
||||||
|
unknownMode: '未知的驗證碼模式: {mode}',
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -11,11 +11,11 @@ import 'element-plus/theme-chalk/el-image-viewer.css'
|
|||||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
import { permissionDirective } from '@/utils/permission'
|
import { permissionDirective } from '@/utils/permission'
|
||||||
|
import i18n from '@/i18n'
|
||||||
|
|
||||||
// 全局路由导航前置守卫
|
|
||||||
router.beforeEach(function (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) {
|
router.beforeEach(function (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) {
|
||||||
if (to.meta?.title) {
|
if (to.meta?.titleKey) {
|
||||||
store.commit('addTab', { title: to.meta.title, path: to.path, name: to.name })
|
store.commit('addTab', { title: to.meta.titleKey, path: to.path, name: to.name })
|
||||||
}
|
}
|
||||||
store.state.activeTab = to.path
|
store.state.activeTab = to.path
|
||||||
if(filterExclude.indexOf(to.path) !== -1 || store.state.loginInfo.token) {
|
if(filterExclude.indexOf(to.path) !== -1 || store.state.loginInfo.token) {
|
||||||
@ -30,7 +30,8 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
|||||||
app.component(key, component)
|
app.component(key, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(router)
|
app.use(i18n)
|
||||||
|
.use(router)
|
||||||
.use(store)
|
.use(store)
|
||||||
.directive('loading', ElLoading.directive)
|
.directive('loading', ElLoading.directive)
|
||||||
.directive('permission', permissionDirective)
|
.directive('permission', permissionDirective)
|
||||||
|
|||||||
@ -8,18 +8,18 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
{ path: '/login', name: 'Login', component: Login },
|
{ path: '/login', name: 'Login', component: Login },
|
||||||
{ path: '/', name: 'Home', component: Home, children: [
|
{ path: '/', name: 'Home', component: Home, children: [
|
||||||
{ path: '/', name: 'Welcome', component: Welcome },
|
{ path: '/', name: 'Welcome', component: Welcome },
|
||||||
{ path: '/system/user', name: 'SystemUser', component: () => import('@/views/system/SystemUser.vue'), meta: { title: '用户管理' } },
|
{ path: '/system/user', name: 'SystemUser', component: () => import('@/views/system/SystemUser.vue'), meta: { titleKey: 'route.systemUser' } },
|
||||||
{ path: '/system/role', name: 'SystemRole', component: () => import('@/views/system/SystemRole.vue'), meta: { title: '角色管理' } },
|
{ path: '/system/role', name: 'SystemRole', component: () => import('@/views/system/SystemRole.vue'), meta: { titleKey: 'route.systemRole' } },
|
||||||
{ path: '/system/config', name: 'SystemConfig', component: () => import('@/views/system/SystemConfig.vue'), meta: { title: '系统配置' } },
|
{ path: '/system/config', name: 'SystemConfig', component: () => import('@/views/system/SystemConfig.vue'), meta: { titleKey: 'route.systemConfig' } },
|
||||||
{ path: '/system/article', name: 'Article', component: () => import('@/views/system/Article.vue'), meta: { title: '博客文章' } },
|
{ path: '/system/article', name: 'Article', component: () => import('@/views/system/Article.vue'), meta: { titleKey: 'route.article' } },
|
||||||
{ path: '/system/statistics', name: 'Statistics', component: () => import('@/views/system/Statistics.vue'), meta: { title: '分析统计' } },
|
{ path: '/system/statistics', name: 'Statistics', component: () => import('@/views/system/Statistics.vue'), meta: { titleKey: 'route.statistics' } },
|
||||||
|
|
||||||
{ path: '/api/music', name: 'Music', component: () => import('@/views/api/Music.vue'), meta: { title: '歌曲库' } },
|
{ path: '/api/music', name: 'Music', component: () => import('@/views/api/Music.vue'), meta: { titleKey: 'route.music' } },
|
||||||
{ path: '/api/hitokoto', name: 'Hitokoto', component: () => import('@/views/api/Hitokoto.vue'), meta: { title: '一言' } },
|
{ path: '/api/hitokoto', name: 'Hitokoto', component: () => import('@/views/api/Hitokoto.vue'), meta: { titleKey: 'route.hitokoto' } },
|
||||||
{ path: '/api/photoWall', name: 'PhotoWall', component: () => import('@/views/api/PhotoWall.vue'), meta: { title: '照片墙' } },
|
{ path: '/api/photoWall', name: 'PhotoWall', component: () => import('@/views/api/PhotoWall.vue'), meta: { titleKey: 'route.photoWall' } },
|
||||||
{ path: '/api/sourceImage', name: 'SourceImage', component: () => import('@/views/api/SourceImage.vue'), meta: { title: '图片资源库' } },
|
{ path: '/api/sourceImage', name: 'SourceImage', component: () => import('@/views/api/SourceImage.vue'), meta: { titleKey: 'route.sourceImage' } },
|
||||||
|
|
||||||
{ path: '/debug/captcha', name: 'CaptchaSandbox', component: () => import('@/views/debug/CaptchaSandbox.vue'), meta: { title: '图形验证码' } },
|
{ path: '/debug/captcha', name: 'CaptchaSandbox', component: () => import('@/views/debug/CaptchaSandbox.vue'), meta: { titleKey: 'route.captcha' } },
|
||||||
]}
|
]}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import axios from 'axios'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
import { router } from '@/router'
|
import { router } from '@/router'
|
||||||
|
import i18n from '@/i18n'
|
||||||
|
|
||||||
const http = axios.create({
|
const http = axios.create({
|
||||||
baseURL: import.meta.env.VITE_APP_API_BASE,
|
baseURL: import.meta.env.VITE_APP_API_BASE,
|
||||||
@ -11,7 +12,6 @@ const http = axios.create({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 添加请求拦截器
|
|
||||||
http.interceptors.request.use(config => {
|
http.interceptors.request.use(config => {
|
||||||
const token = store.state.loginInfo.token
|
const token = store.state.loginInfo.token
|
||||||
if (token !== null) {
|
if (token !== null) {
|
||||||
@ -19,28 +19,24 @@ http.interceptors.request.use(config => {
|
|||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
}, err => {
|
}, err => {
|
||||||
ElMessage.error('请求超时,请稍后再试')
|
ElMessage.error(i18n.global.t('http.timeout'))
|
||||||
return Promise.reject(err)
|
return Promise.reject(err)
|
||||||
})
|
})
|
||||||
|
|
||||||
http.interceptors.response.use(res => {
|
http.interceptors.response.use(res => {
|
||||||
const responseBody = res.data
|
const responseBody = res.data
|
||||||
// 统一响应格式处理
|
|
||||||
switch (responseBody.code) {
|
switch (responseBody.code) {
|
||||||
case 0:
|
case 0:
|
||||||
// 成功,直接返回数据
|
|
||||||
return responseBody.data
|
return responseBody.data
|
||||||
case -1:
|
case -1:
|
||||||
// 失败,显示错误信息
|
ElMessage.error(responseBody.message || i18n.global.t('http.failed'))
|
||||||
ElMessage.error(responseBody.message || '请求失败')
|
return Promise.reject(new Error(responseBody.message || i18n.global.t('http.failed')))
|
||||||
return Promise.reject(new Error(responseBody.message || '请求失败'))
|
|
||||||
default:
|
default:
|
||||||
// 其他情况,兼容没有包装格式的响应
|
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
}, err => {
|
}, err => {
|
||||||
if (err.response?.status >= 500) {
|
if (err.response?.status >= 500) {
|
||||||
ElMessage.error('服务器内部错误')
|
ElMessage.error(i18n.global.t('http.serverError'))
|
||||||
} else if (err.response?.status >= 400) {
|
} else if (err.response?.status >= 400) {
|
||||||
const message = err.response.data?.message
|
const message = err.response.data?.message
|
||||||
message && ElMessage.error(message)
|
message && ElMessage.error(message)
|
||||||
|
|||||||
@ -1,8 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-container class="layout">
|
<el-container class="layout">
|
||||||
<el-header class="layout-header">
|
<el-header class="layout-header">
|
||||||
<div class="main-title">博客管理后台</div>
|
<div class="main-title">{{ t('layout.header.title') }}</div>
|
||||||
<div class="nav-btns-right">
|
<div class="nav-btns-right">
|
||||||
|
<el-dropdown @command="handleLangChange" style="margin-right: 12px;">
|
||||||
|
<el-button link style="color: #fff; font-size: 14px;" plain>
|
||||||
|
{{ currentLangLabel }}
|
||||||
|
<ArrowDown style="width: 14px; margin-left: 4px;" />
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item v-for="lang in languageOptions" :key="lang.value" :command="lang.value" :disabled="lang.value === locale">
|
||||||
|
{{ lang.label }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
<el-button link @click="toggleDarkMode" style="color: #fff; font-size: 18px; margin-right: 12px;" plain>
|
<el-button link @click="toggleDarkMode" style="color: #fff; font-size: 18px; margin-right: 12px;" plain>
|
||||||
<Moon v-if="!isDark" style="width: 18px" />
|
<Moon v-if="!isDark" style="width: 18px" />
|
||||||
<Sunny v-else style="width: 18px" />
|
<Sunny v-else style="width: 18px" />
|
||||||
@ -15,10 +28,10 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item command="home">返回首页</el-dropdown-item>
|
<el-dropdown-item command="home">{{ t('layout.header.backHome') }}</el-dropdown-item>
|
||||||
<el-dropdown-item command="changePassword">修改密码</el-dropdown-item>
|
<el-dropdown-item command="changePassword">{{ t('layout.header.changePassword') }}</el-dropdown-item>
|
||||||
<el-dropdown-item command="about">关于</el-dropdown-item>
|
<el-dropdown-item command="about">{{ t('layout.header.about') }}</el-dropdown-item>
|
||||||
<el-dropdown-item command="logout" divided>退出</el-dropdown-item>
|
<el-dropdown-item command="logout" divided>{{ t('layout.header.logout') }}</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
@ -30,10 +43,10 @@
|
|||||||
<el-sub-menu v-for="menu in menus" :key="menu.name" :index="menu.name">
|
<el-sub-menu v-for="menu in menus" :key="menu.name" :index="menu.name">
|
||||||
<template #title>
|
<template #title>
|
||||||
<component :is="menu.icon" style="width: 18px; margin-right: 5px;"></component>
|
<component :is="menu.icon" style="width: 18px; margin-right: 5px;"></component>
|
||||||
<span>{{menu.title}}</span>
|
<span>{{ t(menu.titleKey) }}</span>
|
||||||
</template>
|
</template>
|
||||||
<el-menu-item v-for="(subItem,subIndex) in menu.child" :key="subIndex" :index="subItem.path" @click="openMenu(subItem.path)">
|
<el-menu-item v-for="(subItem,subIndex) in menu.child" :key="subIndex" :index="subItem.path" @click="openMenu(subItem.path)">
|
||||||
{{subItem.title}}
|
{{ t(subItem.titleKey) }}
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
@ -41,8 +54,8 @@
|
|||||||
<el-main class="layout-main">
|
<el-main class="layout-main">
|
||||||
<div class="layout-tabs">
|
<div class="layout-tabs">
|
||||||
<el-tabs type="card" class="nav-tabs" v-model="store.state.activeTab" @tab-change="openMenu" @tab-remove="removeTab">
|
<el-tabs type="card" class="nav-tabs" v-model="store.state.activeTab" @tab-change="openMenu" @tab-remove="removeTab">
|
||||||
<el-tab-pane label="首页" name="/"></el-tab-pane>
|
<el-tab-pane :label="t('layout.tabs.home')" name="/"></el-tab-pane>
|
||||||
<el-tab-pane v-for="tab in store.state.tabs" :key="tab.name" :label="tab.title" :name="tab.path" closable></el-tab-pane>
|
<el-tab-pane v-for="tab in store.state.tabs" :key="tab.name" :label="t(tab.title)" :name="tab.path" closable></el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</div>
|
</div>
|
||||||
<div class="layout-content">
|
<div class="layout-content">
|
||||||
@ -56,32 +69,32 @@
|
|||||||
</el-container>
|
</el-container>
|
||||||
</el-container>
|
</el-container>
|
||||||
|
|
||||||
<el-dialog v-model="changePwdModal" title="修改密码" width="420px" @closed="resetPwdForm">
|
<el-dialog v-model="changePwdModal" :title="t('layout.passwordDialog.title')" width="420px" @closed="resetPwdForm">
|
||||||
<el-form ref="changePwdForm" :model="changePwdData" :rules="changePwdRules" :label-width="100">
|
<el-form ref="changePwdForm" :model="changePwdData" :rules="changePwdRules" :label-width="100">
|
||||||
<el-form-item label="旧密码" prop="oldPassword">
|
<el-form-item :label="t('layout.passwordDialog.oldPasswordLabel')" prop="oldPassword">
|
||||||
<el-input v-model="changePwdData.oldPassword" type="password" show-password />
|
<el-input v-model="changePwdData.oldPassword" type="password" show-password />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="新密码" prop="newPassword">
|
<el-form-item :label="t('layout.passwordDialog.newPasswordLabel')" prop="newPassword">
|
||||||
<el-input v-model="changePwdData.newPassword" type="password" show-password />
|
<el-input v-model="changePwdData.newPassword" type="password" show-password />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="确认密码" prop="confirmPassword">
|
<el-form-item :label="t('layout.passwordDialog.confirmPasswordLabel')" prop="confirmPassword">
|
||||||
<el-input v-model="changePwdData.confirmPassword" type="password" show-password />
|
<el-input v-model="changePwdData.confirmPassword" type="password" show-password />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="changePwdModal = false" plain>取消</el-button>
|
<el-button @click="changePwdModal = false" plain>{{ t('layout.passwordDialog.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="submitChangePassword" :loading="changePwdLoading" plain>确定</el-button>
|
<el-button type="primary" @click="submitChangePassword" :loading="changePwdLoading" plain>{{ t('layout.passwordDialog.confirm') }}</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="aboutModal" title="关于" width="420px">
|
<el-dialog v-model="aboutModal" :title="t('layout.aboutDialog.title')" width="420px">
|
||||||
<el-descriptions :column="1" border>
|
<el-descriptions :column="1" border>
|
||||||
<el-descriptions-item label="前端版本">
|
<el-descriptions-item :label="t('layout.aboutDialog.frontendVersion')">
|
||||||
{{ version }}
|
{{ version }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="后端版本">
|
<el-descriptions-item :label="t('layout.aboutDialog.backendVersion')">
|
||||||
{{ backendVersion }}
|
{{ backendVersion }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
@ -92,6 +105,8 @@
|
|||||||
import { ref, reactive, computed, nextTick } from 'vue'
|
import { ref, reactive, computed, nextTick } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { setLocale, SUPPORTED_LOCALES, type SupportedLocale } from '@/i18n'
|
||||||
import allMenus from '../config/menu'
|
import allMenus from '../config/menu'
|
||||||
import type { MenuGroup } from '../config/menu'
|
import type { MenuGroup } from '../config/menu'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
@ -102,6 +117,7 @@ import { hasPermission } from '@/utils/permission'
|
|||||||
const store = useStore()
|
const store = useStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
const version = __APP_VERSION__
|
const version = __APP_VERSION__
|
||||||
const defaultActiveMenuKey = ref<string | null>(null)
|
const defaultActiveMenuKey = ref<string | null>(null)
|
||||||
@ -110,7 +126,19 @@ const mainViewReady = ref(false)
|
|||||||
|
|
||||||
const isDark = ref(false)
|
const isDark = ref(false)
|
||||||
const aboutModal = ref(false)
|
const aboutModal = ref(false)
|
||||||
const backendVersion = ref('未知')
|
const backendVersion = ref(t('layout.aboutDialog.unknown'))
|
||||||
|
|
||||||
|
const languageOptions = [
|
||||||
|
{ value: 'zh-CN', label: '简体中文' },
|
||||||
|
{ value: 'zh-TW', label: '繁體中文' },
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
]
|
||||||
|
const currentLangLabel = computed(() => {
|
||||||
|
return languageOptions.find(l => l.value === locale.value)?.label || '简体中文'
|
||||||
|
})
|
||||||
|
function handleLangChange(lang: SupportedLocale) {
|
||||||
|
setLocale(lang)
|
||||||
|
}
|
||||||
|
|
||||||
const menus = computed((): MenuGroup[] => {
|
const menus = computed((): MenuGroup[] => {
|
||||||
return allMenus
|
return allMenus
|
||||||
@ -154,23 +182,23 @@ const changePwdData = reactive({
|
|||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: ''
|
confirmPassword: ''
|
||||||
})
|
})
|
||||||
const changePwdRules = {
|
const changePwdRules = computed(() => ({
|
||||||
oldPassword: [
|
oldPassword: [
|
||||||
{ required: true, message: '请输入旧密码', trigger: 'blur' }
|
{ required: true, message: t('layout.passwordDialog.oldPasswordRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
newPassword: [
|
newPassword: [
|
||||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
{ required: true, message: t('layout.passwordDialog.newPasswordRequired'), trigger: 'blur' },
|
||||||
{ min: 8, max: 16, message: '密码长度8~16位', trigger: 'blur' },
|
{ min: 8, max: 16, message: t('layout.passwordDialog.passwordLength'), trigger: 'blur' },
|
||||||
{ pattern: /^(?![\d]+$)(?![a-zA-Z]+$)(?![-=+_.,]+$)[\da-zA-Z-=+_.,]{8,16}$/, message: '密码由字母、数字、特殊字符中的任意两种组成', trigger: 'blur' }
|
{ pattern: /^(?![\d]+$)(?![a-zA-Z]+$)(?![-=+_.,]+$)[\da-zA-Z-=+_.,]{8,16}$/, message: t('layout.passwordDialog.passwordComplexity'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
confirmPassword: [
|
confirmPassword: [
|
||||||
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
|
{ required: true, message: t('layout.passwordDialog.confirmPasswordRequired'), trigger: 'blur' },
|
||||||
{ validator: (_rule: object, value: string, callback: Function) => {
|
{ validator: (_rule: object, value: string, callback: Function) => {
|
||||||
value !== changePwdData.newPassword ? callback(new Error('两次输入的密码不一致')) : callback()
|
value !== changePwdData.newPassword ? callback(new Error(t('layout.passwordDialog.passwordMismatch'))) : callback()
|
||||||
}, trigger: 'blur'
|
}, trigger: 'blur'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}))
|
||||||
|
|
||||||
const realname = computed((): null | string => {
|
const realname = computed((): null | string => {
|
||||||
return store.state.loginInfo.userInfo
|
return store.state.loginInfo.userInfo
|
||||||
@ -223,7 +251,7 @@ function dropdownMenuCommand(command: string): void {
|
|||||||
|
|
||||||
async function openAboutDialog(): Promise<void> {
|
async function openAboutDialog(): Promise<void> {
|
||||||
aboutModal.value = true
|
aboutModal.value = true
|
||||||
backendVersion.value = '正在获取...'
|
backendVersion.value = t('layout.aboutDialog.fetching')
|
||||||
backendVersion.value = await http.get<never, string>('/system/version')
|
backendVersion.value = await http.get<never, string>('/system/version')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -235,7 +263,7 @@ function submitChangePassword(): void {
|
|||||||
changePwdLoading.value = false
|
changePwdLoading.value = false
|
||||||
})
|
})
|
||||||
changePwdModal.value = false
|
changePwdModal.value = false
|
||||||
ElMessage.success('密码修改成功')
|
ElMessage.success(t('layout.passwordDialog.success'))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function resetPwdForm(): void {
|
function resetPwdForm(): void {
|
||||||
|
|||||||
@ -5,27 +5,27 @@
|
|||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-icon">✦</div>
|
<div class="brand-icon">✦</div>
|
||||||
<h1>Blog Admin</h1>
|
<h1>Blog Admin</h1>
|
||||||
<p>简洁 · 高效 · 专注写作</p>
|
<p>{{ t('login.brandTagline') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-right">
|
<div class="card-right">
|
||||||
<h2 class="form-title">欢迎回来</h2>
|
<h2 class="form-title">{{ t('login.welcomeBack') }}</h2>
|
||||||
<p class="form-subtitle">请登录你的管理账号</p>
|
<p class="form-subtitle">{{ t('login.subtitle') }}</p>
|
||||||
<el-form ref="loginForm" :model="userInfo" :rules="ruleValidate" label-position="top">
|
<el-form ref="loginForm" :model="userInfo" :rules="ruleValidate" label-position="top">
|
||||||
<el-form-item label="用户名" prop="username">
|
<el-form-item :label="t('login.username')" prop="username">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="userInfo.username"
|
v-model="userInfo.username"
|
||||||
placeholder="请输入用户名"
|
:placeholder="t('login.usernamePlaceholder')"
|
||||||
prefix-icon="User"
|
prefix-icon="User"
|
||||||
size="large"
|
size="large"
|
||||||
@keyup.enter="handleLogin"
|
@keyup.enter="handleLogin"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="密码" prop="password">
|
<el-form-item :label="t('login.password')" prop="password">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="userInfo.password"
|
v-model="userInfo.password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="请输入密码"
|
:placeholder="t('login.passwordPlaceholder')"
|
||||||
prefix-icon="Lock"
|
prefix-icon="Lock"
|
||||||
size="large"
|
size="large"
|
||||||
show-password
|
show-password
|
||||||
@ -33,7 +33,7 @@
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-button class="login-btn" type="primary" size="large" :loading="loading" @click="handleLogin" plain>
|
<el-button class="login-btn" type="primary" size="large" :loading="loading" @click="handleLogin" plain>
|
||||||
登 录
|
{{ t('login.loginBtn') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
@ -46,13 +46,13 @@
|
|||||||
align-center
|
align-center
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
append-to-body
|
append-to-body
|
||||||
title="安全验证"
|
:title="t('login.captchaTitle')"
|
||||||
:close-on-click-modal="!loading"
|
:close-on-click-modal="!loading"
|
||||||
:close-on-press-escape="!loading"
|
:close-on-press-escape="!loading"
|
||||||
:show-close="!loading"
|
:show-close="!loading"
|
||||||
@closed="handleCaptchaDialogClosed"
|
@closed="handleCaptchaDialogClosed"
|
||||||
>
|
>
|
||||||
<p class="captcha-dialog-subtitle">请先完成验证码校验,再继续登录。</p>
|
<p class="captcha-dialog-subtitle">{{ t('login.captchaSubtitle') }}</p>
|
||||||
<CaptchaPanel
|
<CaptchaPanel
|
||||||
ref="captchaRef"
|
ref="captchaRef"
|
||||||
class="captcha-box"
|
class="captcha-box"
|
||||||
@ -64,9 +64,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, onBeforeUnmount, reactive, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, reactive, ref } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { VForm } from '../types'
|
import type { VForm } from '../types'
|
||||||
import CaptchaPanel from '@/components/CaptchaPanel.vue'
|
import CaptchaPanel from '@/components/CaptchaPanel.vue'
|
||||||
@ -97,6 +98,7 @@ type CaptchaPanelRef = {
|
|||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
const loginForm = ref<VForm>()
|
const loginForm = ref<VForm>()
|
||||||
const captchaRef = ref<CaptchaPanelRef | null>(null)
|
const captchaRef = ref<CaptchaPanelRef | null>(null)
|
||||||
const captchaDialogVisible = ref(false)
|
const captchaDialogVisible = ref(false)
|
||||||
@ -106,21 +108,25 @@ const userInfo: LoginFormState = reactive({
|
|||||||
username: null,
|
username: null,
|
||||||
password: null
|
password: null
|
||||||
})
|
})
|
||||||
const ruleValidate = {
|
const ruleValidate = computed(() => ({
|
||||||
username: [
|
username: [
|
||||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
{ required: true, message: t('login.usernameRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
password: [
|
password: [
|
||||||
{ required: true, message: '请输入密码', trigger: 'blur' }
|
{ required: true, message: t('login.passwordRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
}
|
}))
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
// created
|
// created
|
||||||
store.commit('logout')
|
store.commit('logout')
|
||||||
store.commit('clearTabs')
|
store.commit('clearTabs')
|
||||||
|
const savedLocale = localStorage.getItem('locale')
|
||||||
|
const savedTheme = localStorage.getItem('theme_mode')
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
|
if (savedLocale) localStorage.setItem('locale', savedLocale)
|
||||||
|
if (savedTheme) localStorage.setItem('theme_mode', savedTheme)
|
||||||
|
|
||||||
async function openCaptchaDialog() {
|
async function openCaptchaDialog() {
|
||||||
captchaDialogVisible.value = true
|
captchaDialogVisible.value = true
|
||||||
@ -200,7 +206,7 @@ function handleCaptchaDialogClosed() {
|
|||||||
|
|
||||||
function handleCaptchaInitError() {
|
function handleCaptchaInitError() {
|
||||||
captchaDialogVisible.value = false
|
captchaDialogVisible.value = false
|
||||||
ElMessage.error('验证码初始化失败')
|
ElMessage.error(t('login.captchaInitError'))
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCaptchaError(error: unknown) {
|
function isCaptchaError(error: unknown) {
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="welcome-page">
|
<div class="welcome-page">
|
||||||
<div class="welcome-header">
|
<div class="welcome-header">
|
||||||
<h2>欢迎回来 👋</h2>
|
<h2>{{ t('welcome.heading') }} 👋</h2>
|
||||||
<p>选择下方快捷入口,开始管理你的博客</p>
|
<p>{{ t('welcome.subtitle') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-sections">
|
<div class="menu-sections">
|
||||||
<div class="menu-section" v-for="menu in menus" :key="menu.name">
|
<div class="menu-section" v-for="menu in menus" :key="menu.name">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<el-icon><component :is="menu.icon" /></el-icon>
|
<el-icon><component :is="menu.icon" /></el-icon>
|
||||||
<span>{{ menu.title }}</span>
|
<span>{{ t(menu.titleKey) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-items">
|
<div class="section-items">
|
||||||
<router-link
|
<router-link
|
||||||
@ -17,7 +17,7 @@
|
|||||||
:key="submenu.path"
|
:key="submenu.path"
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
>
|
>
|
||||||
<span class="item-label">{{ submenu.title }}</span>
|
<span class="item-label">{{ t(submenu.titleKey) }}</span>
|
||||||
<el-icon class="item-arrow"><ArrowRight /></el-icon>
|
<el-icon class="item-arrow"><ArrowRight /></el-icon>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@ -27,10 +27,13 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import allMenus from '../config/menu'
|
import allMenus from '../config/menu'
|
||||||
import type { MenuGroup } from '../config/menu'
|
import type { MenuGroup } from '../config/menu'
|
||||||
import { hasPermission } from '@/utils/permission'
|
import { hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const menus = computed((): MenuGroup[] => {
|
const menus = computed((): MenuGroup[] => {
|
||||||
return allMenus
|
return allMenus
|
||||||
.map(group => ({
|
.map(group => ({
|
||||||
|
|||||||
@ -1,43 +1,43 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<el-form inline :model="search">
|
<el-form inline :model="search">
|
||||||
<el-form-item label="内容">
|
<el-form-item :label="t('api.hitokoto.contentLabel')">
|
||||||
<el-input v-model="search.hitokoto" />
|
<el-input v-model="search.hitokoto" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="类型">
|
<el-form-item :label="t('api.hitokoto.typeLabel')">
|
||||||
<el-select v-model="search.types" multiple collapse-tags>
|
<el-select v-model="search.types" multiple collapse-tags>
|
||||||
<el-option v-for="item in typeList" :key="item.value" :value="item.value" :label="item.label" />
|
<el-option v-for="item in typeList" :key="item.value" :value="item.value" :label="item.label" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="创建时间">
|
<el-form-item :label="t('api.hitokoto.createdAtLabel')">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="search.createdAt"
|
v-model="search.createdAt"
|
||||||
type="daterange"
|
type="daterange"
|
||||||
range-separator="至"
|
:range-separator="t('api.hitokoto.rangeSeparator')"
|
||||||
start-placeholder="开始日期"
|
:start-placeholder="t('api.hitokoto.startDate')"
|
||||||
end-placeholder="结束日期" />
|
:end-placeholder="t('api.hitokoto.endDate')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="btn-container">
|
<div class="btn-container">
|
||||||
<el-button type="primary" @click="addModal = true" v-permission="'hitokoto:save'" plain>添加</el-button>
|
<el-button type="primary" @click="addModal = true" v-permission="'hitokoto:save'" plain>{{ t('common.add') }}</el-button>
|
||||||
<el-button type="danger" @click="deleteAll" v-permission="'hitokoto:delete'" plain>删除</el-button>
|
<el-button type="danger" @click="deleteAll" v-permission="'hitokoto:delete'" plain>{{ t('api.hitokoto.deleteBtn') }}</el-button>
|
||||||
<div class="search-btn">
|
<div class="search-btn">
|
||||||
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
|
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
|
||||||
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
|
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table class="table-container" :data="hitokotoData" v-loading="loading" stripe @selection-change="dataSelect">
|
<el-table class="table-container" :data="hitokotoData" v-loading="loading" stripe @selection-change="dataSelect">
|
||||||
<el-table-column type="selection" width="55" />
|
<el-table-column type="selection" width="55" />
|
||||||
<el-table-column prop="type" label="类型" width="180">
|
<el-table-column prop="type" :label="t('api.hitokoto.tableType')" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ findTypeText(scope.row.type) }}
|
{{ findTypeText(scope.row.type) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="hitokoto" label="内容" />
|
<el-table-column prop="hitokoto" :label="t('api.hitokoto.tableContent')" />
|
||||||
<el-table-column prop="from" label="来自" width="180"/>
|
<el-table-column prop="from" :label="t('api.hitokoto.tableFrom')" width="180"/>
|
||||||
<el-table-column prop="creator" label="作者" width="180"/>
|
<el-table-column prop="creator" :label="t('api.hitokoto.tableCreator')" width="180"/>
|
||||||
<el-table-column prop="number" label="编号" width="70"/>
|
<el-table-column prop="number" :label="t('api.hitokoto.tableNumber')" width="70"/>
|
||||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
<el-table-column prop="createdAt" :label="t('api.hitokoto.createdAtLabel')" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.createdAt) }}
|
{{ datetimeFormat(scope.row.createdAt) }}
|
||||||
</template>
|
</template>
|
||||||
@ -53,12 +53,12 @@
|
|||||||
@current-change="pageChange">
|
@current-change="pageChange">
|
||||||
</el-pagination>
|
</el-pagination>
|
||||||
</div>
|
</div>
|
||||||
<el-dialog v-model="addModal" title="新增一言" >
|
<el-dialog v-model="addModal" :title="t('api.hitokoto.addTitle')" >
|
||||||
<hitokoto-add ref="addForm" :typeList="typeList" :formData="formData" />
|
<hitokoto-add ref="addForm" :typeList="typeList" :formData="formData" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="addModal = false" plain>取消</el-button>
|
<el-button @click="addModal = false" plain>{{ t('common.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="save" :loading="modalLoading" plain>确定</el-button>
|
<el-button type="primary" @click="save" :loading="modalLoading" plain>{{ t('common.confirm') }}</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@ -67,6 +67,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import HitokotoAdd from './HitokotoAdd.vue'
|
import HitokotoAdd from './HitokotoAdd.vue'
|
||||||
import { useBaseList } from '@/model/baselist'
|
import { useBaseList } from '@/model/baselist'
|
||||||
import { Page } from '@/model/common.dto'
|
import { Page } from '@/model/common.dto'
|
||||||
@ -74,6 +75,8 @@ import HitokotoModel from '@/model/api/hitokoto'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
class HitokotoPage extends Page {
|
class HitokotoPage extends Page {
|
||||||
hitokoto?: string
|
hitokoto?: string
|
||||||
types?: string[]
|
types?: string[]
|
||||||
@ -121,19 +124,19 @@ async function save() {
|
|||||||
const data = await http.post<any, any>('/hitokoto/save', formData)
|
const data = await http.post<any, any>('/hitokoto/save', formData)
|
||||||
modalLoading.value = false
|
modalLoading.value = false
|
||||||
addModal.value = false
|
addModal.value = false
|
||||||
ElMessage.success('保存成功')
|
ElMessage.success(t('common.saveSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
Object.keys(formData).forEach(key => delete formData[key])
|
Object.keys(formData).forEach(key => delete formData[key])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function deleteAll() {
|
function deleteAll() {
|
||||||
if (!selectedData.length) {
|
if (!selectedData.length) {
|
||||||
ElMessage.warning('请选择要删除的数据')
|
ElMessage.warning(t('common.selectToDelete'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ElMessageBox.confirm(`是否确认删除选中的${selectedData.length}条数据?`, '确认删除', {type: 'warning'}).then(async () => {
|
ElMessageBox.confirm(t('common.deleteSelectedConfirm', { count: selectedData.length }), t('common.deleteConfirm'), {type: 'warning'}).then(async () => {
|
||||||
await http.delete<any, any>('/hitokoto/delete', {params: {ids: selectedData}})
|
await http.delete<any, any>('/hitokoto/delete', {params: {ids: selectedData}})
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-form ref="hitokotoForm" :model="formData" :rules="ruleValidate" :label-width="80">
|
<el-form ref="hitokotoForm" :model="formData" :rules="ruleValidate" :label-width="80">
|
||||||
<el-form-item label="内容" prop="hitokoto">
|
<el-form-item :label="t('api.hitokoto.formContent')" prop="hitokoto">
|
||||||
<el-input v-model="formData.hitokoto" type="textarea" :rows="4"/>
|
<el-input v-model="formData.hitokoto" type="textarea" :rows="4"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="类型" prop="type">
|
<el-form-item :label="t('api.hitokoto.formType')" prop="type">
|
||||||
<el-select v-model="formData.type">
|
<el-select v-model="formData.type">
|
||||||
<el-option v-for="item in typeList" :value="item.value" :key="item.value" :label="item.label"></el-option>
|
<el-option v-for="item in typeList" :value="item.value" :key="item.value" :label="item.label"></el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="来自">
|
<el-form-item :label="t('api.hitokoto.formFrom')">
|
||||||
<el-input v-model="formData.from" />
|
<el-input v-model="formData.from" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="作者">
|
<el-form-item :label="t('api.hitokoto.formCreator')">
|
||||||
<el-input v-model="formData.creator" />
|
<el-input v-model="formData.creator" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { VForm } from '@/types'
|
import type { VForm } from '@/types'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
typeList: {label: string, value: string}[]
|
typeList: {label: string, value: string}[]
|
||||||
formData: {[propName: string]: string | null}
|
formData: {[propName: string]: string | null}
|
||||||
@ -29,14 +32,14 @@ defineProps<{
|
|||||||
|
|
||||||
const hitokotoForm = ref<VForm>()
|
const hitokotoForm = ref<VForm>()
|
||||||
|
|
||||||
const ruleValidate = {
|
const ruleValidate = computed(() => ({
|
||||||
hitokoto: [
|
hitokoto: [
|
||||||
{ required: true, message: '请输入内容', trigger: 'blur' }
|
{ required: true, message: t('api.hitokoto.contentRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
type: [
|
type: [
|
||||||
{ required: true, message: '请选择类型', trigger: 'blur' }
|
{ required: true, message: t('api.hitokoto.typeRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
}
|
}))
|
||||||
|
|
||||||
defineExpose({ hitokotoForm })
|
defineExpose({ hitokotoForm })
|
||||||
</script>
|
</script>
|
||||||
@ -1,53 +1,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<el-form inline :model="search">
|
<el-form inline :model="search">
|
||||||
<el-form-item label="名称/标题">
|
<el-form-item :label="t('api.music.searchNameTitle')">
|
||||||
<el-input v-model="search.title" />
|
<el-input v-model="search.title" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="所属歌单">
|
<el-form-item :label="t('api.music.searchLibLabel')">
|
||||||
<el-select v-model="search.libIds" multiple collapse-tags clearable>
|
<el-select v-model="search.libIds" multiple collapse-tags clearable>
|
||||||
<el-option v-for="musicLib in musicLibs" :key="musicLib._id" :value="musicLib._id" :label="musicLib.name" />
|
<el-option v-for="musicLib in musicLibs" :key="musicLib._id" :value="musicLib._id" :label="musicLib.name" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="文件类型">
|
<el-form-item :label="t('api.music.searchExtLabel')">
|
||||||
<el-select v-model="search.exts" multiple collapse-tags clearable>
|
<el-select v-model="search.exts" multiple collapse-tags clearable>
|
||||||
<el-option v-for="ext in exts" :key="ext" :value="ext" :label="ext" />
|
<el-option v-for="ext in exts" :key="ext" :value="ext" :label="ext" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="唱片集">
|
<el-form-item :label="t('api.music.searchAlbumLabel')">
|
||||||
<el-input v-model="search.album" />
|
<el-input v-model="search.album" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="艺术家">
|
<el-form-item :label="t('api.music.searchArtistLabel')">
|
||||||
<el-input v-model="search.artist" />
|
<el-input v-model="search.artist" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="btn-container">
|
<div class="btn-container">
|
||||||
<el-button type="success" plain icon="VideoPlay" @click="playMusic">播放</el-button>
|
<el-button type="success" plain icon="VideoPlay" @click="playMusic">{{ t('api.music.playBtn') }}</el-button>
|
||||||
<el-button type="primary" icon="Upload" @click="openUploadModal" v-permission="'music:save'" plain>上传音乐</el-button>
|
<el-button type="primary" icon="Upload" @click="openUploadModal" v-permission="'music:save'" plain>{{ t('api.music.uploadBtn') }}</el-button>
|
||||||
<el-button type="warning" plain icon="Notebook" @click="libDrawerVisible = true" v-permission="'music:save'">歌单管理</el-button>
|
<el-button type="warning" plain icon="Notebook" @click="libDrawerVisible = true" v-permission="'music:save'">{{ t('api.music.libManageBtn') }}</el-button>
|
||||||
<div class="search-btn">
|
<div class="search-btn">
|
||||||
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
|
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
|
||||||
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
|
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table class="table-container" :data="musicData" v-loading="loading" stripe @selection-change="dataSelect">
|
<el-table class="table-container" :data="musicData" v-loading="loading" stripe @selection-change="dataSelect">
|
||||||
<el-table-column type="selection" width="55" />
|
<el-table-column type="selection" width="55" />
|
||||||
<el-table-column prop="name" label="名称" show-overflow-tooltip role=""/>
|
<el-table-column prop="name" :label="t('api.music.tableName')" show-overflow-tooltip role=""/>
|
||||||
<el-table-column prop="ext" label="类型" width="80" />
|
<el-table-column prop="ext" :label="t('api.music.tableType')" width="80" />
|
||||||
<el-table-column prop="size" label="文件大小" width="110">
|
<el-table-column prop="size" :label="t('api.music.tableSize')" width="110">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ prettyBytes(scope.row.size) }}
|
{{ prettyBytes(scope.row.size) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="time" label="时长" width="110">
|
<el-table-column prop="time" :label="t('api.music.tableDuration')" width="110">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ formatDuration(scope.row.time) }}
|
{{ formatDuration(scope.row.time) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="title" label="标题" show-overflow-tooltip />
|
<el-table-column prop="title" :label="t('api.music.tableTitle')" show-overflow-tooltip />
|
||||||
<el-table-column prop="album" label="唱片集" />
|
<el-table-column prop="album" :label="t('api.music.tableAlbum')" />
|
||||||
<el-table-column prop="artist" label="艺术家" />
|
<el-table-column prop="artist" :label="t('api.music.tableArtist')" />
|
||||||
<el-table-column prop="libId" label="所属歌单" >
|
<el-table-column prop="libId" :label="t('api.music.tableLib')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<template v-if="scope.row.isEditing && currentRow">
|
<template v-if="scope.row.isEditing && currentRow">
|
||||||
<el-select v-model="currentRow.libId">
|
<el-select v-model="currentRow.libId">
|
||||||
@ -62,7 +62,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="lyricId" label="歌词" width="80" >
|
<el-table-column prop="lyricId" :label="t('api.music.tableLyric')" width="80" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div style="width: 18px">
|
<div style="width: 18px">
|
||||||
<Check v-if="scope.row.lyricId" />
|
<Check v-if="scope.row.lyricId" />
|
||||||
@ -70,11 +70,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="150" >
|
<el-table-column :label="t('common.operation')" width="150" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link icon="Document" @click="updateLyric(scope.row)" title="歌词" v-permission="'music:save'"></el-button>
|
<el-button link icon="Document" @click="updateLyric(scope.row)" :title="t('api.music.tableLyric')" v-permission="'music:save'"></el-button>
|
||||||
<el-button link icon="Download" @click="download(scope.row)" title="下载"></el-button>
|
<el-button link icon="Download" @click="download(scope.row)" :title="t('api.music.actionDownload')"></el-button>
|
||||||
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除" v-permission="'music:delete'"></el-button>
|
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" :title="t('common.delete')" v-permission="'music:delete'"></el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -88,28 +88,28 @@
|
|||||||
@current-change="pageChange">
|
@current-change="pageChange">
|
||||||
</el-pagination>
|
</el-pagination>
|
||||||
</div>
|
</div>
|
||||||
<el-dialog v-model="modifyLyricModal" title="编辑歌词" :width="600" >
|
<el-dialog v-model="modifyLyricModal" :title="t('api.music.editLyricTitle')" :width="600" >
|
||||||
<el-form v-loading="lyricLoading" ref="lyricForm" :model="lyricFormData" :rules="lyricRuleValidate" :label-width="120">
|
<el-form v-loading="lyricLoading" ref="lyricForm" :model="lyricFormData" :rules="lyricRuleValidate" :label-width="120">
|
||||||
<el-form-item label="网易云ID" prop="cloudId">
|
<el-form-item :label="t('api.music.formCloudId')" prop="cloudId">
|
||||||
<el-input v-model="lyricFormData.cloudId" />
|
<el-input v-model="lyricFormData.cloudId" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="名称" prop="name">
|
<el-form-item :label="t('api.music.formName')" prop="name">
|
||||||
<el-input v-model="lyricFormData.name" />
|
<el-input v-model="lyricFormData.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="歌词" prop="lyric">
|
<el-form-item :label="t('api.music.formLyric')" prop="lyric">
|
||||||
<el-input v-model="lyricFormData.lyric" type="textarea" :rows="4"/>
|
<el-input v-model="lyricFormData.lyric" type="textarea" :rows="4"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="modifyLyricModal = false" plain>取消</el-button>
|
<el-button @click="modifyLyricModal = false" plain>{{ t('common.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="saveLyric" :loading="modalLoading" plain>确定</el-button>
|
<el-button type="primary" @click="saveLyric" :loading="modalLoading" plain>{{ t('common.confirm') }}</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
<el-dialog v-model="uploadModal" title="上传音乐" :width="550" @closed="uploadModalClosed" >
|
<el-dialog v-model="uploadModal" :title="t('api.music.uploadTitle')" :width="550" @closed="uploadModalClosed" >
|
||||||
<el-form ref="uploadForm" :model="uploadFormData" :rules="uploadRules" :label-width="80">
|
<el-form ref="uploadForm" :model="uploadFormData" :rules="uploadRules" :label-width="80">
|
||||||
<el-form-item label="歌单" prop="libId">
|
<el-form-item :label="t('api.music.formLib')" prop="libId">
|
||||||
<el-select v-model="uploadFormData.libId">
|
<el-select v-model="uploadFormData.libId">
|
||||||
<el-option v-for="musicLib in musicLibs" :key="musicLib._id" :value="musicLib._id" :label="musicLib.name" />
|
<el-option v-for="musicLib in musicLibs" :key="musicLib._id" :value="musicLib._id" :label="musicLib.name" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@ -127,13 +127,13 @@
|
|||||||
drag
|
drag
|
||||||
:data="{libId: uploadFormData.libId}">
|
:data="{libId: uploadFormData.libId}">
|
||||||
<div class="el-upload__text">
|
<div class="el-upload__text">
|
||||||
拖拽到此处或<em>点击上传</em>
|
{{ uploadDragParts.before }}<em>{{ uploadDragParts.after }}</em>
|
||||||
</div>
|
</div>
|
||||||
<template #tip>
|
<template #tip>
|
||||||
<div class="el-upload__tip" style="display: flex; align-items: center; gap: 5px;">
|
<div class="el-upload__tip" style="display: flex; align-items: center; gap: 5px;">
|
||||||
<span>支持上传的文件类型</span>
|
<span>{{ t('api.music.uploadTip') }}</span>
|
||||||
<el-tag v-for="ext in uploadExtList" :key="ext" size="small">
|
<el-tag v-for="ext in uploadExtList" :key="ext" size="small">
|
||||||
{{ ext }}<span v-if="ext === 'ncm'"> (自动解码)</span>
|
{{ ext }}<span v-if="ext === 'ncm'"> {{ t('api.music.autoDecode') }}</span>
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -141,37 +141,37 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="uploadModal = false" plain>取消</el-button>
|
<el-button @click="uploadModal = false" plain>{{ t('common.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="uploadMusic" plain>开始上传</el-button>
|
<el-button type="primary" @click="uploadMusic" plain>{{ t('api.music.startUpload') }}</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
<el-drawer v-model="libDrawerVisible" title="歌单管理" size="900px">
|
<el-drawer v-model="libDrawerVisible" :title="t('api.music.libDrawerTitle')" size="900px">
|
||||||
<el-button type="primary" icon="Plus" @click="addLibRow" style="margin-bottom: 12px" plain>添加歌单</el-button>
|
<el-button type="primary" icon="Plus" @click="addLibRow" style="margin-bottom: 12px" plain>{{ t('api.music.addLibBtn') }}</el-button>
|
||||||
<el-table :data="libTableData" stripe>
|
<el-table :data="libTableData" stripe>
|
||||||
<el-table-column prop="name" label="名称">
|
<el-table-column prop="name" :label="t('api.music.tableName')">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-input v-if="scope.row._isNew" v-model="scope.row.name" placeholder="歌单名称" />
|
<el-input v-if="scope.row._isNew" v-model="scope.row.name" :placeholder="t('api.music.libNamePlaceholder')" />
|
||||||
<span v-else>{{ scope.row.name }}</span>
|
<span v-else>{{ scope.row.name }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="path" label="路径">
|
<el-table-column prop="path" :label="t('api.music.tablePath')">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-input v-if="scope.row._isNew" v-model="scope.row.path" placeholder="music/歌单名/" />
|
<el-input v-if="scope.row._isNew" v-model="scope.row.path" :placeholder="t('api.music.libPathPlaceholder')" />
|
||||||
<span v-else>{{ scope.row.path }}</span>
|
<span v-else>{{ scope.row.path }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="musicCount" label="歌曲数量" width="100" />
|
<el-table-column prop="musicCount" :label="t('api.music.tableMusicCount')" width="100" />
|
||||||
<el-table-column label="操作" width="160">
|
<el-table-column :label="t('common.operation')" width="160">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button v-if="scope.row._isNew" link type="primary" icon="Check" @click="saveLib(scope.row)" :loading="libSaving">保存</el-button>
|
<el-button v-if="scope.row._isNew" link type="primary" icon="Check" @click="saveLib(scope.row)" :loading="libSaving">{{ t('common.save') }}</el-button>
|
||||||
<el-button v-if="scope.row._isNew" link icon="Close" @click="cancelAddLib">取消</el-button>
|
<el-button v-if="scope.row._isNew" link icon="Close" @click="cancelAddLib">{{ t('common.cancel') }}</el-button>
|
||||||
<el-button v-if="!scope.row._isNew" link type="danger" icon="Delete" @click="removeLib(scope.row)">删除</el-button>
|
<el-button v-if="!scope.row._isNew" link type="danger" icon="Delete" @click="removeLib(scope.row)">{{ t('common.delete') }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
<el-drawer v-model="musicPlaying" :close-on-click-modal="false" size="1200px" title="播放音乐" class="music-drawer">
|
<el-drawer v-model="musicPlaying" :close-on-click-modal="false" size="1200px" :title="t('api.music.playDrawerTitle')" class="music-drawer">
|
||||||
<music-player-panel
|
<music-player-panel
|
||||||
v-if="musicPlaying && currentMusic"
|
v-if="musicPlaying && currentMusic"
|
||||||
ref="player"
|
ref="player"
|
||||||
@ -195,6 +195,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useBaseList } from '@/model/baselist'
|
import { useBaseList } from '@/model/baselist'
|
||||||
import { MsgResult, Page } from '@/model/common.dto'
|
import { MsgResult, Page } from '@/model/common.dto'
|
||||||
import { UploadInstance, ElMessage, ElMessageBox } from 'element-plus'
|
import { UploadInstance, ElMessage, ElMessageBox } from 'element-plus'
|
||||||
@ -211,6 +212,8 @@ function formatDuration(seconds?: number): string {
|
|||||||
import type { VForm } from '@/types'
|
import type { VForm } from '@/types'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
class MusicPage extends Page {
|
class MusicPage extends Page {
|
||||||
exts: string[] = []
|
exts: string[] = []
|
||||||
title?: string
|
title?: string
|
||||||
@ -250,9 +253,9 @@ const libDrawerVisible = ref(false)
|
|||||||
const libTableData = ref<(MusicLibModel & { _isNew?: boolean })[]>([])
|
const libTableData = ref<(MusicLibModel & { _isNew?: boolean })[]>([])
|
||||||
const libSaving = ref(false)
|
const libSaving = ref(false)
|
||||||
|
|
||||||
const uploadRules = {
|
const uploadRules = computed(() => ({
|
||||||
libId: [{ required: true, message: '请选择歌单', trigger: 'change' }]
|
libId: [{ required: true, message: t('api.music.libRequired'), trigger: 'change' }]
|
||||||
}
|
}))
|
||||||
|
|
||||||
const uploadExtList = computed(() => {
|
const uploadExtList = computed(() => {
|
||||||
return Array.from(new Set([...exts.value, 'ncm']))
|
return Array.from(new Set([...exts.value, 'ncm']))
|
||||||
@ -261,17 +264,24 @@ const uploadAccept = computed(() => {
|
|||||||
return uploadExtList.value.map(ext => `.${ext}`).join(',')
|
return uploadExtList.value.map(ext => `.${ext}`).join(',')
|
||||||
})
|
})
|
||||||
|
|
||||||
const lyricRuleValidate = {
|
const uploadDragParts = computed(() => {
|
||||||
|
const text = t('api.music.uploadDragText')
|
||||||
|
const idx = text.indexOf('{em}')
|
||||||
|
if (idx === -1) return { before: text, after: '' }
|
||||||
|
return { before: text.slice(0, idx), after: text.slice(idx + 4) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const lyricRuleValidate = computed(() => ({
|
||||||
cloudId: [
|
cloudId: [
|
||||||
{ required: true, message: '请输入网易云ID', trigger: 'blur' }
|
{ required: true, message: t('api.music.cloudIdRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
name: [
|
name: [
|
||||||
{ required: true, message: '请输入名称', trigger: 'blur' }
|
{ required: true, message: t('api.music.nameRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
lyric: [
|
lyric: [
|
||||||
{ required: true, message: '请输入歌词正文', trigger: 'blur' }
|
{ required: true, message: t('api.music.lyricRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
}
|
}))
|
||||||
|
|
||||||
let selectedIds: string[] = []
|
let selectedIds: string[] = []
|
||||||
|
|
||||||
@ -318,7 +328,7 @@ async function playMusic() {
|
|||||||
musicPlaying.value = true
|
musicPlaying.value = true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
ElMessage.error('获取播放列表失败')
|
ElMessage.error(t('api.music.getPlaylistFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function updateLib(row: MusicModel) {
|
function updateLib(row: MusicModel) {
|
||||||
@ -333,9 +343,9 @@ function download(row: MusicModel) {
|
|||||||
link.click()
|
link.click()
|
||||||
}
|
}
|
||||||
function remove(row: MusicModel) {
|
function remove(row: MusicModel) {
|
||||||
ElMessageBox.confirm(`是否确认删除 ${row.name} ?`, '确认删除', {type: 'warning'}).then(async () => {
|
ElMessageBox.confirm(t('common.deleteConfirmMsg', { name: row.name }), t('common.deleteConfirm'), {type: 'warning'}).then(async () => {
|
||||||
await http.delete<{params: {id: string}}, any>('/music/delete', {params: {id: row._id}})
|
await http.delete<{params: {id: string}}, any>('/music/delete', {params: {id: row._id}})
|
||||||
ElMessage.success("删除成功")
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
@ -361,7 +371,7 @@ async function saveLyric() {
|
|||||||
await http.post<MusicLyricModel, any>(`/music/lyric/save?musicId=${currentRow.value ? currentRow.value._id : ''}`, lyricFormData.value)
|
await http.post<MusicLyricModel, any>(`/music/lyric/save?musicId=${currentRow.value ? currentRow.value._id : ''}`, lyricFormData.value)
|
||||||
modalLoading.value = false
|
modalLoading.value = false
|
||||||
modifyLyricModal.value = false
|
modifyLyricModal.value = false
|
||||||
ElMessage.success("歌词保存成功")
|
ElMessage.success(t('api.music.lyricSaveSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
lyricFormData.value = {}
|
lyricFormData.value = {}
|
||||||
})
|
})
|
||||||
@ -369,7 +379,7 @@ async function saveLyric() {
|
|||||||
async function saveMusicLib(row: MusicModel) {
|
async function saveMusicLib(row: MusicModel) {
|
||||||
if (!currentRow.value) return
|
if (!currentRow.value) return
|
||||||
await http.post<{id: string, libId: string}, any>('/music/lib/update', {id: currentRow.value._id, libId: currentRow.value.libId})
|
await http.post<{id: string, libId: string}, any>('/music/lib/update', {id: currentRow.value._id, libId: currentRow.value.libId})
|
||||||
ElMessage.success("歌单更新成功")
|
ElMessage.success(t('api.music.libUpdateSuccess'))
|
||||||
row.libId = currentRow.value.libId
|
row.libId = currentRow.value.libId
|
||||||
row.isEditing = false
|
row.isEditing = false
|
||||||
}
|
}
|
||||||
@ -385,10 +395,10 @@ async function uploadMusic() {
|
|||||||
}
|
}
|
||||||
function uploadSuccess(response: MsgResult) {
|
function uploadSuccess(response: MsgResult) {
|
||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
ElMessage.success('上传成功')
|
ElMessage.success(t('common.uploadSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning(response.message || '上传失败')
|
ElMessage.warning(response.message || t('common.uploadFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function uploadError(error: Error) {
|
function uploadError(error: Error) {
|
||||||
@ -462,22 +472,22 @@ function cancelAddLib() {
|
|||||||
}
|
}
|
||||||
async function saveLib(row: MusicLibModel & { _isNew?: boolean }) {
|
async function saveLib(row: MusicLibModel & { _isNew?: boolean }) {
|
||||||
if (!row.name || !row.path) {
|
if (!row.name || !row.path) {
|
||||||
ElMessage.warning('请输入歌单名称和路径')
|
ElMessage.warning(t('api.music.libNamePathRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
libSaving.value = true
|
libSaving.value = true
|
||||||
try {
|
try {
|
||||||
await http.post<any, any>('/music/lib/add', { name: row.name, path: row.path })
|
await http.post<any, any>('/music/lib/add', { name: row.name, path: row.path })
|
||||||
ElMessage.success('歌单创建成功')
|
ElMessage.success(t('api.music.libCreateSuccess'))
|
||||||
await loadLibs()
|
await loadLibs()
|
||||||
} finally {
|
} finally {
|
||||||
libSaving.value = false
|
libSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function removeLib(row: MusicLibModel) {
|
function removeLib(row: MusicLibModel) {
|
||||||
ElMessageBox.confirm(`是否确认删除歌单「${row.name}」?`, '确认删除', { type: 'warning' }).then(async () => {
|
ElMessageBox.confirm(t('api.music.libDeleteConfirmMsg', { name: row.name }), t('common.deleteConfirm'), { type: 'warning' }).then(async () => {
|
||||||
await http.delete<any, any>('/music/lib/delete', { params: { id: row._id } })
|
await http.delete<any, any>('/music/lib/delete', { params: { id: row._id } })
|
||||||
ElMessage.success('歌单删除成功')
|
ElMessage.success(t('api.music.libDeleteSuccess'))
|
||||||
await loadLibs()
|
await loadLibs()
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,8 +14,8 @@
|
|||||||
auto-upload
|
auto-upload
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
style="margin-right: 10px;">
|
style="margin-right: 10px;">
|
||||||
<el-tooltip :content="`图片格式为${allowUploadExt.join('、')},文件大小不超过10MB。`">
|
<el-tooltip :content="t('api.photoWall.uploadTooltip', { exts: allowUploadExt.join('\u3001') })">
|
||||||
<el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'photoWall:save'" plain>上传图片</el-button>
|
<el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'photoWall:save'" plain>{{ t('api.photoWall.uploadBtn') }}</el-button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
<el-button
|
<el-button
|
||||||
@ -25,12 +25,12 @@
|
|||||||
plain
|
plain
|
||||||
v-permission="'photoWall:delete'"
|
v-permission="'photoWall:delete'"
|
||||||
@click="deleteSelected">
|
@click="deleteSelected">
|
||||||
删除选中 ({{ selected.size }})
|
{{ t('api.photoWall.deleteSelected', { count: selected.size }) }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<div class="search-btn">
|
<div class="search-btn">
|
||||||
<el-button link type="primary" :disabled="!photos.length" @click="selectAll" v-if="!isAllSelected">全选</el-button>
|
<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>取消全选</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">反选</el-button>
|
<el-button link type="primary" :disabled="!photos.length" @click="invertSelect">{{ t('api.photoWall.invertSelect') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -66,10 +66,10 @@
|
|||||||
<div class="card-overlay">
|
<div class="card-overlay">
|
||||||
<div class="overlay-info">
|
<div class="overlay-info">
|
||||||
<p class="overlay-name" :title="item.name">{{ item.name }}</p>
|
<p class="overlay-name" :title="item.name">{{ item.name }}</p>
|
||||||
<p class="overlay-size">{{ item.width }} × {{ item.height }}</p>
|
<p class="overlay-size">{{ item.width }} x {{ item.height }}</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button-group class="overlay-actions">
|
<el-button-group class="overlay-actions">
|
||||||
<el-button size="small" icon="View" plain @click.stop="preview(item)">预览</el-button>
|
<el-button size="small" icon="View" plain @click.stop="preview(item)">{{ t('api.photoWall.previewBtn') }}</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
size="small"
|
size="small"
|
||||||
icon="Delete"
|
icon="Delete"
|
||||||
@ -77,7 +77,7 @@
|
|||||||
plain
|
plain
|
||||||
v-permission="'photoWall:delete'"
|
v-permission="'photoWall:delete'"
|
||||||
@click.stop="deleteSingle(item)">
|
@click.stop="deleteSingle(item)">
|
||||||
删除
|
{{ t('common.delete') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-button-group>
|
</el-button-group>
|
||||||
</div>
|
</div>
|
||||||
@ -113,6 +113,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { MsgResult } from '@/model/common.dto'
|
import type { MsgResult } from '@/model/common.dto'
|
||||||
import { Page } from '@/model/common.dto'
|
import { Page } from '@/model/common.dto'
|
||||||
@ -120,11 +121,13 @@ import { useBaseList } from '@/model/baselist'
|
|||||||
import type PhotoWallModel from '@/model/api/photowall'
|
import type PhotoWallModel from '@/model/api/photowall'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
|
|
||||||
// ─── 常量 ─────────────────────────────────────────────
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// --- 常量 ---
|
||||||
const COLUMN_WIDTH = 220 // 每列目标宽度(px)
|
const COLUMN_WIDTH = 220 // 每列目标宽度(px)
|
||||||
const COL_GAP = 14 // 列间距(px)
|
const COL_GAP = 14 // 列间距(px)
|
||||||
|
|
||||||
// ─── 基础状态 ─────────────────────────────────────────
|
// --- 基础状态 ---
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const apiBase = import.meta.env.VITE_APP_API_BASE
|
const apiBase = import.meta.env.VITE_APP_API_BASE
|
||||||
const rootEl = ref<HTMLElement | null>(null)
|
const rootEl = ref<HTMLElement | null>(null)
|
||||||
@ -144,7 +147,7 @@ const previewVisible = ref(false)
|
|||||||
const previewUrls = ref<string[]>([])
|
const previewUrls = ref<string[]>([])
|
||||||
const previewIndex = ref(0)
|
const previewIndex = ref(0)
|
||||||
|
|
||||||
// ─── 瀑布流分列 ───────────────────────────────────────
|
// --- 瀑布流分列 ---
|
||||||
const colCount = ref(4)
|
const colCount = ref(4)
|
||||||
|
|
||||||
function recalcCols() {
|
function recalcCols() {
|
||||||
@ -161,7 +164,7 @@ const columns = computed<PhotoWallModel[][]>(() => {
|
|||||||
return cols
|
return cols
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── 数据加载 ─────────────────────────────────────────
|
// --- 数据加载 ---
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
entered.value = new Set()
|
entered.value = new Set()
|
||||||
@ -183,7 +186,7 @@ async function loadData() {
|
|||||||
}
|
}
|
||||||
setLoadData(loadData)
|
setLoadData(loadData)
|
||||||
|
|
||||||
// ─── 选中 ─────────────────────────────────────────────
|
// --- 选中 ---
|
||||||
function toggleSelect(item: PhotoWallModel) {
|
function toggleSelect(item: PhotoWallModel) {
|
||||||
const s = new Set(selected.value)
|
const s = new Set(selected.value)
|
||||||
s.has(item._id) ? s.delete(item._id) : s.add(item._id)
|
s.has(item._id) ? s.delete(item._id) : s.add(item._id)
|
||||||
@ -208,33 +211,33 @@ function clearSelect() {
|
|||||||
|
|
||||||
const isAllSelected = computed(() => photos.value.length && selected.value.size === photos.value.length)
|
const isAllSelected = computed(() => photos.value.length && selected.value.size === photos.value.length)
|
||||||
|
|
||||||
// ─── 预览 ─────────────────────────────────────────────
|
// --- 预览 ---
|
||||||
function preview(item: PhotoWallModel) {
|
function preview(item: PhotoWallModel) {
|
||||||
previewIndex.value = photos.value.findIndex(p => p._id === item._id) || 0
|
previewIndex.value = photos.value.findIndex(p => p._id === item._id) || 0
|
||||||
previewVisible.value = true
|
previewVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 删除 ─────────────────────────────────────────────
|
// --- 删除 ---
|
||||||
async function deleteSingle(item: PhotoWallModel) {
|
async function deleteSingle(item: PhotoWallModel) {
|
||||||
await ElMessageBox.confirm(`确认删除「${item.name}」?`, '确认删除', { type: 'warning' })
|
await ElMessageBox.confirm(t('api.photoWall.deleteSingleConfirm', { name: item.name }), t('common.deleteConfirm'), { type: 'warning' })
|
||||||
await http.delete('/photoWall/delete', { params: { ids: [item._id] } })
|
await http.delete('/photoWall/delete', { params: { ids: [item._id] } })
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSelected() {
|
async function deleteSelected() {
|
||||||
const ids = [...selected.value]
|
const ids = [...selected.value]
|
||||||
await ElMessageBox.confirm(`确认删除选中的 ${ids.length} 张图片?`, '确认删除', { type: 'warning' })
|
await ElMessageBox.confirm(t('api.photoWall.deleteMultiConfirm', { count: ids.length }), t('common.deleteConfirm'), { type: 'warning' })
|
||||||
await http.delete('/photoWall/delete', { params: { ids } })
|
await http.delete('/photoWall/delete', { params: { ids } })
|
||||||
selected.value = new Set()
|
selected.value = new Set()
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 上传 ─────────────────────────────────────────────
|
// --- 上传 ---
|
||||||
function beforeUpload(file: File): boolean {
|
function beforeUpload(file: File): boolean {
|
||||||
if (file.size > 10 << 20) {
|
if (file.size > 10 << 20) {
|
||||||
ElMessage.warning('文件大小超过 10MB')
|
ElMessage.warning(t('common.fileSizeExceeded'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
isUploading.value = true
|
isUploading.value = true
|
||||||
@ -243,10 +246,10 @@ function beforeUpload(file: File): boolean {
|
|||||||
function uploadSuccess(response: MsgResult) {
|
function uploadSuccess(response: MsgResult) {
|
||||||
isUploading.value = false
|
isUploading.value = false
|
||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
ElMessage.success('上传成功')
|
ElMessage.success(t('common.uploadSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning(response.message || '上传失败')
|
ElMessage.warning(response.message || t('common.uploadFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function uploadError(error: Error) {
|
function uploadError(error: Error) {
|
||||||
@ -254,7 +257,7 @@ function uploadError(error: Error) {
|
|||||||
ElMessage.error(error.message)
|
ElMessage.error(error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 响应式列数 ───────────────────────────────────────
|
// --- 响应式列数 ---
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@ -270,7 +273,7 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
// ─── 可滚动瀑布流区域 ─────────────────────────────────
|
// --- 可滚动瀑布流区域 ---
|
||||||
.masonry-scroll {
|
.masonry-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@ -292,7 +295,7 @@ onBeforeUnmount(() => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 卡片外壳(选中描边) ─────────────────────────────
|
// --- 卡片外壳(选中描边) ---
|
||||||
.card-wrap {
|
.card-wrap {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px);
|
transform: translateY(20px);
|
||||||
@ -333,7 +336,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 卡片内容 ─────────────────────────────────────────
|
// --- 卡片内容 ---
|
||||||
.card-inner {
|
.card-inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -355,7 +358,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 选中角标 ─────────────────────────────────────────
|
// --- 选中角标 ---
|
||||||
.check-badge {
|
.check-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
@ -374,7 +377,7 @@ onBeforeUnmount(() => {
|
|||||||
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Hover 浮层 ───────────────────────────────────────
|
// --- Hover 浮层 ---
|
||||||
.card-overlay {
|
.card-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@ -12,35 +12,35 @@
|
|||||||
auto-upload
|
auto-upload
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
style="margin-right: 10px;">
|
style="margin-right: 10px;">
|
||||||
<el-tooltip :content="`图片格式为${allowUploadExt.join('、')},文件大小不超过10MB。`">
|
<el-tooltip :content="t('api.sourceImage.uploadTooltip', { exts: allowUploadExt.join('\u3001') })">
|
||||||
<el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'sourceImage:save'" plain>上传图片</el-button>
|
<el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'sourceImage:save'" plain>{{ t('api.sourceImage.uploadBtn') }}</el-button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
<el-button type="danger" @click="deleteAll" style="vertical-align: bottom" v-permission="'sourceImage:delete'" plain>批量删除</el-button>
|
<el-button type="danger" @click="deleteAll" style="vertical-align: bottom" v-permission="'sourceImage:delete'" plain>{{ t('api.sourceImage.batchDeleteBtn') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table class="table-container" :data="sourceImageData" v-loading="loading" stripe @selection-change="dataSelect">
|
<el-table class="table-container" :data="sourceImageData" v-loading="loading" stripe @selection-change="dataSelect">
|
||||||
<el-table-column type="selection" width="55" />
|
<el-table-column type="selection" width="55" />
|
||||||
<el-table-column prop="hash" label="md5" width="300" />
|
<el-table-column prop="hash" label="md5" width="300" />
|
||||||
<el-table-column prop="size" label="文件大小" >
|
<el-table-column prop="size" :label="t('api.sourceImage.tableSize')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ prettyBytes(scope.row.size) }}
|
{{ prettyBytes(scope.row.size) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="mime" label="MIME" />
|
<el-table-column prop="mime" label="MIME" />
|
||||||
<el-table-column prop="label" label="标签" >
|
<el-table-column prop="label" :label="t('api.sourceImage.tableLabel')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag v-for="label in scope.row.label" :key="label" effect="plain">{{label}}</el-tag>
|
<el-tag v-for="label in scope.row.label" :key="label" effect="plain">{{label}}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createdAt" label="上传时间" >
|
<el-table-column prop="createdAt" :label="t('api.sourceImage.tableUploadTime')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.createdAt) }}
|
{{ datetimeFormat(scope.row.createdAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" >
|
<el-table-column :label="t('common.operation')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link icon="EditPen" type="primary" @click="modifyTags(scope.row)" title="修改标签" v-permission="'sourceImage:save'"></el-button>
|
<el-button link icon="EditPen" type="primary" @click="modifyTags(scope.row)" :title="t('api.sourceImage.modifyTagsBtn')" v-permission="'sourceImage:save'"></el-button>
|
||||||
<el-button link icon="View" @click="preview(scope.row)" title="预览"></el-button>
|
<el-button link icon="View" @click="preview(scope.row)" :title="t('api.sourceImage.previewBtn')"></el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -54,12 +54,12 @@
|
|||||||
@current-change="pageChange">
|
@current-change="pageChange">
|
||||||
</el-pagination>
|
</el-pagination>
|
||||||
</div>
|
</div>
|
||||||
<el-dialog v-model="modifyModal" title="修改标签" width="630px" @closed="loadData">
|
<el-dialog v-model="modifyModal" :title="t('api.sourceImage.modifyTagsTitle')" width="630px" @closed="loadData">
|
||||||
<el-transfer
|
<el-transfer
|
||||||
v-model="curModifyLabels"
|
v-model="curModifyLabels"
|
||||||
filterable
|
filterable
|
||||||
:render-content="renderFunc"
|
:render-content="renderFunc"
|
||||||
:titles="['可选标签', '已选标签']"
|
:titles="[t('api.sourceImage.availableLabels'), t('api.sourceImage.selectedLabels')]"
|
||||||
:format="{
|
:format="{
|
||||||
noChecked: '${total}',
|
noChecked: '${total}',
|
||||||
hasChecked: '${checked}/${total}',
|
hasChecked: '${checked}/${total}',
|
||||||
@ -79,6 +79,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import prettyBytes from 'pretty-bytes'
|
import prettyBytes from 'pretty-bytes'
|
||||||
import { MsgResult, Page } from '@/model/common.dto'
|
import { MsgResult, Page } from '@/model/common.dto'
|
||||||
@ -86,6 +87,8 @@ import { useBaseList } from '@/model/baselist'
|
|||||||
import { SourceImageModel } from '@/model/api/source-image'
|
import { SourceImageModel } from '@/model/api/source-image'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const apiBase = import.meta.env.VITE_APP_API_BASE
|
const apiBase = import.meta.env.VITE_APP_API_BASE
|
||||||
const { loading, total, search, setLoadData, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new Page())
|
const { loading, total, search, setLoadData, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new Page())
|
||||||
@ -124,12 +127,12 @@ setLoadData(loadData)
|
|||||||
|
|
||||||
function deleteAll(): void {
|
function deleteAll(): void {
|
||||||
if (!selectedData.length) {
|
if (!selectedData.length) {
|
||||||
ElMessage.warning('请选择要删除的数据')
|
ElMessage.warning(t('common.selectToDelete'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ElMessageBox.confirm(`是否确认删除选中的${selectedData.length}条数据?`, '确认删除', {type: 'warning'}).then(async () => {
|
ElMessageBox.confirm(t('common.deleteSelectedConfirm', { count: selectedData.length }), t('common.deleteConfirm'), {type: 'warning'}).then(async () => {
|
||||||
await http.delete('/source-image/delete', {params: {ids: selectedData}})
|
await http.delete('/source-image/delete', {params: {ids: selectedData}})
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
@ -138,7 +141,7 @@ function dataSelect(selection: SourceImageModel[]): void {
|
|||||||
}
|
}
|
||||||
function beforeUpload(file: File): boolean {
|
function beforeUpload(file: File): boolean {
|
||||||
if (file.size > 10 << 20) {
|
if (file.size > 10 << 20) {
|
||||||
ElMessage.warning('文件大小超过10MB')
|
ElMessage.warning(t('common.fileSizeExceeded'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
isUploading.value = true
|
isUploading.value = true
|
||||||
@ -146,10 +149,10 @@ function beforeUpload(file: File): boolean {
|
|||||||
}
|
}
|
||||||
function uploadSuccess(response: MsgResult): void {
|
function uploadSuccess(response: MsgResult): void {
|
||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
ElMessage.success('上传成功')
|
ElMessage.success(t('common.uploadSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning(response.message || '上传失败')
|
ElMessage.warning(response.message || t('common.uploadFailed'))
|
||||||
}
|
}
|
||||||
isUploading.value = false
|
isUploading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,8 +38,8 @@
|
|||||||
<div class="song-info">
|
<div class="song-info">
|
||||||
<h2 class="song-title">{{ currentMusic?.title || 'Untitled' }}</h2>
|
<h2 class="song-title">{{ currentMusic?.title || 'Untitled' }}</h2>
|
||||||
<div class="song-meta">
|
<div class="song-meta">
|
||||||
<span v-if="currentMusic?.album">专辑:{{ currentMusic.album }}</span>
|
<span v-if="currentMusic?.album">{{ t('api.music.player.album') }}{{ currentMusic.album }}</span>
|
||||||
<span v-if="currentMusic?.artist">歌手:{{ currentMusic.artist }}</span>
|
<span v-if="currentMusic?.artist">{{ t('api.music.player.artist') }}{{ currentMusic.artist }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lyrics-area" ref="lyricsContainer">
|
<div class="lyrics-area" ref="lyricsContainer">
|
||||||
@ -50,7 +50,7 @@
|
|||||||
:class="{ 'current-line': index === currentLineIndex }"
|
:class="{ 'current-line': index === currentLineIndex }"
|
||||||
>{{ line[1] || '' }}</p>
|
>{{ line[1] || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!lrcLines.length" class="no-lyrics">暂无歌词</div>
|
<div v-if="!lrcLines.length" class="no-lyrics">{{ t('api.music.player.noLyrics') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -67,17 +67,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- 播放控制 -->
|
<!-- 播放控制 -->
|
||||||
<div class="controls-section">
|
<div class="controls-section">
|
||||||
<button class="ctrl-btn" :class="{ active: shuffle }" @click="emit('toggleShuffle')" title="随机播放">
|
<button class="ctrl-btn" :class="{ active: shuffle }" @click="emit('toggleShuffle')" :title="t('api.music.player.shuffle')">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="ctrl-btn" @click="emit('prev')" title="上一首">
|
<button class="ctrl-btn" @click="emit('prev')" :title="t('api.music.player.prev')">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="ctrl-btn ctrl-btn-play" @click="emit('toggle')" :title="isPlaying ? '暂停' : '播放'">
|
<button class="ctrl-btn ctrl-btn-play" @click="emit('toggle')" :title="isPlaying ? t('api.music.player.pause') : t('api.music.player.play')">
|
||||||
<svg v-if="isPlaying" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
<svg v-if="isPlaying" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||||
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="ctrl-btn" @click="emit('next')" title="下一首">
|
<button class="ctrl-btn" @click="emit('next')" :title="t('api.music.player.next')">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="ctrl-btn" :class="{ active: repeatMode !== 'no_repeat' }" @click="emit('nextMode')" :title="repeatTitle">
|
<button class="ctrl-btn" :class="{ active: repeatMode !== 'no_repeat' }" @click="emit('nextMode')" :title="repeatTitle">
|
||||||
@ -85,7 +85,7 @@
|
|||||||
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
|
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="volume-control">
|
<div class="volume-control">
|
||||||
<button class="ctrl-btn" @click="emit('toggleMute')" :title="muted ? '取消静音' : '静音'">
|
<button class="ctrl-btn" @click="emit('toggleMute')" :title="muted ? t('api.music.player.unmute') : t('api.music.player.mute')">
|
||||||
<svg v-if="muted" viewBox="0 0 24 24" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>
|
<svg v-if="muted" viewBox="0 0 24 24" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>
|
||||||
<svg v-else-if="volume > 0.5" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
|
<svg v-else-if="volume > 0.5" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
|
||||||
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/></svg>
|
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/></svg>
|
||||||
@ -96,8 +96,8 @@
|
|||||||
<!-- 播放列表 -->
|
<!-- 播放列表 -->
|
||||||
<div class="playlist-section" v-if="list.length > 1">
|
<div class="playlist-section" v-if="list.length > 1">
|
||||||
<div class="playlist-header" @click="playlistVisible = !playlistVisible">
|
<div class="playlist-header" @click="playlistVisible = !playlistVisible">
|
||||||
<span>播放列表 ({{ list.length }})</span>
|
<span>{{ t('api.music.player.playlist', { count: list.length }) }}</span>
|
||||||
<span class="playlist-toggle">{{ playlistVisible ? '收起' : '展开' }}</span>
|
<span class="playlist-toggle">{{ playlistVisible ? t('api.music.player.collapse') : t('api.music.player.expand') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<transition name="slide">
|
<transition name="slide">
|
||||||
<div class="playlist-body" v-show="playlistVisible">
|
<div class="playlist-body" v-show="playlistVisible">
|
||||||
@ -122,6 +122,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount, reactive, nextTick } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount, reactive, nextTick } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { MusicPlayerItem, StatType } from '@/model/api/music'
|
import { MusicPlayerItem, StatType } from '@/model/api/music'
|
||||||
import { parseLrc, adaptingThemeColor } from './utils'
|
import { parseLrc, adaptingThemeColor } from './utils'
|
||||||
import { Particle, spawnParticles, updateAndDrawParticles, drawCircularSpectrum, formatTime } from './visualizer'
|
import { Particle, spawnParticles, updateAndDrawParticles, drawCircularSpectrum, formatTime } from './visualizer'
|
||||||
@ -135,6 +136,8 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits(['update:music', 'toggle', 'prev', 'next', 'toggleShuffle', 'nextMode', 'toggleMute', 'selectSong', 'play'])
|
const emit = defineEmits(['update:music', 'toggle', 'prev', 'next', 'toggleShuffle', 'nextMode', 'toggleMute', 'selectSong', 'play'])
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const audioEl = ref<HTMLAudioElement | null>(null)
|
const audioEl = ref<HTMLAudioElement | null>(null)
|
||||||
const progressBar = ref<HTMLDivElement | null>(null)
|
const progressBar = ref<HTMLDivElement | null>(null)
|
||||||
const visualizerCanvas = ref<HTMLCanvasElement | null>(null)
|
const visualizerCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
@ -202,9 +205,9 @@ const lyricsTransformStyle = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const repeatTitle = computed(() => {
|
const repeatTitle = computed(() => {
|
||||||
if (repeatMode.value === 'no_repeat') return '顺序播放'
|
if (repeatMode.value === 'no_repeat') return t('api.music.player.sequential')
|
||||||
if (repeatMode.value === 'repeat_one') return '单曲循环'
|
if (repeatMode.value === 'repeat_one') return t('api.music.player.repeatOne')
|
||||||
return '列表循环'
|
return t('api.music.player.repeatAll')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Audio Visualizer
|
// Audio Visualizer
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<el-button type="primary" @click="startCaptcha" plain>开始验证</el-button>
|
<el-button type="primary" @click="startCaptcha" plain>{{ t('debug.captcha.startBtn') }}</el-button>
|
||||||
<el-button @click="reloadCaptcha" :disabled="!captchaActive">刷新</el-button>
|
<el-button @click="reloadCaptcha" :disabled="!captchaActive">{{ t('debug.captcha.refreshBtn') }}</el-button>
|
||||||
<el-button @click="destroyCaptcha" :disabled="!captchaActive" plain>销毁</el-button>
|
<el-button @click="destroyCaptcha" :disabled="!captchaActive" plain>{{ t('debug.captcha.destroyBtn') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sandbox-grid">
|
<div class="sandbox-grid">
|
||||||
<el-card shadow="never">
|
<el-card shadow="never">
|
||||||
<template #header>验证码类型</template>
|
<template #header>{{ t('debug.captcha.typeCardTitle') }}</template>
|
||||||
|
|
||||||
<p class="type-hint">
|
<p class="type-hint">
|
||||||
可同时勾选多种类型;不选时将按随机类型请求。
|
{{ t('debug.captcha.typeHint') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<el-checkbox-group v-model="selectedTypes" size="large">
|
<el-checkbox-group v-model="selectedTypes" size="large">
|
||||||
@ -21,12 +21,12 @@
|
|||||||
</el-checkbox-group>
|
</el-checkbox-group>
|
||||||
|
|
||||||
<div class="type-actions">
|
<div class="type-actions">
|
||||||
<el-button link type="primary" @click="clearTypes" :disabled="selectedTypes.length === 0">恢复随机</el-button>
|
<el-button link type="primary" @click="clearTypes" :disabled="selectedTypes.length === 0">{{ t('debug.captcha.restoreRandom') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card class="preview-card" shadow="never">
|
<el-card class="preview-card" shadow="never">
|
||||||
<template #header>验证码预览</template>
|
<template #header>{{ t('debug.captcha.previewCardTitle') }}</template>
|
||||||
<CaptchaPanel
|
<CaptchaPanel
|
||||||
ref="captchaRef"
|
ref="captchaRef"
|
||||||
class="captcha-box"
|
class="captcha-box"
|
||||||
@ -43,8 +43,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, ref } from 'vue'
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
import CaptchaPanel from '@/components/CaptchaPanel.vue'
|
import CaptchaPanel from '@/components/CaptchaPanel.vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
type CaptchaType = 'SLIDER' | 'ROTATE' | 'CONCAT' | 'WORD_IMAGE_CLICK'
|
type CaptchaType = 'SLIDER' | 'ROTATE' | 'CONCAT' | 'WORD_IMAGE_CLICK'
|
||||||
|
|
||||||
@ -54,12 +57,12 @@ type CaptchaPanelRef = {
|
|||||||
reload: () => void
|
reload: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const captchaTypeOptions: Array<{ label: string, value: CaptchaType }> = [
|
const captchaTypeOptions = computed<Array<{ label: string, value: CaptchaType }>>(() => [
|
||||||
{ label: '滑块拼图', value: 'SLIDER' },
|
{ label: t('debug.captcha.typeSlider'), value: 'SLIDER' },
|
||||||
{ label: '旋转', value: 'ROTATE' },
|
{ label: t('debug.captcha.typeRotate'), value: 'ROTATE' },
|
||||||
{ label: '拼接', value: 'CONCAT' },
|
{ label: t('debug.captcha.typeConcat'), value: 'CONCAT' },
|
||||||
{ label: '汉字点选', value: 'WORD_IMAGE_CLICK' },
|
{ label: t('debug.captcha.typeWordClick'), value: 'WORD_IMAGE_CLICK' },
|
||||||
]
|
])
|
||||||
|
|
||||||
const captchaRef = ref<CaptchaPanelRef | null>(null)
|
const captchaRef = ref<CaptchaPanelRef | null>(null)
|
||||||
const captchaActive = ref(false)
|
const captchaActive = ref(false)
|
||||||
|
|||||||
@ -1,40 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<el-form inline :model="search">
|
<el-form inline :model="search">
|
||||||
<el-form-item label="标题">
|
<el-form-item :label="t('system.article.formTitle')">
|
||||||
<el-input v-model="search.title" />
|
<el-input v-model="search.title" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="创建时间">
|
<el-form-item :label="t('common.createdAt')">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="search.createDate"
|
v-model="search.createDate"
|
||||||
type="daterange"
|
type="daterange"
|
||||||
range-separator="至"
|
:range-separator="t('system.article.rangeSeparator')"
|
||||||
start-placeholder="开始日期"
|
:start-placeholder="t('system.article.startDate')"
|
||||||
end-placeholder="结束日期" />
|
:end-placeholder="t('system.article.endDate')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="分类">
|
<el-form-item :label="t('system.article.formCategory')">
|
||||||
<el-select v-model="search.category" filterable clearable>
|
<el-select v-model="search.category" filterable clearable>
|
||||||
<el-option v-for="item in categories" :key="item" :value="item" :label="item" />
|
<el-option v-for="item in categories" :key="item" :value="item" :label="item" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="标签">
|
<el-form-item :label="t('system.article.formTag')">
|
||||||
<el-select v-model="search.tag" filterable clearable>
|
<el-select v-model="search.tag" filterable clearable>
|
||||||
<el-option v-for="item in tags" :key="item" :value="item" :label="item" />
|
<el-option v-for="item in tags" :key="item" :value="item" :label="item" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="已分词">
|
<el-form-item :label="t('system.article.formIsSplited')">
|
||||||
<el-select v-model="search.isSplited" >
|
<el-select v-model="search.isSplited" >
|
||||||
<el-option :value="true" label="是" />
|
<el-option :value="true" :label="t('common.yes')" />
|
||||||
<el-option :value="false" label="否" />
|
<el-option :value="false" :label="t('common.no')" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="btn-container">
|
<div class="btn-container">
|
||||||
<el-button type="primary" @click="splitWord" style="vertical-align: bottom" v-permission="'article:edit'" plain>分词处理</el-button>
|
<el-button type="primary" @click="splitWord" style="vertical-align: bottom" v-permission="'article:edit'" plain>{{ t('system.article.splitWordBtn') }}</el-button>
|
||||||
<el-button @click="pullArticles" style="vertical-align: bottom" v-permission="'article:edit'" plain>拉取文章</el-button>
|
<el-button @click="pullArticles" style="vertical-align: bottom" v-permission="'article:edit'" plain>{{ t('system.article.pullArticlesBtn') }}</el-button>
|
||||||
<div class="search-btn">
|
<div class="search-btn">
|
||||||
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
|
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
|
||||||
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
|
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-row style="flex: 1; overflow: hidden;">
|
<el-row style="flex: 1; overflow: hidden;">
|
||||||
@ -52,29 +52,29 @@
|
|||||||
<el-col :span="20" style="height: 100%; display: flex; flex-direction: column;">
|
<el-col :span="20" style="height: 100%; display: flex; flex-direction: column;">
|
||||||
<el-table class="table-container" :data="articleData" v-loading="loading" stripe @selection-change="dataSelect" height="100%">
|
<el-table class="table-container" :data="articleData" v-loading="loading" stripe @selection-change="dataSelect" height="100%">
|
||||||
<el-table-column type="selection" width="55" />
|
<el-table-column type="selection" width="55" />
|
||||||
<el-table-column prop="title" label="标题" />
|
<el-table-column prop="title" :label="t('system.article.tableTitle')" />
|
||||||
<el-table-column prop="path" label="路径" >
|
<el-table-column prop="path" :label="t('system.article.tablePath')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ scope.row.path.join('/') }}
|
{{ scope.row.path.join('/') }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="categories" label="分类" width="150" >
|
<el-table-column prop="categories" :label="t('system.article.tableCategory')" width="150" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ typeof scope.row.categories === 'string' ? scope.row.categories : scope.row.categories?.join(',') }}
|
{{ typeof scope.row.categories === 'string' ? scope.row.categories : scope.row.categories?.join(t('system.article.comma')) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="tags" label="标签" width="180" >
|
<el-table-column prop="tags" :label="t('system.article.tableTag')" width="180" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ typeof scope.row.tags === 'string' ? scope.row.tags : scope.row.tags?.join(',') }}
|
{{ typeof scope.row.tags === 'string' ? scope.row.tags : scope.row.tags?.join(t('system.article.comma')) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="contentLen" label="正文长度" width="100" />
|
<el-table-column prop="contentLen" :label="t('system.article.tableContentLen')" width="100" />
|
||||||
<el-table-column prop="createDate" label="创建时间" width="180" >
|
<el-table-column prop="createDate" :label="t('common.createdAt')" width="180" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.createDate) }}
|
{{ datetimeFormat(scope.row.createDate) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="tags" label="是否已分词" width="120" >
|
<el-table-column prop="tags" :label="t('system.article.tableIsSplited')" width="120" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div style="width: 18px">
|
<div style="width: 18px">
|
||||||
<Check v-if="scope.row.isSplited" />
|
<Check v-if="scope.row.isSplited" />
|
||||||
@ -108,6 +108,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import hyperdown from 'hyperdown'
|
import hyperdown from 'hyperdown'
|
||||||
import { ArticleModel, TreeNodeData, TreeNodeSource } from '@/model/system/article'
|
import { ArticleModel, TreeNodeData, TreeNodeSource } from '@/model/system/article'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
@ -139,6 +140,7 @@ class ArticlePage extends Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const { t } = useI18n()
|
||||||
const { loading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new ArticlePage())
|
const { loading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new ArticlePage())
|
||||||
|
|
||||||
const articleData = ref<ArticleModel[]>([])
|
const articleData = ref<ArticleModel[]>([])
|
||||||
@ -175,18 +177,18 @@ setLoadData(loadData)
|
|||||||
|
|
||||||
function splitWord() {
|
function splitWord() {
|
||||||
if (!selectedData.length) {
|
if (!selectedData.length) {
|
||||||
ElMessage.warning('请选择要执行分词的文章')
|
ElMessage.warning(t('system.article.selectToSplit'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ElMessageBox.confirm(`是否确认对选中的${selectedData.length}篇文章执行分词处理?`, '操作确认', {type: 'info'}).then(async () => {
|
ElMessageBox.confirm(t('system.article.splitConfirmMsg', {count: selectedData.length}), t('system.article.splitConfirmTitle'), {type: 'info'}).then(async () => {
|
||||||
const successNum = await http.put<{_ids: string[]}, any>('/article/splitWord', {_ids: selectedData})
|
const successNum = await http.put<{_ids: string[]}, any>('/article/splitWord', {_ids: selectedData})
|
||||||
ElMessage.success(`${successNum}篇文章分词处理成功`)
|
ElMessage.success(t('system.article.splitSuccess', {count: successNum}))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function pullArticles() {
|
function pullArticles() {
|
||||||
ElMessageBox.confirm('确认拉取全部文章?', '操作确认', {type: 'info'}).then(async () => {
|
ElMessageBox.confirm(t('system.article.pullConfirmMsg'), t('system.article.pullConfirmTitle'), {type: 'info'}).then(async () => {
|
||||||
const { updateCount, createCount } = await http.put<never, any>('/article/pull')
|
const { updateCount, createCount } = await http.put<never, any>('/article/pull')
|
||||||
ElMessage.success(`拉取文章完成,更新 ${updateCount} 篇,创建 ${createCount} 篇`)
|
ElMessage.success(t('system.article.pullSuccess', {updateCount, createCount}))
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
<el-icon><Document /></el-icon>
|
<el-icon><Document /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<div class="stat-label">文章总数</div>
|
<div class="stat-label">{{ t('system.statistics.totalArticles') }}</div>
|
||||||
<div class="stat-value">{{ totalArticles }}</div>
|
<div class="stat-value">{{ totalArticles }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -16,7 +16,7 @@
|
|||||||
<el-icon><FolderOpened /></el-icon>
|
<el-icon><FolderOpened /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<div class="stat-label">分类数量</div>
|
<div class="stat-label">{{ t('system.statistics.totalCategories') }}</div>
|
||||||
<div class="stat-value">{{ totalCategories }}</div>
|
<div class="stat-value">{{ totalCategories }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -25,7 +25,7 @@
|
|||||||
<el-icon><Calendar /></el-icon>
|
<el-icon><Calendar /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<div class="stat-label">最早发布</div>
|
<div class="stat-label">{{ t('system.statistics.earliestPublish') }}</div>
|
||||||
<div class="stat-value">{{ earliestDate }}</div>
|
<div class="stat-value">{{ earliestDate }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -34,7 +34,7 @@
|
|||||||
<el-icon><TrendCharts /></el-icon>
|
<el-icon><TrendCharts /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<div class="stat-label">高频词汇</div>
|
<div class="stat-label">{{ t('system.statistics.topKeywords') }}</div>
|
||||||
<div class="stat-value">{{ topKeyword }}</div>
|
<div class="stat-value">{{ topKeyword }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -47,9 +47,9 @@
|
|||||||
<div class="chart-header">
|
<div class="chart-header">
|
||||||
<div class="chart-title">
|
<div class="chart-title">
|
||||||
<el-icon><PieChart /></el-icon>
|
<el-icon><PieChart /></el-icon>
|
||||||
<span>文章分类分布</span>
|
<span>{{ t('system.statistics.categoryDistribution') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-subtitle">各类别文章数量占比</div>
|
<div class="chart-subtitle">{{ t('system.statistics.categoryDistributionSub') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="categoriesChart" v-loading="categoriesChartLoading" class="chart-body"></div>
|
<div ref="categoriesChart" v-loading="categoriesChartLoading" class="chart-body"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -59,9 +59,9 @@
|
|||||||
<div class="chart-header">
|
<div class="chart-header">
|
||||||
<div class="chart-title">
|
<div class="chart-title">
|
||||||
<el-icon><TrendCharts /></el-icon>
|
<el-icon><TrendCharts /></el-icon>
|
||||||
<span>文章发布趋势</span>
|
<span>{{ t('system.statistics.publishTrend') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-subtitle">各时间段文章发布数量变化</div>
|
<div class="chart-subtitle">{{ t('system.statistics.publishTrendSub') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="publishDatesChart" v-loading="publishDatesChartLoading" class="chart-body"></div>
|
<div ref="publishDatesChart" v-loading="publishDatesChartLoading" class="chart-body"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -71,9 +71,9 @@
|
|||||||
<div class="chart-header">
|
<div class="chart-header">
|
||||||
<div class="chart-title">
|
<div class="chart-title">
|
||||||
<el-icon><Histogram /></el-icon>
|
<el-icon><Histogram /></el-icon>
|
||||||
<span>年度高频词汇分析</span>
|
<span>{{ t('system.statistics.yearlyKeywords') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-subtitle">基于文章分词结果的年度关键词统计</div>
|
<div class="chart-subtitle">{{ t('system.statistics.yearlyKeywordsSub') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="timelineWordsChart" v-loading="timelineWordsChartLoading" class="chart-body timeline-chart-body"></div>
|
<div ref="timelineWordsChart" v-loading="timelineWordsChartLoading" class="chart-body timeline-chart-body"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -83,10 +83,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
import { Document, FolderOpened, Calendar, TrendCharts, PieChart, Histogram } from '@element-plus/icons-vue'
|
import { Document, FolderOpened, Calendar, TrendCharts, PieChart, Histogram } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const categoriesChart = ref<HTMLElement>()
|
const categoriesChart = ref<HTMLElement>()
|
||||||
const publishDatesChart = ref<HTMLElement>()
|
const publishDatesChart = ref<HTMLElement>()
|
||||||
const timelineWordsChart = ref<HTMLElement>()
|
const timelineWordsChart = ref<HTMLElement>()
|
||||||
@ -104,7 +107,7 @@ const topKeyword = ref('-')
|
|||||||
const categoriesChartOption: any = {
|
const categoriesChartOption: any = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
formatter: "{b}: {c}篇 ({d}%)"
|
formatter: (params: any) => `${params.name}: ${params.value}${t('system.statistics.articlesUnit')} (${params.percent}%)`
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
type: 'scroll',
|
type: 'scroll',
|
||||||
@ -117,7 +120,7 @@ const categoriesChartOption: any = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
series: {
|
series: {
|
||||||
name: '类别',
|
name: t('system.statistics.categoryName'),
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: ['45%', '75%'],
|
radius: ['45%', '75%'],
|
||||||
center: ['35%', '50%'],
|
center: ['35%', '50%'],
|
||||||
@ -169,7 +172,7 @@ const publishDatesChartOption: any = {
|
|||||||
containLabel: true
|
containLabel: true
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
name: '发布时间',
|
name: t('system.statistics.publishTime'),
|
||||||
nameTextStyle: {
|
nameTextStyle: {
|
||||||
color: '#909399'
|
color: '#909399'
|
||||||
},
|
},
|
||||||
@ -186,7 +189,7 @@ const publishDatesChartOption: any = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
name: '文章数量',
|
name: t('system.statistics.articleCount'),
|
||||||
nameTextStyle: {
|
nameTextStyle: {
|
||||||
color: '#909399'
|
color: '#909399'
|
||||||
},
|
},
|
||||||
@ -221,7 +224,7 @@ const publishDatesChartOption: any = {
|
|||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
series: {
|
series: {
|
||||||
name: '文章数量',
|
name: t('system.statistics.articleCount'),
|
||||||
type: 'line',
|
type: 'line',
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'circle',
|
symbol: 'circle',
|
||||||
@ -297,7 +300,7 @@ const timelineWordsChartOption: any = {
|
|||||||
right: 40
|
right: 40
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
name: '高频词汇',
|
name: t('system.statistics.keywordName'),
|
||||||
nameTextStyle: {
|
nameTextStyle: {
|
||||||
color: '#909399'
|
color: '#909399'
|
||||||
},
|
},
|
||||||
@ -314,7 +317,7 @@ const timelineWordsChartOption: any = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
name: '出现次数',
|
name: t('system.statistics.occurrenceCount'),
|
||||||
nameTextStyle: {
|
nameTextStyle: {
|
||||||
color: '#909399'
|
color: '#909399'
|
||||||
},
|
},
|
||||||
@ -405,7 +408,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
timelineWordsChartOption.baseOption.timeline.data = timelineData.timelineWords.map((item: any) => item._id)
|
timelineWordsChartOption.baseOption.timeline.data = timelineData.timelineWords.map((item: any) => item._id)
|
||||||
timelineWordsChartOption.options = timelineData.timelineWords.map((item: any) => ({
|
timelineWordsChartOption.options = timelineData.timelineWords.map((item: any) => ({
|
||||||
title: {text: `${item._id}年发布的文章 - 高频词汇TOP10`},
|
title: {text: t('system.statistics.yearlyTitle', {year: item._id})},
|
||||||
xAxis: {data: item.keys.map((keyItem: any) => keyItem.key)},
|
xAxis: {data: item.keys.map((keyItem: any) => keyItem.key)},
|
||||||
series: {data: item.keys.map((keyItem: any) => keyItem.total)}
|
series: {data: item.keys.map((keyItem: any) => keyItem.total)}
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-form inline :model="search" @submit.prevent>
|
<el-form inline :model="search" @submit.prevent>
|
||||||
<el-form-item label="配置项">
|
<el-form-item :label="t('system.config.searchLabel')">
|
||||||
<el-input placeholder="名称/描述" v-model="search.name" />
|
<el-input :placeholder="t('system.config.searchPlaceholder')" v-model="search.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="btn-container">
|
<div class="btn-container">
|
||||||
<el-button type="primary" @click="add" v-permission="'config:save'" plain>添加</el-button>
|
<el-button type="primary" @click="add" v-permission="'config:save'" plain>{{ t('common.add') }}</el-button>
|
||||||
<div class="search-btn">
|
<div class="search-btn">
|
||||||
<el-button type="primary" @click="loadData" icon="Search" plain>搜索</el-button>
|
<el-button type="primary" @click="loadData" icon="Search" plain>{{ t('common.search') }}</el-button>
|
||||||
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
|
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table class="table-container" :data="systemConfigData" v-loading="loading" stripe>
|
<el-table class="table-container" :data="systemConfigData" v-loading="loading" stripe>
|
||||||
@ -18,27 +18,27 @@
|
|||||||
<pre style="margin:0 10px;">{{ JSON.stringify(scope.row.value, null, ' ') }}</pre>
|
<pre style="margin:0 10px;">{{ JSON.stringify(scope.row.value, null, ' ') }}</pre>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="name" label="配置项名称" />
|
<el-table-column prop="name" :label="t('system.config.tableName')" />
|
||||||
<el-table-column prop="description" label="配置项描述" />
|
<el-table-column prop="description" :label="t('system.config.tableDescription')" />
|
||||||
<el-table-column prop="isPublic" label="是否公开" >
|
<el-table-column prop="isPublic" :label="t('system.config.tableIsPublic')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ scope.row.isPublic ? '是' : '否' }}
|
{{ scope.row.isPublic ? t('common.yes') : t('common.no') }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createdAt" label="创建时间" >
|
<el-table-column prop="createdAt" :label="t('common.createdAt')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.createdAt) }}
|
{{ datetimeFormat(scope.row.createdAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="updatedAt" label="更新时间" >
|
<el-table-column prop="updatedAt" :label="t('common.updatedAt')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.updatedAt) }}
|
{{ datetimeFormat(scope.row.updatedAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" >
|
<el-table-column :label="t('common.operation')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link icon="Edit" @click="update(scope.row)" title="修改" v-permission="'config:save'"></el-button>
|
<el-button link icon="Edit" @click="update(scope.row)" :title="t('common.edit')" v-permission="'config:save'"></el-button>
|
||||||
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除" v-permission="'config:delete'"></el-button>
|
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" :title="t('common.delete')" v-permission="'config:delete'"></el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -46,8 +46,8 @@
|
|||||||
<system-config-add ref="addForm" :formData="formData" />
|
<system-config-add ref="addForm" :formData="formData" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="addModal = false" plain>取消</el-button>
|
<el-button @click="addModal = false" plain>{{ t('common.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="save" :loading="modalLoading" plain>确定</el-button>
|
<el-button type="primary" @click="save" :loading="modalLoading" plain>{{ t('common.confirm') }}</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@ -55,6 +55,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
@ -63,6 +64,7 @@ import { SystemConfigModel } from '@/model/system/system-config'
|
|||||||
|
|
||||||
const modalLoading = ref(false)
|
const modalLoading = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const { t } = useI18n()
|
||||||
const search = ref<{name?: string}>({})
|
const search = ref<{name?: string}>({})
|
||||||
const systemConfigData = ref<SystemConfigModel[]>([])
|
const systemConfigData = ref<SystemConfigModel[]>([])
|
||||||
const addModal = ref(false)
|
const addModal = ref(false)
|
||||||
@ -94,7 +96,7 @@ function add() {
|
|||||||
description: '',
|
description: '',
|
||||||
isPublic: false
|
isPublic: false
|
||||||
}
|
}
|
||||||
modalTitle.value = '新增配置项'
|
modalTitle.value = t('system.config.addTitle')
|
||||||
addModal.value = true
|
addModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,7 +104,7 @@ function update(row: SystemConfigModel) {
|
|||||||
const data = Object.assign({}, row)
|
const data = Object.assign({}, row)
|
||||||
data.value = JSON.stringify(data.value, null, ' ')
|
data.value = JSON.stringify(data.value, null, ' ')
|
||||||
formData.value = data
|
formData.value = data
|
||||||
modalTitle.value = '修改配置项'
|
modalTitle.value = t('system.config.editTitle')
|
||||||
addModal.value = true
|
addModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,15 +115,15 @@ async function save() {
|
|||||||
await http.post<SystemConfigModel, any>('/system/config/save', formData.value)
|
await http.post<SystemConfigModel, any>('/system/config/save', formData.value)
|
||||||
modalLoading.value = false
|
modalLoading.value = false
|
||||||
addModal.value = false
|
addModal.value = false
|
||||||
ElMessage.success("保存成功")
|
ElMessage.success(t('common.saveSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(row: SystemConfigModel) {
|
function remove(row: SystemConfigModel) {
|
||||||
ElMessageBox.confirm(`是否确认删除 ${row.name} 配置项?`, '确认删除', {type: 'warning'}).then(async () => {
|
ElMessageBox.confirm(t('system.config.deleteConfirmMsg', {name: row.name}), t('common.deleteConfirm'), {type: 'warning'}).then(async () => {
|
||||||
await http.delete<{params: {id: string}}, any>('/system/config/delete', {params: {id: row._id}})
|
await http.delete<{params: {id: string}}, any>('/system/config/delete', {params: {id: row._id}})
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,26 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-form ref="configForm" :model="formData" :rules="ruleValidate" :label-width="80">
|
<el-form ref="configForm" :model="formData" :rules="ruleValidate" :label-width="80">
|
||||||
<el-form-item label="名称" prop="name">
|
<el-form-item :label="t('system.config.formName')" prop="name">
|
||||||
<el-input v-model="formData.name" />
|
<el-input v-model="formData.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="值" prop="value">
|
<el-form-item :label="t('system.config.formValue')" prop="value">
|
||||||
<el-input v-model="formData.value" type="textarea" placeholder="必须符合JSON字符串格式" :rows="4"/>
|
<el-input v-model="formData.value" type="textarea" :placeholder="t('system.config.formValuePlaceholder')" :rows="4"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="描述">
|
<el-form-item :label="t('system.config.formDescription')">
|
||||||
<el-input v-model="formData.description" />
|
<el-input v-model="formData.description" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="公开">
|
<el-form-item :label="t('system.config.formIsPublic')">
|
||||||
<el-switch
|
<el-switch
|
||||||
v-model="formData.isPublic"
|
v-model="formData.isPublic"
|
||||||
active-text="是"
|
:active-text="t('common.yes')"
|
||||||
inactive-text="否" />
|
:inactive-text="t('common.no')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
import { SystemConfigModel } from '@/model/system/system-config'
|
import { SystemConfigModel } from '@/model/system/system-config'
|
||||||
import { VForm } from '@/types'
|
import { VForm } from '@/types'
|
||||||
@ -29,15 +30,17 @@ const props = defineProps<{
|
|||||||
formData: SystemConfigModel
|
formData: SystemConfigModel
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const configForm = ref<VForm>()
|
const configForm = ref<VForm>()
|
||||||
|
|
||||||
const ruleValidate = computed(() => ({
|
const ruleValidate = computed(() => ({
|
||||||
name: [
|
name: [
|
||||||
{ required: true, message: '请输入配置项名称', trigger: 'blur' },
|
{ required: true, message: t('system.config.nameRequired'), trigger: 'blur' },
|
||||||
{ validator: (rule: object, value: string, callback: Function) => {
|
{ validator: (rule: object, value: string, callback: Function) => {
|
||||||
http.get<any, any>('/system/config/exists', {params: {name: value, id: props.formData._id}}).then(data => {
|
http.get<any, any>('/system/config/exists', {params: {name: value, id: props.formData._id}}).then(data => {
|
||||||
if(data.exists) {
|
if(data.exists) {
|
||||||
callback(new Error('配置项名称已存在'))
|
callback(new Error(t('system.config.nameExists')))
|
||||||
} else {
|
} else {
|
||||||
callback()
|
callback()
|
||||||
}
|
}
|
||||||
@ -46,13 +49,13 @@ const ruleValidate = computed(() => ({
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
value: [
|
value: [
|
||||||
{ required: true, message: '请输入配置项值', trigger: 'blur' },
|
{ required: true, message: t('system.config.valueRequired'), trigger: 'blur' },
|
||||||
{ validator: (rule: object, value: string, callback: Function) => {
|
{ validator: (rule: object, value: string, callback: Function) => {
|
||||||
try {
|
try {
|
||||||
JSON.parse(value)
|
JSON.parse(value)
|
||||||
callback()
|
callback()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
callback(new Error('值不符合JSON字符串格式'))
|
callback(new Error(t('system.config.valueInvalidJson')))
|
||||||
}
|
}
|
||||||
}, trigger: 'blur'
|
}, trigger: 'blur'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<el-form inline :model="search" @submit.prevent>
|
<el-form inline :model="search" @submit.prevent>
|
||||||
<el-form-item label="角色名称/描述">
|
<el-form-item :label="t('system.role.searchLabel')">
|
||||||
<el-input v-model="search.name" />
|
<el-input v-model="search.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="btn-container">
|
<div class="btn-container">
|
||||||
<el-button type="primary" @click="add" v-permission="'role:save'" plain>添加</el-button>
|
<el-button type="primary" @click="add" v-permission="'role:save'" plain>{{ t('common.add') }}</el-button>
|
||||||
<div class="search-btn">
|
<div class="search-btn">
|
||||||
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
|
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
|
||||||
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
|
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table class="table-container" :data="systemRoleData" v-loading="loading" stripe>
|
<el-table class="table-container" :data="systemRoleData" v-loading="loading" stripe>
|
||||||
<el-table-column prop="name" label="角色名称" />
|
<el-table-column prop="name" :label="t('system.role.tableName')" />
|
||||||
<el-table-column prop="description" label="描述" />
|
<el-table-column prop="description" :label="t('system.role.tableDescription')" />
|
||||||
<el-table-column prop="createdAt" label="创建时间" >
|
<el-table-column prop="createdAt" :label="t('common.createdAt')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.createdAt) }}
|
{{ datetimeFormat(scope.row.createdAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="updatedAt" label="更新时间" >
|
<el-table-column prop="updatedAt" :label="t('common.updatedAt')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.updatedAt) }}
|
{{ datetimeFormat(scope.row.updatedAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" >
|
<el-table-column :label="t('common.operation')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link icon="Edit" @click="update(scope.row)" title="修改" v-permission="'role:save'"></el-button>
|
<el-button link icon="Edit" @click="update(scope.row)" :title="t('common.edit')" v-permission="'role:save'"></el-button>
|
||||||
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除" v-permission="'role:delete'"></el-button>
|
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" :title="t('common.delete')" v-permission="'role:delete'"></el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -44,13 +44,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-dialog v-model="addModal" :title="modalTitle" >
|
<el-dialog v-model="addModal" :title="modalTitle" >
|
||||||
<el-form ref="roleForm" :model="formData" :rules="ruleValidate" :label-width="120">
|
<el-form ref="roleForm" :model="formData" :rules="ruleValidate" :label-width="120">
|
||||||
<el-form-item label="角色名称" prop="name">
|
<el-form-item :label="t('system.role.formName')" prop="name">
|
||||||
<el-input v-model="formData.name" />
|
<el-input v-model="formData.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="描述">
|
<el-form-item :label="t('system.role.formDescription')">
|
||||||
<el-input v-model="formData.description" />
|
<el-input v-model="formData.description" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="权限" prop="permissions">
|
<el-form-item :label="t('system.role.formPermission')" prop="permissions">
|
||||||
<el-tree
|
<el-tree
|
||||||
ref="permTree"
|
ref="permTree"
|
||||||
:data="permissionTreeData"
|
:data="permissionTreeData"
|
||||||
@ -65,8 +65,8 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="addModal = false" plain>取消</el-button>
|
<el-button @click="addModal = false" plain>{{ t('common.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="save" :loading="modalLoading" plain>确定</el-button>
|
<el-button type="primary" @click="save" :loading="modalLoading" plain>{{ t('common.confirm') }}</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@ -74,8 +74,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, nextTick } from 'vue'
|
import { ref, reactive, computed, nextTick } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useBaseList } from '@/model/baselist'
|
import { useBaseList } from '@/model/baselist'
|
||||||
import { Page } from '@/model/common.dto'
|
import { Page } from '@/model/common.dto'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
@ -84,6 +85,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
import type { VForm } from '@/types'
|
import type { VForm } from '@/types'
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
class SystemRolePage extends Page {
|
class SystemRolePage extends Page {
|
||||||
name?: string
|
name?: string
|
||||||
@ -95,21 +97,21 @@ class SystemRolePage extends Page {
|
|||||||
|
|
||||||
const { loading, modalLoading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new SystemRolePage())
|
const { loading, modalLoading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange, datetimeFormat } = useBaseList(new SystemRolePage())
|
||||||
|
|
||||||
const ruleValidate = {
|
const ruleValidate = computed(() => ({
|
||||||
name: [
|
name: [
|
||||||
{ required: true, message: '请输入角色名称', trigger: 'blur' }
|
{ required: true, message: t('system.role.nameRequired'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
permissions: [
|
permissions: [
|
||||||
{ validator: (rule: any, value: any, callback: any) => {
|
{ validator: (rule: any, value: any, callback: any) => {
|
||||||
const permissions = getPermissionsToSave()
|
const permissions = getPermissionsToSave()
|
||||||
if (permissions.length === 0) {
|
if (permissions.length === 0) {
|
||||||
callback(new Error('至少需要勾选一项权限'))
|
callback(new Error(t('system.role.permissionRequired')))
|
||||||
} else {
|
} else {
|
||||||
callback()
|
callback()
|
||||||
}
|
}
|
||||||
}, trigger: 'change' }
|
}, trigger: 'change' }
|
||||||
]
|
]
|
||||||
}
|
}))
|
||||||
const systemRoleData = ref<SystemRoleModel[]>([])
|
const systemRoleData = ref<SystemRoleModel[]>([])
|
||||||
const addModal = ref(false)
|
const addModal = ref(false)
|
||||||
const modalTitle = ref<string | null>(null)
|
const modalTitle = ref<string | null>(null)
|
||||||
@ -143,7 +145,7 @@ function add() {
|
|||||||
description: null,
|
description: null,
|
||||||
permissions: []
|
permissions: []
|
||||||
})
|
})
|
||||||
modalTitle.value = '新增角色'
|
modalTitle.value = t('system.role.addTitle')
|
||||||
addModal.value = true
|
addModal.value = true
|
||||||
clearValidate()
|
clearValidate()
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@ -269,16 +271,16 @@ function update(row: SystemRoleModel) {
|
|||||||
description: row.description,
|
description: row.description,
|
||||||
permissions: [...row.permissions]
|
permissions: [...row.permissions]
|
||||||
})
|
})
|
||||||
modalTitle.value = '修改角色'
|
modalTitle.value = t('system.role.editTitle')
|
||||||
addModal.value = true
|
addModal.value = true
|
||||||
clearValidate()
|
clearValidate()
|
||||||
setTreeCheckedByPermissions(row.permissions)
|
setTreeCheckedByPermissions(row.permissions)
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(row: SystemRoleModel) {
|
function remove(row: SystemRoleModel) {
|
||||||
ElMessageBox.confirm(`是否确认删除 ${row.name} 角色?`, '确认删除', {type: 'warning'}).then(async () => {
|
ElMessageBox.confirm(t('system.role.deleteConfirmMsg', {name: row.name}), t('common.deleteConfirm'), {type: 'warning'}).then(async () => {
|
||||||
await http.delete<{params: {id: string}}, any>('/system/role/delete', {params: {id: row._id}})
|
await http.delete<{params: {id: string}}, any>('/system/role/delete', {params: {id: row._id}})
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -291,7 +293,7 @@ async function save() {
|
|||||||
await http.post<SystemRoleModel, any>('/system/role/save', saveData)
|
await http.post<SystemRoleModel, any>('/system/role/save', saveData)
|
||||||
modalLoading.value = false
|
modalLoading.value = false
|
||||||
addModal.value = false
|
addModal.value = false
|
||||||
ElMessage.success("保存成功")
|
ElMessage.success(t('common.saveSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<el-form inline :model="search" @submit.prevent>
|
<el-form inline :model="search" @submit.prevent>
|
||||||
<el-form-item label="用户名/昵称">
|
<el-form-item :label="t('system.user.searchLabel')">
|
||||||
<el-input v-model="search.username" />
|
<el-input v-model="search.username" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="btn-container">
|
<div class="btn-container">
|
||||||
<el-button type="primary" @click="add" v-permission="'user:save'" plain>添加</el-button>
|
<el-button type="primary" @click="add" v-permission="'user:save'" plain>{{ t('common.add') }}</el-button>
|
||||||
<div class="search-btn">
|
<div class="search-btn">
|
||||||
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
|
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
|
||||||
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
|
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table class="table-container" :data="systemUserData" v-loading="loading" stripe>
|
<el-table class="table-container" :data="systemUserData" v-loading="loading" stripe>
|
||||||
<el-table-column prop="username" label="用户名" />
|
<el-table-column prop="username" :label="t('system.user.tableUsername')" />
|
||||||
<el-table-column prop="realname" label="昵称" />
|
<el-table-column prop="realname" :label="t('system.user.tableRealname')" />
|
||||||
<el-table-column prop="createdAt" label="创建时间" >
|
<el-table-column prop="createdAt" :label="t('common.createdAt')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.createdAt) }}
|
{{ datetimeFormat(scope.row.createdAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="updatedAt" label="更新时间" >
|
<el-table-column prop="updatedAt" :label="t('common.updatedAt')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ datetimeFormat(scope.row.updatedAt) }}
|
{{ datetimeFormat(scope.row.updatedAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" >
|
<el-table-column :label="t('common.operation')" >
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link icon="Edit" @click="update(scope.row)" title="修改" v-permission="'user:save'"></el-button>
|
<el-button link icon="Edit" @click="update(scope.row)" :title="t('common.edit')" v-permission="'user:save'"></el-button>
|
||||||
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" title="删除" v-permission="'user:delete'"></el-button>
|
<el-button link type="danger" icon="Delete" @click="remove(scope.row)" :title="t('common.delete')" v-permission="'user:delete'"></el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -44,16 +44,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-dialog v-model="addModal" :title="modalTitle" >
|
<el-dialog v-model="addModal" :title="modalTitle" >
|
||||||
<el-form ref="userForm" :model="formData" :rules="ruleValidate" :label-width="120">
|
<el-form ref="userForm" :model="formData" :rules="ruleValidate" :label-width="120">
|
||||||
<el-form-item label="用户名" prop="username">
|
<el-form-item :label="t('system.user.formUsername')" prop="username">
|
||||||
<el-input v-model="formData.username" />
|
<el-input v-model="formData.username" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="密码" prop="password">
|
<el-form-item :label="t('system.user.formPassword')" prop="password">
|
||||||
<el-input v-model="formData.password" type="password" />
|
<el-input v-model="formData.password" type="password" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="昵称" prop="realname">
|
<el-form-item :label="t('system.user.formRealname')" prop="realname">
|
||||||
<el-input v-model="formData.realname" />
|
<el-input v-model="formData.realname" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="角色" prop="roleIds">
|
<el-form-item :label="t('system.user.formRole')" prop="roleIds">
|
||||||
<el-select v-model="formData.roleIds" multiple >
|
<el-select v-model="formData.roleIds" multiple >
|
||||||
<el-option v-for="role in roles" :key="role._id" :value="role._id" :label="role.name"></el-option>
|
<el-option v-for="role in roles" :key="role._id" :value="role._id" :label="role.name"></el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
@ -61,8 +61,8 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="addModal = false" plain>取消</el-button>
|
<el-button @click="addModal = false" plain>{{ t('common.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="save" :loading="modalLoading" plain>确定</el-button>
|
<el-button type="primary" @click="save" :loading="modalLoading" plain>{{ t('common.confirm') }}</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@ -71,6 +71,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, nextTick } from 'vue'
|
import { ref, reactive, computed, nextTick } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useBaseList } from '@/model/baselist'
|
import { useBaseList } from '@/model/baselist'
|
||||||
import { Page } from '@/model/common.dto'
|
import { Page } from '@/model/common.dto'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
@ -80,6 +81,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
import { VForm } from '@/types'
|
import { VForm } from '@/types'
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
class SystemUserPage extends Page {
|
class SystemUserPage extends Page {
|
||||||
username?: string
|
username?: string
|
||||||
@ -106,17 +108,17 @@ const userForm = ref<VForm>()
|
|||||||
|
|
||||||
const ruleValidate = computed(() => ({
|
const ruleValidate = computed(() => ({
|
||||||
username: [
|
username: [
|
||||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
{ required: true, message: t('system.user.usernameRequired'), trigger: 'blur' },
|
||||||
{ validator: async (rule: object, value: string, callback: Function) => {
|
{ validator: async (rule: object, value: string, callback: Function) => {
|
||||||
const res = await http.get<any, any>('/system/user/exists', {params: {username: value, id: formData._id}})
|
const res = await http.get<any, any>('/system/user/exists', {params: {username: value, id: formData._id}})
|
||||||
res.exists ? callback(new Error('用户名已存在')) : callback()
|
res.exists ? callback(new Error(t('system.user.usernameExists'))) : callback()
|
||||||
}, trigger: 'blur'
|
}, trigger: 'blur'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
password: [
|
password: [
|
||||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
{ required: true, message: t('system.user.passwordRequired'), trigger: 'blur' },
|
||||||
{ min: 8, max: 16, message: '密码长度8~16位', trigger: 'blur' },
|
{ min: 8, max: 16, message: t('system.user.passwordLength'), trigger: 'blur' },
|
||||||
{ pattern: /^(?![\d]+$)(?![a-zA-Z]+$)(?![-=+_.,]+$)[\da-zA-Z-=+_.,]{8,16}$/, message: '密码由字母、数字、特殊字符中的任意两种组成', trigger: 'blur' }
|
{ pattern: /^(?![\d]+$)(?![a-zA-Z]+$)(?![-=+_.,]+$)[\da-zA-Z-=+_.,]{8,16}$/, message: t('system.user.passwordComplexity'), trigger: 'blur' }
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@ -137,7 +139,7 @@ function add() {
|
|||||||
realname: null,
|
realname: null,
|
||||||
roleIds: []
|
roleIds: []
|
||||||
})
|
})
|
||||||
modalTitle.value = '新增用户'
|
modalTitle.value = t('system.user.addTitle')
|
||||||
addModal.value = true
|
addModal.value = true
|
||||||
clearValidate()
|
clearValidate()
|
||||||
}
|
}
|
||||||
@ -149,7 +151,7 @@ function update(row: SystemUserModel) {
|
|||||||
realname: row.realname,
|
realname: row.realname,
|
||||||
roleIds: row.roleIds
|
roleIds: row.roleIds
|
||||||
})
|
})
|
||||||
modalTitle.value = '修改用户'
|
modalTitle.value = t('system.user.editTitle')
|
||||||
addModal.value = true
|
addModal.value = true
|
||||||
clearValidate()
|
clearValidate()
|
||||||
}
|
}
|
||||||
@ -161,15 +163,15 @@ async function save() {
|
|||||||
await http.post<SystemUserModel, any>('/system/user/save', formData)
|
await http.post<SystemUserModel, any>('/system/user/save', formData)
|
||||||
modalLoading.value = false
|
modalLoading.value = false
|
||||||
addModal.value = false
|
addModal.value = false
|
||||||
ElMessage.success("保存成功")
|
ElMessage.success(t('common.saveSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(row: SystemUserModel) {
|
function remove(row: SystemUserModel) {
|
||||||
ElMessageBox.confirm(`是否确认删除 ${row.username} 用户?`, '确认删除', {type: 'warning'}).then(async () => {
|
ElMessageBox.confirm(t('system.user.deleteConfirmMsg', {name: row.username}), t('common.deleteConfirm'), {type: 'warning'}).then(async () => {
|
||||||
await http.delete<{params: {id: string}}, any>('/system/user/delete', {params: {id: row._id}})
|
await http.delete<{params: {id: string}}, any>('/system/user/delete', {params: {id: row._id}})
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success(t('common.deleteSuccess'))
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/vite-env.d.ts
vendored
6
src/vite-env.d.ts
vendored
@ -10,3 +10,9 @@ interface ImportMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare const __APP_VERSION__: string
|
declare const __APP_VERSION__: string
|
||||||
|
|
||||||
|
declare module 'vue-router' {
|
||||||
|
interface RouteMeta {
|
||||||
|
titleKey?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user