feat: 完成管理员密码登录修复和项目清理

- 修复BCryptPasswordEncoder密码验证问题
- 实现密码设置提示弹窗功能(仅对无密码用户显示一次)
- 优化修改密码逻辑和验证流程
- 更新Welcome页面背景样式
- 清理临时SQL文件和测试代码
- 移动数据库备份文件到database/backups目录
- 删除不必要的MD文档和临时文件
This commit is contained in:
AIGC Developer
2025-11-21 16:10:00 +08:00
parent 2961d2b0d0
commit dbd06435cb
384 changed files with 8064 additions and 5080 deletions

View File

@@ -6,7 +6,9 @@
"Bash(find:*)",
"Bash(npm run dev:*)",
"Bash(npm install)",
"Bash(npm cache clean:*)"
"Bash(npm cache clean:*)",
"Bash(git rm:*)",
"Bash(git checkout:*)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,15 @@
<svg width="194" height="41" viewBox="0 0 194 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M54.6165 13.6062L60.593 32.3932H60.8224L66.8111 13.6062H72.6065L64.0824 38.3335H57.3452L48.8089 13.6062H54.6165ZM75.4862 38.3335V19.788H80.6296V38.3335H75.4862ZM78.07 17.3974C77.3053 17.3974 76.6493 17.1439 76.1019 16.6368C75.5626 16.1216 75.293 15.5058 75.293 14.7895C75.293 14.0811 75.5626 13.4734 76.1019 12.9663C76.6493 12.4512 77.3053 12.1936 78.07 12.1936C78.8346 12.1936 79.4866 12.4512 80.0259 12.9663C80.5733 13.4734 80.8469 14.0811 80.8469 14.7895C80.8469 15.5058 80.5733 16.1216 80.0259 16.6368C79.4866 17.1439 78.8346 17.3974 78.07 17.3974ZM91.5836 38.6353C90.175 38.6353 88.8992 38.2731 87.7562 37.5487C86.6213 36.8162 85.7198 35.7416 85.0517 34.325C84.3916 32.9003 84.0616 31.1536 84.0616 29.0849C84.0616 26.9599 84.4037 25.1931 85.0879 23.7845C85.7721 22.3678 86.6816 21.3093 87.8166 20.6091C88.9596 19.9007 90.2112 19.5466 91.5716 19.5466C92.6099 19.5466 93.4752 19.7236 94.1674 20.0778C94.8677 20.4239 95.4312 20.8586 95.8578 21.3818C96.2924 21.8969 96.6225 22.404 96.8478 22.9031H97.0048V13.6062H102.136V38.3335H97.0652V35.3633H96.8478C96.6064 35.8785 96.2643 36.3896 95.8216 36.8967C95.3869 37.3958 94.8194 37.8103 94.1191 38.1403C93.4269 38.4703 92.5817 38.6353 91.5836 38.6353ZM93.2136 34.5423C94.0427 34.5423 94.743 34.3169 95.3145 33.8662C95.894 33.4074 96.3367 32.7674 96.6426 31.9464C96.9565 31.1254 97.1135 30.1635 97.1135 29.0608C97.1135 27.958 96.9605 27.0002 96.6547 26.1872C96.3488 25.3742 95.9061 24.7464 95.3265 24.3037C94.747 23.861 94.0427 23.6396 93.2136 23.6396C92.3684 23.6396 91.6561 23.869 91.0765 24.3278C90.497 24.7866 90.0583 25.4225 89.7605 26.2355C89.4627 27.0485 89.3137 27.9902 89.3137 29.0608C89.3137 30.1394 89.4627 31.0932 89.7605 31.9223C90.0663 32.7433 90.505 33.3872 91.0765 33.8541C91.6561 34.3129 92.3684 34.5423 93.2136 34.5423ZM106.462 38.3335V13.6062H122.834V17.9166H111.69V23.8086H121.747V28.119H111.69V38.3335H106.462ZM131.397 13.6062V38.3335H126.254V13.6062H131.397ZM143.897 38.6957C142.021 38.6957 140.399 38.2973 139.031 37.5004C137.671 36.6955 136.62 35.5766 135.88 34.1439C135.139 32.7031 134.769 31.0328 134.769 29.1332C134.769 27.2175 135.139 25.5432 135.88 24.1105C136.62 22.6697 137.671 21.5508 139.031 20.7539C140.399 19.949 142.021 19.5466 143.897 19.5466C145.772 19.5466 147.39 19.949 148.75 20.7539C150.119 21.5508 151.173 22.6697 151.914 24.1105C152.654 25.5432 153.025 27.2175 153.025 29.1332C153.025 31.0328 152.654 32.7031 151.914 34.1439C151.173 35.5766 150.119 36.6955 148.75 37.5004C147.39 38.2973 145.772 38.6957 143.897 38.6957ZM143.921 34.7113C144.774 34.7113 145.486 34.4699 146.058 33.9869C146.629 33.4959 147.06 32.8278 147.35 31.9826C147.648 31.1375 147.797 30.1756 147.797 29.097C147.797 28.0184 147.648 27.0565 147.35 26.2113C147.06 25.3662 146.629 24.6981 146.058 24.2071C145.486 23.7161 144.774 23.4706 143.921 23.4706C143.06 23.4706 142.335 23.7161 141.748 24.2071C141.168 24.6981 140.729 25.3662 140.431 26.2113C140.142 27.0565 139.997 28.0184 139.997 29.097C139.997 30.1756 140.142 31.1375 140.431 31.9826C140.729 32.8278 141.168 33.4959 141.748 33.9869C142.335 34.4699 143.06 34.7113 143.921 34.7113ZM159.562 38.3335L154.516 19.788H159.719L162.593 32.2483H162.762L165.756 19.788H170.864L173.906 32.1758H174.063L176.888 19.788H182.08L177.045 38.3335H171.6L168.413 26.6701H168.183L164.996 38.3335H159.562Z" fill="black"/>
<g clip-path="url(#clip0_1233_5144)">
<path d="M5.7406 1.64568C2.43935 1.64568 0.00938034 1.57298 0.000366208 1.64568C0.000366202 1.71906 2.11292 5.37389 4.70642 9.58611C7.28533 13.813 12.3557 22.0905 15.9545 27.9611L20.8685 35.9875C21.8796 37.6388 23.6773 38.6457 25.6136 38.6457L26.4301 38.6457C26.4301 38.6457 31.0568 38.7204 31.9965 37.2228C32.2255 36.8288 32.346 36.3807 32.3461 35.925L32.3461 21.8996L31.1801 21.8996L31.1801 26.5969C31.1801 31.1005 31.1655 31.3074 30.889 31.5861C30.7288 31.7476 30.4663 31.8801 30.306 31.8801C29.9855 31.8801 29.7811 31.5866 27.1439 27.257C26.2551 25.8039 24.0844 22.2371 22.2924 19.3312C20.5148 16.4253 17.3534 11.2596 15.2699 7.85467L11.4672 1.64568L5.7406 1.64568ZM31.6615 2.99627C31.443 4.1703 30.525 6.06354 29.6654 7.10564C28.4561 8.60263 26.4304 9.83531 24.5072 10.2316C23.4727 10.4518 23.196 10.5988 23.808 10.5988C24.0267 10.5989 24.5801 10.7012 25.0316 10.8332C28.2662 11.7578 31.0495 14.7674 31.6468 17.9816L31.8363 18.9797L32.0111 18.1135C32.5648 15.3249 34.4443 12.8151 36.9066 11.5676C38.0139 10.9952 39.2815 10.5988 39.9808 10.5988C40.6217 10.5988 40.403 10.4957 39.2084 10.2463C37.46 9.87934 36.1049 9.11623 34.6625 7.66326C33.2638 6.26901 32.5496 5.02127 32.0834 3.17205L31.8217 2.11541L31.6615 2.99627Z" fill="url(#paint0_linear_1233_5144)"/>
</g>
<defs>
<linearGradient id="paint0_linear_1233_5144" x1="23.4862" y1="5.3947" x2="32.9093" y2="11.1248" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#33DDE5"/>
<stop offset="1" stop-color="#0F9CFF"/>
</linearGradient>
<clipPath id="clip0_1233_5144">
<rect width="40.3334" height="40.3334" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -43,8 +43,8 @@ const shouldShowNavBar = computed(() => {
})
const shouldShowFooter = computed(() => {
// 登录和注册页面不显示页脚
return !['login', 'register'].includes(route.name)
// 所有页面都是全屏固定布局,不显示页脚
return false
})
// 监听路由变化,动态设置页面样式
@@ -69,7 +69,7 @@ html {
margin: 0;
padding: 0;
overflow: hidden;
background: transparent;
background: #000510;
}
body {
@@ -78,7 +78,7 @@ body {
margin: 0;
padding: 0;
overflow: hidden;
background: transparent;
background: #000510;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
@@ -88,8 +88,8 @@ body {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
width: 100%;
height: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
@@ -102,8 +102,8 @@ body {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
width: 100%;
height: 100%;
min-height: 100vh;
z-index: -10; /* 确保在最底层 */
pointer-events: none;
@@ -115,6 +115,9 @@ main {
padding: 0;
position: relative;
z-index: 1;
overflow-y: auto;
overflow-x: hidden;
height: 100%;
}
main.with-navbar {
@@ -147,8 +150,8 @@ main.with-navbar {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
width: 100%;
height: 100%;
}
@@ -671,7 +674,7 @@ main.with-navbar {
}
::-webkit-scrollbar-track {
background: #f1f1f1;
background: #050515;
}
::-webkit-scrollbar-thumb {

View File

@@ -25,6 +25,11 @@ export const getCurrentUser = () => {
return api.get('/auth/me')
}
// 修改当前登录用户密码
export const changePassword = (data) => {
return api.post('/auth/change-password', data)
}
// 用户相关API
export const getUsers = (params) => {
return api.get('/users', { params })

View File

@@ -23,7 +23,9 @@ export const getUserStoryboardTasks = async (page = 0, size = 10) => {
/**
* 开始生成视频(从分镜图生成视频)
* @param {string} taskId - 任务ID
* @param {object} params - 视频参数duration, aspectRatio, hdMode
*/
export const startVideoGeneration = async (taskId) => {
return api.post(`/storyboard-video/task/${taskId}/start-video`)
export const startVideoGeneration = async (taskId, params = {}) => {
return api.post(`/storyboard-video/task/${taskId}/start-video`, params)
}

View File

@@ -42,9 +42,26 @@ export const getWorkStats = () => {
return api.get('/works/stats')
}
// 记录下载(增加下载次数)
export const recordDownload = (workId) => {
return api.post(`/works/${workId}/download`)
}
// 获取作品文件下载URL
export const getWorkFileUrl = (workId, download = false) => {
// 构建URL直接返回完整路径用于浏览器打开
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
// 去掉 /api 前缀,因为 nginx 会自动转发
let url = `${baseUrl}/works/${workId}/file`
// 添加 download 参数(可选)
if (download) {
url += '?download=true'
}
return url
}

View File

@@ -22,8 +22,8 @@
<style scoped>
.footer {
height: 60px;
background-color: #f5f5f5;
border-top: 1px solid #e4e7ed;
background-color: transparent;
border-top: none;
}
.footer-content {
@@ -37,7 +37,7 @@
}
.footer-info {
color: #606266;
color: #e5e7ff;
font-size: 14px;
}
@@ -52,14 +52,14 @@
}
.footer-link {
color: #606266;
color: #e5e7ff;
text-decoration: none;
font-size: 14px;
transition: color 0.3s;
}
.footer-link:hover {
color: #409EFF;
color: #ffffff;
}
@media (max-width: 768px) {

View File

@@ -22,7 +22,7 @@
<span>{{ $t('common.welcome') }}</span>
</el-menu-item>
<el-menu-item index="/home">
<el-menu-item index="/admin/dashboard">
<span>{{ $t('common.home') }}</span>
</el-menu-item>
@@ -30,7 +30,7 @@
<span>{{ $t('common.profile') }}</span>
</el-menu-item>
<el-menu-item v-if="userStore.isAuthenticated" index="/orders">
<el-menu-item v-if="userStore.isAuthenticated" index="/admin/orders">
<span>{{ $t('common.orders') }}</span>
</el-menu-item>
@@ -38,12 +38,8 @@
<span>{{ $t('common.payments') }}</span>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin" index="/admin/orders">
<span>{{ $t('common.adminPanel') }}</span>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin" index="/admin/dashboard">
<span>{{ $t('dashboard.title') }}</span>
<span>{{ $t('common.adminPanel') }}</span>
</el-menu-item>
</el-menu>

View File

@@ -27,7 +27,509 @@ export default {
noPermissionMsg: 'Insufficient permission, only administrators can access admin panel',
logoutConfirm: 'Are you sure you want to logout?',
logoutSuccess: 'Logout successful',
tip: 'Tip'
tip: 'Tip',
submit: 'Submit',
back: 'Back',
next: 'Next',
previous: 'Previous',
close: 'Close',
success: 'Success',
error: 'Error',
warning: 'Warning',
info: 'Info'
},
login: {
title: 'Login',
emailCodeLogin: 'Email Verification Login',
emailPasswordLogin: 'Email Password Login',
emailPlaceholder: 'Enter email address',
codePlaceholder: 'Enter verification code',
passwordPlaceholder: 'Enter password',
getCode: 'Get Code',
loginButton: 'Login',
loginOrRegister: 'Login/Register',
loggingIn: 'Logging in...',
agreement: 'By logging in, you agree to the Terms of Service and Privacy Policy',
testAccounts: 'Test Accounts',
admin: 'Admin',
normalUser: 'Normal User',
quickEmail: 'Quick Email'
},
home: {
title: 'Home',
exploreCreativity: 'Explore Unlimited Creativity',
subtitle: 'Transform Your Imagination into Reality with AI',
getStarted: 'Get Started',
learnMore: 'Learn More',
features: 'Core Features',
textToVideo: 'Text to Video',
textToVideoDesc: 'Enter text descriptions and AI generates high-quality videos',
imageToVideo: 'Image to Video',
imageToVideoDesc: 'Upload images and AI generates dynamic videos',
storyboardVideo: 'Storyboard Video',
storyboardVideoDesc: 'Professional storyboarding for cinematic effects',
myWorks: 'My Works',
myWorksDesc: 'Manage and view all your creations',
subscription: 'Subscription',
subscriptionDesc: 'Unlock more advanced features and resources'
},
profile: {
title: 'Profile',
userInfo: 'User Information',
username: 'Username',
email: 'Email',
memberLevel: 'Membership Level',
expiryDate: 'Expiry Date',
availablePoints: 'Available Points',
frozenPoints: 'Frozen Points',
accountStats: 'Account Statistics',
totalWorks: 'Total Works',
videoGenerated: 'Videos Generated',
storageUsed: 'Storage Used',
editProfile: 'Edit Profile',
changePassword: 'Change Password',
viewWorks: 'View Works',
upgradeMembership: 'Upgrade Membership',
subscription: 'Subscription',
myWorks: 'My Works',
tools: 'Tools',
noUsername: 'No username set',
published: 'Published',
userId: 'ID',
noWorksYet: 'No works yet, start creating!',
createSimilar: 'Create Similar',
workDetail: 'Work Details',
category: 'Category',
inputDetails: 'Input Details',
createTime: 'Create Time',
workId: 'Work ID',
date: 'Date',
duration: 'Duration',
quality: 'Quality',
aspectRatio: 'Aspect Ratio',
unknown: 'Unknown',
anonymousUser: 'Anonymous User',
browserNotSupport: 'Your browser does not support video playback',
noPrompt: 'No prompt available',
dashboard: 'Dashboard',
orderManagement: 'Order Management',
memberManagement: 'Member Management',
systemSettings: 'System Settings',
insufficientPermission: 'Insufficient permission, only administrators can access',
logoutConfirm: 'Are you sure you want to logout?',
logoutSuccess: 'Logged out successfully',
logoutFailed: 'Logout failed',
loadWorksFailed: 'Failed to load works',
loadUserInfoFailed: 'Failed to load user info',
loadDetailFailed: 'Failed to load work details',
profileEditDevMsg: 'Profile edit feature under development'
},
video: {
create: 'Create',
generating: 'Generating',
completed: 'Completed',
failed: 'Failed',
prompt: 'Prompt',
promptPlaceholder: 'Enter video description...',
optimizePrompt: 'Optimize Prompt',
uploadImage: 'Upload Image',
selectImage: 'Select Image',
generate: 'Generate Video',
duration: 'Duration',
resolution: 'Resolution',
style: 'Style',
aspectRatio: 'Aspect Ratio',
advancedSettings: 'Advanced Settings',
cost: 'Points Cost',
estimatedTime: 'Estimated Time',
result: 'Result',
download: 'Download',
share: 'Share',
regenerate: 'Regenerate',
saveToWorks: 'Save to Works',
videoUrl: 'Video URL',
status: 'Status',
createdAt: 'Created At',
// Text to Video translations
textToVideo: {
aspectRatio: 'Aspect Ratio',
dateFormat: '{year}-{month}-{day} {hours}:{minutes}',
pointsUpdated: 'User points updated',
pointsUpdateFailed: 'Failed to update user points',
textInputPlaceholder: 'Enter video description, e.g.: horses running on the grassland...',
userAvatar: 'User Avatar',
optimizing: 'Optimizing...',
oneClickOptimize: 'One-Click Optimize',
hdMode: 'HD Mode',
hdModeCost: '+20 Points',
startGenerate: 'Start Generation',
pleaseLogin: 'Please Login First',
loginRequired: 'Login required to start creating',
loginNow: 'Login Now',
inProgress: 'In Progress',
noVideoUrl: 'No Video Available',
withWatermark: 'With Watermark',
withoutWatermark: 'Without Watermark',
createSimilar: 'Create Similar',
downloadVideo: 'Download Video',
deleteWork: 'Delete Work',
generationFailed: 'Generation Failed',
checkInputOrRetry: 'Please check input or retry',
regenerate: 'Regenerate',
startCreating: 'Start creating your video!',
noDescription: 'No Description',
queuing: 'Queuing...',
subscribeToSpeedUp: 'Subscribe to speed up',
noResult: 'No Result',
pleaseLoginFirst: 'Please login first',
taskInProgress: 'Task already in progress, please wait',
pleaseEnterText: 'Please enter video description',
creatingTask: 'Creating task...',
taskCreated: 'Task created successfully, generating...',
createTaskFailed: 'Failed to create task',
videoCompleted: 'Video generation completed!',
videoFailed: 'Video generation failed:',
statusPending: 'Pending',
statusProcessing: 'Processing',
statusCancelled: 'Cancelled',
statusUnknown: 'Unknown Status',
pleaseEnterPrompt: 'Please enter prompt first',
promptTooLong: 'Prompt too long, max 2000 characters',
optimizingPrompt: 'Optimizing prompt...',
optimizeSuccess: 'Prompt optimized successfully!',
optimizeNoChange: 'Prompt is already optimal',
optimizeFailed: 'Optimization failed, please retry',
requestParamError: 'Invalid request parameters',
requestTimeout: 'Request timeout, check network',
serverError: 'Server error, please retry later',
networkError: 'Network error, check connection',
networkConnectionError: 'Network connection failed',
downloadStarted: 'Download started',
videoUrlNotAvailable: 'Video URL not available',
noWorkToDelete: 'No work to delete',
deleteConfirm: 'Are you sure to delete this work? This cannot be undone.',
confirmDelete: 'Confirm Delete',
workDeleted: 'Work deleted',
deleteCancelled: 'Delete cancelled',
historyLoadSuccess: 'History loaded: {count} items',
historyLoadFailed: 'Failed to load history',
historyParamsFilled: 'History parameters filled, ready to generate',
cancelFunctionTBD: 'Cancel function under development',
unfinishedTaskDetected: 'Unfinished task detected, restoring...'
},
// Image to Video translations
imageToVideo: {
userAvatar: 'User Avatar',
firstFrame: 'First Frame',
promptPlaceholder: 'Describe the content you want to generate with the image',
optimizing: 'Optimizing...',
optimizePrompt: 'Optimize',
hdMode: 'HD Mode',
hdModeCost: 'Costs 20 points when enabled',
startGenerate: 'Start Generate',
pleaseLogin: 'Please Login',
loginRequired: 'Login required to submit task',
loginNow: 'Login Now',
inProgress: 'In Progress',
statusPending: 'Pending',
statusProcessing: 'Processing',
statusCompleted: 'Completed',
statusFailed: 'Failed',
statusCancelled: 'Cancelled',
noVideoUrl: 'Video generated but URL not available',
withWatermark: 'With Watermark',
withoutWatermark: 'Without Watermark (Member Exclusive)',
createSimilar: 'Create Similar',
downloadVideo: 'Download Video',
deleteWork: 'Delete Work',
retry: 'Retry',
generateFailed: 'Generation Failed',
generateFailedDesc: 'Please check input or retry',
startCreating: 'Start creating your first work!',
tip1: 'Upload first frame image',
tip2: 'Enter description text',
tip3: 'Select video parameters',
tip4: 'Click to start generating',
noDescription: 'No Description',
queuing: 'Queuing',
subscribeToSpeedUp: 'Subscribe to speed up generation',
cancel: 'Cancel',
firstFrameImage: 'First Frame Image',
noResult: 'No Result',
year: '',
month: '',
day: '',
fileSizeLimit: 'Image file size cannot exceed 100MB',
invalidImageFile: 'Please select a valid image file',
pleaseLoginFirst: 'Please login before submitting task',
taskInProgress: 'A task is already in progress, please wait or cancel it',
uploadFirstFrameRequired: 'Please upload first frame image',
enterDescriptionRequired: 'Please enter description text',
creatingTask: 'Creating task...',
taskCreatedSuccess: 'Task created successfully, processing...',
createTaskFailed: 'Failed to create task',
createTaskFailedRetry: 'Failed to create task, please retry',
videoGenerateCompleted: 'Video generation completed!',
videoGenerateFailed: 'Video generation failed: ',
enterPromptFirst: 'Please enter prompt',
promptTooLong: 'Prompt too long, please keep within 2000 characters',
optimizingPrompt: 'Optimizing prompt, please wait...',
promptOptimizedSuccess: 'Prompt optimized successfully!',
promptAlreadyOptimized: 'Prompt already optimized, may not have obvious changes',
optimizeFailed: 'Optimization failed',
optimizePromptFailed: 'Failed to optimize prompt',
requestParameterError: 'Request parameter error',
requestTimeout: 'Request timeout, please retry later',
serverError: 'Server error, please retry later',
networkError: 'Network error, please check connection',
networkConnectionError: 'Network connection error, please check your network',
startDownload: 'Starting video download',
videoUrlNotAvailable: 'Video URL not available',
noWorkToDelete: 'No work to delete',
confirmDeleteWork: 'Are you sure to delete this work?',
confirmDelete: 'Confirm Delete',
workDeleted: 'Work deleted',
deleteCancelled: 'Deletion cancelled',
historyParamsFilled: 'History parameters filled, ready to generate',
cancelFeatureTodo: 'Cancel feature coming soon',
resumingTask: 'Unfinished task detected, resuming...'
},
// Storyboard Video translations
storyboard: {
userAvatar: 'User Avatar',
generateStoryboard: 'Generate Storyboard',
generateVideo: 'Generate Video',
uploadStoryboard: 'Upload Storyboard (can generate video directly)',
uploadHint: 'Upload 1-6 storyboard images, can generate video directly without text description',
addMore: 'Add More',
uploadedCount: 'Uploaded {count}/6',
uploadLimit: 'Limit reached',
uploadedImage: 'Uploaded image {index}',
maxImages: 'Maximum 6 images allowed',
maxImagesWarning: 'Maximum 6 images allowed, you have uploaded {current}, you can upload {remaining} more',
fileSizeLimit: 'Image file size cannot exceed 100MB',
invalidFileType: 'Please select valid image files',
uploadSuccess: 'Successfully uploaded {count} images',
imageRemoved: 'Image removed',
promptPlaceholder: 'Example: a coffee advertisement\n\nTip: Simple description is enough, AI will automatically optimize it into professional storyboard\nSupports Chinese or English input, the system will automatically translate and optimize it into professional storyboard description',
tip1: '💡 AI will automatically generate professional storyboards based on your description',
tip2: '🎬 Supports various scene compositions and camera types',
optimizing: 'Optimizing...',
enterPrompt: 'Please enter prompt',
promptTooLong: 'Prompt is too long, please keep it within 2000 characters',
optimizingPrompt: 'Optimizing prompt, please wait...',
optimizeSuccess: 'Prompt optimized successfully!',
alreadyOptimized: 'Prompt already optimized, but may have no obvious changes',
optimizeFailed: 'Failed to optimize prompt',
paramError: 'Request parameter error',
timeout: 'Request timeout, please try again later',
serverError: 'Server error, please try again later',
networkError: 'Network error, please check your connection',
storyboardImage: 'Storyboard',
noStoryboard: 'No storyboard yet',
hdMode: 'HD Mode (1080P)',
hdCost: 'Costs 20 points when enabled',
pleaseLogin: 'Please login first',
loginRequired: 'Login required to submit task',
loginNow: 'Login Now',
loginBeforeSubmit: 'Please login before submitting task',
inProgress: 'In Progress',
generatingVideo: 'Generating video, please wait...',
progress: 'Progress: {progress}%',
generatingStoryboardText: 'Generating storyboard, please wait...',
generatingVideoText: 'Generating video, please wait...',
startCreating: 'Start creating your first work!',
noDescription: 'No description',
queuing: 'Queuing',
subscribeToSpeed: 'Subscribe to improve generation speed',
noResult: 'No result yet',
uploadOrGenerateFirst: 'Please upload or generate storyboard first',
uploadOrInputPrompt: 'Please upload storyboard or enter prompt',
startGenerateVideo: 'Start Generate Video',
generateVideoWithUpload: 'Generate Video with Uploaded Image',
startGenerateStoryboard: 'Start Generate Storyboard',
startGenerate: 'Start Generate',
enterDescription: 'Please enter description',
startingGenerate: 'Starting to generate storyboard...',
taskCreated: 'Storyboard task created successfully!',
createTaskFailed: 'Failed to create task',
generateFailed: 'Failed to generate storyboard',
taskTimeout: 'Task timeout, please check later',
storyboardCompleted: 'Storyboard generation completed! Please click "Start Generate" button to generate video',
videoCompleted: 'Video generation completed!',
taskFailed: 'Task failed',
unknownError: 'Unknown error',
startingVideoGenerate: 'Starting to generate video...',
videoTaskStarted: 'Video generation task started, please wait...',
videoStartFailed: 'Failed to start video generation',
defaultPrompt: 'Generate video from image',
videoTaskCreated: 'Video task created successfully, processing...',
createVideoTaskFailed: 'Failed to create video task',
generateVideoFailed: 'Failed to generate video',
videoTaskTimeout: 'Video task timeout, please check later',
videoGenerateFailed: 'Video generation failed',
paramsFilled: 'Parameters filled from history, ready to generate',
cancelFeaturePending: 'Cancel feature pending implementation',
taskCompleted: 'Task completed!',
resumingVideoTask: 'Detected unfinished video generation task, resuming...',
resumingStoryboardTask: 'Detected unfinished storyboard generation task, resuming...',
resumingTask: 'Detected unfinished task, resuming...'
}
},
works: {
title: 'My Works',
all: 'All',
textToVideo: 'Text to Video',
imageToVideo: 'Image to Video',
storyboardVideo: 'Storyboard Video',
filter: 'Filter',
sortBy: 'Sort By',
newest: 'Newest',
oldest: 'Oldest',
noWorks: 'No Works Yet',
noWorksDesc: 'Start creating your first work!',
viewDetail: 'View Details',
deleteConfirm: 'Are you sure you want to delete this work?',
deleteSuccess: 'Deleted successfully',
deleteFailed: 'Delete failed',
video: 'Video',
image: 'Image',
dateFilter: 'Date',
today: 'Today',
thisWeek: 'This Week',
thisMonth: 'This Month',
taskType: 'Task Type',
resolution: 'Resolution',
sd: 'SD',
hd: 'HD',
uhd: 'UHD',
ratio: 'Ratio',
time: 'Time',
popular: 'Popular',
searchPlaceholder: 'Name/Prompt/ID',
selectItems: 'Select {count} items',
selectedCount: '{count} selected',
favorite: 'Favorite',
downloadWithWatermark: 'Download with Watermark',
downloadWithoutWatermark: 'Download without Watermark',
memberOnly: 'Member Only',
rename: 'Rename',
referenceImagePrompt: 'Image 1 running in Image 2 video',
allLoaded: '✓ All content loaded',
noContent: 'No content found',
backToTop: 'Back to Top',
createSimilarInfo: 'Create similar based on "{title}"',
goToCreate: 'Go to create page',
downloadStart: 'Download started: {title}',
shareComingSoon: 'Share feature coming soon',
downloadWithWatermarkStart: 'Starting download with watermark',
downloadWithoutWatermarkStart: 'Starting download without watermark (Member exclusive)',
renameDevMsg: 'Rename feature under development',
deleteWorkConfirm: 'Are you sure to delete this work?',
deleteConfirmTitle: 'Delete Confirmation',
bulkDownloadStart: 'Starting download of {count} files',
bulkDeleteConfirm: 'Are you sure to delete {count} selected items?',
bulkDeleteSuccess: 'Selected items deleted',
filtersReset: 'Filters reset',
processing: 'Processing...',
noPreview: 'No Preview',
videoLoadFailed: 'Video Load Failed',
videoFileNotExist: 'Video file may not exist or has been deleted',
retry: 'Retry',
deleteFailedWork: 'Delete This Work',
deleteFailedWorkConfirm: 'This work\'s video failed to load. Are you sure you want to delete it? This action cannot be undone.'
},
subscription: {
title: 'Subscription',
choosePlan: 'Choose Plan',
currentPlan: 'Current Plan',
free: 'Free',
standard: 'Standard',
professional: 'Professional',
perMonth: '/month',
subscribe: 'Subscribe Now',
renew: 'Renew',
upgrade: 'Upgrade',
features: 'Features',
unlimited: 'Unlimited',
limited: 'Limited',
pointsPerMonth: 'Points/Month',
videoQuality: 'Video Quality',
support: 'Support',
priorityQueue: 'Priority Queue',
advancedFeatures: 'Advanced Features',
// New keys
userAvatar: 'User Avatar',
loading: 'Loading...',
pointsDetails: 'Points Details',
myOrders: 'My Orders',
currentActivePlan: 'Current Active Plan',
expiryTime: 'Expiry Time',
permanent: 'Permanent',
remainingPoints: 'Remaining Points',
plans: 'Plans',
currentPackage: 'Current Plan',
firstPurchaseDiscount: 'First Purchase Discount up to 15% off',
bestValue: 'Best Value',
standardPoints: '200 points per month',
premiumPoints: '1000 points per month',
freeNewUserBonus: 'New users get 50 points free on first login',
fastGeneration: 'Fast Generation',
superFastGeneration: 'Super Fast Generation',
commercialUse: 'Commercial Use',
noWatermark: 'No Watermark',
earlyAccess: 'Early Access to New Features',
// Points history related
pointsUsageHistory: 'Points Usage History',
pointsUsageOverview: 'Points Usage Overview',
totalRecharge: 'Total Recharge',
totalConsumption: 'Total Consumption',
currentPoints: 'Current Points',
noPointsHistory: 'No points usage history',
description: 'Description',
time: 'Time',
orderNumber: 'Order Number',
taskId: 'Task ID',
recharge: 'Recharge',
consume: 'Consume',
// Messages
pleaseLogin: 'Please login first',
loadUserInfoFailed: 'Failed to load user info, using defaults',
loadUserInfoError: 'Failed to load user info: {message}',
loadPointsHistoryFailed: 'Failed to load points history',
generatingQRCode: 'Generating payment QR code...',
subscriptionFailed: 'Subscription failed, please try again',
qrCodeGenerated: 'QR code generated, please scan with Alipay',
qrCodeGenerationFailed: 'Failed to generate QR code, please try again',
qrCodeEmpty: 'QR code generation failed: QR code is empty',
createPaymentFailed: 'Failed to create payment order',
qrCodeGenerationError: 'QR code generation failed: {message}',
pleaseTryAgain: 'Please try again',
refreshPage: 'Please refresh and try again',
paymentSuccess: 'Payment successful! Updating information...',
infoUpdated: 'Information updated!',
paymentProcessingFailed: 'Payment successful but order processing failed, please contact support',
paymentFailed: 'Payment failed, please try again',
creatingOrder: 'Creating order...',
paymentPageLoadFailed: 'Failed to load payment page',
createAlipayPaymentFailed: 'Failed to create Alipay payment',
createPaymentOrderFailed: 'Failed to create payment order, please try again',
// Plan descriptions
standardDescription: 'Standard Subscription - 200 points per month',
premiumDescription: 'Premium Subscription - 1000 points per month'
},
welcome: {
@@ -61,6 +563,10 @@ export default {
systemUptime: 'System Uptime'
},
admin: {
exitAdmin: 'Exit Admin'
},
dashboard: {
title: 'Dashboard',
totalUsers: 'Total Users',
@@ -84,7 +590,9 @@ export default {
month9: 'Sep',
month10: 'Oct',
month11: 'Nov',
month12: 'Dec'
month12: 'Dec',
pleaseLogin: 'Please login first',
loadDataFailed: 'Failed to load dashboard data'
},
orders: {
@@ -175,10 +683,8 @@ export default {
cleanupStats: 'Cleanup Statistics',
manualCleanup: 'Manual Cleanup',
autoCleanup: 'Auto Cleanup',
// Membership pricing
perMonth: '/month',
includesPoints: 'Includes {points} points/month',
// Cleanup stats
cleanupStatsInfo: 'Cleanup Statistics',
refresh: 'Refresh',
currentTotalTasks: 'Current Total Tasks',
@@ -188,7 +694,6 @@ export default {
cleanupLogsCount: 'Cleanup Logs',
retentionDays: 'Retention Days',
days: 'days',
// Cleanup actions
cleanupActions: 'Cleanup Actions',
performFullCleanup: 'Perform Full Cleanup',
cleanupUserTasks: 'Cleanup User Tasks',
@@ -196,13 +701,11 @@ export default {
fullCleanupDescDetail: 'Export successful tasks to archive, delete failed tasks',
userCleanupDesc: 'User Cleanup',
userCleanupDescDetail: 'Cleanup all tasks for specified user',
// Cleanup config
cleanupConfig: 'Cleanup Configuration',
taskRetentionDays: 'Task Retention Days',
taskRetentionTip: 'Days to retain completed tasks',
archiveRetentionDays: 'Archive Retention Days',
archiveRetentionTip: 'Days to retain archived data',
// Edit membership dialog
membershipLevel: 'Membership Level',
selectLevelPlaceholder: 'Select membership level',
freeMembership: 'Free Membership',
@@ -214,7 +717,6 @@ export default {
monthly: 'Monthly',
quarterly: 'Quarterly',
yearly: 'Yearly',
// User cleanup dialog
enterUsername: 'Enter username to cleanup',
warning: 'Warning',
cleanupWarning: 'This operation will cleanup all tasks for this user, including:',

View File

@@ -27,7 +27,510 @@ export default {
noPermissionMsg: '权限不足,只有管理员才能访问后台管理',
logoutConfirm: '确定要退出登录吗?',
logoutSuccess: '退出登录成功',
tip: '提示'
tip: '提示',
submit: '提交',
back: '返回',
next: '下一步',
previous: '上一步',
close: '关闭',
success: '成功',
error: '错误',
warning: '警告',
info: '信息'
},
login: {
title: '登录',
emailCodeLogin: '邮箱验证码登录',
emailPasswordLogin: '邮箱密码登录',
emailPlaceholder: '请输入邮箱地址',
codePlaceholder: '请输入验证码',
passwordPlaceholder: '请输入密码',
getCode: '获取验证码',
loginButton: '登录',
loginOrRegister: '登陆/注册',
loggingIn: '登录中...',
agreement: '登录即表示您同意遵守用户协议和隐私政策',
testAccounts: '测试邮箱',
admin: '管理员',
normalUser: '普通用户',
quickEmail: '快捷输入'
},
home: {
title: '首页',
exploreCreativity: '探索无限创意',
subtitle: '用 AI 将你的想象变为现实',
getStarted: '开始创作',
learnMore: '了解更多',
features: '核心功能',
textToVideo: '文生视频',
textToVideoDesc: '输入文字描述AI 自动生成高质量视频',
imageToVideo: '图生视频',
imageToVideoDesc: '上传图片AI 智能生成动态视频',
storyboardVideo: '分镜视频',
storyboardVideoDesc: '专业分镜制作,打造电影级效果',
myWorks: '我的作品',
myWorksDesc: '管理和查看你的所有创作',
subscription: '会员订阅',
subscriptionDesc: '解锁更多高级功能和资源'
},
profile: {
title: '个人主页',
userInfo: '用户信息',
username: '用户名',
email: '邮箱',
memberLevel: '会员等级',
expiryDate: '到期时间',
availablePoints: '可用积分',
frozenPoints: '冻结积分',
accountStats: '账户统计',
totalWorks: '作品总数',
videoGenerated: '生成视频数',
storageUsed: '已用存储',
editProfile: '编辑资料',
changePassword: '修改密码',
viewWorks: '查看作品',
upgradeMembership: '升级会员',
subscription: '会员订阅',
myWorks: '我的作品',
tools: '工具',
noUsername: '未设置用户名',
published: '已发布',
userId: 'ID',
noWorksYet: '暂无作品,开始创作吧!',
createSimilar: '做同款',
workDetail: '作品详情',
category: '分类',
inputDetails: '输入详情',
createTime: '创建时间',
workId: '作品 ID',
date: '日期',
duration: '时长',
quality: '清晰度',
aspectRatio: '宽高比',
unknown: '未知',
anonymousUser: '匿名用户',
browserNotSupport: '您的浏览器不支持视频播放',
noPrompt: '暂无提示词',
dashboard: '数据仪表盘',
orderManagement: '订单管理',
memberManagement: '会员管理',
systemSettings: '系统设置',
insufficientPermission: '权限不足,只有管理员才能访问',
logoutConfirm: '确定要退出登录吗?',
logoutSuccess: '已退出登录',
logoutFailed: '退出登录失败',
loadWorksFailed: '加载作品列表失败',
loadUserInfoFailed: '获取用户信息失败',
loadDetailFailed: '加载作品详情失败',
profileEditDevMsg: '个人简介编辑功能待实现'
},
video: {
create: '创建',
generating: '生成中',
completed: '已完成',
failed: '失败',
prompt: '提示词',
promptPlaceholder: '请输入视频描述...',
optimizePrompt: '优化提示词',
uploadImage: '上传图片',
selectImage: '选择图片',
generate: '生成视频',
duration: '时长',
resolution: '分辨率',
style: '风格',
aspectRatio: '宽高比',
advancedSettings: '高级设置',
cost: '消耗积分',
estimatedTime: '预计用时',
result: '生成结果',
download: '下载',
share: '分享',
regenerate: '重新生成',
saveToWorks: '保存到作品',
videoUrl: '视频链接',
status: '状态',
createdAt: '创建时间',
// 文生视频专用翻译
textToVideo: {
aspectRatio: '宽高比',
dateFormat: '{year}年{month}月{day}日 {hours}:{minutes}',
pointsUpdated: '用户积分已更新',
pointsUpdateFailed: '更新用户积分失败',
textInputPlaceholder: '请输入视频描述,例如:在草原上奔跑的骏马...',
userAvatar: '用户头像',
optimizing: '优化中...',
oneClickOptimize: '一键优化',
hdMode: '高清模式',
hdModeCost: '+20积分',
startGenerate: '开始生成',
pleaseLogin: '请先登录',
loginRequired: '登录后才能开始创作',
loginNow: '立即登录',
inProgress: '进行中',
noVideoUrl: '暂无视频',
withWatermark: '带水印',
withoutWatermark: '不带水印',
createSimilar: '做同款',
downloadVideo: '下载视频',
deleteWork: '删除作品',
generationFailed: '生成失败',
checkInputOrRetry: '请检查输入或重新尝试',
regenerate: '重新生成',
startCreating: '开始创作你的视频吧!',
noDescription: '无描述',
queuing: '排队中...',
subscribeToSpeedUp: '订阅会员可加速',
noResult: '暂无结果',
pleaseLoginFirst: '请先登录后再创建任务',
taskInProgress: '当前已有任务正在生成中,请稍候',
pleaseEnterText: '请输入视频描述文本',
creatingTask: '正在创建任务...',
taskCreated: '任务创建成功,开始生成',
createTaskFailed: '创建任务失败',
videoCompleted: '视频生成完成!',
videoFailed: '视频生成失败:',
statusPending: '排队中',
statusProcessing: '生成中',
statusCancelled: '已取消',
statusUnknown: '未知状态',
pleaseEnterPrompt: '请先输入提示词',
promptTooLong: '提示词过长最多2000字符',
optimizingPrompt: '正在优化提示词...',
optimizeSuccess: '提示词优化成功!',
optimizeNoChange: '提示词已是最优,无需优化',
optimizeFailed: '优化失败,请稍后重试',
requestParamError: '请求参数错误',
requestTimeout: '请求超时,请检查网络',
serverError: '服务器错误,请稍后重试',
networkError: '网络错误,请检查连接',
networkConnectionError: '网络连接失败',
downloadStarted: '开始下载',
videoUrlNotAvailable: '视频链接不可用',
noWorkToDelete: '没有可删除的作品',
deleteConfirm: '确定要删除这个作品吗?删除后将无法恢复。',
confirmDelete: '确认删除',
workDeleted: '作品已删除',
deleteCancelled: '已取消删除',
historyLoadSuccess: '历史记录加载成功: {count}条',
historyLoadFailed: '加载历史记录失败',
historyParamsFilled: '已填充历史参数,可以开始生成',
cancelFunctionTBD: '取消功能开发中',
unfinishedTaskDetected: '检测到未完成任务,正在恢复...'
},
// 图生视频专用翻译
imageToVideo: {
userAvatar: '用户头像',
firstFrame: '首帧',
promptPlaceholder: '结合图片,描述想要生成的内容',
optimizing: '优化中...',
optimizePrompt: '一键优化',
hdMode: '高清模式',
hdModeCost: '开启消耗20积分',
startGenerate: '开始生成',
pleaseLogin: '请先登录',
loginRequired: '需要登录后才能提交任务',
loginNow: '立即登录',
inProgress: '进行中',
statusPending: '等待中',
statusProcessing: '处理中',
statusCompleted: '已完成',
statusFailed: '失败',
statusCancelled: '已取消',
noVideoUrl: '视频生成完成,但未获取到视频链接',
withWatermark: '带水印',
withoutWatermark: '不带水印 会员专享',
createSimilar: '做同款',
downloadVideo: '下载视频',
deleteWork: '删除作品',
retry: '重新生成',
generateFailed: '生成失败',
generateFailedDesc: '请检查输入内容或重试',
startCreating: '开始创作您的第一个作品吧!',
tip1: '上传首帧图片',
tip2: '输入描述文字',
tip3: '选择视频参数',
tip4: '点击开始生成',
noDescription: '无描述',
queuing: '排队中',
subscribeToSpeedUp: '订阅套餐以提升生成速度',
cancel: '取消',
firstFrameImage: '首帧图片',
noResult: '暂无结果',
year: '年',
month: '月',
day: '日',
fileSizeLimit: '图片文件大小不能超过100MB',
invalidImageFile: '请选择有效的图片文件',
pleaseLoginFirst: '请先登录后再提交任务',
taskInProgress: '已有任务在进行中,请等待完成或取消当前任务',
uploadFirstFrameRequired: '请上传首帧图片',
enterDescriptionRequired: '请输入描述文字',
creatingTask: '正在创建任务...',
taskCreatedSuccess: '任务创建成功,开始处理...',
createTaskFailed: '创建任务失败',
createTaskFailedRetry: '创建任务失败,请重试',
videoGenerateCompleted: '视频生成完成!',
videoGenerateFailed: '视频生成失败:',
enterPromptFirst: '请输入提示词',
promptTooLong: '提示词过长请控制在2000字符以内',
optimizingPrompt: '正在优化提示词,请稍候...',
promptOptimizedSuccess: '提示词优化成功!',
promptAlreadyOptimized: '提示词已优化,但可能无明显变化',
optimizeFailed: '优化失败',
optimizePromptFailed: '优化提示词失败',
requestParameterError: '请求参数错误',
requestTimeout: '请求超时,请稍后重试',
serverError: '服务器错误,请稍后重试',
networkError: '网络错误,请检查网络连接',
networkConnectionError: '网络连接错误,请检查您的网络',
startDownload: '开始下载视频',
videoUrlNotAvailable: '视频链接不可用',
noWorkToDelete: '没有可删除的作品',
confirmDeleteWork: '确定要删除这个作品吗?',
confirmDelete: '确认删除',
workDeleted: '作品已删除',
deleteCancelled: '已取消删除',
historyParamsFilled: '已填充历史记录参数,可以开始生成',
cancelFeatureTodo: '取消功能待实现',
resumingTask: '检测到未完成的任务,继续处理中...',
resumingStoryboardTask: '检测到分镜图生成任务,继续处理中...'
},
// 分镜视频专用翻译
storyboard: {
userAvatar: '用户头像',
generateStoryboard: '生成分镜图',
generateVideo: '生成视频',
uploadStoryboard: '上传分镜图 (可直接生成视频)',
uploadHint: '支持上传 1-6 张分镜图,可直接生成视频,无需文字描述',
addMore: '继续添加',
uploadedCount: '已上传 {count}/6',
uploadLimit: '已达上限',
uploadedImage: '上传的图片 {index}',
maxImages: '最多只能上传6张图片',
maxImagesWarning: '最多只能上传6张图片您已上传{current}张,还可以上传{remaining}张',
fileSizeLimit: '图片文件大小不能超过100MB',
invalidFileType: '请选择有效的图片文件',
uploadSuccess: '成功上传 {count} 张图片',
imageRemoved: '已删除图片',
promptPlaceholder: '例如:一个咖啡的广告\n\n提示:简单描述即可AI会自动优化成专业的分镜图\n支持中文或英文输入系统会自动翻译并优化为专业的分镜图描述',
tip1: '💡 AI会根据您的描述自动生成专业分镜图',
tip2: '🎬 支持多种画面构图和镜头类型描述',
optimizing: '优化中...',
enterPrompt: '请输入提示词',
promptTooLong: '提示词过长请控制在2000字符以内',
optimizingPrompt: '正在优化提示词,请稍候...',
optimizeSuccess: '提示词优化成功!',
alreadyOptimized: '提示词已优化,但可能无明显变化',
optimizeFailed: '优化提示词失败',
paramError: '请求参数错误',
timeout: '请求超时,请稍后重试',
serverError: '服务器错误,请稍后重试',
networkError: '网络错误,请检查网络连接',
storyboardImage: '分镜图',
noStoryboard: '暂无分镜图',
hdMode: '高清模式 (1080P)',
hdCost: '开启消耗20积分',
pleaseLogin: '请先登录',
loginRequired: '需要登录后才能提交任务',
loginNow: '立即登录',
loginBeforeSubmit: '请先登录后再提交任务',
inProgress: '进行中',
generatingVideo: '正在生成视频,请稍候...',
progress: '进度: {progress}%',
generatingStoryboardText: '正在生成分镜图,请稍候...',
generatingVideoText: '正在生成视频,请稍候...',
startCreating: '开始创作您的第一个作品吧!',
noDescription: '无描述',
queuing: '排队中',
subscribeToSpeed: '订阅套餐以提升生成速度',
noResult: '暂无结果',
uploadOrGenerateFirst: '请先上传分镜图或生成分镜图',
uploadOrInputPrompt: '请上传分镜图或输入提示词',
startGenerateVideo: '开始生成视频',
generateVideoWithUpload: '使用上传图片生成视频',
startGenerateStoryboard: '开始生成分镜图',
startGenerate: '开始生成',
enterDescription: '请输入描述文字',
startingGenerate: '开始生成分镜图...',
taskCreated: '分镜图任务创建成功!',
createTaskFailed: '创建任务失败',
generateFailed: '生成分镜图失败',
taskTimeout: '任务超时,请稍后查看',
storyboardCompleted: '分镜图生成完成!请点击"开始生成"按钮生成视频',
videoCompleted: '视频生成完成!',
taskFailed: '任务失败',
unknownError: '未知错误',
startingVideoGenerate: '开始生成视频...',
videoTaskStarted: '视频生成任务已启动,请稍候...',
videoStartFailed: '启动视频生成失败',
defaultPrompt: '根据图片生成视频',
videoTaskCreated: '视频任务创建成功,开始处理...',
createVideoTaskFailed: '创建视频任务失败',
generateVideoFailed: '生成视频失败',
videoTaskTimeout: '视频任务超时,请稍后查看',
videoGenerateFailed: '视频生成失败',
paramsFilled: '已填充历史记录参数,可以开始生成',
cancelFeaturePending: '取消功能待实现',
taskCompleted: '任务已完成!',
resumingVideoTask: '检测到未完成的视频生成任务,继续处理中...',
resumingStoryboardTask: '检测到未完成的分镜图生成任务,继续处理中...',
resumingTask: '检测到未完成的任务,继续处理中...'
}
},
works: {
title: '我的作品',
all: '全部',
textToVideo: '文生视频',
imageToVideo: '图生视频',
storyboardVideo: '分镜视频',
filter: '筛选',
sortBy: '排序',
newest: '最新',
oldest: '最旧',
noWorks: '暂无作品',
noWorksDesc: '开始创作你的第一个作品吧!',
viewDetail: '查看详情',
deleteConfirm: '确定要删除这个作品吗?',
deleteSuccess: '删除成功',
deleteFailed: '删除失败',
video: '视频',
image: '图片',
dateFilter: '日期',
today: '今天',
thisWeek: '本周',
thisMonth: '本月',
taskType: '任务类型',
resolution: '清晰度',
sd: '标清',
hd: '高清',
uhd: '超清',
ratio: '比例',
time: '时间',
popular: '热门',
searchPlaceholder: '名字/提示词/ID',
selectItems: '选择{count}个项目',
selectedCount: '已选 {count} 个项目',
favorite: '收藏',
downloadWithWatermark: '带水印下载',
downloadWithoutWatermark: '不带水印下载',
memberOnly: '会员',
rename: '重命名',
referenceImagePrompt: '图1在图2中奔跑视频',
allLoaded: '✓ 已加载全部内容',
noContent: '没有找到相关内容',
backToTop: '回到顶部',
createSimilarInfo: '基于作品"{title}"创建同款',
goToCreate: '跳转到创作页面',
downloadStart: '开始下载:{title}',
shareComingSoon: '分享链接功能即将上线',
downloadWithWatermarkStart: '开始下载带水印版本',
downloadWithoutWatermarkStart: '开始下载不带水印版本(会员专享)',
renameDevMsg: '重命名功能开发中',
deleteWorkConfirm: '确定删除该作品吗?',
deleteConfirmTitle: '删除确认',
bulkDownloadStart: '开始下载 {count} 个文件',
bulkDeleteConfirm: '确定删除选中的 {count} 个项目吗?',
bulkDeleteSuccess: '已删除选中项目',
filtersReset: '筛选器已重置',
processing: '生成中...',
noPreview: '无预览',
videoLoadFailed: '视频加载失败',
videoFileNotExist: '视频文件可能不存在或已被删除',
retry: '重试',
deleteFailedWork: '删除此作品',
deleteFailedWorkConfirm: '此作品视频加载失败,确定要删除吗?删除后无法恢复。'
},
subscription: {
title: '会员订阅',
choosePlan: '选择套餐',
currentPlan: '当前套餐',
free: '免费版',
standard: '标准版',
professional: '专业版',
perMonth: '/月',
subscribe: '立即订阅',
renew: '续费',
upgrade: '升级',
features: '功能特性',
unlimited: '无限',
limited: '有限',
pointsPerMonth: '积分/月',
videoQuality: '视频质量',
support: '客服支持',
priorityQueue: '优先队列',
advancedFeatures: '高级功能',
// 新增键
userAvatar: '用户头像',
loading: '加载中...',
pointsDetails: '积分详情',
myOrders: '我的订单',
currentActivePlan: '当前生效权益',
expiryTime: '到期时间',
permanent: '永久',
remainingPoints: '剩余积分',
plans: '套餐',
currentPackage: '当前套餐',
firstPurchaseDiscount: '首购低至8.5折',
bestValue: '超值之选',
standardPoints: '每月200积分',
premiumPoints: '每月1000积分',
freeNewUserBonus: '新用户首次登陆免费获得50积分',
fastGeneration: '快速通道生成',
superFastGeneration: '极速通道生成',
commercialUse: '支持商用',
noWatermark: '下载去水印',
earlyAccess: '新功能优先体验',
// 积分历史相关
pointsUsageHistory: '积分使用情况',
pointsUsageOverview: '积分使用总览',
totalRecharge: '总充值',
totalConsumption: '总消耗',
currentPoints: '当前积分',
noPointsHistory: '暂无积分使用记录',
description: '描述',
time: '时间',
orderNumber: '订单号',
taskId: '任务ID',
recharge: '充值',
consume: '消耗',
// 消息提示
pleaseLogin: '请先登录',
loadUserInfoFailed: '获取用户信息失败,使用默认值',
loadUserInfoError: '加载用户信息失败: {message}',
loadPointsHistoryFailed: '获取积分使用历史失败',
generatingQRCode: '正在生成支付二维码...',
subscriptionFailed: '订阅处理失败,请重试',
qrCodeGenerated: '二维码已生成,请使用支付宝扫码支付',
qrCodeGenerationFailed: '生成二维码失败,请重试',
qrCodeEmpty: '二维码生成失败:二维码为空',
createPaymentFailed: '创建支付订单失败',
qrCodeGenerationError: '二维码生成失败:{message}',
pleaseTryAgain: '请重试',
refreshPage: '请刷新页面重试',
paymentSuccess: '支付成功!正在更新信息...',
infoUpdated: '信息已更新!',
paymentProcessingFailed: '支付成功但处理订单失败,请联系客服',
paymentFailed: '支付失败,请重试',
creatingOrder: '正在创建订单...',
paymentPageLoadFailed: '支付页面加载失败',
createAlipayPaymentFailed: '创建支付宝支付失败',
createPaymentOrderFailed: '创建支付订单失败,请重试',
// 套餐描述
standardDescription: '标准版订阅 - 每月200积分',
premiumDescription: '专业版订阅 - 每月1000积分'
},
welcome: {
@@ -61,6 +564,10 @@ export default {
systemUptime: '系统运行时间'
},
admin: {
exitAdmin: '退出后台'
},
dashboard: {
title: '数据仪表台',
totalUsers: '用户总数',
@@ -84,7 +591,9 @@ export default {
month9: '9月',
month10: '10月',
month11: '11月',
month12: '12月'
month12: '12月',
pleaseLogin: '请先登录',
loadDataFailed: '加载仪表盘数据失败'
},
orders: {
@@ -175,10 +684,8 @@ export default {
cleanupStats: '清理统计',
manualCleanup: '手动清理',
autoCleanup: '自动清理',
// Membership pricing
perMonth: '/月',
includesPoints: '包含{points}资源点/月',
// Cleanup stats
cleanupStatsInfo: '清理统计信息',
refresh: '刷新',
currentTotalTasks: '当前任务总数',
@@ -188,7 +695,6 @@ export default {
cleanupLogsCount: '清理日志数',
retentionDays: '保留天数',
days: '天',
// Cleanup actions
cleanupActions: '清理操作',
performFullCleanup: '执行完整清理',
cleanupUserTasks: '清理指定用户任务',
@@ -196,13 +702,11 @@ export default {
fullCleanupDescDetail: '将成功任务导出到归档表,删除失败任务',
userCleanupDesc: '用户清理',
userCleanupDescDetail: '清理指定用户的所有任务',
// Cleanup config
cleanupConfig: '清理配置',
taskRetentionDays: '任务保留天数',
taskRetentionTip: '任务完成后保留的天数',
archiveRetentionDays: '归档保留天数',
archiveRetentionTip: '归档数据保留的天数',
// Edit membership dialog
membershipLevel: '会员等级',
selectLevelPlaceholder: '请选择会员等级',
freeMembership: '免费版会员',
@@ -214,7 +718,6 @@ export default {
monthly: '月付',
quarterly: '季付',
yearly: '年付',
// User cleanup dialog
enterUsername: '请输入要清理的用户名',
warning: '警告',
cleanupWarning: '此操作将清理该用户的所有任务,包括:',

View File

@@ -3,17 +3,14 @@ import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
// 路由组件 - 使用懒加载优化性能
const Home = () => import('@/views/Home.vue')
const Login = () => import('@/views/Login.vue')
const Register = () => import('@/views/Register.vue')
const Orders = () => import('@/views/Orders.vue')
const OrderDetail = () => import('@/views/OrderDetail.vue')
const OrderCreate = () => import('@/views/OrderCreate.vue')
const Payments = () => import('@/views/Payments.vue')
const PaymentCreate = () => import('@/views/PaymentCreate.vue')
const AdminOrders = () => import('@/views/AdminOrders.vue')
const AdminDashboard = () => import('@/views/AdminDashboard.vue')
const Dashboard = () => import('@/views/Dashboard.vue')
const Welcome = () => import('@/views/Welcome.vue')
const Profile = () => import('@/views/Profile.vue')
const Subscription = () => import('@/views/Subscription.vue')
@@ -32,6 +29,8 @@ const GenerateTaskRecord = () => import('@/views/GenerateTaskRecord.vue')
const HelloWorld = () => import('@/views/HelloWorld.vue')
const TaskStatusPage = () => import('@/views/TaskStatusPage.vue')
const TermsOfService = () => import('@/views/TermsOfService.vue')
const UserAgreement = () => import('@/views/UserAgreement.vue')
const PrivacyPolicy = () => import('@/views/PrivacyPolicy.vue')
const routes = [
{
@@ -105,12 +104,6 @@ const routes = [
component: Welcome,
meta: { title: '欢迎', guest: true }
},
{
path: '/home',
name: 'Home',
component: Home,
meta: { title: '首页', requiresAuth: true }
},
{
path: '/profile',
name: 'Profile',
@@ -135,12 +128,6 @@ const routes = [
component: Register,
meta: { title: '注册', guest: true }
},
{
path: '/orders',
name: 'Orders',
component: Orders,
meta: { title: '订单管理', requiresAuth: true }
},
{
path: '/orders/:id',
name: 'OrderDetail',
@@ -213,6 +200,18 @@ const routes = [
component: TermsOfService,
meta: { title: 'Vionow 服务条款' }
},
{
path: '/user-agreement',
name: 'UserAgreement',
component: UserAgreement,
meta: { title: '用户协议' }
},
{
path: '/privacy-policy',
name: 'PrivacyPolicy',
component: PrivacyPolicy,
meta: { title: '隐私政策' }
},
]
const router = createRouter({

View File

@@ -95,12 +95,16 @@ export const useUserStore = defineStore('user', () => {
const fetchCurrentUser = async () => {
try {
const response = await getCurrentUser()
if (response.success) {
user.value = response.data
// 统一使用 response.data 格式
const data = response.data || response
if (data.success) {
user.value = data.data
sessionStorage.setItem('user', JSON.stringify(user.value))
} else {
// 会话无效,清除本地存储
clearUserData()
console.warn('获取用户信息失败:', data.message)
// 不要立即清除用户数据,保持当前登录状态
// 只在明确的401/认证失败时才由axios拦截器处理登出
}
} catch (error) {
console.error('Fetch user error:', error)

View File

@@ -3,23 +3,7 @@
<!-- 左侧导航栏 -->
<aside class="sidebar">
<div class="logo">
<div class="logo-icon">
<svg viewBox="0 0 194 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M54.6165 13.6062L60.593 32.3932H60.8224L66.8111 13.6062H72.6065L64.0824 38.3335H57.3452L48.8089 13.6062H54.6165ZM75.4862 38.3335V19.788H80.6296V38.3335H75.4862ZM78.07 17.3974C77.3053 17.3974 76.6493 17.1439 76.1019 16.6368C75.5626 16.1216 75.293 15.5058 75.293 14.7895C75.293 14.0811 75.5626 13.4734 76.1019 12.9663C76.6493 12.4512 77.3053 12.1936 78.07 12.1936C78.8346 12.1936 79.4866 12.4512 80.0259 12.9663C80.5733 13.4734 80.8469 14.0811 80.8469 14.7895C80.8469 15.5058 80.5733 16.1216 80.0259 16.6368C79.4866 17.1439 78.8346 17.3974 78.07 17.3974ZM91.5836 38.6353C90.175 38.6353 88.8992 38.2731 87.7562 37.5487C86.6213 36.8162 85.7198 35.7416 85.0517 34.325C84.3916 32.9003 84.0616 31.1536 84.0616 29.0849C84.0616 26.9599 84.4037 25.1931 85.0879 23.7845C85.7721 22.3678 86.6816 21.3093 87.8166 20.6091C88.9596 19.9007 90.2112 19.5466 91.5716 19.5466C92.6099 19.5466 93.4752 19.7236 94.1674 20.0778C94.8677 20.4239 95.4312 20.8586 95.8578 21.3818C96.2924 21.8969 96.6225 22.404 96.8478 22.9031H97.0048V13.6062H102.136V38.3335H97.0652V35.3633H96.8478C96.6064 35.8785 96.2643 36.3896 95.8216 36.8967C95.3869 37.3958 94.8194 37.8103 94.1191 38.1403C93.4269 38.4703 92.5817 38.6353 91.5836 38.6353ZM93.2136 34.5423C94.0427 34.5423 94.743 34.3169 95.3145 33.8662C95.894 33.4074 96.3367 32.7674 96.6426 31.9464C96.9565 31.1254 97.1135 30.1635 97.1135 29.0608C97.1135 27.958 96.9605 27.0002 96.6547 26.1872C96.3488 25.3742 95.9061 24.7464 95.3265 24.3037C94.747 23.861 94.0427 23.6396 93.2136 23.6396C92.3684 23.6396 91.6561 23.869 91.0765 24.3278C90.497 24.7866 90.0583 25.4225 89.7605 26.2355C89.4627 27.0485 89.3137 27.9902 89.3137 29.0608C89.3137 30.1394 89.4627 31.0932 89.7605 31.9223C90.0663 32.7433 90.505 33.3872 91.0765 33.8541C91.6561 34.3129 92.3684 34.5423 93.2136 34.5423ZM106.462 38.3335V13.6062H122.834V17.9166H111.69V23.8086H121.747V28.119H111.69V38.3335H106.462ZM131.397 13.6062V38.3335H126.254V13.6062H131.397ZM143.897 38.6957C142.021 38.6957 140.399 38.2973 139.031 37.5004C137.671 36.6955 136.62 35.5766 135.88 34.1439C135.139 32.7031 134.769 31.0328 134.769 29.1332C134.769 27.2175 135.139 25.5432 135.88 24.1105C136.62 22.6697 137.671 21.5508 139.031 20.7539C140.399 19.949 142.021 19.5466 143.897 19.5466C145.772 19.5466 147.39 19.949 148.75 20.7539C150.119 21.5508 151.173 22.6697 151.914 24.1105C152.654 25.5432 153.025 27.2175 153.025 29.1332C153.025 31.0328 152.654 32.7031 151.914 34.1439C151.173 35.5766 150.119 36.6955 148.75 37.5004C147.39 38.2973 145.772 38.6957 143.897 38.6957ZM143.921 34.7113C144.774 34.7113 145.486 34.4699 146.058 33.9869C146.629 33.4959 147.06 32.8278 147.35 31.9826C147.648 31.1375 147.797 30.1756 147.797 29.097C147.797 28.0184 147.648 27.0565 147.35 26.2113C147.06 25.3662 146.629 24.6981 146.058 24.2071C145.486 23.7161 144.774 23.4706 143.921 23.4706C143.06 23.4706 142.335 23.7161 141.748 24.2071C141.168 24.6981 140.729 25.3662 140.431 26.2113C140.142 27.0565 139.997 28.0184 139.997 29.097C139.997 30.1756 140.142 31.1375 140.431 31.9826C140.729 32.8278 141.168 33.4959 141.748 33.9869C142.335 34.4699 143.06 34.7113 143.921 34.7113ZM159.562 38.3335L154.516 19.788H159.719L162.593 32.2483H162.762L165.756 19.788H170.864L173.906 32.1758H174.063L176.888 19.788H182.08L177.045 38.3335H171.6L168.413 26.6701H168.183L164.996 38.3335H159.562Z" fill="white"/>
<g clip-path="url(#clip0)">
<path d="M5.7406 1.64568C2.43935 1.64568 0.00938034 1.57298 0.000366208 1.64568C0.000366202 1.71906 2.11292 5.37389 4.70642 9.58611C7.28533 13.813 12.3557 22.0905 15.9545 27.9611L20.8685 35.9875C21.8796 37.6388 23.6773 38.6457 25.6136 38.6457L26.4301 38.6457C26.4301 38.6457 31.0568 38.7204 31.9965 37.2228C32.2255 36.8288 32.346 36.3807 32.3461 35.925L32.3461 21.8996L31.1801 21.8996L31.1801 26.5969C31.1801 31.1005 31.1655 31.3074 30.889 31.5861C30.7288 31.7476 30.4663 31.8801 30.306 31.8801C29.9855 31.8801 29.7811 31.5866 27.1439 27.257C26.2551 25.8039 24.0844 22.2371 22.2924 19.3312C20.5148 16.4253 17.3534 11.2596 15.2699 7.85467L11.4672 1.64568L5.7406 1.64568ZM31.6615 2.99627C31.443 4.1703 30.525 6.06354 29.6654 7.10564C28.4561 8.60263 26.4304 9.83531 24.5072 10.2316C23.4727 10.4518 23.196 10.5988 23.808 10.5988C24.0267 10.5989 24.5801 10.7012 25.0316 10.8332C28.2662 11.7578 31.0495 14.7674 31.6468 17.9816L31.8363 18.9797L32.0111 18.1135C32.5648 15.3249 34.4443 12.8151 36.9066 11.5676C38.0139 10.9952 39.2815 10.5988 39.9808 10.5988C40.6217 10.5988 40.403 10.4957 39.2084 10.2463C37.46 9.87934 36.1049 9.11623 34.6625 7.66326C33.2638 6.26901 32.5496 5.02127 32.0834 3.17205L31.8217 2.11541L31.6615 2.99627Z" fill="url(#paint0)"/>
</g>
<defs>
<linearGradient id="paint0" x1="23.4862" y1="5.3947" x2="32.9093" y2="11.1248" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#33DDE5"/>
<stop offset="1" stop-color="#0F9CFF"/>
</linearGradient>
<clipPath id="clip0">
<rect width="40.3334" height="40.3334" fill="white"/>
</clipPath>
</defs>
</svg>
</div>
<img src="/images/backgrounds/logo-admin.svg" alt="Logo" />
</div>
<nav class="nav-menu">
@@ -69,10 +53,19 @@
</div>
<div class="header-actions">
<LanguageSwitcher />
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
<el-dropdown @command="handleUserCommand">
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="exitAdmin">
{{ $t('admin.exitAdmin') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
@@ -170,7 +163,7 @@ import {
ArrowDown,
Money
} from '@element-plus/icons-vue'
import { getDashboardOverview, getConversionRate, getDailyActiveUsersTrend } from '@/api/dashboard'
import { getDashboardOverview, getConversionRate, getDailyActiveUsersTrend, getSystemStatus } from '@/api/dashboard'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
@@ -238,6 +231,14 @@ const goToSettings = () => {
router.push('/system-settings')
}
// 处理用户头像下拉菜单
const handleUserCommand = (command) => {
if (command === 'exitAdmin') {
// 退出后台,返回个人首页
router.push('/profile')
}
}
// 格式化数字
const formatNumber = (num) => {
if (num >= 10000) {
@@ -469,6 +470,9 @@ const loadConversionChart = async () => {
}
}
// 定时器
let systemStatsTimer = null
// 页面加载时获取数据
onMounted(async () => {
console.log('后台管理页面加载完成')
@@ -477,20 +481,47 @@ onMounted(async () => {
await nextTick()
await loadDailyActiveChart()
await loadConversionChart()
// 每30秒刷新一次系统状态
systemStatsTimer = setInterval(() => {
fetchSystemStats()
}, 30000)
})
// 获取系统统计数据
const fetchSystemStats = async () => {
try {
// 临时使用计算值后续可以从API获取
// 计算在线用户数(这里简化处理)
const randomOnline = Math.floor(Math.random() * 50) + 10
onlineUsers.value = `${randomOnline}/500`
// 计算系统运行时间(基于当前时间简单模拟)
const hours = new Date().getHours()
const minutes = new Date().getMinutes()
systemUptime.value = `${hours}小时${minutes}`
const response = await getSystemStatus()
console.log('系统状态API返回:', response)
if (response && response.data && response.data.success) {
const data = response.data.data
console.log('系统状态数据:', data)
// 设置在线用户数
const currentOnline = data.onlineUsers || 0
const maxOnline = data.maxUsers || 500
onlineUsers.value = `${currentOnline}/${maxOnline}`
console.log(`在线用户数已更新: ${onlineUsers.value}`)
// 设置系统运行时间
if (data.uptime) {
// 假设后端返回的是秒数,转换为小时和分钟
const totalSeconds = data.uptime
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
systemUptime.value = `${hours}小时${minutes}`
} else if (data.uptimeFormatted) {
// 或者后端直接返回格式化的字符串
systemUptime.value = data.uptimeFormatted
} else {
systemUptime.value = '未知'
}
console.log(`系统运行时间已更新: ${systemUptime.value}`)
} else {
console.warn('系统状态API返回格式不正确:', response)
throw new Error('获取系统状态失败')
}
} catch (error) {
console.error('获取系统统计失败:', error)
onlineUsers.value = '0/500'
@@ -498,7 +529,7 @@ const fetchSystemStats = async () => {
}
}
// 组件卸载时清理图表
// 组件卸载时清理图表和定时器
onUnmounted(() => {
if (dailyActiveChartInstance) {
dailyActiveChartInstance.dispose()
@@ -508,6 +539,11 @@ onUnmounted(() => {
conversionChartInstance.dispose()
conversionChartInstance = null
}
// 清理定时器
if (systemStatsTimer) {
clearInterval(systemStatsTimer)
systemStatsTimer = null
}
})
</script>
@@ -522,8 +558,8 @@ onUnmounted(() => {
/* 左侧导航栏 */
.sidebar {
width: 240px;
background: white;
border-right: 1px solid #e9ecef;
background: #ffffff;
border-right: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
padding: 24px 0;
@@ -533,22 +569,15 @@ onUnmounted(() => {
.logo {
display: flex;
align-items: center;
padding: 0 50px;
justify-content: center;
padding: 0 24px;
margin-bottom: 32px;
}
.logo-icon {
width: 100%;
height: auto;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.logo-icon svg, .logo-icon img {
.logo img {
width: 100%;
height: auto;
max-width: 180px;
object-fit: contain;
}
@@ -565,19 +594,19 @@ onUnmounted(() => {
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
color: #6b7280;
color: #4b5563;
font-size: 14px;
font-weight: 500;
}
.nav-item:hover {
background: #f3f4f6;
color: #374151;
color: #1f2937;
}
.nav-item.active {
background: #dbeafe;
color: #3b82f6;
background: rgba(64, 158, 255, 0.15);
color: #409EFF;
}
.nav-item .el-icon {
@@ -592,9 +621,15 @@ onUnmounted(() => {
.sidebar-footer {
padding: 20px;
border-top: 1px solid #e9ecef;
background: #f8f9fa;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
margin-top: auto;
color: #4b5563;
}
.sidebar-footer .highlight {
color: #409EFF;
font-weight: 600;
}
.online-users,

View File

@@ -3,30 +3,14 @@
<!-- 左侧导航栏 -->
<aside class="sidebar">
<div class="logo">
<div class="logo-icon">
<svg viewBox="0 0 194 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M54.6165 13.6062L60.593 32.3932H60.8224L66.8111 13.6062H72.6065L64.0824 38.3335H57.3452L48.8089 13.6062H54.6165ZM75.4862 38.3335V19.788H80.6296V38.3335H75.4862ZM78.07 17.3974C77.3053 17.3974 76.6493 17.1439 76.1019 16.6368C75.5626 16.1216 75.293 15.5058 75.293 14.7895C75.293 14.0811 75.5626 13.4734 76.1019 12.9663C76.6493 12.4512 77.3053 12.1936 78.07 12.1936C78.8346 12.1936 79.4866 12.4512 80.0259 12.9663C80.5733 13.4734 80.8469 14.0811 80.8469 14.7895C80.8469 15.5058 80.5733 16.1216 80.0259 16.6368C79.4866 17.1439 78.8346 17.3974 78.07 17.3974ZM91.5836 38.6353C90.175 38.6353 88.8992 38.2731 87.7562 37.5487C86.6213 36.8162 85.7198 35.7416 85.0517 34.325C84.3916 32.9003 84.0616 31.1536 84.0616 29.0849C84.0616 26.9599 84.4037 25.1931 85.0879 23.7845C85.7721 22.3678 86.6816 21.3093 87.8166 20.6091C88.9596 19.9007 90.2112 19.5466 91.5716 19.5466C92.6099 19.5466 93.4752 19.7236 94.1674 20.0778C94.8677 20.4239 95.4312 20.8586 95.8578 21.3818C96.2924 21.8969 96.6225 22.404 96.8478 22.9031H97.0048V13.6062H102.136V38.3335H97.0652V35.3633H96.8478C96.6064 35.8785 96.2643 36.3896 95.8216 36.8967C95.3869 37.3958 94.8194 37.8103 94.1191 38.1403C93.4269 38.4703 92.5817 38.6353 91.5836 38.6353ZM93.2136 34.5423C94.0427 34.5423 94.743 34.3169 95.3145 33.8662C95.894 33.4074 96.3367 32.7674 96.6426 31.9464C96.9565 31.1254 97.1135 30.1635 97.1135 29.0608C97.1135 27.958 96.9605 27.0002 96.6547 26.1872C96.3488 25.3742 95.9061 24.7464 95.3265 24.3037C94.747 23.861 94.0427 23.6396 93.2136 23.6396C92.3684 23.6396 91.6561 23.869 91.0765 24.3278C90.497 24.7866 90.0583 25.4225 89.7605 26.2355C89.4627 27.0485 89.3137 27.9902 89.3137 29.0608C89.3137 30.1394 89.4627 31.0932 89.7605 31.9223C90.0663 32.7433 90.505 33.3872 91.0765 33.8541C91.6561 34.3129 92.3684 34.5423 93.2136 34.5423ZM106.462 38.3335V13.6062H122.834V17.9166H111.69V23.8086H121.747V28.119H111.69V38.3335H106.462ZM131.397 13.6062V38.3335H126.254V13.6062H131.397ZM143.897 38.6957C142.021 38.6957 140.399 38.2973 139.031 37.5004C137.671 36.6955 136.62 35.5766 135.88 34.1439C135.139 32.7031 134.769 31.0328 134.769 29.1332C134.769 27.2175 135.139 25.5432 135.88 24.1105C136.62 22.6697 137.671 21.5508 139.031 20.7539C140.399 19.949 142.021 19.5466 143.897 19.5466C145.772 19.5466 147.39 19.949 148.75 20.7539C150.119 21.5508 151.173 22.6697 151.914 24.1105C152.654 25.5432 153.025 27.2175 153.025 29.1332C153.025 31.0328 152.654 32.7031 151.914 34.1439C151.173 35.5766 150.119 36.6955 148.75 37.5004C147.39 38.2973 145.772 38.6957 143.897 38.6957ZM143.921 34.7113C144.774 34.7113 145.486 34.4699 146.058 33.9869C146.629 33.4959 147.06 32.8278 147.35 31.9826C147.648 31.1375 147.797 30.1756 147.797 29.097C147.797 28.0184 147.648 27.0565 147.35 26.2113C147.06 25.3662 146.629 24.6981 146.058 24.2071C145.486 23.7161 144.774 23.4706 143.921 23.4706C143.06 23.4706 142.335 23.7161 141.748 24.2071C141.168 24.6981 140.729 25.3662 140.431 26.2113C140.142 27.0565 139.997 28.0184 139.997 29.097C139.997 30.1756 140.142 31.1375 140.431 31.9826C140.729 32.8278 141.168 33.4959 141.748 33.9869C142.335 34.4699 143.06 34.7113 143.921 34.7113ZM159.562 38.3335L154.516 19.788H159.719L162.593 32.2483H162.762L165.756 19.788H170.864L173.906 32.1758H174.063L176.888 19.788H182.08L177.045 38.3335H171.6L168.413 26.6701H168.183L164.996 38.3335H159.562Z" fill="white"/>
<g clip-path="url(#clip0)">
<path d="M5.7406 1.64568C2.43935 1.64568 0.00938034 1.57298 0.000366208 1.64568C0.000366202 1.71906 2.11292 5.37389 4.70642 9.58611C7.28533 13.813 12.3557 22.0905 15.9545 27.9611L20.8685 35.9875C21.8796 37.6388 23.6773 38.6457 25.6136 38.6457L26.4301 38.6457C26.4301 38.6457 31.0568 38.7204 31.9965 37.2228C32.2255 36.8288 32.346 36.3807 32.3461 35.925L32.3461 21.8996L31.1801 21.8996L31.1801 26.5969C31.1801 31.1005 31.1655 31.3074 30.889 31.5861C30.7288 31.7476 30.4663 31.8801 30.306 31.8801C29.9855 31.8801 29.7811 31.5866 27.1439 27.257C26.2551 25.8039 24.0844 22.2371 22.2924 19.3312C20.5148 16.4253 17.3534 11.2596 15.2699 7.85467L11.4672 1.64568L5.7406 1.64568ZM31.6615 2.99627C31.443 4.1703 30.525 6.06354 29.6654 7.10564C28.4561 8.60263 26.4304 9.83531 24.5072 10.2316C23.4727 10.4518 23.196 10.5988 23.808 10.5988C24.0267 10.5989 24.5801 10.7012 25.0316 10.8332C28.2662 11.7578 31.0495 14.7674 31.6468 17.9816L31.8363 18.9797L32.0111 18.1135C32.5648 15.3249 34.4443 12.8151 36.9066 11.5676C38.0139 10.9952 39.2815 10.5988 39.9808 10.5988C40.6217 10.5988 40.403 10.4957 39.2084 10.2463C37.46 9.87934 36.1049 9.11623 34.6625 7.66326C33.2638 6.26901 32.5496 5.02127 32.0834 3.17205L31.8217 2.11541L31.6615 2.99627Z" fill="url(#paint0)"/>
</g>
<defs>
<linearGradient id="paint0" x1="23.4862" y1="5.3947" x2="32.9093" y2="11.1248" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#33DDE5"/>
<stop offset="1" stop-color="#0F9CFF"/>
</linearGradient>
<clipPath id="clip0">
<rect width="40.3334" height="40.3334" fill="white"/>
</clipPath>
</defs>
</svg>
</div>
<img src="/images/backgrounds/logo-admin.svg" alt="Logo" />
</div>
<nav class="nav-menu">
<div class="nav-item" @click="goToDashboard">
<div v-if="isAdminMode" class="nav-item" @click="goToDashboard">
<el-icon><Grid /></el-icon>
<span>{{ $t('nav.dashboard') }}</span>
</div>
<div class="nav-item" @click="goToMembers">
<div v-if="isAdminMode" class="nav-item" @click="goToMembers">
<el-icon><User /></el-icon>
<span>{{ $t('nav.members') }}</span>
</div>
@@ -34,18 +18,27 @@
<el-icon><ShoppingCart /></el-icon>
<span>{{ $t('nav.orders') }}</span>
</div>
<div class="nav-item" @click="goToAPI">
<div v-if="isAdminMode" class="nav-item" @click="goToAPI">
<el-icon><Document /></el-icon>
<span>{{ $t('nav.apiManagement') }}</span>
</div>
<div class="nav-item" @click="goToTasks">
<div v-if="isAdminMode" class="nav-item" @click="goToTasks">
<el-icon><Document /></el-icon>
<span>{{ $t('nav.tasks') }}</span>
</div>
<div class="nav-item" @click="goToSettings">
<div v-if="isAdminMode" class="nav-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>{{ $t('nav.systemSettings') }}</span>
</div>
<!-- 普通用户模式的导航 -->
<div v-if="!isAdminMode" class="nav-item" @click="goToProfile">
<el-icon><User /></el-icon>
<span>{{ $t('nav.profile') || '个人主页' }}</span>
</div>
<div v-if="!isAdminMode" class="nav-item" @click="goToWorks">
<el-icon><Document /></el-icon>
<span>{{ $t('nav.myWorks') || '我的作品' }}</span>
</div>
</nav>
<div class="sidebar-footer">
<div class="online-users">
@@ -67,9 +60,21 @@
</div>
<div class="header-actions">
<LanguageSwitcher />
<div class="user-avatar">
<el-dropdown v-if="isAdminMode" @command="handleUserCommand">
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="exitAdmin">
{{ $t('admin.exitAdmin') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div v-else class="user-avatar" @click="goToProfile">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
</div>
</header>
@@ -200,7 +205,7 @@
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Grid,
@@ -216,10 +221,14 @@ import {
CreditCard,
Wallet
} from '@element-plus/icons-vue'
import { getAdminOrders, getOrderStats, deleteOrder as deleteOrderAPI, deleteOrders } from '@/api/orders'
import { getOrders, getAdminOrders, getOrderStats, deleteOrder as deleteOrderAPI, deleteOrders } from '@/api/orders'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
const route = useRoute()
// 判断是否为管理员模式(基于路由路径)
const isAdminMode = computed(() => route.path.includes('/admin/'))
const loading = ref(false)
const orders = ref([])
@@ -261,6 +270,23 @@ const goToSettings = () => {
router.push('/system-settings')
}
// 普通用户模式的导航函数
const goToProfile = () => {
router.push('/profile')
}
const goToWorks = () => {
router.push('/works')
}
// 处理用户头像下拉菜单
const handleUserCommand = (command) => {
if (command === 'exitAdmin') {
// 退出后台,返回个人首页
router.push('/profile')
}
}
// 获取状态样式类
const getStatusClass = (status) => {
const statusMap = {
@@ -461,21 +487,23 @@ const deleteSelected = async () => {
const fetchOrders = async () => {
try {
loading.value = true
const response = await getAdminOrders({
// 根据模式调用不同的 API
const apiFunction = isAdminMode.value ? getAdminOrders : getOrders
const response = await apiFunction({
page: currentPage.value - 1,
size: pageSize.value,
status: filters.status,
search: filters.search || searchText.value
})
console.log('获取订单列表响应:', response)
// 后端返回格式: { success: true, data: { content: [...], totalElements: 100, ... } }
// axios会将响应包装在response.data中
const responseData = response?.data || response || {}
console.log('解析后的响应数据:', responseData)
if (responseData.success && responseData.data) {
const pageData = responseData.data
if (pageData.content) {
@@ -503,7 +531,7 @@ const fetchOrders = async () => {
console.error('API返回数据格式错误:', responseData)
ElMessage.error('API返回数据格式错误')
}
} catch (error) {
console.error('获取订单列表失败:', error)
ElMessage.error('获取订单列表失败: ' + (error.message || '未知错误'))
@@ -555,8 +583,8 @@ const fetchSystemStats = async () => {
/* 左侧导航栏 */
.sidebar {
width: 240px;
background: white;
border-right: 1px solid #e9ecef;
background: #ffffff;
border-right: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
padding: 24px 0;
@@ -566,22 +594,15 @@ const fetchSystemStats = async () => {
.logo {
display: flex;
align-items: center;
padding: 0 50px;
justify-content: center;
padding: 0 24px;
margin-bottom: 32px;
}
.logo-icon {
width: 100%;
height: auto;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.logo-icon svg, .logo-icon img {
.logo img {
width: 100%;
height: auto;
max-width: 180px;
object-fit: contain;
}
@@ -598,19 +619,19 @@ const fetchSystemStats = async () => {
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
color: #6b7280;
color: #4b5563;
font-size: 14px;
font-weight: 500;
}
.nav-item:hover {
background: #f3f4f6;
color: #374151;
color: #1f2937;
}
.nav-item.active {
background: #dbeafe;
color: #3b82f6;
background: rgba(64, 158, 255, 0.15);
color: #409EFF;
}
.nav-item .el-icon {
@@ -625,21 +646,27 @@ const fetchSystemStats = async () => {
.sidebar-footer {
padding: 20px;
border-top: 1px solid #e9ecef;
background: #f8f9fa;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
margin-top: auto;
color: #4b5563;
}
.online-users,
.system-uptime {
font-size: 13px;
color: #6b7280;
color: #4b5563;
margin-bottom: 8px;
line-height: 1.5;
}
.sidebar-footer .highlight {
color: #409EFF;
font-weight: 600;
}
.highlight {
color: #3b82f6;
color: #409EFF;
font-weight: 600;
}

View File

@@ -3,23 +3,7 @@
<!-- 左侧导航栏 -->
<aside class="sidebar">
<div class="logo">
<div class="logo-icon">
<svg viewBox="0 0 194 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M54.6165 13.6062L60.593 32.3932H60.8224L66.8111 13.6062H72.6065L64.0824 38.3335H57.3452L48.8089 13.6062H54.6165ZM75.4862 38.3335V19.788H80.6296V38.3335H75.4862ZM78.07 17.3974C77.3053 17.3974 76.6493 17.1439 76.1019 16.6368C75.5626 16.1216 75.293 15.5058 75.293 14.7895C75.293 14.0811 75.5626 13.4734 76.1019 12.9663C76.6493 12.4512 77.3053 12.1936 78.07 12.1936C78.8346 12.1936 79.4866 12.4512 80.0259 12.9663C80.5733 13.4734 80.8469 14.0811 80.8469 14.7895C80.8469 15.5058 80.5733 16.1216 80.0259 16.6368C79.4866 17.1439 78.8346 17.3974 78.07 17.3974ZM91.5836 38.6353C90.175 38.6353 88.8992 38.2731 87.7562 37.5487C86.6213 36.8162 85.7198 35.7416 85.0517 34.325C84.3916 32.9003 84.0616 31.1536 84.0616 29.0849C84.0616 26.9599 84.4037 25.1931 85.0879 23.7845C85.7721 22.3678 86.6816 21.3093 87.8166 20.6091C88.9596 19.9007 90.2112 19.5466 91.5716 19.5466C92.6099 19.5466 93.4752 19.7236 94.1674 20.0778C94.8677 20.4239 95.4312 20.8586 95.8578 21.3818C96.2924 21.8969 96.6225 22.404 96.8478 22.9031H97.0048V13.6062H102.136V38.3335H97.0652V35.3633H96.8478C96.6064 35.8785 96.2643 36.3896 95.8216 36.8967C95.3869 37.3958 94.8194 37.8103 94.1191 38.1403C93.4269 38.4703 92.5817 38.6353 91.5836 38.6353ZM93.2136 34.5423C94.0427 34.5423 94.743 34.3169 95.3145 33.8662C95.894 33.4074 96.3367 32.7674 96.6426 31.9464C96.9565 31.1254 97.1135 30.1635 97.1135 29.0608C97.1135 27.958 96.9605 27.0002 96.6547 26.1872C96.3488 25.3742 95.9061 24.7464 95.3265 24.3037C94.747 23.861 94.0427 23.6396 93.2136 23.6396C92.3684 23.6396 91.6561 23.869 91.0765 24.3278C90.497 24.7866 90.0583 25.4225 89.7605 26.2355C89.4627 27.0485 89.3137 27.9902 89.3137 29.0608C89.3137 30.1394 89.4627 31.0932 89.7605 31.9223C90.0663 32.7433 90.505 33.3872 91.0765 33.8541C91.6561 34.3129 92.3684 34.5423 93.2136 34.5423ZM106.462 38.3335V13.6062H122.834V17.9166H111.69V23.8086H121.747V28.119H111.69V38.3335H106.462ZM131.397 13.6062V38.3335H126.254V13.6062H131.397ZM143.897 38.6957C142.021 38.6957 140.399 38.2973 139.031 37.5004C137.671 36.6955 136.62 35.5766 135.88 34.1439C135.139 32.7031 134.769 31.0328 134.769 29.1332C134.769 27.2175 135.139 25.5432 135.88 24.1105C136.62 22.6697 137.671 21.5508 139.031 20.7539C140.399 19.949 142.021 19.5466 143.897 19.5466C145.772 19.5466 147.39 19.949 148.75 20.7539C150.119 21.5508 151.173 22.6697 151.914 24.1105C152.654 25.5432 153.025 27.2175 153.025 29.1332C153.025 31.0328 152.654 32.7031 151.914 34.1439C151.173 35.5766 150.119 36.6955 148.75 37.5004C147.39 38.2973 145.772 38.6957 143.897 38.6957ZM143.921 34.7113C144.774 34.7113 145.486 34.4699 146.058 33.9869C146.629 33.4959 147.06 32.8278 147.35 31.9826C147.648 31.1375 147.797 30.1756 147.797 29.097C147.797 28.0184 147.648 27.0565 147.35 26.2113C147.06 25.3662 146.629 24.6981 146.058 24.2071C145.486 23.7161 144.774 23.4706 143.921 23.4706C143.06 23.4706 142.335 23.7161 141.748 24.2071C141.168 24.6981 140.729 25.3662 140.431 26.2113C140.142 27.0565 139.997 28.0184 139.997 29.097C139.997 30.1756 140.142 31.1375 140.431 31.9826C140.729 32.8278 141.168 33.4959 141.748 33.9869C142.335 34.4699 143.06 34.7113 143.921 34.7113ZM159.562 38.3335L154.516 19.788H159.719L162.593 32.2483H162.762L165.756 19.788H170.864L173.906 32.1758H174.063L176.888 19.788H182.08L177.045 38.3335H171.6L168.413 26.6701H168.183L164.996 38.3335H159.562Z" fill="white"/>
<g clip-path="url(#clip0)">
<path d="M5.7406 1.64568C2.43935 1.64568 0.00938034 1.57298 0.000366208 1.64568C0.000366202 1.71906 2.11292 5.37389 4.70642 9.58611C7.28533 13.813 12.3557 22.0905 15.9545 27.9611L20.8685 35.9875C21.8796 37.6388 23.6773 38.6457 25.6136 38.6457L26.4301 38.6457C26.4301 38.6457 31.0568 38.7204 31.9965 37.2228C32.2255 36.8288 32.346 36.3807 32.3461 35.925L32.3461 21.8996L31.1801 21.8996L31.1801 26.5969C31.1801 31.1005 31.1655 31.3074 30.889 31.5861C30.7288 31.7476 30.4663 31.8801 30.306 31.8801C29.9855 31.8801 29.7811 31.5866 27.1439 27.257C26.2551 25.8039 24.0844 22.2371 22.2924 19.3312C20.5148 16.4253 17.3534 11.2596 15.2699 7.85467L11.4672 1.64568L5.7406 1.64568ZM31.6615 2.99627C31.443 4.1703 30.525 6.06354 29.6654 7.10564C28.4561 8.60263 26.4304 9.83531 24.5072 10.2316C23.4727 10.4518 23.196 10.5988 23.808 10.5988C24.0267 10.5989 24.5801 10.7012 25.0316 10.8332C28.2662 11.7578 31.0495 14.7674 31.6468 17.9816L31.8363 18.9797L32.0111 18.1135C32.5648 15.3249 34.4443 12.8151 36.9066 11.5676C38.0139 10.9952 39.2815 10.5988 39.9808 10.5988C40.6217 10.5988 40.403 10.4957 39.2084 10.2463C37.46 9.87934 36.1049 9.11623 34.6625 7.66326C33.2638 6.26901 32.5496 5.02127 32.0834 3.17205L31.8217 2.11541L31.6615 2.99627Z" fill="url(#paint0)"/>
</g>
<defs>
<linearGradient id="paint0" x1="23.4862" y1="5.3947" x2="32.9093" y2="11.1248" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#33DDE5"/>
<stop offset="1" stop-color="#0F9CFF"/>
</linearGradient>
<clipPath id="clip0">
<rect width="40.3334" height="40.3334" fill="white"/>
</clipPath>
</defs>
</svg>
</div>
<img src="/images/backgrounds/logo-admin.svg" alt="Logo" />
</div>
<nav class="nav-menu">
<div class="nav-item" @click="goToDashboard">
@@ -67,10 +51,19 @@
</div>
<div class="header-actions">
<LanguageSwitcher />
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
<el-dropdown @command="handleUserCommand">
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="exitAdmin">
{{ $t('admin.exitAdmin') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
@@ -173,6 +166,14 @@ const goToSettings = () => {
router.push('/system-settings')
}
// 处理用户头像下拉菜单
const handleUserCommand = (command) => {
if (command === 'exitAdmin') {
// 退出后台,返回个人首页
router.push('/profile')
}
}
// 格式化JWT过期时间显示
const formatJwtExpiration = (hours) => {
if (!hours) return ''
@@ -316,22 +317,15 @@ const fetchSystemStats = async () => {
.logo {
display: flex;
align-items: center;
padding: 0 50px;
justify-content: center;
padding: 0 24px;
margin-bottom: 32px;
}
.logo-icon {
width: 100%;
height: auto;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.logo-icon svg, .logo-icon img {
.logo img {
width: 100%;
height: auto;
max-width: 180px;
object-fit: contain;
}

View File

@@ -1,626 +0,0 @@
<template>
<div class="dashboard">
<div class="dashboard-header">
<h1>数据仪表盘</h1>
<p class="dashboard-subtitle">系统数据概览与关键指标监控</p>
</div>
<!-- 概览卡片 -->
<div class="overview-cards">
<div class="card">
<div class="card-icon users">
<i class="fas fa-users"></i>
</div>
<div class="card-content">
<h3>{{ overviewData.totalUsers || 0 }}</h3>
<p>用户总数</p>
</div>
</div>
<div class="card">
<div class="card-icon paying">
<i class="fas fa-credit-card"></i>
</div>
<div class="card-content">
<h3>{{ overviewData.payingUsers || 0 }}</h3>
<p>付费用户数</p>
</div>
</div>
<div class="card">
<div class="card-icon revenue">
<i class="fas fa-dollar-sign"></i>
</div>
<div class="card-content">
<h3>¥{{ formatNumber(overviewData.todayRevenue || 0) }}</h3>
<p>今日收入</p>
</div>
</div>
<div class="card">
<div class="card-icon conversion">
<i class="fas fa-chart-line"></i>
</div>
<div class="card-content">
<h3>{{ overviewData.conversionRate || 0 }}%</h3>
<p>转化率</p>
</div>
</div>
</div>
<!-- 图表区域 -->
<div class="charts-section">
<div class="chart-container">
<h3>日活用户趋势</h3>
<div class="chart" ref="dailyActiveChart"></div>
</div>
<div class="chart-container">
<h3>收入趋势</h3>
<div class="chart" ref="revenueChart"></div>
</div>
</div>
<!-- 分布图表 -->
<div class="distribution-section">
<div class="chart-container">
<h3>订单状态分布</h3>
<div class="chart" ref="orderStatusChart"></div>
</div>
<div class="chart-container">
<h3>支付方式分布</h3>
<div class="chart" ref="paymentMethodChart"></div>
</div>
</div>
<!-- 最近订单 -->
<div class="recent-orders">
<h3>最近订单</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>订单号</th>
<th>用户</th>
<th>金额</th>
<th>状态</th>
<th>创建时间</th>
</tr>
</thead>
<tbody>
<tr v-for="order in recentOrders" :key="order.id">
<td>{{ order.orderNumber }}</td>
<td>{{ order.username }}</td>
<td>¥{{ formatNumber(order.totalAmount) }}</td>
<td>
<span class="status-badge" :class="getStatusClass(order.status)">
{{ getStatusText(order.status) }}
</span>
</td>
<td>{{ formatDate(order.createdAt) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted, nextTick } from 'vue'
import * as dashboardAPI from '@/api/dashboard'
// 动态加载ECharts
const loadECharts = () => {
return new Promise((resolve, reject) => {
if (window.echarts) {
resolve(window.echarts)
return
}
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js'
script.onload = () => resolve(window.echarts)
script.onerror = reject
document.head.appendChild(script)
})
}
export default {
name: 'Dashboard',
setup() {
const overviewData = ref({})
const dailyActiveData = ref([])
const revenueData = ref([])
const orderStatusData = ref([])
const paymentMethodData = ref([])
const recentOrders = ref([])
const dailyActiveChart = ref(null)
const revenueChart = ref(null)
const orderStatusChart = ref(null)
const paymentMethodChart = ref(null)
// 加载仪表盘数据
const loadDashboardData = async () => {
try {
const [overviewRes, monthlyRes, conversionRes, ordersRes] = await Promise.all([
dashboardAPI.getDashboardOverview(),
dashboardAPI.getMonthlyRevenue(),
dashboardAPI.getConversionRate(),
dashboardAPI.getRecentOrders()
])
overviewData.value = overviewRes.data || {}
revenueData.value = monthlyRes.data?.monthlyData || []
recentOrders.value = ordersRes.data?.recentOrders || []
// 计算转化率
if (conversionRes.data) {
overviewData.value.conversionRate = conversionRes.data.conversionRate || 0
}
await nextTick()
initCharts()
} catch (error) {
console.error('加载仪表盘数据失败:', error)
}
}
// 初始化图表
const initCharts = async () => {
try {
const echarts = await loadECharts()
await initDailyActiveChart(echarts)
await initRevenueChart(echarts)
await initOrderStatusChart(echarts)
await initPaymentMethodChart(echarts)
} catch (error) {
console.error('加载ECharts失败:', error)
}
}
// 日活用户图表
const initDailyActiveChart = async (echarts) => {
if (!dailyActiveChart.value) return
const chart = echarts.init(dailyActiveChart.value)
const option = {
tooltip: {
trigger: 'axis'
},
xAxis: {
type: 'category',
data: dailyActiveData.value.map(item => item.date)
},
yAxis: {
type: 'value'
},
series: [{
data: dailyActiveData.value.map(item => item.activeUsers),
type: 'line',
smooth: true,
areaStyle: {
opacity: 0.3
},
lineStyle: {
color: '#4CAF50'
},
itemStyle: {
color: '#4CAF50'
}
}]
}
chart.setOption(option)
}
// 收入趋势图表
const initRevenueChart = async (echarts) => {
if (!revenueChart.value) return
const chart = echarts.init(revenueChart.value)
const option = {
tooltip: {
trigger: 'axis',
formatter: function(params) {
return `${params[0].axisValue}<br/>收入: ¥${params[0].value}`
}
},
xAxis: {
type: 'category',
data: revenueData.value.map(item => item.date)
},
yAxis: {
type: 'value'
},
series: [{
data: revenueData.value.map(item => item.revenue),
type: 'bar',
itemStyle: {
color: '#2196F3'
}
}]
}
chart.setOption(option)
}
// 订单状态分布图表
const initOrderStatusChart = async (echarts) => {
if (!orderStatusChart.value) return
const chart = echarts.init(orderStatusChart.value)
const option = {
tooltip: {
trigger: 'item'
},
series: [{
type: 'pie',
radius: '50%',
data: orderStatusData.value.map(item => ({
value: item.count,
name: item.status
})),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}]
}
chart.setOption(option)
}
// 支付方式分布图表
const initPaymentMethodChart = async (echarts) => {
if (!paymentMethodChart.value) return
const chart = echarts.init(paymentMethodChart.value)
const option = {
tooltip: {
trigger: 'item'
},
series: [{
type: 'pie',
radius: '50%',
data: paymentMethodData.value.map(item => ({
value: item.count,
name: item.method
})),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}]
}
chart.setOption(option)
}
// 格式化数字
const formatNumber = (num) => {
if (typeof num === 'string') {
num = parseFloat(num)
}
return num.toLocaleString()
}
// 格式化日期
const formatDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleString('zh-CN')
}
// 获取状态样式类
const getStatusClass = (status) => {
const statusMap = {
'PENDING': 'status-pending',
'CONFIRMED': 'status-confirmed',
'PAID': 'status-paid',
'PROCESSING': 'status-processing',
'SHIPPED': 'status-shipped',
'DELIVERED': 'status-delivered',
'COMPLETED': 'status-completed',
'CANCELLED': 'status-cancelled',
'REFUNDED': 'status-refunded'
}
return statusMap[status] || 'status-default'
}
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
'PENDING': '待支付',
'CONFIRMED': '已确认',
'PAID': '已支付',
'PROCESSING': '处理中',
'SHIPPED': '已发货',
'DELIVERED': '已送达',
'COMPLETED': '已完成',
'CANCELLED': '已取消',
'REFUNDED': '已退款'
}
return statusMap[status] || status
}
onMounted(() => {
loadDashboardData()
})
return {
overviewData,
dailyActiveData,
revenueData,
orderStatusData,
paymentMethodData,
recentOrders,
dailyActiveChart,
revenueChart,
orderStatusChart,
paymentMethodChart,
formatNumber,
formatDate,
getStatusClass,
getStatusText
}
}
}
</script>
<style scoped>
.dashboard {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
position: relative;
overflow-x: hidden;
}
/* 页面特殊效果 */
.dashboard::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.3) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.3) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(120, 219, 255, 0.3) 0%, transparent 50%);
animation: dashboardGlow 8s ease-in-out infinite alternate;
pointer-events: none;
z-index: 1;
}
@keyframes dashboardGlow {
0% { opacity: 0.3; }
100% { opacity: 0.7; }
}
/* 内容层级 */
.dashboard > * {
position: relative;
z-index: 2;
}
.dashboard-header {
text-align: center;
margin-bottom: 30px;
}
.dashboard-header h1 {
color: #333;
margin-bottom: 10px;
}
.dashboard-subtitle {
color: #666;
font-size: 16px;
}
.overview-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.card {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
transition: transform 0.2s;
}
.card:hover {
transform: translateY(-2px);
}
.card-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15px;
font-size: 24px;
color: white;
}
.card-icon.users {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card-icon.paying {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.card-icon.revenue {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.card-icon.conversion {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
.card-content h3 {
margin: 0 0 5px 0;
font-size: 28px;
font-weight: bold;
color: #333;
}
.card-content p {
margin: 0;
color: #666;
font-size: 14px;
}
.charts-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.distribution-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.chart-container {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.chart-container h3 {
margin: 0 0 15px 0;
color: #333;
font-size: 18px;
}
.chart {
height: 300px;
width: 100%;
}
.recent-orders {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.recent-orders h3 {
margin: 0 0 20px 0;
color: #333;
font-size: 18px;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background-color: #f8f9fa;
font-weight: 600;
color: #333;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-pending {
background-color: #fff3cd;
color: #856404;
}
.status-confirmed {
background-color: #d1ecf1;
color: #0c5460;
}
.status-paid {
background-color: #d4edda;
color: #155724;
}
.status-processing {
background-color: #cce5ff;
color: #004085;
}
.status-shipped {
background-color: #e2e3e5;
color: #383d41;
}
.status-delivered {
background-color: #d1ecf1;
color: #0c5460;
}
.status-completed {
background-color: #d4edda;
color: #155724;
}
.status-cancelled {
background-color: #f8d7da;
color: #721c24;
}
.status-refunded {
background-color: #f8d7da;
color: #721c24;
}
@media (max-width: 768px) {
.dashboard {
padding: 10px;
}
.overview-cards {
grid-template-columns: 1fr;
}
.charts-section,
.distribution-section {
grid-template-columns: 1fr;
}
.chart {
height: 250px;
}
}
</style>

View File

@@ -3,23 +3,7 @@
<!-- 左侧导航栏 -->
<aside class="sidebar">
<div class="logo">
<div class="logo-icon">
<svg viewBox="0 0 194 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M54.6165 13.6062L60.593 32.3932H60.8224L66.8111 13.6062H72.6065L64.0824 38.3335H57.3452L48.8089 13.6062H54.6165ZM75.4862 38.3335V19.788H80.6296V38.3335H75.4862ZM78.07 17.3974C77.3053 17.3974 76.6493 17.1439 76.1019 16.6368C75.5626 16.1216 75.293 15.5058 75.293 14.7895C75.293 14.0811 75.5626 13.4734 76.1019 12.9663C76.6493 12.4512 77.3053 12.1936 78.07 12.1936C78.8346 12.1936 79.4866 12.4512 80.0259 12.9663C80.5733 13.4734 80.8469 14.0811 80.8469 14.7895C80.8469 15.5058 80.5733 16.1216 80.0259 16.6368C79.4866 17.1439 78.8346 17.3974 78.07 17.3974ZM91.5836 38.6353C90.175 38.6353 88.8992 38.2731 87.7562 37.5487C86.6213 36.8162 85.7198 35.7416 85.0517 34.325C84.3916 32.9003 84.0616 31.1536 84.0616 29.0849C84.0616 26.9599 84.4037 25.1931 85.0879 23.7845C85.7721 22.3678 86.6816 21.3093 87.8166 20.6091C88.9596 19.9007 90.2112 19.5466 91.5716 19.5466C92.6099 19.5466 93.4752 19.7236 94.1674 20.0778C94.8677 20.4239 95.4312 20.8586 95.8578 21.3818C96.2924 21.8969 96.6225 22.404 96.8478 22.9031H97.0048V13.6062H102.136V38.3335H97.0652V35.3633H96.8478C96.6064 35.8785 96.2643 36.3896 95.8216 36.8967C95.3869 37.3958 94.8194 37.8103 94.1191 38.1403C93.4269 38.4703 92.5817 38.6353 91.5836 38.6353ZM93.2136 34.5423C94.0427 34.5423 94.743 34.3169 95.3145 33.8662C95.894 33.4074 96.3367 32.7674 96.6426 31.9464C96.9565 31.1254 97.1135 30.1635 97.1135 29.0608C97.1135 27.958 96.9605 27.0002 96.6547 26.1872C96.3488 25.3742 95.9061 24.7464 95.3265 24.3037C94.747 23.861 94.0427 23.6396 93.2136 23.6396C92.3684 23.6396 91.6561 23.869 91.0765 24.3278C90.497 24.7866 90.0583 25.4225 89.7605 26.2355C89.4627 27.0485 89.3137 27.9902 89.3137 29.0608C89.3137 30.1394 89.4627 31.0932 89.7605 31.9223C90.0663 32.7433 90.505 33.3872 91.0765 33.8541C91.6561 34.3129 92.3684 34.5423 93.2136 34.5423ZM106.462 38.3335V13.6062H122.834V17.9166H111.69V23.8086H121.747V28.119H111.69V38.3335H106.462ZM131.397 13.6062V38.3335H126.254V13.6062H131.397ZM143.897 38.6957C142.021 38.6957 140.399 38.2973 139.031 37.5004C137.671 36.6955 136.62 35.5766 135.88 34.1439C135.139 32.7031 134.769 31.0328 134.769 29.1332C134.769 27.2175 135.139 25.5432 135.88 24.1105C136.62 22.6697 137.671 21.5508 139.031 20.7539C140.399 19.949 142.021 19.5466 143.897 19.5466C145.772 19.5466 147.39 19.949 148.75 20.7539C150.119 21.5508 151.173 22.6697 151.914 24.1105C152.654 25.5432 153.025 27.2175 153.025 29.1332C153.025 31.0328 152.654 32.7031 151.914 34.1439C151.173 35.5766 150.119 36.6955 148.75 37.5004C147.39 38.2973 145.772 38.6957 143.897 38.6957ZM143.921 34.7113C144.774 34.7113 145.486 34.4699 146.058 33.9869C146.629 33.4959 147.06 32.8278 147.35 31.9826C147.648 31.1375 147.797 30.1756 147.797 29.097C147.797 28.0184 147.648 27.0565 147.35 26.2113C147.06 25.3662 146.629 24.6981 146.058 24.2071C145.486 23.7161 144.774 23.4706 143.921 23.4706C143.06 23.4706 142.335 23.7161 141.748 24.2071C141.168 24.6981 140.729 25.3662 140.431 26.2113C140.142 27.0565 139.997 28.0184 139.997 29.097C139.997 30.1756 140.142 31.1375 140.431 31.9826C140.729 32.8278 141.168 33.4959 141.748 33.9869C142.335 34.4699 143.06 34.7113 143.921 34.7113ZM159.562 38.3335L154.516 19.788H159.719L162.593 32.2483H162.762L165.756 19.788H170.864L173.906 32.1758H174.063L176.888 19.788H182.08L177.045 38.3335H171.6L168.413 26.6701H168.183L164.996 38.3335H159.562Z" fill="white"/>
<g clip-path="url(#clip0)">
<path d="M5.7406 1.64568C2.43935 1.64568 0.00938034 1.57298 0.000366208 1.64568C0.000366202 1.71906 2.11292 5.37389 4.70642 9.58611C7.28533 13.813 12.3557 22.0905 15.9545 27.9611L20.8685 35.9875C21.8796 37.6388 23.6773 38.6457 25.6136 38.6457L26.4301 38.6457C26.4301 38.6457 31.0568 38.7204 31.9965 37.2228C32.2255 36.8288 32.346 36.3807 32.3461 35.925L32.3461 21.8996L31.1801 21.8996L31.1801 26.5969C31.1801 31.1005 31.1655 31.3074 30.889 31.5861C30.7288 31.7476 30.4663 31.8801 30.306 31.8801C29.9855 31.8801 29.7811 31.5866 27.1439 27.257C26.2551 25.8039 24.0844 22.2371 22.2924 19.3312C20.5148 16.4253 17.3534 11.2596 15.2699 7.85467L11.4672 1.64568L5.7406 1.64568ZM31.6615 2.99627C31.443 4.1703 30.525 6.06354 29.6654 7.10564C28.4561 8.60263 26.4304 9.83531 24.5072 10.2316C23.4727 10.4518 23.196 10.5988 23.808 10.5988C24.0267 10.5989 24.5801 10.7012 25.0316 10.8332C28.2662 11.7578 31.0495 14.7674 31.6468 17.9816L31.8363 18.9797L32.0111 18.1135C32.5648 15.3249 34.4443 12.8151 36.9066 11.5676C38.0139 10.9952 39.2815 10.5988 39.9808 10.5988C40.6217 10.5988 40.403 10.4957 39.2084 10.2463C37.46 9.87934 36.1049 9.11623 34.6625 7.66326C33.2638 6.26901 32.5496 5.02127 32.0834 3.17205L31.8217 2.11541L31.6615 2.99627Z" fill="url(#paint0)"/>
</g>
<defs>
<linearGradient id="paint0" x1="23.4862" y1="5.3947" x2="32.9093" y2="11.1248" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#33DDE5"/>
<stop offset="1" stop-color="#0F9CFF"/>
</linearGradient>
<clipPath id="clip0">
<rect width="40.3334" height="40.3334" fill="white"/>
</clipPath>
</defs>
</svg>
</div>
<img src="/images/backgrounds/logo-admin.svg" alt="Logo" />
</div>
<nav class="nav-menu">
<div class="nav-item" @click="goToDashboard">
@@ -67,10 +51,19 @@
</div>
<div class="header-actions">
<LanguageSwitcher />
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
<el-dropdown @command="handleUserCommand">
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="exitAdmin">
{{ $t('admin.exitAdmin') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
@@ -231,6 +224,14 @@ const goToSettings = () => {
router.push('/system-settings')
}
// 处理用户头像下拉菜单
const handleUserCommand = (command) => {
if (command === 'exitAdmin') {
// 退出后台,返回个人首页
router.push('/profile')
}
}
// 表格操作
const isAllSelected = computed(() => {
return taskRecords.value.length > 0 && selectedTasks.value.length === taskRecords.value.length
@@ -626,22 +627,15 @@ const fetchSystemStats = async () => {
.logo {
display: flex;
align-items: center;
padding: 0 50px;
justify-content: center;
padding: 0 24px;
margin-bottom: 32px;
}
.logo-icon {
width: 100%;
height: auto;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.logo-icon svg, .logo-icon img {
.logo img {
width: 100%;
height: auto;
max-width: 180px;
object-fit: contain;
}

View File

@@ -1,901 +0,0 @@
<template>
<div class="dashboard">
<!-- 左侧导航栏 -->
<aside class="sidebar">
<div class="logo">
<div class="logo-icon">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
</div>
</div>
<nav class="nav-menu">
<div class="nav-item active">
<el-icon><Grid /></el-icon>
<span>{{ $t('nav.dashboard') }}</span>
</div>
<div class="nav-item" @click="goToUsers">
<el-icon><UserIcon /></el-icon>
<span>{{ $t('nav.members') }}</span>
</div>
<div class="nav-item" @click="goToOrders">
<el-icon><ShoppingCart /></el-icon>
<span>{{ $t('nav.orders') }}</span>
</div>
<div class="nav-item" @click="goToAPI">
<el-icon><Document /></el-icon>
<span>{{ $t('nav.apiManagement') }}</span>
</div>
<div class="nav-item" @click="goToTasks">
<el-icon><Document /></el-icon>
<span>{{ $t('nav.tasks') }}</span>
</div>
<div class="nav-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>{{ $t('nav.systemSettings') }}</span>
</div>
</nav>
<div class="sidebar-footer">
<div class="online-users">
{{ $t('nav.onlineUsers') }}: <span class="highlight">{{ systemStatus.onlineUsers }}/500</span>
</div>
<div class="system-uptime">
{{ $t('nav.systemUptime') }}: <span class="highlight">{{ systemStatus.systemUptime }}</span>
</div>
</div>
</aside>
<!-- 主内容区域 -->
<main class="main-content">
<!-- 顶部搜索栏 -->
<header class="top-header">
<div class="search-bar">
<el-icon class="search-icon"><Search /></el-icon>
<input type="text" :placeholder="$t('common.searchPlaceholder')" class="search-input" />
</div>
<div class="header-actions">
<LanguageSwitcher />
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" :alt="$t('dashboard.userAvatar')" />
<el-icon class="dropdown-icon"><ArrowDown /></el-icon>
</div>
</div>
</header>
<!-- KPI 卡片区域 -->
<section class="kpi-section">
<div class="kpi-card">
<div class="kpi-icon user-icon">
<el-icon><UserIcon /></el-icon>
</div>
<div class="kpi-content">
<div class="kpi-title">{{ $t('dashboard.totalUsers') }}</div>
<div class="kpi-value">{{ formatNumber(dashboardData.totalUsers) }}</div>
<div class="kpi-trend positive">+12% {{ $t('dashboard.comparedToLastMonth') }}</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-icon paid-user-icon">
<el-icon><UserIcon /></el-icon>
<div class="currency-symbol">¥</div>
</div>
<div class="kpi-content">
<div class="kpi-title">{{ $t('dashboard.paidUsers') }}</div>
<div class="kpi-value">{{ formatNumber(dashboardData.paidUsers) }}</div>
<div class="kpi-trend negative">-5% {{ $t('dashboard.comparedToLastMonth') }}</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-icon revenue-icon">
<el-icon><Money /></el-icon>
</div>
<div class="kpi-content">
<div class="kpi-title">{{ $t('dashboard.todayRevenue') }}</div>
<div class="kpi-value">{{ formatCurrency(dashboardData.todayRevenue) }}</div>
<div class="kpi-trend positive">+15% {{ $t('dashboard.comparedToLastMonth') }}</div>
</div>
</div>
</section>
<!-- 图表区域 -->
<section class="charts-section">
<!-- 日活用户趋势图 -->
<DailyActiveUsersChart />
<!-- 用户转化率图 -->
<div class="chart-card full-width">
<div class="chart-header">
<h3>{{ $t('dashboard.conversionRate') }}</h3>
<div class="year-selector">
<span>{{ $t('dashboard.year2025') }}</span>
<el-icon><ArrowDown /></el-icon>
</div>
</div>
<div class="chart-container">
<div class="bar-chart">
<div class="bar" style="height: 15%;"></div>
<div class="bar" style="height: 25%;"></div>
<div class="bar" style="height: 20%;"></div>
<div class="bar" style="height: 30%;"></div>
<div class="bar" style="height: 18%;"></div>
<div class="bar" style="height: 22%;"></div>
<div class="bar" style="height: 28%;"></div>
<div class="bar" style="height: 35%;"></div>
<div class="bar active" style="height: 40%;"></div>
<div class="bar" style="height: 25%;"></div>
<div class="bar" style="height: 20%;"></div>
<div class="bar" style="height: 18%;"></div>
</div>
<div class="chart-x-axis">
<span>{{ $t('dashboard.month1') }}</span>
<span>{{ $t('dashboard.month2') }}</span>
<span>{{ $t('dashboard.month3') }}</span>
<span>{{ $t('dashboard.month4') }}</span>
<span>{{ $t('dashboard.month5') }}</span>
<span>{{ $t('dashboard.month6') }}</span>
<span>{{ $t('dashboard.month7') }}</span>
<span>{{ $t('dashboard.month8') }}</span>
<span>{{ $t('dashboard.month9') }}</span>
<span>{{ $t('dashboard.month10') }}</span>
<span>{{ $t('dashboard.month11') }}</span>
<span>{{ $t('dashboard.month12') }}</span>
</div>
<div class="chart-y-axis">
<span>20%</span>
<span>15%</span>
<span>10%</span>
<span>5%</span>
<span>0%</span>
</div>
</div>
</div>
</section>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
import {
Grid,
User as UserIcon,
ShoppingCart,
Document,
Setting,
Search,
ArrowDown,
Money
} from '@element-plus/icons-vue'
import * as dashboardAPI from '@/api/dashboard'
import DailyActiveUsersChart from '@/components/DailyActiveUsersChart.vue'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
const userStore = useUserStore()
// 数据状态
const loading = ref(false)
const selectedYear = ref('2024')
const dashboardData = ref({
totalUsers: 0,
paidUsers: 0,
todayRevenue: 0,
totalOrders: 0,
totalRevenue: 0,
monthRevenue: 0
})
const monthlyData = ref([])
const conversionData = ref({
totalUsers: 0,
paidUsers: 0,
conversionRate: 0,
membershipStats: []
})
const systemStatus = ref({
onlineUsers: 0,
systemUptime: '0小时0分',
databaseStatus: '正常',
serviceStatus: '运行中'
})
// 清理未使用的图表相关代码
// 导航功能
const goToUsers = () => {
router.push('/member-management')
}
const goToOrders = () => {
if (userStore.isAuthenticated) {
router.push('/orders')
} else {
ElMessage.warning('请先登录')
router.push('/login')
}
}
const goToAPI = () => {
router.push('/api-management')
}
const goToTasks = () => {
router.push('/generate-task-record')
}
const goToSettings = () => {
router.push('/system-settings')
}
// 加载仪表盘数据
const loadDashboardData = async () => {
try {
loading.value = true
// 并行加载所有数据
const [overviewRes, monthlyRes, conversionRes, statusRes] = await Promise.all([
dashboardAPI.getDashboardOverview(),
dashboardAPI.getMonthlyRevenue(selectedYear.value),
dashboardAPI.getConversionRate(),
dashboardAPI.getSystemStatus()
])
// 处理概览数据
if (overviewRes) {
dashboardData.value = {
totalUsers: overviewRes.totalUsers || 0,
paidUsers: overviewRes.paidUsers || 0,
todayRevenue: overviewRes.todayRevenue || 0,
totalOrders: overviewRes.totalOrders || 0,
totalRevenue: overviewRes.totalRevenue || 0,
monthRevenue: overviewRes.monthRevenue || 0
}
}
// 处理月度数据
if (monthlyRes && monthlyRes.monthlyData) {
monthlyData.value = monthlyRes.monthlyData
}
// 处理转化率数据
if (conversionRes) {
conversionData.value = {
totalUsers: conversionRes.totalUsers || 0,
paidUsers: conversionRes.paidUsers || 0,
conversionRate: conversionRes.conversionRate || 0,
membershipStats: conversionRes.membershipStats || []
}
}
// 处理系统状态
if (statusRes) {
systemStatus.value = {
onlineUsers: statusRes.onlineUsers || 0,
systemUptime: statusRes.systemUptime || '0小时0分',
databaseStatus: statusRes.databaseStatus || '正常',
serviceStatus: statusRes.serviceStatus || '运行中'
}
}
} catch (error) {
console.error('加载仪表盘数据失败:', error)
ElMessage.error('加载仪表盘数据失败')
// 使用默认数据作为后备
dashboardData.value = {
totalUsers: 10,
paidUsers: 8,
todayRevenue: 0,
totalOrders: 180,
totalRevenue: 0,
monthRevenue: 0
}
monthlyData.value = [
{ month: 1, revenue: 0, orderCount: 0 },
{ month: 2, revenue: 0, orderCount: 0 },
{ month: 3, revenue: 0, orderCount: 0 },
{ month: 4, revenue: 0, orderCount: 0 },
{ month: 5, revenue: 0, orderCount: 0 },
{ month: 6, revenue: 0, orderCount: 0 },
{ month: 7, revenue: 0, orderCount: 0 },
{ month: 8, revenue: 0, orderCount: 0 },
{ month: 9, revenue: 0, orderCount: 0 },
{ month: 10, revenue: 0, orderCount: 0 },
{ month: 11, revenue: 0, orderCount: 0 },
{ month: 12, revenue: 0, orderCount: 0 }
]
conversionData.value = {
totalUsers: 10,
paidUsers: 8,
conversionRate: 80,
membershipStats: []
}
systemStatus.value = {
onlineUsers: 50,
systemUptime: '48小时32分',
databaseStatus: '正常',
serviceStatus: '运行中'
}
} finally {
loading.value = false
}
}
// 格式化数字
const formatNumber = (num) => {
if (num >= 10000) {
return (num / 10000).toFixed(1) + '万'
}
return num.toLocaleString()
}
// 格式化金额
const formatCurrency = (amount) => {
return '¥' + amount.toLocaleString()
}
onMounted(() => {
loadDashboardData()
})
</script>
<style scoped>
.dashboard {
display: flex;
min-height: 100vh;
background: #f8fafc;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 左侧导航栏 */
.sidebar {
width: 320px;
background: white;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
padding: 24px 0;
}
.logo {
display: flex;
align-items: center;
padding: 0 28px;
margin-bottom: 32px;
}
.logo-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.logo-icon img {
width: 100%;
height: 100%;
object-fit: contain;
}
.nav-menu {
flex: 1;
padding: 0 24px;
}
.nav-item {
display: flex;
align-items: center;
padding: 18px 24px;
margin-bottom: 6px;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
color: #64748b;
font-size: 16px;
}
.nav-item:hover {
background: #f1f5f9;
color: #334155;
}
.nav-item.active {
background: #eff6ff;
color: #3b82f6;
}
.nav-item .el-icon {
margin-right: 16px;
font-size: 22px;
}
.nav-item span {
font-size: 16px;
font-weight: 500;
}
.sidebar-footer {
padding: 0 32px 20px;
margin-top: auto;
}
.online-users,
.system-uptime {
font-size: 14px;
color: #64748b;
margin-bottom: 10px;
line-height: 1.5;
}
.highlight {
color: #3b82f6;
font-weight: 600;
font-size: 15px;
}
/* 主内容区域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: #f8fafc;
}
/* 顶部搜索栏 */
.top-header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.search-bar {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 12px;
color: #94a3b8;
font-size: 16px;
}
.search-input {
width: 300px;
padding: 8px 12px 8px 40px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
background: #f8fafc;
outline: none;
}
.search-input:focus {
border-color: #3b82f6;
background: white;
}
.search-input::placeholder {
color: #94a3b8;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.user-avatar {
display: flex;
align-items: center;
cursor: pointer;
}
.user-avatar img {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
margin-right: 8px;
}
.dropdown-icon {
font-size: 12px;
color: #64748b;
}
/* 用户菜单样式 */
.user-menu-teleport {
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
border: 1px solid rgba(0, 0, 0, 0.08);
padding: 8px 0;
min-width: 200px;
z-index: 99999;
}
.menu-item {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
transition: all 0.2s ease;
color: #333;
font-size: 14px;
}
.menu-item:hover {
background: #f5f7fa;
color: #667eea;
}
.menu-item.logout {
color: #f56565;
}
.menu-item.logout:hover {
background: #fef2f2;
color: #e53e3e;
}
.menu-divider {
height: 1px;
background: #e2e8f0;
margin: 4px 0;
}
.menu-item .el-icon {
margin-right: 12px;
font-size: 16px;
}
/* KPI 卡片区域 */
.kpi-section {
padding: 24px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.kpi-card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 16px;
}
.kpi-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.kpi-icon .el-icon {
font-size: 24px;
}
.user-icon {
background: #fef3c7;
color: #f59e0b;
}
.paid-user-icon {
background: #dbeafe;
color: #3b82f6;
}
.paid-user-icon .currency-symbol {
position: absolute;
bottom: -2px;
right: -2px;
width: 16px;
height: 16px;
background: #3b82f6;
color: white;
border-radius: 50%;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.revenue-icon {
background: #fce7f3;
color: #ec4899;
}
.kpi-content {
flex: 1;
}
.kpi-title {
font-size: 14px;
color: #64748b;
margin-bottom: 8px;
}
.kpi-value {
font-size: 24px;
font-weight: 700;
color: #1e293b;
margin-bottom: 4px;
}
.kpi-trend {
font-size: 12px;
font-weight: 500;
}
.kpi-trend.positive {
color: #059669;
}
.kpi-trend.negative {
color: #dc2626;
}
/* 图表区域 */
.charts-section {
padding: 0 24px 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.chart-card.full-width {
width: 100%;
}
.chart-card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.chart-header h3 {
font-size: 16px;
font-weight: 600;
color: #1e293b;
margin: 0;
}
.year-selector {
display: flex;
align-items: center;
gap: 8px;
color: #64748b;
font-size: 14px;
cursor: pointer;
}
.chart-container {
position: relative;
height: 300px;
}
/* 日活用户趋势图 - SVG曲线图 */
.line-chart {
position: relative;
width: 100%;
height: 200px;
margin-bottom: 20px;
background: white;
border-radius: 8px;
overflow: hidden;
}
.chart-svg {
width: 100%;
height: 100%;
}
.chart-line-path {
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
animation: drawLine 2s ease-in-out forwards;
}
.chart-dot {
opacity: 0;
animation: fadeInDots 0.5s ease-in-out forwards;
animation-delay: 1.5s;
}
.highlight-dot {
opacity: 0;
animation: highlightDot 0.5s ease-in-out forwards;
animation-delay: 2s;
}
.highlight-ring {
opacity: 0;
animation: highlightRing 1s ease-in-out infinite;
animation-delay: 2s;
}
.tooltip-group {
opacity: 0;
animation: fadeInTooltip 0.5s ease-in-out forwards;
animation-delay: 2.5s;
}
.tooltip-bg {
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.15));
}
.tooltip-arrow {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
/* 动画效果 */
@keyframes drawLine {
to {
stroke-dashoffset: 0;
}
}
@keyframes fadeInDots {
to {
opacity: 1;
}
}
@keyframes highlightDot {
to {
opacity: 1;
}
}
@keyframes highlightRing {
0%, 100% {
opacity: 0.2;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(1.2);
}
}
@keyframes fadeInTooltip {
to {
opacity: 1;
}
}
/* 悬停效果 */
.chart-dot:hover {
r: 6;
fill: #2563eb;
transition: all 0.2s ease;
}
.highlight-dot:hover {
r: 8;
fill: #2563eb;
transition: all 0.2s ease;
}
.chart-x-axis {
display: flex;
justify-content: space-between;
padding: 0 20px;
font-size: 12px;
color: #64748b;
}
.chart-y-axis {
position: absolute;
left: 0;
top: 0;
height: 200px;
display: flex;
flex-direction: column;
justify-content: space-between;
font-size: 12px;
color: #64748b;
}
/* 用户转化率图 */
.bar-chart {
display: flex;
align-items: end;
justify-content: space-between;
height: 200px;
margin-bottom: 20px;
padding: 0 20px;
}
.bar {
width: 20px;
background: #dbeafe;
border-radius: 2px 2px 0 0;
transition: all 0.3s ease;
}
.bar.active {
background: #3b82f6;
}
.bar:hover {
background: #2563eb;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.kpi-section {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.dashboard {
flex-direction: column;
}
.sidebar {
width: 100%;
height: auto;
}
.nav-menu {
display: flex;
overflow-x: auto;
padding: 0 16px;
}
.nav-item {
white-space: nowrap;
margin-right: 16px;
margin-bottom: 0;
}
.sidebar-footer {
display: none;
}
.search-input {
width: 200px;
}
.kpi-section {
padding: 16px;
}
.charts-section {
padding: 0 16px 16px;
}
}
</style>

View File

@@ -81,9 +81,6 @@
<div v-else class="work-placeholder">
<div class="play-icon"></div>
</div>
<div class="work-overlay">
<div class="overlay-text">{{ work.prompt || work.text || '图生视频' }}</div>
</div>
<!-- 鼠标悬停时显示的做同款按钮 -->
<div class="hover-create-btn" @click.stop="goToCreate(work)">
<el-button type="primary" size="small" round>
@@ -396,10 +393,10 @@ onMounted(() => {
/* 主内容区域 */
.main-content {
flex: 1;
padding: 24px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 24px;
gap: 20px;
}
/* 用户信息卡片 */
@@ -461,7 +458,7 @@ onMounted(() => {
.published-works {
display: flex;
flex-direction: column;
gap: 20px;
gap: 16px;
}
.works-tabs {
@@ -492,16 +489,17 @@ onMounted(() => {
.works-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.work-item {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
border-radius: 8px;
overflow: hidden;
transition: all 0.2s;
cursor: pointer;
}
.work-item:hover {
@@ -585,25 +583,31 @@ onMounted(() => {
/* work-overlay / overlay-text 样式已移除(不再使用) */
.work-info {
padding: 16px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
}
.work-title {
font-size: 16px;
font-size: 14px;
font-weight: 600;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-meta {
font-size: 12px;
font-size: 11px;
color: #9ca3af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-actions {
padding: 0 16px 16px;
padding: 0 12px 12px;
opacity: 0;
transition: opacity 0.2s ease;
}
@@ -617,7 +621,7 @@ onMounted(() => {
}
.work-director {
padding: 0 16px 16px;
padding: 0 12px 12px;
text-align: center;
}
@@ -634,7 +638,7 @@ onMounted(() => {
}
.works-grid {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
}

View File

@@ -4,7 +4,7 @@
<header class="top-header">
<div class="header-left">
<button class="back-btn" @click="goBack">
首页
{{ t('common.home') }}
</button>
</div>
<div class="header-right">
@@ -16,7 +16,7 @@
</div>
<LanguageSwitcher />
<div class="user-avatar" @click="toggleUserMenu" ref="userAvatarRef">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<img src="/images/backgrounds/avatar-default.svg" :alt="t('video.imageToVideo.userAvatar')" />
</div>
</div>
</header>
@@ -27,9 +27,9 @@
<div class="left-panel">
<!-- 创作模式标签 -->
<div class="creation-tabs">
<div class="tab" @click="goToTextToVideo">文生视频</div>
<div class="tab active">图生视频</div>
<div class="tab" @click="goToStoryboard">分镜视频</div>
<div class="tab" @click="goToTextToVideo">{{ t('home.textToVideo') }}</div>
<div class="tab active">{{ t('home.imageToVideo') }}</div>
<div class="tab" @click="goToStoryboard">{{ t('home.storyboardVideo') }}</div>
</div>
<!-- 图片输入区域 -->
@@ -38,10 +38,10 @@
<div class="upload-box" @click="uploadFirstFrame">
<div v-if="!firstFrameImage" class="upload-placeholder">
<div class="upload-icon">+</div>
<div class="upload-text">首帧</div>
<div class="upload-text">{{ t('video.imageToVideo.firstFrame') }}</div>
</div>
<div v-else class="upload-preview">
<img :src="firstFrameImage" alt="首帧" />
<img :src="firstFrameImage" :alt="t('video.imageToVideo.firstFrame')" />
<button class="remove-btn" @click.stop="removeFirstFrame">×</button>
</div>
</div>
@@ -50,15 +50,15 @@
<!-- 文本输入区域 -->
<div class="text-input-section">
<textarea
<textarea
v-model="inputText"
placeholder="结合图片,描述想要生成的内容"
:placeholder="t('video.imageToVideo.promptPlaceholder')"
class="text-input"
rows="6"
></textarea>
<div class="optimize-btn">
<button class="optimize-button" @click="optimizePromptHandler" :disabled="!inputText.trim() || optimizingPrompt">
{{ optimizingPrompt ? '优化中...' : '一键优化' }}
{{ optimizingPrompt ? t('video.imageToVideo.optimizing') : t('video.imageToVideo.optimizePrompt') }}
</button>
</div>
</div>
@@ -66,46 +66,45 @@
<!-- 视频设置 -->
<div class="video-settings">
<div class="setting-item">
<label>比例</label>
<label>{{ t('video.aspectRatio') }}</label>
<select v-model="aspectRatio" class="setting-select">
<option value="16:9">16:9</option>
<option value="4:3">4:3</option>
<option value="1:1">1:1</option>
<option value="3:4">3:4</option>
<option value="9:16">9:16</option>
</select>
</div>
<div class="setting-item">
<label>时长</label>
<label>{{ t('video.duration') }}</label>
<select v-model="duration" class="setting-select">
<option value="10">10s</option>
<option value="15">15s</option>
<option value="25">25s</option>
</select>
</div>
<div class="setting-item">
<label>高清模式 (1080P)</label>
<label>{{ t('video.imageToVideo.hdMode') }} (1080P)</label>
<div class="hd-setting">
<el-switch v-model="hdMode" />
<span class="cost-text">开启消耗20积分</span>
<span class="cost-text">{{ t('video.imageToVideo.hdModeCost') }}</span>
</div>
</div>
</div>
<!-- 生成按钮 -->
<div class="generate-section">
<button
class="generate-btn"
<button
class="generate-btn"
@click="startGenerate"
:disabled="!isAuthenticated"
:disabled="!isAuthenticated || inProgress || isCreatingTask"
>
{{ isAuthenticated ? '开始生成' : '请先登录' }}
{{ !isAuthenticated ? t('video.imageToVideo.pleaseLogin') : (inProgress || isCreatingTask) ? t('video.imageToVideo.taskInProgress') : t('video.imageToVideo.startGenerate') }}
</button>
<div v-if="!isAuthenticated" class="login-tip">
<p>需要登录后才能提交任务</p>
<button class="login-link-btn" @click="goToLogin">立即登录</button>
<p>{{ t('video.imageToVideo.loginRequired') }}</p>
<button class="login-link-btn" @click="goToLogin">{{ t('video.imageToVideo.loginNow') }}</button>
</div>
</div>
</div>
@@ -117,7 +116,7 @@
<div class="task-status" v-if="currentTask">
<div class="status-header">
<h3>{{ getStatusText(taskStatus) }}</h3>
<div class="task-id">图生视频 {{ formatDate(currentTask.createdAt) }}</div>
<div class="task-id">{{ t('home.imageToVideo') }} {{ formatDate(currentTask.createdAt) }}</div>
</div>
<!-- 任务描述 -->
@@ -130,7 +129,7 @@
<!-- 生成中的状态 -->
<div v-if="inProgress" class="generating-container">
<div class="generating-placeholder">
<div class="generating-text">生成中</div>
<div class="generating-text">{{ t('video.generating') }}</div>
<div class="progress-bar-large">
<div class="progress-fill-large" :style="{ width: taskProgress + '%' }"></div>
</div>
@@ -143,7 +142,7 @@
<div class="task-info-header">
<div class="task-checkbox">
<input type="checkbox" id="inProgress" v-model="showInProgress">
<label for="inProgress">进行中</label>
<label for="inProgress">{{ t('video.imageToVideo.inProgress') }}</label>
</div>
</div>
@@ -158,7 +157,7 @@
poster=""
></video>
<div v-else class="no-video-placeholder">
<div class="no-video-text">视频生成完成但未获取到视频链接</div>
<div class="no-video-text">{{ t('video.imageToVideo.noVideoUrl') }}</div>
</div>
<!-- 水印选择覆盖层 -->
@@ -166,11 +165,11 @@
<div class="watermark-options">
<div class="watermark-option">
<input type="radio" id="withWatermark" name="watermark" value="with" v-model="watermarkOption">
<label for="withWatermark">带水印</label>
<label for="withWatermark">{{ t('video.imageToVideo.withWatermark') }}</label>
</div>
<div class="watermark-option">
<input type="radio" id="withoutWatermark" name="watermark" value="without" v-model="watermarkOption">
<label for="withoutWatermark">不带水印 会员专享</label>
<label for="withoutWatermark">{{ t('video.imageToVideo.withoutWatermark') }}</label>
</div>
</div>
</div>
@@ -179,14 +178,14 @@
<!-- 操作按钮区域 -->
<div class="result-actions">
<button class="action-btn primary" @click="createSimilar">做同款</button>
<button class="action-btn primary" @click="createSimilar">{{ t('video.imageToVideo.createSimilar') }}</button>
<div class="action-icons">
<button class="icon-btn" @click="downloadVideo" title="下载视频">
<button class="icon-btn" @click="downloadVideo" :title="t('video.imageToVideo.downloadVideo')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
</button>
<button class="icon-btn" @click="deleteWork" title="删除作品">
<button class="icon-btn" @click="deleteWork" :title="t('video.imageToVideo.deleteWork')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</svg>
@@ -199,11 +198,11 @@
<div v-else-if="taskStatus === 'FAILED'" class="failed-container">
<div class="failed-placeholder">
<div class="failed-icon"></div>
<div class="failed-text">生成失败</div>
<div class="failed-desc">请检查输入内容或重试</div>
<div class="failed-text">{{ t('video.imageToVideo.generateFailed') }}</div>
<div class="failed-desc">{{ t('video.imageToVideo.generateFailedDesc') }}</div>
</div>
<div class="result-actions">
<button class="action-btn primary" @click="retryTask">重新生成</button>
<button class="action-btn primary" @click="retryTask">{{ t('video.imageToVideo.retry') }}</button>
</div>
</div>
@@ -215,16 +214,10 @@
</div>
<!-- 默认提示 -->
<!-- 初始状态 -->
<div class="preview-content" v-else>
<div class="preview-placeholder">
<div class="placeholder-text">开始创作您的第一个作品吧!</div>
<div class="placeholder-tips">
<p> 上传首帧图片</p>
<p> 输入描述文字</p>
<p> 选择视频参数</p>
<p> 点击开始生成</p>
</div>
<div class="placeholder-text">{{ t('video.imageToVideo.startCreating') }}</div>
</div>
</div>
@@ -239,24 +232,24 @@
<!-- 顶部状态复选框 -->
<div class="history-status-checkbox" v-if="task.status === 'PENDING' || task.status === 'PROCESSING'">
<input type="checkbox" :checked="true" disabled>
<label>进行中</label>
<label>{{ t('video.imageToVideo.inProgress') }}</label>
</div>
<!-- 头部信息 -->
<div class="history-item-header">
<span class="history-type">图生视频</span>
<span class="history-type">{{ t('home.imageToVideo') }}</span>
<span class="history-date">{{ formatDate(task.createdAt) }}</span>
</div>
<!-- 描述文字 -->
<div class="history-prompt">{{ task.prompt || '无描述' }}</div>
<div class="history-prompt">{{ task.prompt || t('video.imageToVideo.noDescription') }}</div>
<!-- 预览区域 -->
<div class="history-preview">
<div v-if="task.status === 'PENDING' || task.status === 'PROCESSING'" class="history-placeholder">
<div class="queue-text">排队中</div>
<div class="queue-link">订阅套餐以提升生成速度</div>
<button class="cancel-btn" @click="cancelTask(task.taskId)">取消</button>
<div class="queue-text">{{ t('video.imageToVideo.queuing') }}</div>
<div class="queue-link">{{ t('video.imageToVideo.subscribeToSpeedUp') }}</div>
<button class="cancel-btn" @click="cancelTask(task.taskId)">{{ t('video.imageToVideo.cancel') }}</button>
</div>
<div v-else-if="task.status === 'COMPLETED' && task.resultUrl" class="history-video-thumbnail" @click="toggleHistoryVideo(task)">
<video
@@ -273,16 +266,16 @@
</div>
</div>
<div v-else-if="task.firstFrameUrl" class="history-image-thumbnail">
<img :src="task.firstFrameUrl" alt="首帧图片" />
<img :src="task.firstFrameUrl" :alt="t('video.imageToVideo.firstFrameImage')" />
</div>
<div v-else class="history-placeholder">
<div class="no-result-text">暂无结果</div>
<div class="no-result-text">{{ t('video.imageToVideo.noResult') }}</div>
</div>
</div>
<!-- 做同款按钮 -->
<div class="history-actions">
<button class="similar-btn" @click="createSimilarFromHistory(task)">做同款</button>
<button class="similar-btn" @click="createSimilarFromHistory(task)">{{ t('video.imageToVideo.createSimilar') }}</button>
</div>
</div>
</div>
@@ -296,24 +289,24 @@
<div v-if="showUserMenu" class="user-menu-teleport" :style="menuStyle">
<div class="menu-item" @click="goToProfile">
<el-icon><User /></el-icon>
<span>个人资料</span>
<span>{{ t('common.userProfile') }}</span>
</div>
<div class="menu-item" @click="goToMyWorks">
<el-icon><VideoCamera /></el-icon>
<span>我的作品</span>
<span>{{ t('profile.myWorks') }}</span>
</div>
<div class="menu-item" @click="goToSubscription">
<el-icon><Star /></el-icon>
<span>会员订阅</span>
<span>{{ t('profile.subscription') }}</span>
</div>
<div class="menu-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>系统设置</span>
<span>{{ t('common.settings') }}</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item logout" @click="logout">
<el-icon><SwitchButton /></el-icon>
<span>退出登录</span>
<span>{{ t('common.logout') }}</span>
</div>
</div>
</Teleport>
@@ -323,6 +316,7 @@
<script setup>
import { ref, reactive, onUnmounted, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElLoading } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { User, VideoCamera, Star, Setting, SwitchButton } from '@element-plus/icons-vue'
@@ -333,6 +327,7 @@ import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
const userStore = useUserStore()
const { t } = useI18n()
// 计算是否已登录
const isAuthenticated = computed(() => userStore.isAuthenticated)
@@ -361,6 +356,8 @@ const optimizingPrompt = ref(false) // 优化提示词状态
const historyTasks = ref([]) // 历史记录
const playingVideos = ref({}) // 正在播放的视频
const videoRefs = ref({}) // 视频元素引用
const isCreatingTask = ref(false) // 标记是否正在创建任务,避免重复恢复
const hasRestoredTask = ref(false) // 标记是否已经恢复过任务
// 用户菜单相关
const showUserMenu = ref(false)
@@ -442,13 +439,13 @@ const uploadFirstFrame = () => {
// 验证文件大小最大100MB与后端配置保持一致
const maxFileSize = 100 * 1024 * 1024 // 100MB
if (file.size > maxFileSize) {
ElMessage.error('图片文件大小不能超过100MB')
ElMessage.error(t('video.imageToVideo.fileSizeLimit'))
return
}
// 验证文件类型
if (!file.type.startsWith('image/')) {
ElMessage.error('请选择有效的图片文件')
ElMessage.error(t('video.imageToVideo.invalidImageFile'))
return
}
@@ -473,13 +470,13 @@ const uploadLastFrame = () => {
// 验证文件大小最大100MB与后端配置保持一致
const maxFileSize = 100 * 1024 * 1024 // 100MB
if (file.size > maxFileSize) {
ElMessage.error('图片文件大小不能超过100MB')
ElMessage.error(t('video.imageToVideo.fileSizeLimit'))
return
}
// 验证文件类型
if (!file.type.startsWith('image/')) {
ElMessage.error('请选择有效的图片文件')
ElMessage.error(t('video.imageToVideo.invalidImageFile'))
return
}
@@ -508,33 +505,35 @@ const removeLastFrame = () => {
const startGenerate = async () => {
// 检查登录状态
if (!userStore.isAuthenticated) {
ElMessage.warning('请先登录后再提交任务')
ElMessage.warning(t('video.imageToVideo.pleaseLoginFirst'))
goToLogin()
return
}
// 检查是否已有任务在进行中
if (inProgress.value) {
ElMessage.warning('已有任务在进行中,请等待完成或取消当前任务')
ElMessage.warning(t('video.imageToVideo.taskInProgress'))
return
}
// 验证表单
if (!firstFrameFile.value) {
ElMessage.error('请上传首帧图片')
ElMessage.error(t('video.imageToVideo.uploadFirstFrameRequired'))
return
}
if (!inputText.value.trim()) {
ElMessage.error('请输入描述文字')
ElMessage.error(t('video.imageToVideo.enterDescriptionRequired'))
return
}
// 标记正在创建任务
isCreatingTask.value = true
// 显示加载状态
const loading = ElLoading.service({
lock: true,
text: '正在创建任务...',
text: t('video.imageToVideo.creatingTask'),
background: 'rgba(0, 0, 0, 0.7)'
})
@@ -557,26 +556,47 @@ const startGenerate = async () => {
taskProgress.value = 0
taskStatus.value = 'PENDING'
ElMessage.success('任务创建成功,开始处理...')
ElMessage.success(t('video.imageToVideo.taskCreatedSuccess'))
// 更新用户积分信息(任务创建后积分已被扣除)
try {
await userStore.fetchCurrentUser()
console.log('用户积分已更新')
} catch (error) {
console.error('更新用户积分失败:', error)
}
// 使用 setTimeout 确保不阻塞任务流程
setTimeout(async () => {
try {
await userStore.fetchCurrentUser()
console.log('用户积分已更新')
} catch (error) {
console.error('更新用户积分失败:', error)
// 积分更新失败不影响任务流程
}
}, 0)
// 开始轮询任务状态
startPollingTask()
// 延迟重置创建标志,避免立即触发恢复逻辑
setTimeout(() => {
isCreatingTask.value = false
}, 2000)
} else {
ElMessage.error(response.data?.message || '创建任务失败')
// 任务创建失败,重置所有状态
ElMessage.error(response.data?.message || t('video.imageToVideo.createTaskFailed'))
inProgress.value = false
isCreatingTask.value = false
currentTask.value = null
taskStatus.value = ''
taskProgress.value = 0
}
} catch (error) {
console.error('创建任务失败:', error)
ElMessage.error('创建任务失败,请重试')
ElMessage.error(t('video.imageToVideo.createTaskFailedRetry'))
// 异常情况,重置所有状态
inProgress.value = false
isCreatingTask.value = false
currentTask.value = null
taskStatus.value = ''
taskProgress.value = 0
} finally {
loading.close()
}
@@ -619,15 +639,18 @@ const startPollingTask = () => {
} else if (currentTask.value && !currentTask.value.resultUrl) {
console.warn('任务完成但未获取到resultUrl')
}
ElMessage.success('视频生成完成!')
ElMessage.success(t('video.imageToVideo.videoGenerateCompleted'))
// 更新用户积分信息
try {
await userStore.fetchCurrentUser()
console.log('用户积分已更新')
} catch (error) {
console.error('更新用户积分失败:', error)
}
// 更新用户积分信息(使用 setTimeout 确保不阻塞)
setTimeout(async () => {
try {
await userStore.fetchCurrentUser()
console.log('用户积分已更新')
} catch (error) {
console.error('更新用户积分失败:', error)
// 积分更新失败不影响任务流程
}
}, 0)
// 可以在这里跳转到结果页面或显示结果
console.log('任务完成:', taskData)
@@ -636,7 +659,7 @@ const startPollingTask = () => {
(error) => {
inProgress.value = false
taskStatus.value = 'FAILED'
ElMessage.error('视频生成失败:' + error.message)
ElMessage.error(t('video.imageToVideo.videoGenerateFailed') + error.message)
console.error('任务失败:', error)
}
)
@@ -646,13 +669,13 @@ const startPollingTask = () => {
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
'PENDING': '等待中',
'PROCESSING': '处理中',
'COMPLETED': '已完成',
'FAILED': '失败',
'CANCELLED': '已取消'
'PENDING': t('video.imageToVideo.statusPending'),
'PROCESSING': t('video.imageToVideo.statusProcessing'),
'COMPLETED': t('video.imageToVideo.statusCompleted'),
'FAILED': t('video.imageToVideo.statusFailed'),
'CANCELLED': t('video.imageToVideo.statusCancelled')
}
return statusMap[status] || '未知'
return statusMap[status] || t('profile.unknown')
}
// 获取状态样式类
@@ -676,7 +699,7 @@ const formatDate = (dateString) => {
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}${month}${day} ${hours}:${minutes}`
return `${year}${t('video.imageToVideo.year')}${month}${t('video.imageToVideo.month')}${day}${t('video.imageToVideo.day')} ${hours}:${minutes}`
}
// 根据aspectRatio获取视频播放器样式
@@ -697,65 +720,65 @@ const getVideoPlayerStyle = () => {
// 优化提示词
const optimizePromptHandler = async () => {
if (!inputText.value.trim()) {
ElMessage.warning('请输入提示词')
ElMessage.warning(t('video.imageToVideo.enterPromptFirst'))
return
}
// 长度检查
if (inputText.value.length > 2000) {
ElMessage.warning('提示词过长请控制在2000字符以内')
ElMessage.warning(t('video.imageToVideo.promptTooLong'))
return
}
try {
optimizingPrompt.value = true
const loading = ElLoading.service({
lock: false,
text: '正在优化提示词,请稍候...',
text: t('video.imageToVideo.optimizingPrompt'),
background: 'rgba(0, 0, 0, 0.3)'
})
const response = await optimizePrompt(inputText.value.trim(), 'image-to-video')
loading.close()
if (response.data && response.data.success) {
const data = response.data.data
const optimized = data.optimizedPrompt
// 检查是否真正优化了
if (data.optimized && optimized !== inputText.value.trim()) {
inputText.value = optimized
ElMessage.success('提示词优化成功!')
ElMessage.success(t('video.imageToVideo.promptOptimizedSuccess'))
} else {
ElMessage.warning('提示词已优化,但可能无明显变化')
ElMessage.warning(t('video.imageToVideo.promptAlreadyOptimized'))
}
} else {
ElMessage.error(response.data?.message || '优化失败')
ElMessage.error(response.data?.message || t('video.imageToVideo.optimizeFailed'))
}
} catch (error) {
console.error('优化提示词失败:', error)
let errorMessage = '优化提示词失败'
let errorMessage = t('video.imageToVideo.optimizePromptFailed')
if (error.response) {
const status = error.response.status
if (status === 400) {
errorMessage = error.response.data?.message || '请求参数错误'
errorMessage = error.response.data?.message || t('video.imageToVideo.requestParameterError')
} else if (status === 408 || error.code === 'ECONNABORTED') {
errorMessage = '请求超时,请稍后重试'
errorMessage = t('video.imageToVideo.requestTimeout')
} else if (status >= 500) {
errorMessage = '服务器错误,请稍后重试'
errorMessage = t('video.imageToVideo.serverError')
} else {
errorMessage = error.response.data?.message || '优化失败'
errorMessage = error.response.data?.message || t('video.imageToVideo.optimizeFailed')
}
} else if (error.request) {
errorMessage = '网络错误,请检查网络连接'
errorMessage = t('video.imageToVideo.networkError')
} else if (error.code === 'ERR_NETWORK') {
errorMessage = '网络连接错误,请检查您的网络'
errorMessage = t('video.imageToVideo.networkConnectionError')
} else {
errorMessage = error.message || '优化失败'
errorMessage = error.message || t('video.imageToVideo.optimizeFailed')
}
ElMessage.error(errorMessage)
} finally {
optimizingPrompt.value = false
@@ -777,9 +800,9 @@ const downloadVideo = () => {
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('开始下载视频')
ElMessage.success(t('video.imageToVideo.startDownload'))
} else {
ElMessage.error('视频链接不可用')
ElMessage.error(t('video.imageToVideo.videoUrlNotAvailable'))
}
}
@@ -800,22 +823,22 @@ const retryTask = () => {
// 删除作品
const deleteWork = () => {
if (!currentTask.value) {
ElMessage.error('没有可删除的作品')
ElMessage.error(t('video.imageToVideo.noWorkToDelete'))
return
}
// 确认删除
ElMessage.confirm('确定要删除这个作品吗?', '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
ElMessage.confirm(t('video.imageToVideo.confirmDeleteWork'), t('video.imageToVideo.confirmDelete'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}).then(() => {
// 这里可以调用删除API
currentTask.value = null
taskStatus.value = ''
ElMessage.success('作品已删除')
ElMessage.success(t('video.imageToVideo.workDeleted'))
}).catch(() => {
ElMessage.info('已取消删除')
ElMessage.info(t('video.imageToVideo.deleteCancelled'))
})
}
@@ -882,13 +905,13 @@ const createSimilarFromHistory = (task) => {
}
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
ElMessage.success('已填充历史记录参数,可以开始生成')
ElMessage.success(t('video.imageToVideo.historyParamsFilled'))
}
// 取消任务
const cancelTask = async (taskId) => {
try {
ElMessage.info('取消功能待实现')
ElMessage.info(t('video.imageToVideo.cancelFeatureTodo'))
// TODO: 实现取消任务API
} catch (error) {
console.error('取消任务失败:', error)
@@ -960,9 +983,15 @@ const toggleHistoryVideo = (task) => {
watch(() => userStore.isAuthenticated, (isAuth) => {
if (isAuth) {
loadHistory()
restoreProcessingTask()
// 延迟恢复任务,避免与创建任务冲突
setTimeout(() => {
if (!isCreatingTask.value) {
restoreProcessingTask()
}
}, 500)
} else {
historyTasks.value = []
hasRestoredTask.value = false
}
})
@@ -971,6 +1000,18 @@ const restoreProcessingTask = async () => {
if (!userStore.isAuthenticated) {
return
}
// 如果正在创建任务,跳过恢复逻辑
if (isCreatingTask.value) {
console.log('[Task Restore] 跳过恢复:正在创建新任务')
return
}
// 如果已经恢复过任务且当前有任务在进行中,跳过
if (hasRestoredTask.value && currentTask.value) {
console.log('[Task Restore] 跳过恢复:已有任务在进行中')
return
}
try {
const response = await getProcessingWorks()
@@ -1009,7 +1050,10 @@ const restoreProcessingTask = async () => {
taskProgress.value = 50 // 初始进度设为50%
console.log('恢复正在进行中的任务:', work.taskId, '状态:', work.status)
ElMessage.info('检测到未完成的任务,继续处理中...')
ElMessage.info(t('video.imageToVideo.resumingTask'))
// 标记已恢复任务
hasRestoredTask.value = true
// 开始轮询任务状态
startPollingTask()
@@ -1601,7 +1645,6 @@ onUnmounted(() => {
background: #1a1a1a;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 40px;
@@ -1612,19 +1655,8 @@ onUnmounted(() => {
color: #9ca3af;
font-weight: 500;
line-height: 1.6;
margin-bottom: 20px;
}
.placeholder-tips {
text-align: left;
color: #9ca3af;
font-size: 14px;
line-height: 1.8;
}
.placeholder-tips p {
margin: 8px 0;
}
/* 任务状态样式 */
.task-status {

View File

@@ -59,11 +59,6 @@
<!-- 视频操作按钮 -->
<div class="video-actions">
<el-tooltip content="分享" placement="bottom">
<el-button circle size="small" @click="shareVideo">
<el-icon><Share /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="下载" placement="bottom">
<el-button circle size="small" @click="downloadVideo">
<el-icon><Download /></el-icon>
@@ -153,7 +148,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { imageToVideoApi } from '@/api/imageToVideo'
import {
User, Setting, Document, User as Picture, User as VideoPlay, User as VideoPause,
User as FullScreen, User as Share, User as Download, User as Delete, User as ArrowUp, User as ArrowDown
User as FullScreen, User as Download, User as Delete, User as ArrowUp, User as ArrowDown
} from '@element-plus/icons-vue'
const route = useRoute()
@@ -227,10 +222,6 @@ const toggleFullscreen = () => {
}
// 操作按钮方法
const shareVideo = () => {
ElMessage.info('分享功能开发中')
}
const downloadVideo = () => {
ElMessage.success('开始下载视频')
}

View File

@@ -7,20 +7,28 @@
<!-- 登录卡片 -->
<div class="login-card">
<!-- Logo图标 -->
<div class="card-logo">
<svg width="306" height="37" viewBox="0 0 306 37" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.266 9.22263e-06L23.218 0.64601C22.952 2.54601 22.648 4.33201 22.268 5.96601H34.276V8.58801C33.668 11.818 32.946 15.048 32.034 18.24L28.158 17.138C28.918 15.048 29.564 12.616 30.096 9.80401H21.242C20.292 12.616 19.152 14.896 17.784 16.644L14.592 14.402C17.024 10.982 18.582 6.15601 19.266 9.22263e-06ZM13.11 29.792C11.97 28.044 10.83 26.334 9.65201 24.662C7.52401 28.766 4.97801 32.034 1.97601 34.466L7.80571e-06 30.894C3.00201 28.69 5.43401 25.536 7.29601 21.432C5.16801 18.658 2.96401 15.96 0.608008 13.3L3.23001 10.792C4.94001 12.502 6.84001 14.668 8.89201 17.252C9.72801 14.516 10.412 11.552 10.944 8.32201H0.684008V4.48401H14.82V8.13201C13.984 12.768 12.844 16.91 11.476 20.558C12.92 22.496 14.402 24.586 15.998 26.866L13.11 29.792ZM22.762 11.894H26.676C26.676 14.516 26.524 16.91 26.22 19.114C28.044 24.738 31.122 29.26 35.53 32.642L32.87 35.758C29.45 32.756 26.828 29.146 24.928 24.928C23.56 29.298 20.406 32.908 15.504 35.758L12.996 32.68C17.176 30.172 19.912 27.17 21.204 23.636C22.192 20.596 22.724 16.682 22.762 11.894ZM41.762 0.83601C44.308 2.81201 46.474 4.82601 48.26 6.84001L45.524 9.57601C44.004 7.67601 41.876 5.58601 39.102 3.34401L41.762 0.83601ZM64.486 35.036C62.32 35.036 59.964 34.998 57.418 34.96C54.834 34.922 52.706 34.694 51.072 34.276C49.438 33.82 48.07 32.946 46.93 31.654C46.398 30.97 45.866 30.666 45.41 30.666C44.65 30.666 43.282 32.452 41.306 36.062L38.494 33.402C40.394 30.286 42.104 28.272 43.7 27.436V16.91H38.57V13.338H47.31V27.702C47.462 27.854 47.652 28.006 47.842 28.234C48.792 29.26 49.704 30.02 50.616 30.514C51.794 31.046 53.542 31.388 55.898 31.464C58.178 31.502 60.876 31.54 63.992 31.54C65.74 31.54 67.488 31.502 69.312 31.464C71.136 31.388 72.504 31.35 73.492 31.274L72.58 35.036H64.486ZM50.198 27.74L49.134 24.244C49.818 23.94 50.198 23.294 50.198 22.344V3.91401C53.466 2.88801 56.05 1.67201 58.026 0.30401L60.23 3.49601C58.634 4.56001 56.544 5.58601 53.884 6.53601V23.066C55.632 22.572 57.304 22.04 58.976 21.47L59.698 25.194C56.962 26.03 53.808 26.866 50.198 27.74ZM68.59 26.448H65.778L64.714 22.686L67.336 22.876C68.02 22.876 68.362 22.458 68.362 21.66V6.00401H64.334V30.514H60.534V2.50801H72.086V22.458C72.086 25.118 70.908 26.448 68.59 26.448ZM84.93 9.15801C86.07 11.02 87.21 13.11 88.312 15.39L85.614 16.758H91.694V8.43601H78.47V4.59801H91.694V0.64601H95.722V4.59801H109.288V8.43601H95.722V16.758H98.762C100.054 14.288 101.156 11.666 102.106 8.89201L105.982 10.26C104.994 12.654 103.892 14.82 102.676 16.758H110.352V20.596H98.762C101.422 24.434 105.716 27.892 111.644 30.932L108.908 34.352C102.752 30.324 98.344 25.84 95.722 20.938V35.644H91.694V20.9C89.224 26.144 84.664 30.628 77.976 34.39L75.772 30.818C82.004 27.74 86.298 24.32 88.578 20.596H76.988V16.758H84.778C83.714 14.516 82.612 12.54 81.434 10.83L84.93 9.15801ZM134.33 28.158V32.11C128.402 33.326 122.056 34.39 115.292 35.226L114.342 31.426C117.268 31.122 120.156 30.78 122.968 30.362V24.434H116.09V20.634H122.968V16.796H126.958V20.634H133.228V24.434H126.958V29.678C129.466 29.222 131.898 28.69 134.33 28.158ZM114.988 1.78601H134.178V5.51001H124.716C123.196 8.58801 121.828 10.982 120.612 12.654C123.348 12.35 126.084 12.008 128.82 11.552C128.136 10.488 127.414 9.38601 126.654 8.28401L129.694 6.34601C132.202 9.65201 134.102 12.54 135.432 14.972L132.126 17.252C131.67 16.34 131.176 15.428 130.644 14.516C125.894 15.352 120.726 16.036 115.178 16.606L114.304 13.072C114.988 12.996 115.482 12.844 115.862 12.692C116.926 11.818 118.484 9.42401 120.46 5.51001H114.988V1.78601ZM144.02 35.34H138.358L137.484 31.502C139.308 31.654 141.018 31.73 142.652 31.73C143.526 31.73 143.982 31.236 143.982 30.324V0.64601H148.01V31.35C148.01 34.01 146.68 35.34 144.02 35.34ZM136.686 4.06601H140.524V27.284H136.686V4.06601Z" fill="white"/>
<path d="M163.552 4.59801H168.378L175.826 26.714H175.94L183.388 4.59801H188.214L178.562 31.73H173.204L163.552 4.59801ZM193.617 4.06601C194.453 4.06601 195.175 4.33201 195.745 4.86401C196.277 5.39601 196.581 6.08001 196.581 6.91601C196.581 7.75201 196.277 8.47401 195.707 9.00601C195.137 9.53801 194.453 9.80401 193.617 9.80401C192.781 9.80401 192.097 9.53801 191.527 9.00601C190.957 8.43601 190.691 7.75201 190.691 6.91601C190.691 6.08001 190.957 5.39601 191.527 4.86401C192.097 4.33201 192.781 4.06601 193.617 4.06601ZM191.451 12.084H195.783V31.73H191.451V12.084ZM215.14 4.06601H219.472V31.73H215.444V29.64C214.076 31.388 212.1 32.262 209.516 32.262C206.59 32.262 204.31 31.236 202.676 29.184C201.156 27.284 200.396 24.814 200.396 21.812C200.396 18.924 201.118 16.53 202.638 14.63C204.234 12.578 206.476 11.552 209.288 11.552C211.568 11.552 213.506 12.616 215.14 14.782V4.06601ZM210.314 15.048C208.338 15.048 206.932 15.694 206.02 16.986C205.222 18.088 204.842 19.684 204.842 21.812C204.842 23.94 205.184 25.574 205.944 26.714C206.818 28.082 208.224 28.766 210.162 28.766C211.834 28.766 213.164 28.082 214.076 26.752C214.874 25.536 215.292 23.94 215.292 22.04V21.736C215.292 19.646 214.76 17.974 213.772 16.758C212.86 15.618 211.682 15.048 210.314 15.048ZM224.395 4.59801H242.901V8.39801H228.841V15.922H242.103V19.722H228.841V31.73H224.395V4.59801ZM246.513 4.06601H250.845V31.73H246.513V4.06601ZM264.901 11.552C267.865 11.552 270.259 12.502 272.083 14.478C273.869 16.416 274.781 18.886 274.781 21.926C274.781 24.928 273.869 27.398 272.121 29.298C270.297 31.274 267.865 32.262 264.901 32.262C261.899 32.262 259.505 31.274 257.681 29.298C255.895 27.398 255.021 24.928 255.021 21.926C255.021 18.886 255.895 16.416 257.719 14.478C259.505 12.502 261.899 11.552 264.901 11.552ZM264.901 15.086C263.077 15.086 261.709 15.77 260.721 17.214C259.885 18.43 259.467 20.026 259.467 21.926C259.467 23.826 259.885 25.384 260.721 26.6C261.709 28.006 263.077 28.728 264.901 28.728C266.687 28.728 268.093 28.006 269.081 26.6C269.917 25.346 270.373 23.788 270.373 21.926C270.373 20.026 269.917 18.43 269.081 17.214C268.093 15.77 266.687 15.086 264.901 15.086ZM276.476 12.084H281.264L285.178 26.6L289.054 12.084H293.044L296.92 26.6L300.834 12.084H305.622L299.01 31.73H294.982L291.068 17.366L287.116 31.73H283.088L276.476 12.084Z" fill="#0DC0FF"/>
</svg>
<!-- 欢迎标题 -->
<div class="welcome-title">
<span class="welcome-text">欢迎来到</span>
<span class="brand-name">VidFlow</span>
</div>
<!-- 登录标题 -->
<div class="login-title">
<div class="login-methods">
<button :class="['method-btn', { active: loginType === 'email' }]" @click="() => { loginType = 'email'; clearForm(); }">邮箱验证码登录</button>
<button :class="['method-btn', { active: loginType === 'password' }]" @click="() => { loginType = 'password'; clearForm(); }">邮箱密码登录</button>
</div>
<!-- 登录方式切换 -->
<div class="login-tabs">
<svg width="248" height="59" viewBox="0 0 248 59" fill="none" xmlns="http://www.w3.org/2000/svg" class="tabs-svg">
<!-- 邮箱登录 -->
<g class="tab-email" :class="{ active: loginType === 'email' }" @click="loginType = 'email'" style="cursor: pointer;">
<path d="M13.598 21.112V40.638H11.076V39.13H4.316V40.638H1.768V21.112H6.344V17.55H8.996V21.112H13.598ZM4.316 36.712H6.344V31.122H4.316V36.712ZM8.996 36.712H11.076V31.122H8.996V36.712ZM4.316 28.73H6.344V23.556H4.316V28.73ZM8.996 23.556V28.73H11.076V23.556H8.996ZM15.34 18.772H24.232V20.748C23.452 23.4 22.62 25.818 21.736 28.054C23.556 30.654 24.466 32.76 24.492 34.398C24.492 35.958 24.154 37.024 23.504 37.596C22.802 38.22 21.398 38.532 19.318 38.532L18.512 35.802C19.474 35.906 20.28 35.984 20.956 35.984C21.372 35.958 21.658 35.828 21.814 35.62C21.918 35.464 21.97 35.048 21.996 34.398C21.97 32.786 20.982 30.68 19.032 28.054C19.864 26.156 20.67 23.868 21.45 21.164H17.914V41.73H15.34V18.772ZM27.716 27.326H31.616V24.83H34.346V27.326H37.57V29.926H34.346V30.446C35.542 31.538 36.79 32.734 38.038 34.06L36.53 36.348C35.698 35.048 34.97 33.93 34.346 33.046V41.756H31.616V33.202C30.654 35.23 29.432 37.102 27.924 38.818L26.754 35.776C28.756 34.06 30.238 32.11 31.226 29.926H27.716V27.326ZM49.4 25.48V41.73H46.774V40.82H41.106V41.73H38.48V25.48H49.4ZM41.106 38.428H46.774V36.244H41.106V38.428ZM41.106 33.956H46.774V32.058H41.106V33.956ZM41.106 29.77H46.774V27.872H41.106V29.77ZM31.538 21.762C30.966 22.75 30.316 23.634 29.614 24.466L27.248 22.958C28.782 21.294 29.874 19.5 30.498 17.576L33.124 18.148C32.968 18.564 32.838 18.98 32.682 19.37H38.974V21.762H35.568C36.088 22.49 36.504 23.192 36.842 23.842L34.346 24.778C33.878 23.738 33.306 22.724 32.682 21.762H31.538ZM42.64 21.762C42.12 22.828 41.574 23.816 40.95 24.726L38.636 23.244C39.962 21.424 40.898 19.474 41.444 17.446L44.018 18.018C43.862 18.486 43.732 18.928 43.602 19.37H50.83V21.762H46.67C47.19 22.49 47.632 23.192 47.97 23.842L45.578 24.752C45.11 23.712 44.538 22.724 43.862 21.762H42.64ZM57.538 28.522H72.566V35.282H57.538V28.522ZM69.836 32.89V30.888H60.268V32.89H69.836ZM60.45 35.438C61.282 36.296 62.036 37.31 62.712 38.454H67.626C68.354 37.466 68.978 36.426 69.524 35.36L72.046 36.27C71.578 37.05 71.084 37.778 70.564 38.454H76.232V41.028H53.716V38.454H59.826C59.28 37.726 58.63 37.05 57.902 36.4L60.45 35.438ZM56.368 21.112C57.564 21.996 58.63 22.854 59.514 23.686C60.424 22.802 61.152 21.866 61.724 20.878H55.822V18.382H64.792V20.41C64.194 21.918 63.388 23.27 62.374 24.466H68.822C67.262 22.75 66.014 20.904 65.104 18.876L67.366 17.628C67.782 18.59 68.276 19.5 68.848 20.358C69.758 19.63 70.538 18.85 71.188 18.018L73.086 19.708C72.306 20.644 71.37 21.528 70.304 22.308C70.72 22.828 71.188 23.296 71.708 23.764C72.8 22.932 73.71 21.996 74.49 20.982L76.388 22.646C75.608 23.634 74.672 24.544 73.632 25.35C74.776 26.182 76.024 26.962 77.428 27.664L75.634 29.744C73.606 28.6 71.838 27.352 70.33 25.974V26.936H60.788V26.104C59.124 27.56 57.07 28.782 54.626 29.796L52.962 27.664C54.782 26.962 56.316 26.156 57.616 25.246C56.758 24.466 55.744 23.66 54.548 22.828L56.368 21.112ZM82.368 18.408H97.864V26.806H102.258V29.276H98.228L100.282 30.966C98.93 32.63 97.396 33.956 95.68 34.892C97.604 36.296 99.84 37.518 102.388 38.61L101.01 41.002C97.37 39.286 94.432 37.232 92.196 34.814V38.974C92.196 40.794 91.39 41.73 89.804 41.73H86.762L86.164 39.182C87.1 39.286 88.01 39.364 88.894 39.364C89.284 39.364 89.492 39 89.492 38.324V34.918C86.944 37.154 83.928 39.156 80.47 40.95L79.378 38.428C83.278 36.66 86.632 34.58 89.492 32.136V29.276H79.768V26.806H95.108V25.012H83.252V22.672H95.108V20.852H82.368V18.408ZM83.018 29.666C84.526 30.706 85.8 31.746 86.84 32.786L85.072 34.554C84.162 33.566 82.888 32.526 81.224 31.382L83.018 29.666ZM98.176 29.276H92.196V31.824C92.69 32.37 93.236 32.89 93.834 33.41C95.498 32.422 96.954 31.044 98.176 29.276Z" />
</g>
<!-- 分隔线 -->
<path d="M124 18.75V40.25" stroke="#9EA9B6"/>
<!-- 密码登录 -->
<g class="tab-password" :class="{ active: loginType === 'password' }" @click="loginType = 'password'" style="cursor: pointer;">
<path d="M155.362 18.46V35.126H152.996V20.956H148.212V35.126H145.768V18.46H155.362ZM149.434 22.49H151.722V31.018C151.644 33.93 151.202 36.27 150.37 38.012C149.564 39.65 148.264 40.898 146.47 41.782L145.014 39.494C146.704 38.636 147.848 37.57 148.472 36.322C149.044 34.944 149.356 33.176 149.434 31.018V22.49ZM152.684 35.724C153.984 37.05 155.076 38.35 155.986 39.624L154.036 41.574C153.308 40.274 152.294 38.896 150.942 37.414L152.684 35.724ZM162.044 30.732H160.328V38.298C161.342 37.882 162.33 37.362 163.318 36.738L163.786 39.156C162.122 40.196 160.224 41.028 158.092 41.704L156.974 39.286C157.442 39.052 157.676 38.688 157.676 38.22V30.732H156.064V28.132H157.676V17.68H160.328V28.132H168.622V30.732H164.384C165.528 34.45 167.14 37.336 169.168 39.364L167.322 41.47C165.008 39.026 163.24 35.438 162.044 30.732ZM166.23 18.746L168.31 20.41C166.516 23.322 164.436 25.506 162.044 26.91L160.588 24.83C162.772 23.478 164.67 21.45 166.23 18.746ZM174.966 18.356H191.034V26.026H174.966V18.356ZM188.278 23.608V20.8H177.722V23.608H188.278ZM176.76 30.16H171.118V27.534H194.856V30.16H179.516L178.814 32.422H191.372C191.164 36.894 190.748 39.546 190.072 40.378C189.396 41.184 188.122 41.6 186.198 41.6C184.924 41.6 183.78 41.522 182.766 41.392L181.882 38.922C183.286 39.052 184.508 39.13 185.6 39.13C186.874 39.13 187.654 38.87 187.966 38.402C188.252 37.908 188.46 36.738 188.59 34.866H175.564L176.76 30.16ZM201.538 28.522H216.566V35.282H201.538V28.522ZM213.836 32.89V30.888H204.268V32.89H213.836ZM204.45 35.438C205.282 36.296 206.036 37.31 206.712 38.454H211.626C212.354 37.466 212.978 36.426 213.524 35.36L216.046 36.27C215.578 37.05 215.084 37.778 214.564 38.454H220.232V41.028H197.716V38.454H203.826C203.28 37.726 202.63 37.05 201.902 36.4L204.45 35.438ZM200.368 21.112C201.564 21.996 202.63 22.854 203.514 23.686C204.424 22.802 205.152 21.866 205.724 20.878H199.822V18.382H208.792V20.41C208.194 21.918 207.388 23.27 206.374 24.466H212.822C211.262 22.75 210.014 20.904 209.104 18.876L211.366 17.628C211.782 18.59 212.276 19.5 212.848 20.358C213.758 19.63 214.538 18.85 215.188 18.018L217.086 19.708C216.306 20.644 215.37 21.528 214.304 22.308C214.72 22.828 215.188 23.296 215.708 23.764C216.8 22.932 217.71 21.996 218.49 20.982L220.388 22.646C219.608 23.634 218.672 24.544 217.632 25.35C218.776 26.182 220.024 26.962 221.428 27.664L219.634 29.744C217.606 28.6 215.838 27.352 214.33 25.974V26.936H204.788V26.104C203.124 27.56 201.07 28.782 198.626 29.796L196.962 27.664C198.782 26.962 200.316 26.156 201.616 25.246C200.758 24.466 199.744 23.66 198.548 22.828L200.368 21.112ZM226.368 18.408H241.864V26.806H246.258V29.276H242.228L244.282 30.966C242.93 32.63 241.396 33.956 239.68 34.892C241.604 36.296 243.84 37.518 246.388 38.61L245.01 41.002C241.37 39.286 238.432 37.232 236.196 34.814V38.974C236.196 40.794 235.39 41.73 233.804 41.73H230.762L230.164 39.182C231.1 39.286 232.01 39.364 232.894 39.364C233.284 39.364 233.492 39 233.492 38.324V34.918C230.944 37.154 227.928 39.156 224.47 40.95L223.378 38.428C227.278 36.66 230.632 34.58 233.492 32.136V29.276H223.768V26.806H239.108V25.012H227.252V22.672H239.108V20.852H226.368V18.408ZM227.018 29.666C228.526 30.706 229.8 31.746 230.84 32.786L229.072 34.554C228.162 33.566 226.888 32.526 225.224 31.382L227.018 29.666ZM242.176 29.276H236.196V31.824C236.69 32.37 237.236 32.89 237.834 33.41C239.498 32.422 240.954 31.044 242.176 29.276Z" />
</g>
</svg>
</div>
<!-- 登录表单 -->
@@ -38,31 +46,28 @@
@keyup.enter="handleLogin"
/>
<div class="input-error" v-if="errors.email">{{ errors.email }}</div>
<!-- 快捷输入标签 -->
<div class="quick-email-tags">
<span class="email-tag" @click="fillQuickEmail('984523799@qq.com')">984523799@qq.com</span>
</div>
</div>
<!-- 验证码输入仅验证码登录显示 -->
<div class="code-input-group" v-if="loginType === 'email'">
<div class="code-input-wrapper" v-if="loginType === 'email'">
<el-input
ref="codeInput"
v-model="loginForm.code"
placeholder="请输入验证码"
class="code-input"
@keyup.enter="handleLogin"
/>
<div class="input-error" v-if="errors.code">{{ errors.code }}</div>
<el-button
type="primary"
plain
class="get-code-btn"
:disabled="countdown > 0 || !isEmailValid"
@click="getEmailCode"
>
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</el-button>
<template #suffix>
<span
class="get-code-text"
:class="{ disabled: countdown > 0 || !isEmailValid }"
@click="getEmailCode"
>
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</span>
</template>
</el-input>
<div class="input-error" v-if="errors.code">{{ errors.code }}</div>
</div>
<!-- 密码输入仅密码登录显示 -->
@@ -84,21 +89,8 @@
<!-- 协议文字 -->
<p class="agreement-text">
登录即表示您同意遵守用户协议和隐私政策
登录即表示您同意遵守<router-link to="/user-agreement" class="agreement-link">用户协议</router-link><router-link to="/privacy-policy" class="agreement-link">隐私政策</router-link>
</p>
<!-- 测试邮箱提示 -->
<div class="test-accounts">
<el-divider>测试邮箱</el-divider>
<div class="account-list">
<div class="account-item" @click="fillTestAccount('admin@example.com', '123456')">
<strong>管理员:</strong> admin@example.com
</div>
<div class="account-item" @click="fillTestAccount('13689270819@example.com', '123456')">
<strong>普通用户:</strong> 13689270819@example.com
</div>
</div>
</div>
</div>
</div>
</div>
@@ -109,7 +101,7 @@ import { ref, reactive, onMounted, computed, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
import { loginWithEmail, login, sendEmailCode, setDevEmailCode } from '@/api/auth'
import { loginWithEmail, login, sendEmailCode, setDevEmailCode, getCurrentUser } from '@/api/auth'
const router = useRouter()
const route = useRoute()
@@ -168,36 +160,12 @@ const clearForm = async () => {
}
// 快速填充测试账号
const fillTestAccount = async (email, code) => {
loginForm.email = email
// 根据当前登录方式,将第二个参数作为验证码或密码
if (loginType.value === 'password') {
loginForm.password = code
await nextTick()
passwordInput.value && passwordInput.value.focus && passwordInput.value.focus()
} else {
loginForm.code = code
await nextTick()
codeInput.value && codeInput.value.focus && codeInput.value.focus()
}
}
// 快速填充邮箱(快捷输入)
const fillQuickEmail = (email) => {
loginForm.email = email
}
// 组件挂载时设置默认测试账号或从URL参数读取邮箱
// 组件挂载时从URL参数读取邮箱
onMounted(() => {
// 从URL参数中读取邮箱
if (route.query.email) {
loginForm.email = route.query.email
} else {
// 设置默认的测试邮箱
loginForm.email = 'admin@example.com'
}
// 不设置验证码,让用户手动输入
})
@@ -314,10 +282,32 @@ const handleLogin = async () => {
if (response && response.data && response.data.success) {
// 保存用户信息和token
sessionStorage.setItem('token', response.data.data.token)
sessionStorage.setItem('user', JSON.stringify(response.data.data.user))
userStore.user = response.data.data.user
userStore.token = response.data.data.token
const loginUser = response.data.data.user
const loginToken = response.data.data.token
sessionStorage.setItem('token', loginToken)
sessionStorage.setItem('user', JSON.stringify(loginUser))
userStore.user = loginUser
userStore.token = loginToken
// 默认不要求设置密码
sessionStorage.removeItem('needSetPassword')
// 额外调用 /auth/me 获取真实的密码状态(避免后端出于安全清空 passwordHash
try {
const meResp = await getCurrentUser()
const meData = meResp.data
if (meData && meData.success && meData.data) {
const userFromMe = meData.data
const pwd = userFromMe.passwordHash
if (!pwd || String(pwd).trim() === '') {
// 当前用户还没有设置密码,标记为需要设置密码
sessionStorage.setItem('needSetPassword', '1')
}
}
} catch (e) {
// 获取用户信息失败时忽略,正常继续登录流程
console.warn('获取当前用户信息用于检测密码状态失败:', e)
}
console.log('登录成功,用户信息:', userStore.user)
ElMessage.success('登录成功')
@@ -325,9 +315,10 @@ const handleLogin = async () => {
// 等待一下确保状态更新
await new Promise(resolve => setTimeout(resolve, 200))
// 跳转到原始路径或个人主页
const redirectPath = route.query.redirect || '/profile'
console.log('准备跳转到:', redirectPath)
// 如果需要设置密码,优先跳转到个人主页,由个人主页负责弹出修改密码弹窗
const needSetPassword = sessionStorage.getItem('needSetPassword') === '1'
const redirectPath = needSetPassword ? '/profile' : (route.query.redirect || '/profile')
console.log('准备跳转到:', redirectPath, '需要设置密码:', needSetPassword)
// 使用replace而不是push避免浏览器历史记录问题
await router.replace(redirectPath)
@@ -350,7 +341,7 @@ const handleLogin = async () => {
<style scoped>
.login-page {
min-height: 100vh;
width: 100vw;
width: 100%;
height: 100vh;
background: url('/images/backgrounds/login-bg.svg') center/cover no-repeat;
position: fixed;
@@ -382,44 +373,67 @@ const handleLogin = async () => {
top: 50%;
right: 145px;
transform: translateY(-50%);
width: 800px;
width: 773px;
height: 796px;
max-width: 90vw;
background: rgba(121, 121, 121, 0.1);
backdrop-filter: blur(50px) saturate(180%);
-webkit-backdrop-filter: blur(50px) saturate(180%);
backdrop-filter: blur(50px);
-webkit-backdrop-filter: blur(50px);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
padding: 50px;
padding: 80px 82px;
z-index: 10;
overflow: hidden;
}
.login-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent);
opacity: 0.8;
}
/* 卡片内Logo */
.card-logo {
text-align: center;
margin-bottom: 40px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
justify-content: flex-start;
}
.card-logo svg {
width: auto;
height: 40px;
/* 欢迎标题 */
.welcome-title {
text-align: center;
margin-bottom: 50px;
font-size: 36px;
font-weight: 500;
}
.welcome-text {
color: rgba(255, 255, 255, 0.9);
margin-right: 8px;
}
.brand-name {
color: #00D4FF;
font-weight: 600;
}
/* 登录方式切换 */
.login-tabs {
display: flex;
justify-content: flex-start;
align-items: center;
margin-bottom: 50px;
}
.tabs-svg {
width: 248px;
height: 59px;
}
.tab-email path,
.tab-password path {
fill: #9EA9B6;
transition: fill 0.3s ease;
}
.tab-email.active path,
.tab-password.active path {
fill: white;
}
.tab-email:hover path,
.tab-password:hover path {
fill: rgba(255, 255, 255, 0.8);
}
/* 登录表单 */
@@ -429,56 +443,9 @@ const handleLogin = async () => {
gap: 25px;
}
/* 登录标题 */
.login-title {
text-align: center;
margin-bottom: 20px;
}
.login-subtitle {
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
margin: 0;
text-align: center;
}
/* 登录方式切换 */
.login-methods {
display: inline-flex;
gap: 8px;
}
.method-btn {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
font-weight: 500;
}
.method-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.85);
}
.method-btn.active {
background: rgba(64, 158, 255, 0.15);
border-color: rgba(64, 158, 255, 0.3);
color: #66B1FF;
}
.password-input-group {
margin-top: 10px;
}
/* 邮箱输入组 */
.email-input-group {
margin-bottom: 20px;
margin-bottom: 0;
}
.email-input {
@@ -515,27 +482,25 @@ const handleLogin = async () => {
}
.email-input :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(217, 217, 217, 0.2);
border: none;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
height: 55px;
box-shadow: none;
height: 80px;
transition: all 0.3s ease;
}
.email-input :deep(.el-input__wrapper:hover) {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
background: rgba(217, 217, 217, 0.25);
}
.email-input :deep(.el-input__wrapper.is-focus) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(64, 158, 255, 0.4);
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
background: rgba(217, 217, 217, 0.3);
box-shadow: none;
}
.email-input :deep(.el-input__inner) {
color: white;
color: rgba(255, 255, 255, 0.5);
background: transparent;
font-size: 16px;
}
@@ -545,37 +510,31 @@ const handleLogin = async () => {
}
/* 验证码输入组 */
.code-input-group {
display: flex;
gap: 12px;
}
.code-input {
flex: 1;
.code-input-wrapper {
margin-top: 30px;
}
.code-input :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(217, 217, 217, 0.2);
border: none;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
height: 55px;
box-shadow: none;
height: 80px;
transition: all 0.3s ease;
padding-right: 15px;
}
.code-input :deep(.el-input__wrapper:hover) {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
background: rgba(217, 217, 217, 0.25);
}
.code-input :deep(.el-input__wrapper.is-focus) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(64, 158, 255, 0.4);
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
background: rgba(217, 217, 217, 0.3);
box-shadow: none;
}
.code-input :deep(.el-input__inner) {
color: white;
color: rgba(255, 255, 255, 0.5);
background: transparent;
font-size: 16px;
}
@@ -584,31 +543,56 @@ const handleLogin = async () => {
color: rgba(255, 255, 255, 0.5);
}
.get-code-btn {
background: rgba(64, 158, 255, 0.12);
border: 1px solid rgba(64, 158, 255, 0.3);
color: #66B1FF;
border-radius: 10px;
padding: 0 20px;
font-size: 16px;
height: 55px;
transition: all 0.3s ease;
font-weight: 500;
.get-code-text {
color: #00D4FF;
font-size: 14px;
cursor: pointer;
user-select: none;
white-space: nowrap;
transition: opacity 0.3s ease;
}
.get-code-btn:hover {
background: rgba(64, 158, 255, 0.85);
border-color: rgba(64, 158, 255, 0.8);
color: white;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.25);
.get-code-text:hover {
opacity: 0.8;
}
.get-code-btn:disabled {
.get-code-text.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.password-input-group {
margin-top: 30px;
}
.password-input-group :deep(.el-input__wrapper) {
background: rgba(217, 217, 217, 0.2);
border: none;
border-radius: 10px;
box-shadow: none;
height: 80px;
transition: all 0.3s ease;
}
.password-input-group :deep(.el-input__wrapper:hover) {
background: rgba(217, 217, 217, 0.25);
}
.password-input-group :deep(.el-input__wrapper.is-focus) {
background: rgba(217, 217, 217, 0.3);
box-shadow: none;
}
.password-input-group :deep(.el-input__inner) {
color: rgba(255, 255, 255, 0.5);
background: transparent;
font-size: 16px;
}
.password-input-group :deep(.el-input__inner::placeholder) {
color: rgba(255, 255, 255, 0.5);
}
.input-error {
color: #ff7875;
font-size: 12px;
@@ -619,22 +603,20 @@ const handleLogin = async () => {
/* 登录按钮 */
.login-button {
width: 100%;
height: 52px;
background: linear-gradient(135deg, #409EFF 0%, #66B1FF 100%);
height: 80px;
background: #0DC0FF;
border: none;
border-radius: 10px;
color: white;
font-size: 16px;
font-weight: 600;
margin-top: 15px;
font-size: 18px;
font-weight: 500;
margin-top: 30px;
transition: all 0.3s ease;
box-shadow: 0 4px 16px rgba(64, 158, 255, 0.25);
}
.login-button:hover {
background: linear-gradient(135deg, #66B1FF 0%, #409EFF 100%);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(64, 158, 255, 0.35);
background: #4DD4FF;
transform: translateY(-1px);
}
.login-button:active {
@@ -644,50 +626,25 @@ const handleLogin = async () => {
/* 协议文字 */
.agreement-text {
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 14px;
margin: 25px 0 0 0;
line-height: 1.4;
}
/* 测试账号提示 */
.test-accounts {
margin-top: 30px;
}
.test-accounts :deep(.el-divider__text) {
color: rgba(255, 255, 255, 0.6);
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
margin: 20px 0 0 0;
line-height: 30px;
width: 266px;
height: 30px;
margin-left: auto;
margin-right: auto;
}
.account-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 15px;
.agreement-link {
color: #00D4FF;
text-decoration: none;
transition: opacity 0.3s ease;
}
.account-item {
font-size: 12px;
color: rgba(255, 255, 255, 0.75);
padding: 8px 14px;
background: rgba(255, 255, 255, 0.04);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
transition: all 0.3s ease;
}
.account-item:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.12);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.account-item strong {
color: #409EFF;
margin-right: 8px;
.agreement-link:hover {
opacity: 0.8;
text-decoration: underline;
}
/* 响应式设计 */
@@ -695,6 +652,7 @@ const handleLogin = async () => {
.login-card {
right: 5%;
width: 450px;
height: auto;
}
}
@@ -707,6 +665,7 @@ const handleLogin = async () => {
margin: 50px auto;
width: 90%;
max-width: 500px;
height: auto;
}
.logo {
@@ -722,6 +681,7 @@ const handleLogin = async () => {
@media (max-width: 480px) {
.login-card {
padding: 40px 25px;
height: auto;
}
.code-input-group {

View File

@@ -3,23 +3,7 @@
<!-- 左侧导航栏 -->
<aside class="sidebar">
<div class="logo">
<div class="logo-icon">
<svg viewBox="0 0 194 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M54.6165 13.6062L60.593 32.3932H60.8224L66.8111 13.6062H72.6065L64.0824 38.3335H57.3452L48.8089 13.6062H54.6165ZM75.4862 38.3335V19.788H80.6296V38.3335H75.4862ZM78.07 17.3974C77.3053 17.3974 76.6493 17.1439 76.1019 16.6368C75.5626 16.1216 75.293 15.5058 75.293 14.7895C75.293 14.0811 75.5626 13.4734 76.1019 12.9663C76.6493 12.4512 77.3053 12.1936 78.07 12.1936C78.8346 12.1936 79.4866 12.4512 80.0259 12.9663C80.5733 13.4734 80.8469 14.0811 80.8469 14.7895C80.8469 15.5058 80.5733 16.1216 80.0259 16.6368C79.4866 17.1439 78.8346 17.3974 78.07 17.3974ZM91.5836 38.6353C90.175 38.6353 88.8992 38.2731 87.7562 37.5487C86.6213 36.8162 85.7198 35.7416 85.0517 34.325C84.3916 32.9003 84.0616 31.1536 84.0616 29.0849C84.0616 26.9599 84.4037 25.1931 85.0879 23.7845C85.7721 22.3678 86.6816 21.3093 87.8166 20.6091C88.9596 19.9007 90.2112 19.5466 91.5716 19.5466C92.6099 19.5466 93.4752 19.7236 94.1674 20.0778C94.8677 20.4239 95.4312 20.8586 95.8578 21.3818C96.2924 21.8969 96.6225 22.404 96.8478 22.9031H97.0048V13.6062H102.136V38.3335H97.0652V35.3633H96.8478C96.6064 35.8785 96.2643 36.3896 95.8216 36.8967C95.3869 37.3958 94.8194 37.8103 94.1191 38.1403C93.4269 38.4703 92.5817 38.6353 91.5836 38.6353ZM93.2136 34.5423C94.0427 34.5423 94.743 34.3169 95.3145 33.8662C95.894 33.4074 96.3367 32.7674 96.6426 31.9464C96.9565 31.1254 97.1135 30.1635 97.1135 29.0608C97.1135 27.958 96.9605 27.0002 96.6547 26.1872C96.3488 25.3742 95.9061 24.7464 95.3265 24.3037C94.747 23.861 94.0427 23.6396 93.2136 23.6396C92.3684 23.6396 91.6561 23.869 91.0765 24.3278C90.497 24.7866 90.0583 25.4225 89.7605 26.2355C89.4627 27.0485 89.3137 27.9902 89.3137 29.0608C89.3137 30.1394 89.4627 31.0932 89.7605 31.9223C90.0663 32.7433 90.505 33.3872 91.0765 33.8541C91.6561 34.3129 92.3684 34.5423 93.2136 34.5423ZM106.462 38.3335V13.6062H122.834V17.9166H111.69V23.8086H121.747V28.119H111.69V38.3335H106.462ZM131.397 13.6062V38.3335H126.254V13.6062H131.397ZM143.897 38.6957C142.021 38.6957 140.399 38.2973 139.031 37.5004C137.671 36.6955 136.62 35.5766 135.88 34.1439C135.139 32.7031 134.769 31.0328 134.769 29.1332C134.769 27.2175 135.139 25.5432 135.88 24.1105C136.62 22.6697 137.671 21.5508 139.031 20.7539C140.399 19.949 142.021 19.5466 143.897 19.5466C145.772 19.5466 147.39 19.949 148.75 20.7539C150.119 21.5508 151.173 22.6697 151.914 24.1105C152.654 25.5432 153.025 27.2175 153.025 29.1332C153.025 31.0328 152.654 32.7031 151.914 34.1439C151.173 35.5766 150.119 36.6955 148.75 37.5004C147.39 38.2973 145.772 38.6957 143.897 38.6957ZM143.921 34.7113C144.774 34.7113 145.486 34.4699 146.058 33.9869C146.629 33.4959 147.06 32.8278 147.35 31.9826C147.648 31.1375 147.797 30.1756 147.797 29.097C147.797 28.0184 147.648 27.0565 147.35 26.2113C147.06 25.3662 146.629 24.6981 146.058 24.2071C145.486 23.7161 144.774 23.4706 143.921 23.4706C143.06 23.4706 142.335 23.7161 141.748 24.2071C141.168 24.6981 140.729 25.3662 140.431 26.2113C140.142 27.0565 139.997 28.0184 139.997 29.097C139.997 30.1756 140.142 31.1375 140.431 31.9826C140.729 32.8278 141.168 33.4959 141.748 33.9869C142.335 34.4699 143.06 34.7113 143.921 34.7113ZM159.562 38.3335L154.516 19.788H159.719L162.593 32.2483H162.762L165.756 19.788H170.864L173.906 32.1758H174.063L176.888 19.788H182.08L177.045 38.3335H171.6L168.413 26.6701H168.183L164.996 38.3335H159.562Z" fill="white"/>
<g clip-path="url(#clip0)">
<path d="M5.7406 1.64568C2.43935 1.64568 0.00938034 1.57298 0.000366208 1.64568C0.000366202 1.71906 2.11292 5.37389 4.70642 9.58611C7.28533 13.813 12.3557 22.0905 15.9545 27.9611L20.8685 35.9875C21.8796 37.6388 23.6773 38.6457 25.6136 38.6457L26.4301 38.6457C26.4301 38.6457 31.0568 38.7204 31.9965 37.2228C32.2255 36.8288 32.346 36.3807 32.3461 35.925L32.3461 21.8996L31.1801 21.8996L31.1801 26.5969C31.1801 31.1005 31.1655 31.3074 30.889 31.5861C30.7288 31.7476 30.4663 31.8801 30.306 31.8801C29.9855 31.8801 29.7811 31.5866 27.1439 27.257C26.2551 25.8039 24.0844 22.2371 22.2924 19.3312C20.5148 16.4253 17.3534 11.2596 15.2699 7.85467L11.4672 1.64568L5.7406 1.64568ZM31.6615 2.99627C31.443 4.1703 30.525 6.06354 29.6654 7.10564C28.4561 8.60263 26.4304 9.83531 24.5072 10.2316C23.4727 10.4518 23.196 10.5988 23.808 10.5988C24.0267 10.5989 24.5801 10.7012 25.0316 10.8332C28.2662 11.7578 31.0495 14.7674 31.6468 17.9816L31.8363 18.9797L32.0111 18.1135C32.5648 15.3249 34.4443 12.8151 36.9066 11.5676C38.0139 10.9952 39.2815 10.5988 39.9808 10.5988C40.6217 10.5988 40.403 10.4957 39.2084 10.2463C37.46 9.87934 36.1049 9.11623 34.6625 7.66326C33.2638 6.26901 32.5496 5.02127 32.0834 3.17205L31.8217 2.11541L31.6615 2.99627Z" fill="url(#paint0)"/>
</g>
<defs>
<linearGradient id="paint0" x1="23.4862" y1="5.3947" x2="32.9093" y2="11.1248" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#33DDE5"/>
<stop offset="1" stop-color="#0F9CFF"/>
</linearGradient>
<clipPath id="clip0">
<rect width="40.3334" height="40.3334" fill="white"/>
</clipPath>
</defs>
</svg>
</div>
<img src="/images/backgrounds/logo-admin.svg" alt="Logo" />
</div>
<nav class="nav-menu">
<div class="nav-item" @click="goToDashboard">
@@ -67,10 +51,19 @@
</div>
<div class="header-actions">
<LanguageSwitcher />
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
<el-dropdown @command="handleUserCommand">
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="exitAdmin">
{{ $t('admin.exitAdmin') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
@@ -302,6 +295,14 @@ const goToSettings = () => {
router.push('/system-settings')
}
// 处理用户头像下拉菜单
const handleUserCommand = (command) => {
if (command === 'exitAdmin') {
// 退出后台,返回个人首页
router.push('/profile')
}
}
// 表格操作
const isAllSelected = computed(() => {
return memberList.value.length > 0 && selectedMembers.value.length === memberList.value.length
@@ -599,22 +600,15 @@ const fetchSystemStats = async () => {
.logo {
display: flex;
align-items: center;
padding: 0 50px;
justify-content: center;
padding: 0 24px;
margin-bottom: 32px;
}
.logo-icon {
width: 100%;
height: auto;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.logo-icon svg, .logo-icon img {
.logo img {
width: 100%;
height: auto;
max-width: 180px;
object-fit: contain;
}

File diff suppressed because it is too large Load Diff

View File

@@ -303,7 +303,7 @@ const handleSubmit = async () => {
if (response.success) {
ElMessage.success('虚拟商品订单创建成功!商品将发送到您的邮箱')
router.push('/orders')
router.push('/admin/orders')
} else {
ElMessage.error(response.message || '创建订单失败')
}

View File

@@ -1,990 +0,0 @@
<template>
<div class="order-management">
<!-- 左侧导航栏 -->
<aside class="sidebar">
<div class="logo">
<div class="logo-icon">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
</div>
</div>
<nav class="nav-menu">
<div class="nav-item" @click="goToDashboard">
<el-icon><Grid /></el-icon>
<span>数据仪表台</span>
</div>
<div class="nav-item" @click="goToMembers">
<el-icon><User /></el-icon>
<span>会员管理</span>
</div>
<div class="nav-item active">
<el-icon><ShoppingCart /></el-icon>
<span>订单管理</span>
</div>
<div class="nav-item" @click="goToAPI">
<el-icon><Document /></el-icon>
<span>API管理</span>
</div>
<div class="nav-item" @click="goToTasks">
<el-icon><Document /></el-icon>
<span>生成任务记录</span>
</div>
<div class="nav-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</div>
</nav>
<div class="sidebar-footer">
<div class="online-users">
当前在线用户: <span class="highlight">{{ systemStatus.onlineUsers }}/500</span>
</div>
<div class="system-uptime">
系统运行时间: <span class="highlight">{{ systemStatus.systemUptime }}</span>
</div>
</div>
</aside>
<!-- 主内容区域 -->
<main class="main-content">
<!-- 顶部搜索栏 -->
<header class="top-header">
<div class="search-bar">
<el-icon class="search-icon"><Search /></el-icon>
<input type="text" placeholder="搜索你的想要的内容" class="search-input" />
</div>
<div class="header-actions">
<LanguageSwitcher />
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<el-icon class="dropdown-icon"><ArrowDown /></el-icon>
</div>
</div>
</header>
<!-- 订单管理内容 -->
<div class="order-content">
<div class="order-header">
<h1 class="order-title">订单管理</h1>
<div class="selection-info" v-if="selectedOrders.length > 0">
已选择{{ selectedOrders.length }}
</div>
</div>
<!-- 筛选和操作栏 -->
<div class="filter-bar">
<div class="filter-controls">
<el-select v-model="filters.orderType" placeholder="全部类型" @change="loadOrders">
<el-option label="全部类型" value="" />
<el-option label="会员订阅" value="SUBSCRIPTION" />
<el-option label="视频生成" value="SERVICE" />
<el-option label="产品订单" value="PRODUCT" />
</el-select>
<el-select v-model="filters.status" placeholder="全部状态" @change="loadOrders">
<el-option label="全部状态" value="" />
<el-option label="已完成" value="COMPLETED" />
<el-option label="处理中" value="PROCESSING" />
<el-option label="已取消" value="CANCELLED" />
<el-option label="待支付" value="PENDING" />
</el-select>
</div>
<div class="action-controls">
<el-button
type="danger"
:disabled="selectedOrders.length === 0"
@click="deleteSelectedOrders">
删除
</el-button>
</div>
</div>
<!-- 订单表格 -->
<div class="order-table-container">
<table class="order-table">
<thead>
<tr>
<th class="checkbox-column">
<el-checkbox
v-model="selectAll"
@change="toggleSelectAll"
:indeterminate="isIndeterminate">
</el-checkbox>
</th>
<th>订单编号</th>
<th>用户名</th>
<th>金额</th>
<th>支付方式</th>
<th>状态</th>
<th>创建时间</th>
<th>编辑</th>
</tr>
</thead>
<tbody>
<tr v-for="order in orders" :key="order.id" class="order-row">
<td class="checkbox-column">
<el-checkbox
v-model="selectedOrders"
:value="order.id"
@change="updateSelection">
</el-checkbox>
</td>
<td class="order-number">{{ order.orderNumber }}</td>
<td class="username">{{ order.user?.username || '未知用户' }}</td>
<td class="amount">¥{{ formatAmount(order.totalAmount) }}</td>
<td class="payment-method">
<div class="payment-icon" :class="getPaymentMethodClass(order.paymentMethod)">
<el-icon v-if="order.paymentMethod === 'ALIPAY'"><CreditCard /></el-icon>
<el-icon v-else-if="order.paymentMethod === 'WECHAT'"><CreditCard /></el-icon>
<el-icon v-else><CreditCard /></el-icon>
</div>
</td>
<td class="status">
<span class="status-tag" :class="getStatusClass(order.status)">
{{ getStatusText(order.status) }}
</span>
</td>
<td class="created-time">{{ formatDateTime(order.createdAt) }}</td>
<td class="actions">
<span class="action-link" @click="viewOrder(order)">查看</span>
<span class="action-link delete" @click="deleteOrder(order)">删除</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="pagination-container">
<div class="pagination">
<button class="page-btn" @click="goToPage(currentPage - 1)" :disabled="currentPage <= 1">
<el-icon><ArrowLeft /></el-icon>
</button>
<div class="page-numbers">
<button
v-for="page in visiblePages"
:key="page"
class="page-number"
:class="{ active: page === currentPage }"
@click="goToPage(page)">
{{ page }}
</button>
<span v-if="showEllipsis" class="ellipsis">...</span>
<button
v-if="totalPages > 1"
class="page-number"
:class="{ active: totalPages === currentPage }"
@click="goToPage(totalPages)">
{{ totalPages }}
</button>
</div>
<button class="page-btn" @click="goToPage(currentPage + 1)" :disabled="currentPage >= totalPages">
<el-icon><ArrowRight /></el-icon>
</button>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Grid,
User,
ShoppingCart,
Document,
Setting,
User as Search,
User as ArrowDown,
User as ArrowLeft,
User as ArrowRight,
User as View,
User as Delete,
CreditCard,
Wallet,
Money
} from '@element-plus/icons-vue'
import * as orderAPI from '@/api/orders'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
// 系统状态
const systemStatus = ref({
onlineUsers: 87,
systemUptime: '48小时32分'
})
// 订单数据
const orders = ref([])
const loading = ref(false)
// 筛选条件
const filters = reactive({
orderType: '',
status: '',
search: ''
})
// 分页
const currentPage = ref(1)
const pageSize = ref(10)
const totalOrders = ref(0)
// 选择
const selectedOrders = ref([])
const selectAll = ref(false)
// 计算属性
const totalPages = computed(() => Math.ceil(totalOrders.value / pageSize.value))
const isIndeterminate = computed(() => {
return selectedOrders.value.length > 0 && selectedOrders.value.length < orders.value.length
})
const visiblePages = computed(() => {
const pages = []
const start = Math.max(1, currentPage.value - 2)
const end = Math.min(totalPages.value, start + 4)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
const showEllipsis = computed(() => {
return totalPages.value > 5 && currentPage.value < totalPages.value - 2
})
// 加载订单数据
const loadOrders = async () => {
try {
loading.value = true
const params = {
page: currentPage.value - 1,
size: pageSize.value,
sortBy: 'createdAt',
sortDir: 'desc'
}
if (filters.orderType) {
params.orderType = filters.orderType
}
if (filters.status) {
params.status = filters.status
}
if (filters.search) {
params.search = filters.search
}
const response = await orderAPI.getOrders(params)
if (response.success) {
orders.value = response.data.content || []
totalOrders.value = response.data.totalElements || 0
} else {
ElMessage.error(response.message || '加载订单数据失败')
}
} catch (error) {
console.error('加载订单数据失败:', error)
ElMessage.error('加载订单数据失败')
} finally {
loading.value = false
}
}
// 格式化金额
const formatAmount = (amount) => {
if (!amount) return '0.00'
return parseFloat(amount).toFixed(2)
}
// 格式化日期时间
const formatDateTime = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
'COMPLETED': '已完成',
'PROCESSING': '处理中',
'CANCELLED': '已取消',
'PENDING': '待支付',
'CONFIRMED': '已确认',
'PAID': '已支付',
'SHIPPED': '已发货',
'DELIVERED': '已送达',
'REFUNDED': '已退款'
}
return statusMap[status] || status
}
// 获取状态样式类
const getStatusClass = (status) => {
const classMap = {
'COMPLETED': 'status-completed',
'PROCESSING': 'status-processing',
'CANCELLED': 'status-cancelled',
'PENDING': 'status-pending',
'CONFIRMED': 'status-confirmed',
'PAID': 'status-paid',
'SHIPPED': 'status-shipped',
'DELIVERED': 'status-delivered',
'REFUNDED': 'status-refunded'
}
return classMap[status] || ''
}
// 获取支付方式样式类
const getPaymentMethodClass = (method) => {
const classMap = {
'ALIPAY': 'payment-alipay',
'WECHAT': 'payment-wechat',
'PAYPAL': 'payment-paypal'
}
return classMap[method] || 'payment-default'
}
// 全选/取消全选
const toggleSelectAll = () => {
if (selectAll.value) {
selectedOrders.value = orders.value.map(order => order.id)
} else {
selectedOrders.value = []
}
}
// 更新选择状态
const updateSelection = () => {
selectAll.value = selectedOrders.value.length === orders.value.length
}
// 查看订单
const viewOrder = (order) => {
router.push(`/orders/${order.id}`)
}
// 删除单个订单
const deleteOrder = async (order) => {
try {
await ElMessageBox.confirm(
`确定要删除订单 ${order.orderNumber} 吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const response = await orderAPI.deleteOrder(order.id)
if (response.success) {
ElMessage.success('订单删除成功')
loadOrders()
} else {
ElMessage.error(response.message || '删除订单失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除订单失败:', error)
ElMessage.error('删除订单失败')
}
}
}
// 批量删除订单
const deleteSelectedOrders = async () => {
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedOrders.value.length} 个订单吗?`,
'确认批量删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const response = await orderAPI.deleteOrders(selectedOrders.value)
if (response.success) {
ElMessage.success(`批量删除成功,共删除 ${response.deletedCount} 个订单`)
selectedOrders.value = []
selectAll.value = false
loadOrders()
} else {
ElMessage.error(response.message || '批量删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('批量删除失败:', error)
ElMessage.error('批量删除失败')
}
}
}
// 分页导航
const goToPage = (page) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
loadOrders()
}
}
// 导航功能
const goToDashboard = () => {
router.push('/home')
}
const goToMembers = () => {
router.push('/member-management')
}
const goToAPI = () => {
router.push('/api-management')
}
const goToTasks = () => {
router.push('/generate-task-record')
}
const goToSettings = () => {
router.push('/system-settings')
}
onMounted(() => {
loadOrders()
})
</script>
<style scoped>
.order-management {
display: flex;
min-height: 100vh;
background: #f8fafc;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 左侧导航栏 */
.sidebar {
width: 320px;
background: white;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
padding: 24px 0;
}
.logo {
display: flex;
align-items: center;
padding: 0 28px;
margin-bottom: 32px;
}
.logo-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.logo-icon img {
width: 100%;
height: 100%;
object-fit: contain;
}
.nav-menu {
flex: 1;
padding: 0 24px;
}
.nav-item {
display: flex;
align-items: center;
padding: 18px 24px;
margin-bottom: 6px;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
color: #64748b;
font-size: 16px;
}
.nav-item:hover {
background: #f1f5f9;
color: #334155;
}
.nav-item.active {
background: #3b82f6;
color: white;
}
.nav-item .el-icon {
margin-right: 16px;
font-size: 22px;
}
.nav-item span {
font-size: 16px;
font-weight: 500;
}
.sidebar-footer {
padding: 0 32px 20px;
margin-top: auto;
}
.online-users,
.system-uptime {
font-size: 14px;
color: #64748b;
margin-bottom: 10px;
line-height: 1.5;
}
.highlight {
color: #3b82f6;
font-weight: 600;
font-size: 15px;
}
/* 主内容区域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: #f8fafc;
}
/* 顶部搜索栏 */
.top-header {
background: white;
padding: 16px 24px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.search-bar {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 12px;
color: #9ca3af;
font-size: 16px;
}
.search-input {
padding: 8px 12px 8px 40px;
border: 1px solid #d1d5db;
border-radius: 8px;
width: 300px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: #3b82f6;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.user-avatar {
display: flex;
align-items: center;
cursor: pointer;
}
.user-avatar img {
width: 32px;
height: 32px;
border-radius: 50%;
margin-right: 8px;
}
.dropdown-icon {
font-size: 12px;
color: #6b7280;
}
/* 订单内容 */
.order-content {
flex: 1;
padding: 24px;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.order-title {
font-size: 24px;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.selection-info {
font-size: 14px;
color: #6b7280;
}
/* 筛选栏 */
.filter-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 16px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.filter-controls {
display: flex;
gap: 16px;
}
.action-controls {
display: flex;
gap: 12px;
}
/* 订单表格 */
.order-table-container {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.order-table {
width: 100%;
border-collapse: collapse;
}
.order-table th {
background: #f8fafc;
padding: 16px;
text-align: left;
font-weight: 600;
color: #374151;
border-bottom: 1px solid #e5e7eb;
}
.order-table td {
padding: 16px;
border-bottom: 1px solid #f3f4f6;
}
.order-row:hover {
background: #f9fafb;
}
.checkbox-column {
width: 50px;
text-align: center;
}
.order-number {
font-family: 'Monaco', 'Menlo', monospace;
font-weight: 500;
color: #1f2937;
}
.username {
font-weight: 500;
color: #374151;
}
.amount {
font-weight: 600;
color: #059669;
}
.payment-method {
display: flex;
align-items: center;
}
.payment-icon {
width: 24px;
height: 24px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
}
.payment-alipay {
background: #1677ff;
}
.payment-wechat {
background: #07c160;
}
.payment-paypal {
background: #0070ba;
}
.payment-default {
background: #6b7280;
}
.status-tag {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-completed {
background: #dcfce7;
color: #166534;
}
.status-processing {
background: #dbeafe;
color: #1e40af;
}
.status-cancelled {
background: #fee2e2;
color: #dc2626;
}
.status-pending {
background: #fef3c7;
color: #d97706;
}
.status-confirmed {
background: #e0e7ff;
color: #3730a3;
}
.status-paid {
background: #d1fae5;
color: #065f46;
}
.status-shipped {
background: #dbeafe;
color: #1e40af;
}
.status-delivered {
background: #dcfce7;
color: #166534;
}
.status-refunded {
background: #f3e8ff;
color: #7c3aed;
}
.created-time {
color: #6b7280;
font-size: 14px;
}
.actions {
display: flex;
gap: 16px;
}
.action-link {
color: #3b82f6;
cursor: pointer;
font-size: 14px;
transition: color 0.2s;
}
.action-link:hover {
color: #1d4ed8;
}
.action-link.delete {
color: #dc2626;
}
.action-link.delete:hover {
color: #b91c1c;
}
/* 分页 */
.pagination-container {
display: flex;
justify-content: center;
margin-top: 24px;
}
.pagination {
display: flex;
align-items: center;
gap: 8px;
}
.page-btn {
width: 32px;
height: 32px;
border: 1px solid #d1d5db;
background: white;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.page-btn:hover:not(:disabled) {
border-color: #3b82f6;
color: #3b82f6;
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-numbers {
display: flex;
gap: 4px;
}
.page-number {
width: 32px;
height: 32px;
border: 1px solid #d1d5db;
background: white;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
}
.page-number:hover {
border-color: #3b82f6;
color: #3b82f6;
}
.page-number.active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
.ellipsis {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
font-size: 14px;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.sidebar {
width: 280px;
}
.search-input {
width: 250px;
}
}
@media (max-width: 768px) {
.order-management {
flex-direction: column;
}
.sidebar {
width: 100%;
height: auto;
}
.nav-menu {
display: flex;
overflow-x: auto;
padding: 0 16px;
}
.nav-item {
white-space: nowrap;
margin-right: 16px;
margin-bottom: 0;
}
.order-content {
padding: 16px;
}
.filter-bar {
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.filter-controls {
justify-content: center;
}
.order-table-container {
overflow-x: auto;
}
.order-table {
min-width: 800px;
}
}
</style>

View File

@@ -0,0 +1,578 @@
<template>
<div class="privacy-page">
<div class="privacy-container">
<!-- 返回按钮 -->
<div class="back-button" @click="goBack">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 12H5M5 12L12 19M5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>返回</span>
</div>
<!-- 中文版本 -->
<div class="privacy-content">
<h1>隐私政策</h1>
<p class="update-date">最后更新2025年11月1日</p>
<p class="intro">
本隐私政策阐述了当您下称"您""用户"通过网站 (https://vionow.com/) 访问或使用我们的服务时Vionow下称"公司"、"我们"或"我们的")关于收集、使用和披露个人信息的政策和程序。本政策还解释了您的隐私权以及适用法律如何保护您。
</p>
<p class="intro">
使用本服务即表示您同意我们根据本隐私政策收集和使用信息本文件的编写已考虑到最佳实践和相关的法律标准
</p>
<section>
<h2>解释与定义</h2>
<h3>解释</h3>
<p>首字母大写的词语具有下文定义的含义无论其以单数还是复数形式出现其含义均相同</p>
<h3>定义</h3>
<p>为本隐私政策之目的</p>
<ul>
<li><strong>账户</strong>指您为访问我们的服务而创建的唯一个人资料</li>
<li><strong>关联公司</strong>指控制我们被我们控制或与我们共同受控的任何实体</li>
<li><strong>Cookies</strong>指网站放置在您设备上的小型数据文件</li>
<li><strong>公司</strong>指在香港适用法律下运营的 Vionow</li>
<li><strong>国家</strong>指中国香港</li>
<li><strong>设备</strong>指能够访问本服务的任何技术设备</li>
<li><strong>个人数据</strong>指与已识别或可识别的个人相关的任何信息</li>
<li><strong>服务</strong>指公司提供的平台及相关服务</li>
<li><strong>服务提供商</strong>指与我们签约以处理数据或提供服务的第三方</li>
<li><strong>使用数据</strong>指在使用服务过程中自动收集的数据</li>
<li><strong>网站</strong>指位于 https://vionow.com/ 的在线平台。</li>
<li><strong></strong>指访问或使用本服务的任何个人或实体</li>
</ul>
</section>
<section>
<h2>数据收集</h2>
<h3>收集的数据类型</h3>
<h4>个人数据</h4>
<p>我们可能会要求您提供个人数据包括但不限于</p>
<ul>
<li>电子邮箱地址</li>
<li>姓名</li>
<li>电话号码</li>
<li>您自愿提供的其他身份标识信息</li>
</ul>
<h4>使用数据</h4>
<p>自动收集的数据可能包括</p>
<ul>
<li>IP 地址</li>
<li>浏览器和设备信息</li>
<li>访问时间与访问页面</li>
<li>诊断和性能数据</li>
</ul>
</section>
<section>
<h2>追踪技术</h2>
<p>我们使用Cookies 及类似工具来增强功能和进行分析</p>
<ul>
<li><strong>必要性 Cookies</strong>用于启用核心功能和保障安全</li>
<li><strong>偏好性 Cookies</strong>用于存储用户设置</li>
<li><strong>分析性 Cookies</strong>用于衡量性能和使用情况</li>
</ul>
<p>您可以通过浏览器修改Cookie 设置禁用 Cookies 可能会影响某些功能的正常使用</p>
</section>
<section>
<h2>数据的使用</h2>
<p>我们可能将您的信息用于以下目的</p>
<ul>
<li>提供改进和维护服务</li>
<li>管理您的账户</li>
<li>履行合同和法律义务</li>
<li>就服务更新支持或营销事宜与您沟通取决于您的偏好</li>
<li>分析使用模式以改善用户体验</li>
<li>用于内部研发</li>
<li>遵守法规和法律要求</li>
</ul>
</section>
<section>
<h2>用户生成内容与上传的图片</h2>
<p>
当您向本服务上传内容例如图片渲染图您保留该内容的完全所有权但是通过我们的平台提交此类内容即表示您授予公司一项非独占全球性免版税不可撤销且永久的许可授权我们仅为推广营销或展示本服务功能之有限目的使用复制修改和公开展示该等内容包括但不限于在网站营销材料或社交媒体上使用前提是该内容不包含个人数据可识别的个人或第三方的机密或专有信息
</p>
<p>
如果您希望选择退出此许可您可以随时通过<a href="mailto:contact@vionow.com">contact@vionow.com</a> 与我们联系收到有效请求后我们将停止所有相关的推广使用并尽合理努力从未来的材料中移除相关内容
</p>
</section>
<section>
<h2>数据保留</h2>
<p>
我们仅在为实现本政策所述目的或遵守适用法律所必需的时间内保留个人数据使用数据可能为分析安全或法律合规目的而保留
</p>
</section>
<section>
<h2>数据传输</h2>
<p>
您的数据可能会在您所在司法管辖区之外进行处理我们将采取合理的保障措施确保根据适用标准提供适当的保护
</p>
</section>
<section>
<h2>您的权利</h2>
<p>您有权</p>
<ul>
<li>访问更正或删除您的个人数据</li>
<li>反对或限制某些处理活动</li>
<li>撤回同意如适用</li>
<li>向监管机构投诉</li>
</ul>
<p>您可以通过<a href="mailto:contact@vionow.com">contact@vionow.com</a> 联系我们提交请求</p>
</section>
<section>
<h2>数据披露</h2>
<p>我们可能在以下情况下披露您的个人数据</p>
<ul>
<li>向根据合同义务行事的服务提供商披露</li>
<li>与公司重组或出售相关的披露</li>
<li>为遵守法律义务或捍卫我们的合法权利</li>
<li>为保护用户或公众的安全或权利</li>
</ul>
</section>
<section>
<h2>儿童隐私</h2>
<p>
我们的服务不面向13岁以下的个人我们不会有意收集未成年人的个人数据如果您认为有未成年人提交了个人数据请联系我们以请求删除
</p>
</section>
<section>
<h2>第三方网站</h2>
<p>
我们的网站可能包含指向外部网站的链接我们对其内容或隐私惯例不承担任何责任
</p>
</section>
<section>
<h2>本政策的变更</h2>
<p>
我们保留随时修订本隐私政策的权利重大变更将通过电子邮件或网站上的醒目通知进行传达
</p>
</section>
<section>
<h2>联系我们</h2>
<p>
如果您对本隐私政策有任何疑问或希望行使您的权利请通过以下方式联系我们<br>
电子邮箱<a href="mailto:contact@vionow.com">contact@vionow.com</a>
</p>
</section>
<!-- 分隔线 -->
<div class="divider"></div>
<!-- English Version -->
<h1 class="english-title">Privacy Policy</h1>
<p class="update-date">Last updated: June 12, 2025</p>
<p class="intro">
This Privacy Policy outlines the policies and procedures of Vionow ("the Company", "We", "Us", or "Our") regarding the collection, use, and disclosure of personal information when You ("You" or "User") access or use Our services via the Website (https://vionow.com/). It also explains Your privacy rights and how applicable laws protect You.
</p>
<p class="intro">
By using the Service, You consent to the collection and use of information in accordance with this Privacy Policy. This document has been prepared with consideration for best practices and relevant legal standards.
</p>
<section>
<h2>Interpretation and Definitions</h2>
<h3>Interpretation</h3>
<p>Capitalized words have meanings defined below, which apply equally whether singular or plural.</p>
<h3>Definitions</h3>
<p>For purposes of this Privacy Policy:</p>
<ul>
<li><strong>Account:</strong> A unique profile created by You to access Our Service.</li>
<li><strong>Affiliate:</strong> Any entity that controls, is controlled by, or is under common control with Us.</li>
<li><strong>Cookies:</strong> Small data files placed on Your device by the Website.</li>
<li><strong>Company:</strong> Vionow, operating under applicable laws in Hong Kong.</li>
<li><strong>Country:</strong> Hong Kong.</li>
<li><strong>Device:</strong> Any technology capable of accessing the Service.</li>
<li><strong>Personal Data:</strong> Information identifying or reasonably identifiable to an individual.</li>
<li><strong>Service:</strong> The platform and related services provided by the Company.</li>
<li><strong>Service Provider:</strong> Third parties contracted to process data or deliver services.</li>
<li><strong>Usage Data:</strong> Automatically collected data about Service usage.</li>
<li><strong>Website:</strong> The online platform located at https://vionow.com/.</li>
<li><strong>You:</strong> Any individual or entity accessing or using the Service.</li>
</ul>
</section>
<section>
<h2>Collection of Data</h2>
<h3>Types of Data Collected</h3>
<h4>Personal Data</h4>
<p>We may request Personal Data including but not limited to:</p>
<ul>
<li>Email address</li>
<li>First and last name</li>
<li>Phone number</li>
<li>Other voluntarily provided identifiers</li>
</ul>
<h4>Usage Data</h4>
<p>Collected automatically and may include:</p>
<ul>
<li>IP address</li>
<li>Browser and device information</li>
<li>Access times and visited pages</li>
<li>Diagnostic and performance data</li>
</ul>
</section>
<section>
<h2>Tracking Technologies</h2>
<p>We utilize Cookies and similar tools to enhance functionality and analytics:</p>
<ul>
<li><strong>Essential Cookies:</strong> Enable core features and security.</li>
<li><strong>Preference Cookies:</strong> Store user settings.</li>
<li><strong>Analytics Cookies:</strong> Measure performance and usage.</li>
</ul>
<p>You may modify cookie settings via Your browser. Declining cookies may impair certain functionalities.</p>
</section>
<section>
<h2>Use of Data</h2>
<p>We may use Your information for the following purposes:</p>
<ul>
<li>To deliver, improve, and maintain the Service</li>
<li>To administer Your Account</li>
<li>To fulfill contractual and legal obligations</li>
<li>To communicate with You regarding service updates, support, or marketing (subject to Your preferences)</li>
<li>To analyze usage patterns and improve the user experience</li>
<li>For internal research and development</li>
<li>To comply with regulatory and legal requirements</li>
</ul>
</section>
<section>
<h2>User-Generated Content and Uploaded Images</h2>
<p>
When You upload content (e.g., images, renders) to the Service, You retain full ownership of that content. However, by submitting such content through Our platform, You hereby grant the Company a non-exclusive, worldwide, royalty-free, irrevocable, and perpetual license to use, reproduce, modify, and publicly display such content strictly for the limited purpose of promoting, marketing, or demonstrating the functionality of the Service, including but not limited to use on the Website, in marketing materials, or on social media, provided that such content does not contain personal data, identifiable individuals, or confidential or proprietary information of third parties.
</p>
<p>
If You wish to opt out of this license, You may do so at any time by contacting Us at <a href="mailto:contact@vionow.com">contact@vionow.com</a>. Upon receipt of a valid request, We will cease all promotional use and make reasonable efforts to remove the relevant content from future materials.
</p>
</section>
<section>
<h2>Retention of Data</h2>
<p>
We retain Personal Data only as long as necessary to achieve the purposes described herein or as required by applicable law. Usage Data may be retained for analytics, security, or legal compliance.
</p>
</section>
<section>
<h2>Data Transfers</h2>
<p>
Your data may be processed outside of Your jurisdiction. We implement reasonable safeguards to ensure appropriate protection in accordance with applicable standards.
</p>
</section>
<section>
<h2>Your Rights</h2>
<p>You have the right to:</p>
<ul>
<li>Access, rectify, or delete Your Personal Data</li>
<li>Object to or restrict certain processing activities</li>
<li>Withdraw consent (where applicable)</li>
<li>File a complaint with a supervisory authority</li>
</ul>
<p>Requests can be submitted by contacting Us at <a href="mailto:contact@vionow.com">contact@vionow.com</a>.</p>
</section>
<section>
<h2>Disclosure of Data</h2>
<p>We may disclose Your Personal Data:</p>
<ul>
<li>To service providers acting under contractual obligations</li>
<li>In connection with corporate restructuring or sale</li>
<li>To comply with legal obligations or defend Our legal rights</li>
<li>To protect the safety or rights of Users or the public</li>
</ul>
</section>
<section>
<h2>Children's Privacy</h2>
<p>
Our Service is not directed to individuals under the age of 13. We do not knowingly collect Personal Data from minors. If You believe that a minor has submitted Personal Data, please contact Us to request removal.
</p>
</section>
<section>
<h2>Third-Party Websites</h2>
<p>
Our Website may contain links to external websites. We are not responsible for their content or privacy practices.
</p>
</section>
<section>
<h2>Changes to This Policy</h2>
<p>
We reserve the right to amend this Privacy Policy at any time. Material changes will be communicated via email or a prominent notice on the Website.
</p>
</section>
<section>
<h2>Contact Us</h2>
<p>
If You have any questions about this Privacy Policy or wish to exercise Your rights, contact us at:<br>
Email: <a href="mailto:contact@vionow.com">contact@vionow.com</a>
</p>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const goBack = () => {
router.back()
}
</script>
<style scoped>
.privacy-page {
min-height: 100vh;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
padding: 40px 20px;
overflow-y: auto;
}
.privacy-container {
max-width: 900px;
max-height: calc(100vh - 80px);
margin: 0 auto;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(20px);
border-radius: 20px;
padding: 60px 80px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
overflow-y: auto;
overflow-x: hidden;
}
/* 自定义滚动条样式 */
.privacy-container::-webkit-scrollbar {
width: 8px;
}
.privacy-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
}
.privacy-container::-webkit-scrollbar-thumb {
background: rgba(0, 212, 255, 0.3);
border-radius: 10px;
transition: background 0.3s ease;
}
.privacy-container::-webkit-scrollbar-thumb:hover {
background: rgba(0, 212, 255, 0.5);
}
/* Firefox 滚动条样式 */
.privacy-container {
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.3) rgba(255, 255, 255, 0.05);
}
.back-button {
display: inline-flex;
align-items: center;
gap: 8px;
color: #00D4FF;
font-size: 16px;
cursor: pointer;
margin-bottom: 30px;
transition: all 0.3s ease;
padding: 8px 16px;
border-radius: 8px;
}
.back-button:hover {
background: rgba(0, 212, 255, 0.1);
transform: translateX(-4px);
}
.back-button svg {
width: 20px;
height: 20px;
}
.privacy-content {
color: rgba(255, 255, 255, 0.9);
line-height: 1.8;
}
h1 {
font-size: 32px;
font-weight: 600;
color: #00D4FF;
margin-bottom: 20px;
text-align: center;
}
.english-title {
margin-top: 60px;
}
.update-date {
text-align: center;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
margin-bottom: 30px;
font-style: italic;
}
h2 {
font-size: 24px;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
margin-top: 40px;
margin-bottom: 16px;
}
h3 {
font-size: 20px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-top: 24px;
margin-bottom: 12px;
}
h4 {
font-size: 18px;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
margin-top: 20px;
margin-bottom: 10px;
}
p {
font-size: 16px;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 16px;
}
.intro {
font-size: 16px;
line-height: 1.8;
margin-bottom: 20px;
}
section {
margin-bottom: 32px;
}
ul {
list-style: none;
padding-left: 0;
margin: 16px 0;
}
ul li {
position: relative;
padding-left: 24px;
margin-bottom: 12px;
color: rgba(255, 255, 255, 0.8);
font-size: 16px;
}
ul li::before {
content: "·";
position: absolute;
left: 8px;
color: #00D4FF;
font-size: 20px;
font-weight: bold;
}
a {
color: #00D4FF;
text-decoration: none;
transition: opacity 0.3s ease;
}
a:hover {
opacity: 0.8;
text-decoration: underline;
}
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
margin: 60px 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.privacy-container {
padding: 40px 30px;
}
h1 {
font-size: 26px;
}
h2 {
font-size: 20px;
}
h3 {
font-size: 18px;
}
h4 {
font-size: 16px;
}
p, ul li {
font-size: 14px;
}
}
@media (max-width: 480px) {
.privacy-page {
padding: 20px 10px;
}
.privacy-container {
padding: 30px 20px;
}
h1 {
font-size: 22px;
}
h2 {
font-size: 18px;
}
h3 {
font-size: 16px;
}
}
</style>

View File

@@ -11,36 +11,36 @@
<nav class="nav-menu">
<div class="nav-item active">
<el-icon><User /></el-icon>
<span>个人主页</span>
<span>{{ t('profile.title') }}</span>
</div>
<div class="nav-item">
<el-icon><Compass /></el-icon>
<span @click="goToSubscription">会员订阅</span>
<span @click="goToSubscription">{{ t('profile.subscription') }}</span>
</div>
<div class="nav-item">
<el-icon><Document /></el-icon>
<span @click="goToMyWorks">我的作品</span>
<span @click="goToMyWorks">{{ t('profile.myWorks') }}</span>
</div>
</nav>
<!-- 工具分隔线 -->
<div class="divider">
<span>工具</span>
<span>{{ t('profile.tools') }}</span>
</div>
<!-- 工具菜单 -->
<nav class="tools-menu">
<div class="nav-item">
<el-icon><VideoPlay /></el-icon>
<span @click="goToTextToVideo">文生视频</span>
<span @click="goToTextToVideo">{{ t('home.textToVideo') }}</span>
</div>
<div class="nav-item">
<el-icon><Picture /></el-icon>
<span @click="goToImageToVideo">图生视频</span>
<span @click="goToImageToVideo">{{ t('home.imageToVideo') }}</span>
</div>
<div class="nav-item">
<el-icon><Film /></el-icon>
<span @click="goToStoryboardVideo">分镜视频</span>
<span @click="goToStoryboardVideo">{{ t('home.storyboardVideo') }}</span>
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
</div>
</nav>
@@ -59,7 +59,7 @@
</div>
<LanguageSwitcher />
<div class="user-status" @click="showUserMenu = !showUserMenu" ref="userStatusRef">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" class="status-icon" />
<img src="/images/backgrounds/avatar-default.svg" :alt="t('dashboard.userAvatar')" class="status-icon" />
</div>
</div>
</header>
@@ -68,19 +68,19 @@
<section class="profile-section">
<div class="profile-info">
<div class="avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="avatar" class="avatar-image" />
<img src="/images/backgrounds/avatar-default.svg" :alt="t('dashboard.userAvatar')" class="avatar-image" />
</div>
<div class="user-details">
<h2 class="username">{{ userInfo.nickname || userInfo.username || '未设置用户名' }}</h2>
<h2 class="username">{{ userInfo.nickname || userInfo.username || t('profile.noUsername') }}</h2>
<p class="profile-status" v-if="userInfo.bio">{{ userInfo.bio }}</p>
<p class="user-id">ID {{ userInfo.id || '加载中...' }}</p>
<p class="user-id">{{ t('profile.userId') }} {{ userInfo.id || t('common.loading') }}</p>
</div>
</div>
</section>
<!-- 已发布内容 -->
<section class="published-section">
<h3 class="section-title">已发布</h3>
<h3 class="section-title">{{ t('profile.published') }}</h3>
<div class="video-grid">
<div class="video-item" v-for="(video, index) in videos" :key="video.id || index" v-loading="loading">
<div class="video-thumbnail" @click="openDetail(video)">
@@ -97,21 +97,21 @@
<!-- 如果有封面图thumbnailUrl使用图片 -->
<img
v-else-if="video.cover && video.cover !== video.resultUrl"
:src="video.cover"
:alt="video.title"
class="video-cover-img"
:src="video.cover"
:alt="video.title"
class="video-cover-img"
/>
<!-- 否则使用占位符 -->
<div v-else class="figure"></div>
</div>
<div class="video-action">
<el-button v-if="index === 0" type="primary" size="small" @click.stop="createSimilar(video)">做同款</el-button>
<el-button v-if="index === 0" type="primary" size="small" @click.stop="createSimilar(video)">{{ t('profile.createSimilar') }}</el-button>
<span v-else class="director-text">DIRECTED BY VANNOCENT</span>
</div>
</div>
</div>
<div v-if="!loading && videos.length === 0" class="empty-works">
<div class="empty-text">暂无作品开始创作吧</div>
<div class="empty-text">{{ t('profile.noWorksYet') }}</div>
</div>
</div>
</section>
@@ -139,7 +139,7 @@
:poster="selectedItem.cover"
controls
>
您的浏览器不支持视频播放
{{ t('profile.browserNotSupport') }}
</video>
<img
v-else
@@ -154,26 +154,26 @@
<div class="detail-header">
<div class="user-info">
<div class="avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" class="avatar-image" />
<img src="/images/backgrounds/avatar-default.svg" :alt="t('dashboard.userAvatar')" class="avatar-image" />
</div>
<div class="username">{{ (selectedItem && selectedItem.username) || '匿名用户' }}</div>
<div class="username">{{ (selectedItem && selectedItem.username) || t('profile.anonymousUser') }}</div>
</div>
</div>
<div class="tabs">
<div class="tab" :class="{ active: activeDetailTab === 'detail' }" @click="activeDetailTab = 'detail'">作品详情</div>
<div class="tab" :class="{ active: activeDetailTab === 'detail' }" @click="activeDetailTab = 'detail'">{{ t('profile.workDetail') }}</div>
<div class="tab" :class="{ active: activeDetailTab === 'category' }" @click="activeDetailTab = 'category'">{{ selectedItem.category }}</div>
</div>
<div class="description-section" v-if="activeDetailTab === 'detail'">
<h3 class="section-title">提示词</h3>
<h3 class="section-title">{{ t('video.prompt') }}</h3>
<p class="description-text">{{ getDescription(selectedItem) }}</p>
</div>
<!-- 参考图特殊内容 -->
<div class="reference-content" v-if="activeDetailTab === 'category' && selectedItem.category === '参考图'">
<div class="input-details-section">
<h3 class="section-title">输入详情</h3>
<h3 class="section-title">{{ t('profile.inputDetails') }}</h3>
<div class="input-images">
<div class="input-image-item">
<img :src="selectedItem.cover" :alt="selectedItem.title" class="input-thumbnail" />
@@ -183,53 +183,53 @@
</div>
</div>
</div>
<div class="description-section">
<h3 class="section-title">提示词</h3>
<h3 class="section-title">{{ t('video.prompt') }}</h3>
<p class="description-text">图1在图2中奔跑视频</p>
</div>
</div>
<!-- 其他分类的内容 -->
<div class="description-section" v-if="activeDetailTab === 'category' && selectedItem.category !== '参考图'">
<h3 class="section-title">提示词</h3>
<h3 class="section-title">{{ t('video.prompt') }}</h3>
<p class="description-text">{{ getDescription(selectedItem) }}</p>
</div>
<div class="metadata-section">
<div class="metadata-item">
<span class="label">创建时间</span>
<span class="label">{{ t('profile.createTime') }}</span>
<span class="value">{{ selectedItem.createTime }}</span>
</div>
<div class="metadata-item">
<span class="label">作品 ID</span>
<span class="label">{{ t('profile.workId') }}</span>
<span class="value">{{ selectedItem.id }}</span>
</div>
<div class="metadata-item">
<span class="label">日期</span>
<span class="label">{{ t('profile.date') }}</span>
<span class="value">{{ selectedItem.date }}</span>
</div>
<div class="metadata-item">
<span class="label">分类</span>
<span class="label">{{ t('profile.category') }}</span>
<span class="value">{{ selectedItem.category }}</span>
</div>
<div class="metadata-item" v-if="selectedItem.type === 'video'">
<span class="label">时长</span>
<span class="value">{{ formatDuration(selectedItem.duration) || '未知' }}</span>
<span class="label">{{ t('profile.duration') }}</span>
<span class="value">{{ formatDuration(selectedItem.duration) || t('profile.unknown') }}</span>
</div>
<div class="metadata-item" v-if="selectedItem.type === 'video'">
<span class="label">清晰度</span>
<span class="value">{{ selectedItem.quality || '未知' }}</span>
<span class="label">{{ t('profile.quality') }}</span>
<span class="value">{{ selectedItem.quality || t('profile.unknown') }}</span>
</div>
<div class="metadata-item" v-if="selectedItem.type === 'video'">
<span class="label">宽高比</span>
<span class="value">{{ selectedItem.aspectRatio || '未知' }}</span>
<span class="label">{{ t('profile.aspectRatio') }}</span>
<span class="value">{{ selectedItem.aspectRatio || t('profile.unknown') }}</span>
</div>
</div>
<div class="action-section">
<button class="create-similar-btn" @click="createSimilar(selectedItem)">
做同款
{{ t('profile.createSimilar') }}
</button>
</div>
</div>
@@ -243,28 +243,72 @@
<template v-if="userStore.isAdmin">
<div class="menu-item" @click="goToDashboard">
<el-icon><User /></el-icon>
<span>数据仪表盘</span>
<span>{{ t('profile.dashboard') }}</span>
</div>
<div class="menu-item" @click="goToOrders">
<el-icon><Document /></el-icon>
<span>订单管理</span>
<span>{{ t('profile.orderManagement') }}</span>
</div>
<div class="menu-item" @click="goToMembers">
<el-icon><User /></el-icon>
<span>会员管理</span>
<span>{{ t('profile.memberManagement') }}</span>
</div>
<div class="menu-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>系统设置</span>
<span>{{ t('profile.systemSettings') }}</span>
</div>
</template>
<!-- 修改密码所有登录用户可见 -->
<div class="menu-item" @click="openChangePasswordDialog">
<el-icon><Lock /></el-icon>
<span>{{ t('profile.changePassword') }}</span>
</div>
<!-- 退出登录 -->
<div class="menu-item" @click="logout">
<el-icon><User /></el-icon>
<span>退出登录</span>
<span>{{ t('common.logout') }}</span>
</div>
</div>
</Teleport>
<!-- 修改密码弹窗 -->
<el-dialog
v-model="changePasswordDialogVisible"
:title="t('profile.changePassword')"
width="420px"
>
<el-form :model="changePasswordForm" label-width="90px">
<el-form-item label="当前密码">
<el-input
v-model="changePasswordForm.oldPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="新密码" required>
<el-input
v-model="changePasswordForm.newPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" required>
<el-input
v-model="changePasswordForm.confirmPassword"
type="password"
show-password
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="changePasswordDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="changePasswordLoading" @click="submitChangePassword">
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
@@ -277,6 +321,7 @@ import {
Document,
Star,
Setting,
Lock,
Compass,
VideoPlay,
Picture,
@@ -284,11 +329,13 @@ import {
} from '@element-plus/icons-vue'
import { getMyWorks } from '@/api/userWorks'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
import { getCurrentUser } from '@/api/auth'
import { getCurrentUser, changePassword } from '@/api/auth'
import { getWorkDetail } from '@/api/userWorks'
import { useI18n } from 'vue-i18n'
const router = useRouter()
const userStore = useUserStore()
const { t } = useI18n()
// 控制用户菜单显示
const showUserMenu = ref(false)
@@ -304,6 +351,56 @@ const userInfo = ref({
points: 0,
frozenPoints: 0
})
// 修改密码弹窗
const changePasswordDialogVisible = ref(false)
const changePasswordLoading = ref(false)
const changePasswordForm = ref({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const openChangePasswordDialog = () => {
showUserMenu.value = false
changePasswordForm.value = {
oldPassword: '',
newPassword: '',
confirmPassword: ''
}
changePasswordDialogVisible.value = true
}
const submitChangePassword = async () => {
if (!changePasswordForm.value.newPassword) {
ElMessage.error('新密码不能为空')
return
}
if (changePasswordForm.value.newPassword.length < 6) {
ElMessage.error('新密码长度不能少于6位')
return
}
if (changePasswordForm.value.newPassword !== changePasswordForm.value.confirmPassword) {
ElMessage.error('两次输入的密码不一致')
return
}
try {
changePasswordLoading.value = true
await changePassword({
oldPassword: changePasswordForm.value.oldPassword,
newPassword: changePasswordForm.value.newPassword
})
ElMessage.success('密码修改成功')
changePasswordDialogVisible.value = false
} catch (error) {
console.error('修改密码失败:', error)
const msg = error?.response?.data?.message || '修改密码失败'
ElMessage.error(msg)
} finally {
changePasswordLoading.value = false
}
}
const userLoading = ref(false)
// 视频数据
@@ -356,14 +453,14 @@ const goToDashboard = () => {
if (userStore.isAdmin) {
router.push('/admin/dashboard')
} else {
ElMessage.warning('权限不足,只有管理员才能访问数据仪表盘')
ElMessage.warning(t('profile.insufficientPermission'))
}
}
// 跳转到订单管理
const goToOrders = () => {
showUserMenu.value = false
router.push('/orders')
router.push('/admin/orders')
}
// 跳转到会员管理
@@ -373,7 +470,7 @@ const goToMembers = () => {
if (userStore.isAdmin) {
router.push('/member-management')
} else {
ElMessage.warning('权限不足,只有管理员才能访问会员管理')
ElMessage.warning(t('profile.insufficientPermission'))
}
}
@@ -384,7 +481,7 @@ const goToSettings = () => {
if (userStore.isAdmin) {
router.push('/system-settings')
} else {
ElMessage.warning('权限不足,只有管理员才能访问系统设置')
ElMessage.warning(t('profile.insufficientPermission'))
}
}
@@ -392,21 +489,21 @@ const goToSettings = () => {
const logout = async () => {
try {
showUserMenu.value = false
// 清除用户数据
await userStore.logoutUser()
// 清除其他可能的本地存储
localStorage.removeItem('user')
localStorage.removeItem('token')
ElMessage.success('已退出登录')
ElMessage.success(t('profile.logoutSuccess'))
// 跳转到登录页
router.push('/login')
} catch (error) {
console.error('退出登录失败:', error)
ElMessage.error('退出登录失败')
ElMessage.error(t('profile.logoutFailed'))
}
}
@@ -423,11 +520,11 @@ const openDetail = async (item) => {
selectedItem.value = transformWorkData(work)
} else {
console.error('获取作品详情失败:', response?.data?.message || '未知错误')
ElMessage.error('获取作品详情失败')
ElMessage.error(t('profile.loadDetailFailed'))
}
} catch (error) {
console.error('加载作品详情失败:', error)
ElMessage.error('加载作品详情失败: ' + (error.message || '未知错误'))
ElMessage.error(t('profile.loadDetailFailed') + ': ' + (error.message || '未知错误'))
}
}
@@ -445,9 +542,9 @@ const getDescription = (item) => {
if (desc) return desc
// 回退文案
if (item.type === 'video') {
return '暂无提示词'
return t('profile.noPrompt')
}
return '暂无提示词'
return t('profile.noPrompt')
}
// 格式化时长
@@ -531,13 +628,36 @@ const loadUserInfo = async () => {
frozenPoints: user.frozenPoints || 0
}
console.log('设置后的用户信息:', userInfo.value)
// 检查用户是否需要设置密码(只有数据库中真正没有密码时才弹窗,且只弹一次)
const needSetPasswordFlag = sessionStorage.getItem('needSetPassword') === '1'
const hasShownPasswordDialog = sessionStorage.getItem('hasShownPasswordDialog') === '1'
// 检查后端返回的用户密码状态
const hasNoPassword = !user.passwordHash || String(user.passwordHash).trim() === ''
// 只有在以下情况下才弹出修改密码弹窗:
// 1. 用户数据库中确实没有密码hasNoPassword = true
// 2. 有 needSetPassword 标记(来自登录流程)
// 3. 还没有显示过弹窗(避免重复显示)
if (hasNoPassword && needSetPasswordFlag && !hasShownPasswordDialog) {
console.log('检测到用户没有设置密码,弹出修改密码弹窗')
openChangePasswordDialog()
// 清除标记,确保只弹一次
sessionStorage.removeItem('needSetPassword')
sessionStorage.setItem('hasShownPasswordDialog', '1')
} else if (needSetPasswordFlag && !hasNoPassword) {
// 如果有标记但用户已经有密码了,清除标记
console.log('用户已有密码,清除 needSetPassword 标记')
sessionStorage.removeItem('needSetPassword')
}
} else {
console.error('获取用户信息失败:', response?.data?.message || '未知错误')
ElMessage.error('获取用户信息失败')
ElMessage.error(t('profile.loadUserInfoFailed'))
}
} catch (error) {
console.error('加载用户信息失败:', error)
ElMessage.error('加载用户信息失败: ' + (error.message || '未知错误'))
ElMessage.error(t('profile.loadUserInfoFailed') + ': ' + (error.message || '未知错误'))
} finally {
userLoading.value = false
}
@@ -564,7 +684,7 @@ const loadVideos = async () => {
}
} catch (error) {
console.error('加载作品列表失败:', error)
ElMessage.error('加载作品列表失败: ' + (error.message || '未知错误'))
ElMessage.error(t('profile.loadWorksFailed') + ': ' + (error.message || '未知错误'))
} finally {
loading.value = false
}
@@ -573,7 +693,7 @@ const loadVideos = async () => {
// 编辑个人资料
const editProfile = () => {
// TODO: 可以跳转到编辑页面或打开编辑对话框
ElMessage.info('个人简介编辑功能待实现')
ElMessage.info(t('profile.profileEditDevMsg'))
}
// 点击外部关闭菜单

View File

@@ -62,9 +62,6 @@
<div class="work-item" v-for="(work, index) in publishedWorks" :key="work.id" @click="openDetail(work)">
<div class="work-thumbnail">
<img :src="work.cover" :alt="work.title" />
<div class="work-overlay">
<div class="overlay-text">{{ work.text }}</div>
</div>
<!-- 鼠标悬停时显示的做同款按钮 -->
<div class="hover-create-btn" @click.stop="goToCreate(work)">
<el-button type="primary" size="small" round>
@@ -355,10 +352,10 @@ onMounted(() => {
/* 主内容区域 */
.main-content {
flex: 1;
padding: 24px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 24px;
gap: 20px;
}
/* 用户信息卡片 */
@@ -420,7 +417,7 @@ onMounted(() => {
.published-works {
display: flex;
flex-direction: column;
gap: 20px;
gap: 16px;
}
.works-tabs {
@@ -451,14 +448,14 @@ onMounted(() => {
.works-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.work-item {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
border-radius: 8px;
overflow: hidden;
transition: all 0.2s;
cursor: pointer;
@@ -509,42 +506,34 @@ onMounted(() => {
transform: scale(1.05);
}
.work-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
padding: 20px;
}
.overlay-text {
font-size: 16px;
font-weight: 600;
color: #fff;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
}
/* work-overlay / overlay-text 样式已移除(不再使用) */
.work-info {
padding: 16px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
}
.work-title {
font-size: 16px;
font-size: 14px;
font-weight: 600;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-meta {
font-size: 12px;
font-size: 11px;
color: #9ca3af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-actions {
padding: 0 16px 16px;
padding: 0 12px 12px;
opacity: 0;
transition: opacity 0.2s ease;
}
@@ -558,7 +547,7 @@ onMounted(() => {
}
.work-director {
padding: 0 16px 16px;
padding: 0 12px 12px;
text-align: center;
}
@@ -575,7 +564,7 @@ onMounted(() => {
}
.works-grid {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,36 +11,36 @@
<nav class="nav-menu">
<div class="nav-item" @click="goToProfile">
<el-icon><User /></el-icon>
<span>个人主页</span>
<span>{{ $t('common.profile') }}</span>
</div>
<div class="nav-item" :class="{ active: currentSection === 'subscription' }" @click="setSection('subscription')">
<el-icon><Compass /></el-icon>
<span>会员订阅</span>
<span>{{ $t('subscription.title') }}</span>
</div>
<div class="nav-item" @click="goToMyWorks">
<el-icon><Document /></el-icon>
<span>我的作品</span>
<span>{{ $t('profile.myWorks') }}</span>
</div>
</nav>
<!-- 工具分隔线 -->
<div class="divider">
<span>工具</span>
<span>{{ $t('profile.tools') }}</span>
</div>
<!-- 工具菜单 -->
<nav class="tools-menu">
<div class="nav-item" @click="goToTextToVideo">
<el-icon><VideoPlay /></el-icon>
<span>文生视频</span>
<span>{{ $t('home.textToVideo') }}</span>
</div>
<div class="nav-item" @click="goToImageToVideo">
<el-icon><Picture /></el-icon>
<span>图生视频</span>
<span>{{ $t('home.imageToVideo') }}</span>
</div>
<div class="nav-item storyboard-item" @click="goToStoryboardVideo">
<el-icon><Film /></el-icon>
<span>分镜视频</span>
<span>{{ $t('home.storyboardVideo') }}</span>
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
</div>
</nav>
@@ -58,8 +58,8 @@
<span class="points-number">{{ userStore.availablePoints }}</span>
</div>
<LanguageSwitcher />
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<div class="user-avatar" @click="showUserMenu = !showUserMenu" ref="userStatusRef">
<img src="/images/backgrounds/avatar-default.svg" :alt="$t('subscription.userAvatar')" />
</div>
</div>
</header>
@@ -74,11 +74,11 @@
<div class="row-top">
<div class="user-left">
<div class="avatar-wrap">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" class="avatar-image" />
<img src="/images/backgrounds/avatar-default.svg" :alt="$t('subscription.userAvatar')" class="avatar-image" />
</div>
<div class="user-meta">
<div class="username">{{ userInfo.username || '加载中...' }}</div>
<div class="user-id">ID {{ userInfo.userId || '...' }}</div>
<div class="username">{{ userInfo.username || t('subscription.loading') }}</div>
<div class="user-id">{{ $t('profile.userId') }} {{ userInfo.userId || '...' }}</div>
</div>
</div>
<div class="user-right">
@@ -88,24 +88,24 @@
</div>
<span>{{ userStore.availablePoints }}</span>
</div>
<button class="mini-btn" @click="goToOrderDetails">积分详情</button>
<button class="mini-btn" @click="goToWorks">我的订单</button>
<button class="mini-btn" @click="goToOrderDetails">{{ $t('subscription.pointsDetails') }}</button>
<button class="mini-btn" @click="goToWorks">{{ $t('subscription.myOrders') }}</button>
</div>
</div>
<!-- 下层三项总结 -->
<div class="row-bottom">
<div class="summary-item">
<div class="summary-label">当前生效权益</div>
<div class="summary-value">{{ subscriptionInfo.currentPlan || '免费版' }}</div>
<div class="summary-label">{{ $t('subscription.currentActivePlan') }}</div>
<div class="summary-value">{{ subscriptionInfo.currentPlan || t('subscription.free') }}</div>
</div>
<div class="divider-v"></div>
<div class="summary-item">
<div class="summary-label">到期时间</div>
<div class="summary-value">{{ subscriptionInfo.expiryTime || '永久' }}</div>
<div class="summary-label">{{ $t('subscription.expiryTime') }}</div>
<div class="summary-value">{{ subscriptionInfo.expiryTime || t('subscription.permanent') }}</div>
</div>
<div class="divider-v"></div>
<div class="summary-item">
<div class="summary-label">剩余积分</div>
<div class="summary-label">{{ $t('subscription.remainingPoints') }}</div>
<div class="summary-value highlight">
<div class="star-icon">
<el-icon><Star /></el-icon>
@@ -118,75 +118,75 @@
<!-- 套餐选择 -->
<section class="subscription-packages">
<h3 class="section-title">套餐</h3>
<h3 class="section-title">{{ $t('subscription.plans') }}</h3>
<div class="packages-grid">
<!-- 免费版 -->
<div class="package-card free-card" :class="{ selected: selectedPlan === 'free' }" @click="selectPlan('free')">
<div class="package-header">
<h4 class="package-title">免费版</h4>
<h4 class="package-title">{{ $t('subscription.free') }}</h4>
</div>
<div class="package-price">${{ membershipPrices.free }}/</div>
<div class="package-price">${{ membershipPrices.free }}{{ $t('subscription.perMonth') }}</div>
<div class="points-box points-box-placeholder">&nbsp;</div>
<button class="package-button current">当前套餐</button>
<button class="package-button current">{{ $t('subscription.currentPackage') }}</button>
<div class="package-features">
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>新用户首次登陆免费获得50积分</span>
<span>{{ $t('subscription.freeNewUserBonus') }}</span>
</div>
</div>
</div>
<!-- 标准版 -->
<div class="package-card standard-card" :class="{ selected: selectedPlan === 'standard' }" @click="selectPlan('standard')">
<div class="package-header">
<h4 class="package-title">标准版</h4>
<div class="discount-tag">首购低至8.5</div>
<h4 class="package-title">{{ $t('subscription.standard') }}</h4>
<div class="discount-tag">{{ $t('subscription.firstPurchaseDiscount') }}</div>
</div>
<div class="package-price">${{ membershipPrices.standard }}/</div>
<div class="points-box">每月200积分</div>
<button class="package-button subscribe" @click.stop="handleSubscribe('standard')">立即订阅</button>
<div class="package-price">${{ membershipPrices.standard }}{{ $t('subscription.perMonth') }}</div>
<div class="points-box">{{ $t('subscription.standardPoints') }}</div>
<button class="package-button subscribe" @click.stop="handleSubscribe('standard')">{{ $t('subscription.subscribe') }}</button>
<div class="package-features">
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>快速通道生成</span>
<span>{{ $t('subscription.fastGeneration') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>支持商用</span>
<span>{{ $t('subscription.commercialUse') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>下载去水印</span>
<span>{{ $t('subscription.noWatermark') }}</span>
</div>
</div>
</div>
<!-- 专业版 -->
<div class="package-card premium-card" :class="{ selected: selectedPlan === 'premium' }" @click="selectPlan('premium')">
<div class="package-header">
<h4 class="package-title">专业版</h4>
<div class="value-tag">超值之选</div>
<h4 class="package-title">{{ $t('subscription.professional') }}</h4>
<div class="value-tag">{{ $t('subscription.bestValue') }}</div>
</div>
<div class="package-price">${{ membershipPrices.premium }}/</div>
<div class="points-box">每月1000积分</div>
<button class="package-button premium" @click.stop="handleSubscribe('premium')">立即订阅</button>
<div class="package-price">${{ membershipPrices.premium }}{{ $t('subscription.perMonth') }}</div>
<div class="points-box">{{ $t('subscription.premiumPoints') }}</div>
<button class="package-button premium" @click.stop="handleSubscribe('premium')">{{ $t('subscription.subscribe') }}</button>
<div class="package-features">
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>极速通道生成</span>
<span>{{ $t('subscription.superFastGeneration') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>支持商用</span>
<span>{{ $t('subscription.commercialUse') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>下载去水印</span>
<span>{{ $t('subscription.noWatermark') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>新功能优先体验</span>
<span>{{ $t('subscription.earlyAccess') }}</span>
</div>
</div>
</div>
@@ -194,12 +194,40 @@
</section>
</template>
</main>
<!-- 用户菜单下拉管理员功能 -->
<Teleport to="body">
<div v-if="showUserMenu" class="user-menu-teleport" :style="menuStyle">
<template v-if="userStore.isAdmin">
<div class="menu-item" @click="goToDashboard">
<el-icon><User /></el-icon>
<span>{{ t('profile.dashboard') }}</span>
</div>
<div class="menu-item" @click="goToOrders">
<el-icon><Document /></el-icon>
<span>{{ t('profile.orderManagement') }}</span>
</div>
<div class="menu-item" @click="goToMembers">
<el-icon><User /></el-icon>
<span>{{ t('profile.memberManagement') }}</span>
</div>
<div class="menu-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>{{ t('profile.systemSettings') }}</span>
</div>
</template>
<div class="menu-item" @click="logout">
<el-icon><User /></el-icon>
<span>{{ t('common.logout') }}</span>
</div>
</div>
</Teleport>
</div>
<!-- 积分详情模态框 -->
<el-dialog
v-model="pointsHistoryDialogVisible"
title="积分使用情况"
:title="$t('subscription.pointsUsageHistory')"
width="80%"
class="points-history-dialog"
:modal="true"
@@ -209,31 +237,31 @@
>
<div class="points-history-content">
<div class="points-summary">
<h3>积分使用总览</h3>
<h3>{{ $t('subscription.pointsUsageOverview') }}</h3>
<div class="summary-stats">
<div class="stat-item">
<span class="stat-label">总充值</span>
<span class="stat-label">{{ $t('subscription.totalRecharge') }}</span>
<span class="stat-value positive">+{{ totalRecharge || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">总消耗</span>
<span class="stat-label">{{ $t('subscription.totalConsumption') }}</span>
<span class="stat-value negative">{{ totalConsume || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">当前积分</span>
<span class="stat-label">{{ $t('subscription.currentPoints') }}</span>
<span class="stat-value current">{{ (userInfo.points || 0) - (userInfo.frozenPoints || 0) }}</span>
</div>
</div>
</div>
<div class="points-history-list" v-loading="pointsHistoryLoading">
<div v-if="pointsHistory.length === 0 && !pointsHistoryLoading" class="empty-history">
<p>暂无积分使用记录</p>
<p>{{ $t('subscription.noPointsHistory') }}</p>
</div>
<div class="history-item" v-for="(item, index) in pointsHistory" :key="index">
<div class="history-header">
<span class="history-type" :class="item.type === '充值' ? 'recharge' : 'consume'">
{{ item.type }}
<span class="history-type" :class="isRechargeType(item.type) ? 'recharge' : 'consume'">
{{ mapPointsTypeToI18nKey(item.type) }}
</span>
<span class="history-points" :class="item.points > 0 ? 'positive' : 'negative'">
{{ item.points > 0 ? '+' : '' }}{{ item.points }}
@@ -241,10 +269,10 @@
</div>
<div class="history-details">
<div class="history-info">
<p><strong>描述</strong>{{ item.description }}</p>
<p><strong>时间</strong>{{ formatDateTime(item.time) }}</p>
<p v-if="item.orderNumber"><strong>订单号</strong>{{ item.orderNumber }}</p>
<p v-if="item.taskId"><strong>任务ID</strong>{{ item.taskId }}</p>
<p><strong>{{ $t('subscription.description') }}</strong>{{ item.description }}</p>
<p><strong>{{ $t('subscription.time') }}</strong>{{ formatDateTime(item.time) }}</p>
<p v-if="item.orderNumber"><strong>{{ $t('subscription.orderNumber') }}</strong>{{ item.orderNumber }}</p>
<p v-if="item.taskId"><strong>{{ $t('subscription.taskId') }}</strong>{{ item.taskId }}</p>
</div>
</div>
</div>
@@ -264,7 +292,8 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import MyWorks from '@/views/MyWorks.vue'
import PaymentModal from '@/components/PaymentModal.vue'
import { useRouter } from 'vue-router'
@@ -276,9 +305,28 @@ import { getMembershipLevels } from '@/api/members'
import { useUserStore } from '@/stores/user'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
// 用户菜单状态
const showUserMenu = ref(false)
const userStatusRef = ref(null)
// 计算用户菜单 Teleport 的位置
const menuStyle = computed(() => {
if (!userStatusRef.value || !showUserMenu.value) return {}
const rect = userStatusRef.value.getBoundingClientRect()
return {
position: 'fixed',
top: `${rect.bottom + 8}px`,
right: `${window.innerWidth - rect.right}px`,
zIndex: 99999
}
})
// 用户信息和订阅信息
const userInfo = ref({
username: '',
@@ -290,11 +338,49 @@ const userInfo = ref({
})
const subscriptionInfo = ref({
currentPlan: '免费版',
expiryTime: '永久',
currentPlan: t('subscription.free'),
expiryTime: t('subscription.permanent'),
paidAt: null
})
// 套餐名称映射函数将后端返回的中文套餐名映射到国际化key
const mapPlanNameToI18nKey = (planName) => {
if (!planName) return t('subscription.free')
// 移除"会员"、"套餐"等后缀,并转为小写
const planLower = planName.replace(/会员|套餐|版本/g, '').toLowerCase()
if (planLower.includes('专业') || planLower.includes('premium') || planLower.includes('professional')) {
return t('subscription.professional')
} else if (planLower.includes('标准') || planLower.includes('standard')) {
return t('subscription.standard')
} else if (planLower.includes('免费') || planLower.includes('free')) {
return t('subscription.free')
}
// 如果无法识别,返回原始值
return planName
}
// 积分类型映射函数将后端返回的中文类型映射到国际化key
const mapPointsTypeToI18nKey = (type) => {
if (!type) return type
if (type === '充值' || type.toLowerCase() === 'recharge') {
return t('subscription.recharge')
} else if (type === '消耗' || type.toLowerCase() === 'consume' || type.toLowerCase() === 'consumption') {
return t('subscription.consume')
}
// 如果无法识别,返回原始值
return type
}
// 检查类型是否为充值类型用于CSS类
const isRechargeType = (type) => {
return type === '充值' || type.toLowerCase() === 'recharge'
}
// 会员等级价格配置
const membershipPrices = ref({
free: 0,
@@ -313,16 +399,16 @@ const loadUserSubscriptionInfo = async () => {
// 检查用户是否已认证
if (!userStore.isAuthenticated) {
console.warn('用户未认证,跳转到登录页')
ElMessage.warning('请先登录')
ElMessage.warning(t('subscription.pleaseLogin'))
router.push('/login')
return
}
// 检查token是否存在
const token = userStore.token || sessionStorage.getItem('token')
if (!token || token === 'null' || token.trim() === '') {
console.warn('未找到有效的token跳转到登录页')
ElMessage.warning('请先登录')
ElMessage.warning(t('subscription.pleaseLogin'))
router.push('/login')
return
}
@@ -354,10 +440,10 @@ const loadUserSubscriptionInfo = async () => {
email: data.email || '',
nickname: data.nickname || ''
}
subscriptionInfo.value = {
currentPlan: data.currentPlan || '免费版',
expiryTime: data.expiryTime || '永久',
currentPlan: mapPlanNameToI18nKey(data.currentPlan),
expiryTime: data.expiryTime || t('subscription.permanent'),
paidAt: data.paidAt || null
}
@@ -378,10 +464,10 @@ const loadUserSubscriptionInfo = async () => {
email: data.email || '',
nickname: data.nickname || ''
}
subscriptionInfo.value = {
currentPlan: data.currentPlan || '免费版',
expiryTime: data.expiryTime || '永久',
currentPlan: mapPlanNameToI18nKey(data.currentPlan),
expiryTime: data.expiryTime || t('subscription.permanent'),
paidAt: data.paidAt || null
}
console.log('用户信息加载成功(备用路径):', userInfo.value)
@@ -390,21 +476,21 @@ const loadUserSubscriptionInfo = async () => {
} else {
console.error('获取用户订阅信息失败: 响应数据为空或格式不正确')
console.error('完整响应:', JSON.stringify(response.data, null, 2))
ElMessage.warning('获取用户信息失败,使用默认值')
ElMessage.warning(t('subscription.loadUserInfoFailed'))
}
}
} else {
console.error('获取用户订阅信息失败: response.data为空')
console.error('完整响应对象:', response)
ElMessage.warning('获取用户信息失败,使用默认值')
ElMessage.warning(t('subscription.loadUserInfoFailed'))
}
} catch (error) {
console.error('加载用户订阅信息失败:', error)
console.error('错误详情:', error.response?.data || error.message)
// 如果是认证失败401、403、302或HTML响应响应拦截器已经处理了跳转
if (error.response?.status === 401 ||
error.response?.status === 403 ||
if (error.response?.status === 401 ||
error.response?.status === 403 ||
error.response?.status === 302 ||
error.message?.includes('认证失败') ||
error.message?.includes('redirect')) {
@@ -412,9 +498,9 @@ const loadUserSubscriptionInfo = async () => {
console.warn('认证失败,响应拦截器已处理跳转')
return
}
// 其他错误才显示消息
ElMessage.error('加载用户信息失败: ' + (error.response?.data?.message || error.message || '请刷新页面重试'))
ElMessage.error(t('subscription.loadUserInfoError', { message: error.response?.data?.message || error.message || t('subscription.refreshPage') }))
}
}
@@ -480,6 +566,15 @@ onMounted(async () => {
}
})
// 挂载/卸载时注册全局点击监听,用于关闭用户菜单
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
// 跳转到个人主页
const goToProfile = () => {
router.push('/profile')
@@ -540,11 +635,11 @@ const loadPointsHistory = async () => {
pointsHistory.value = response.data.data || []
} else {
console.error('获取积分使用历史失败:', response.data.message)
ElMessage.error('获取积分使用历史失败')
ElMessage.error(t('subscription.loadPointsHistoryFailed'))
}
} catch (error) {
console.error('加载积分使用历史失败:', error)
ElMessage.error('加载积分使用历史失败')
ElMessage.error(t('subscription.loadPointsHistoryFailed'))
} finally {
pointsHistoryLoading.value = false
}
@@ -574,6 +669,62 @@ const goToWorks = () => {
router.push('/works')
}
// 点击外部关闭用户菜单
const handleClickOutside = (event) => {
if (!userStatusRef.value) return
if (!userStatusRef.value.contains(event.target)) {
showUserMenu.value = false
}
}
// 管理员菜单导航
const goToDashboard = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/admin/dashboard')
} else {
ElMessage.warning(t('profile.insufficientPermission'))
}
}
const goToOrders = () => {
showUserMenu.value = false
router.push('/admin/orders')
}
const goToMembers = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/member-management')
} else {
ElMessage.warning(t('profile.insufficientPermission'))
}
}
const goToSettings = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/system-settings')
} else {
ElMessage.warning(t('profile.insufficientPermission'))
}
}
// 退出登录
const logout = async () => {
try {
showUserMenu.value = false
await userStore.logoutUser()
localStorage.removeItem('user')
localStorage.removeItem('token')
ElMessage.success(t('profile.logoutSuccess'))
router.push('/login')
} catch (error) {
console.error('退出登录失败:', error)
ElMessage.error(t('profile.logoutFailed'))
}
}
// 选中套餐(紫色边框)
const selectedPlan = ref('free')
const selectPlan = (plan) => {
@@ -604,12 +755,12 @@ const handleSubscribe = async (planType) => {
console.log('支付模态框应该已显示')
// 直接生成二维码
ElMessage.info('正在生成支付二维码...')
ElMessage.info(t('subscription.generatingQRCode'))
await generateQRCode(planType, planInfo)
} catch (error) {
console.error('订阅处理失败:', error)
ElMessage.error('订阅处理失败,请重试')
ElMessage.error(t('subscription.subscriptionFailed'))
}
}
@@ -667,28 +818,28 @@ const generateQRCode = async (planType, planInfo) => {
console.log('7. 模拟二维码已隐藏')
}
ElMessage.success('二维码已生成,请使用支付宝扫码支付')
ElMessage.success(t('subscription.qrCodeGenerated'))
console.log('=== 二维码生成完成 ===')
} catch (error) {
console.error('生成二维码失败:', error)
ElMessage.error('生成二维码失败,请重试')
ElMessage.error(t('subscription.qrCodeGenerationFailed'))
}
} else {
ElMessage.error('二维码生成失败:二维码为空')
ElMessage.error(t('subscription.qrCodeEmpty'))
}
} else {
console.error('支付宝响应失败:', alipayResponse)
ElMessage.error(alipayResponse.data?.message || '生成二维码失败')
ElMessage.error(alipayResponse.data?.message || t('subscription.qrCodeGenerationFailed'))
}
} else {
console.error('创建支付订单失败:', createResponse)
ElMessage.error(createResponse.data?.message || '创建支付订单失败')
ElMessage.error(createResponse.data?.message || t('subscription.createPaymentFailed'))
}
} catch (error) {
console.error('=== 二维码生成出错 ===')
console.error('错误详情:', error)
ElMessage.error(`二维码生成失败:${error.message || '请重试'}`)
ElMessage.error(t('subscription.qrCodeGenerationError', { message: error.message || t('subscription.pleaseTryAgain') }))
}
}
@@ -696,16 +847,16 @@ const generateQRCode = async (planType, planInfo) => {
const getPlanInfo = (planType) => {
const plans = {
standard: {
name: '标准版',
name: t('subscription.standard'),
price: 59,
points: 200,
description: '标准版订阅 - 每月200积分'
description: t('subscription.standardDescription')
},
premium: {
name: '专业版',
name: t('subscription.professional'),
price: 259,
points: 1000,
description: '专业版订阅 - 每月1000积分'
description: t('subscription.premiumDescription')
}
}
return plans[planType]
@@ -715,36 +866,36 @@ const getPlanInfo = (planType) => {
const handlePaymentSuccess = async (paymentData) => {
try {
console.log('✅ 收到支付成功事件,支付数据:', paymentData)
ElMessage.success('支付成功!正在更新信息...')
ElMessage.success(t('subscription.paymentSuccess'))
// 关闭支付模态框
paymentModalVisible.value = false
// 重新加载用户订阅信息
await loadUserSubscriptionInfo()
ElMessage.success('信息已更新!')
ElMessage.success(t('subscription.infoUpdated'))
} catch (error) {
console.error('❌ 支付成功处理失败:', error)
ElMessage.error('支付成功但处理订单失败,请联系客服')
ElMessage.error(t('subscription.paymentProcessingFailed'))
}
}
// 支付失败处理
const handlePaymentError = (error) => {
console.error('支付失败:', error)
ElMessage.error('支付失败,请重试')
ElMessage.error(t('subscription.paymentFailed'))
}
// 创建订阅订单
const createSubscriptionOrder = async (planType, planInfo) => {
try {
ElMessage.info('正在创建订单...')
ElMessage.info(t('subscription.creatingOrder'))
// 生成订单号
const orderId = `SUB_${planType}_${Date.now()}`
// 创建支付订单数据
const paymentData = {
orderId: orderId,
@@ -757,13 +908,13 @@ const createSubscriptionOrder = async (planType, planInfo) => {
// 先创建支付记录
const createResponse = await createPayment(paymentData)
if (createResponse.data.success) {
const paymentId = createResponse.data.data.id
// 然后创建支付宝支付
const alipayResponse = await createAlipayPayment({ paymentId })
if (alipayResponse.data.success) {
// 跳转到支付宝支付页面
const paymentForm = alipayResponse.data.data
@@ -771,33 +922,33 @@ const createSubscriptionOrder = async (planType, planInfo) => {
const tempDiv = document.createElement('div')
tempDiv.innerHTML = paymentForm
document.body.appendChild(tempDiv)
// 自动提交表单
const form = tempDiv.querySelector('form')
if (form) {
form.submit()
} else {
ElMessage.error('支付页面加载失败')
ElMessage.error(t('subscription.paymentPageLoadFailed'))
}
// 清理临时元素
setTimeout(() => {
if (document.body.contains(tempDiv)) {
document.body.removeChild(tempDiv)
}
}, 1000)
} else {
ElMessage.error(alipayResponse.data.message || '创建支付宝支付失败')
ElMessage.error(alipayResponse.data.message || t('subscription.createAlipayPaymentFailed'))
}
} else {
ElMessage.error(createResponse.data.message || '创建支付订单失败')
ElMessage.error(createResponse.data.message || t('subscription.createPaymentFailed'))
}
} catch (error) {
console.error('创建支付订单失败:', error)
ElMessage.error('创建支付订单失败,请重试')
ElMessage.error(t('subscription.createPaymentOrderFailed'))
}
}
</script>
@@ -989,6 +1140,39 @@ const createSubscriptionOrder = async (planType, planInfo) => {
transform: scale(1.05);
}
/* 用户菜单样式(右上角头像下拉) */
.user-menu-teleport {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
min-width: 160px;
overflow: hidden;
}
.menu-item {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s ease;
color: white;
font-size: 14px;
}
.menu-item:hover {
background: #2a2a2a;
}
.menu-item .el-icon {
margin-right: 8px;
font-size: 16px;
}
.menu-item:not(:last-child) {
border-bottom: 1px solid #333;
}
/* 套餐选择 */
.subscription-packages {
padding: 0 30px 30px; /* 与顶部盒子保持一致的左右留白 */

View File

@@ -3,23 +3,7 @@
<!-- 左侧导航栏 -->
<aside class="sidebar">
<div class="logo">
<div class="logo-icon">
<svg viewBox="0 0 194 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M54.6165 13.6062L60.593 32.3932H60.8224L66.8111 13.6062H72.6065L64.0824 38.3335H57.3452L48.8089 13.6062H54.6165ZM75.4862 38.3335V19.788H80.6296V38.3335H75.4862ZM78.07 17.3974C77.3053 17.3974 76.6493 17.1439 76.1019 16.6368C75.5626 16.1216 75.293 15.5058 75.293 14.7895C75.293 14.0811 75.5626 13.4734 76.1019 12.9663C76.6493 12.4512 77.3053 12.1936 78.07 12.1936C78.8346 12.1936 79.4866 12.4512 80.0259 12.9663C80.5733 13.4734 80.8469 14.0811 80.8469 14.7895C80.8469 15.5058 80.5733 16.1216 80.0259 16.6368C79.4866 17.1439 78.8346 17.3974 78.07 17.3974ZM91.5836 38.6353C90.175 38.6353 88.8992 38.2731 87.7562 37.5487C86.6213 36.8162 85.7198 35.7416 85.0517 34.325C84.3916 32.9003 84.0616 31.1536 84.0616 29.0849C84.0616 26.9599 84.4037 25.1931 85.0879 23.7845C85.7721 22.3678 86.6816 21.3093 87.8166 20.6091C88.9596 19.9007 90.2112 19.5466 91.5716 19.5466C92.6099 19.5466 93.4752 19.7236 94.1674 20.0778C94.8677 20.4239 95.4312 20.8586 95.8578 21.3818C96.2924 21.8969 96.6225 22.404 96.8478 22.9031H97.0048V13.6062H102.136V38.3335H97.0652V35.3633H96.8478C96.6064 35.8785 96.2643 36.3896 95.8216 36.8967C95.3869 37.3958 94.8194 37.8103 94.1191 38.1403C93.4269 38.4703 92.5817 38.6353 91.5836 38.6353ZM93.2136 34.5423C94.0427 34.5423 94.743 34.3169 95.3145 33.8662C95.894 33.4074 96.3367 32.7674 96.6426 31.9464C96.9565 31.1254 97.1135 30.1635 97.1135 29.0608C97.1135 27.958 96.9605 27.0002 96.6547 26.1872C96.3488 25.3742 95.9061 24.7464 95.3265 24.3037C94.747 23.861 94.0427 23.6396 93.2136 23.6396C92.3684 23.6396 91.6561 23.869 91.0765 24.3278C90.497 24.7866 90.0583 25.4225 89.7605 26.2355C89.4627 27.0485 89.3137 27.9902 89.3137 29.0608C89.3137 30.1394 89.4627 31.0932 89.7605 31.9223C90.0663 32.7433 90.505 33.3872 91.0765 33.8541C91.6561 34.3129 92.3684 34.5423 93.2136 34.5423ZM106.462 38.3335V13.6062H122.834V17.9166H111.69V23.8086H121.747V28.119H111.69V38.3335H106.462ZM131.397 13.6062V38.3335H126.254V13.6062H131.397ZM143.897 38.6957C142.021 38.6957 140.399 38.2973 139.031 37.5004C137.671 36.6955 136.62 35.5766 135.88 34.1439C135.139 32.7031 134.769 31.0328 134.769 29.1332C134.769 27.2175 135.139 25.5432 135.88 24.1105C136.62 22.6697 137.671 21.5508 139.031 20.7539C140.399 19.949 142.021 19.5466 143.897 19.5466C145.772 19.5466 147.39 19.949 148.75 20.7539C150.119 21.5508 151.173 22.6697 151.914 24.1105C152.654 25.5432 153.025 27.2175 153.025 29.1332C153.025 31.0328 152.654 32.7031 151.914 34.1439C151.173 35.5766 150.119 36.6955 148.75 37.5004C147.39 38.2973 145.772 38.6957 143.897 38.6957ZM143.921 34.7113C144.774 34.7113 145.486 34.4699 146.058 33.9869C146.629 33.4959 147.06 32.8278 147.35 31.9826C147.648 31.1375 147.797 30.1756 147.797 29.097C147.797 28.0184 147.648 27.0565 147.35 26.2113C147.06 25.3662 146.629 24.6981 146.058 24.2071C145.486 23.7161 144.774 23.4706 143.921 23.4706C143.06 23.4706 142.335 23.7161 141.748 24.2071C141.168 24.6981 140.729 25.3662 140.431 26.2113C140.142 27.0565 139.997 28.0184 139.997 29.097C139.997 30.1756 140.142 31.1375 140.431 31.9826C140.729 32.8278 141.168 33.4959 141.748 33.9869C142.335 34.4699 143.06 34.7113 143.921 34.7113ZM159.562 38.3335L154.516 19.788H159.719L162.593 32.2483H162.762L165.756 19.788H170.864L173.906 32.1758H174.063L176.888 19.788H182.08L177.045 38.3335H171.6L168.413 26.6701H168.183L164.996 38.3335H159.562Z" fill="white"/>
<g clip-path="url(#clip0)">
<path d="M5.7406 1.64568C2.43935 1.64568 0.00938034 1.57298 0.000366208 1.64568C0.000366202 1.71906 2.11292 5.37389 4.70642 9.58611C7.28533 13.813 12.3557 22.0905 15.9545 27.9611L20.8685 35.9875C21.8796 37.6388 23.6773 38.6457 25.6136 38.6457L26.4301 38.6457C26.4301 38.6457 31.0568 38.7204 31.9965 37.2228C32.2255 36.8288 32.346 36.3807 32.3461 35.925L32.3461 21.8996L31.1801 21.8996L31.1801 26.5969C31.1801 31.1005 31.1655 31.3074 30.889 31.5861C30.7288 31.7476 30.4663 31.8801 30.306 31.8801C29.9855 31.8801 29.7811 31.5866 27.1439 27.257C26.2551 25.8039 24.0844 22.2371 22.2924 19.3312C20.5148 16.4253 17.3534 11.2596 15.2699 7.85467L11.4672 1.64568L5.7406 1.64568ZM31.6615 2.99627C31.443 4.1703 30.525 6.06354 29.6654 7.10564C28.4561 8.60263 26.4304 9.83531 24.5072 10.2316C23.4727 10.4518 23.196 10.5988 23.808 10.5988C24.0267 10.5989 24.5801 10.7012 25.0316 10.8332C28.2662 11.7578 31.0495 14.7674 31.6468 17.9816L31.8363 18.9797L32.0111 18.1135C32.5648 15.3249 34.4443 12.8151 36.9066 11.5676C38.0139 10.9952 39.2815 10.5988 39.9808 10.5988C40.6217 10.5988 40.403 10.4957 39.2084 10.2463C37.46 9.87934 36.1049 9.11623 34.6625 7.66326C33.2638 6.26901 32.5496 5.02127 32.0834 3.17205L31.8217 2.11541L31.6615 2.99627Z" fill="url(#paint0)"/>
</g>
<defs>
<linearGradient id="paint0" x1="23.4862" y1="5.3947" x2="32.9093" y2="11.1248" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#33DDE5"/>
<stop offset="1" stop-color="#0F9CFF"/>
</linearGradient>
<clipPath id="clip0">
<rect width="40.3334" height="40.3334" fill="white"/>
</clipPath>
</defs>
</svg>
</div>
<img src="/images/backgrounds/logo-admin.svg" alt="Logo" />
</div>
<nav class="nav-menu">
<div class="nav-item" @click="goToDashboard">
@@ -67,10 +51,19 @@
</div>
<div class="header-actions">
<LanguageSwitcher />
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
<el-dropdown @command="handleUserCommand">
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="exitAdmin">
{{ $t('admin.exitAdmin') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
@@ -466,7 +459,7 @@ const cleanupConfig = reactive({
})
const goToDashboard = () => {
router.push('/home')
router.push('/admin/dashboard')
}
const goToMembers = () => {
@@ -474,7 +467,7 @@ const goToMembers = () => {
}
const goToOrders = () => {
router.push('/orders')
router.push('/admin/orders')
}
const goToAPI = () => {
@@ -489,6 +482,14 @@ const goToSettings = () => {
router.push('/system-settings')
}
// 处理用户头像下拉菜单
const handleUserCommand = (command) => {
if (command === 'exitAdmin') {
// 退出后台,返回个人首页
router.push('/profile')
}
}
const editLevel = (level) => {
// 映射后端数据到前端表单
editForm.id = level.id
@@ -767,22 +768,15 @@ const fetchSystemStats = async () => {
.logo {
display: flex;
align-items: center;
padding: 0 50px;
justify-content: center;
padding: 0 24px;
margin-bottom: 32px;
}
.logo-icon {
width: 100%;
height: auto;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.logo-icon svg, .logo-icon img {
.logo img {
width: 100%;
height: auto;
max-width: 180px;
object-fit: contain;
}

View File

@@ -75,9 +75,6 @@
<div v-else class="work-placeholder">
<div class="play-icon"></div>
</div>
<div class="work-overlay">
<div class="overlay-text">{{ work.prompt || work.text || '文生视频' }}</div>
</div>
<!-- 鼠标悬停时显示的做同款按钮 -->
<div class="hover-create-btn" @click.stop="goToCreate(work)">
<el-button type="primary" size="small" round>

View File

@@ -4,7 +4,7 @@
<header class="top-header">
<div class="header-left">
<button class="back-btn" @click="goBack">
首页
{{ t('common.home') }}
</button>
</div>
<div class="header-right">
@@ -16,7 +16,7 @@
</div>
<LanguageSwitcher />
<div class="user-avatar" @click="toggleUserMenu" ref="userAvatarRef">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<img src="/images/backgrounds/avatar-default.svg" :alt="t('video.textToVideo.userAvatar')" />
</div>
</div>
</header>
@@ -27,22 +27,22 @@
<div class="left-panel">
<!-- 创作模式标签 -->
<div class="creation-tabs">
<div class="tab active">文生视频</div>
<div class="tab" @click="goToImageToVideo">图生视频</div>
<div class="tab" @click="goToStoryboardVideo">分镜视频</div>
<div class="tab active">{{ t('home.textToVideo') }}</div>
<div class="tab" @click="goToImageToVideo">{{ t('home.imageToVideo') }}</div>
<div class="tab" @click="goToStoryboardVideo">{{ t('home.storyboardVideo') }}</div>
</div>
<!-- 文本输入区域 -->
<div class="text-input-section">
<textarea
<textarea
v-model="inputText"
placeholder="输入文字,描述想要生成的内容"
:placeholder="t('video.textToVideo.textInputPlaceholder')"
class="text-input"
rows="8"
></textarea>
<div class="optimize-btn">
<button class="optimize-button" @click="optimizePromptHandler" :disabled="!inputText.trim() || optimizingPrompt">
{{ optimizingPrompt ? '优化中...' : '一键优化' }}
{{ optimizingPrompt ? t('video.textToVideo.optimizing') : t('video.textToVideo.oneClickOptimize') }}
</button>
</div>
</div>
@@ -50,44 +50,43 @@
<!-- 视频设置 -->
<div class="video-settings">
<div class="setting-item">
<label>比例</label>
<label>{{ t('video.textToVideo.aspectRatio') }}</label>
<select v-model="aspectRatio" class="setting-select">
<option value="16:9">16:9</option>
<option value="9:16">9:16</option>
<option value="1:1">1:1</option>
</select>
</div>
<div class="setting-item">
<label>时长</label>
<label>{{ t('video.duration') }}</label>
<select v-model="duration" class="setting-select">
<option value="10">10s</option>
<option value="15">15s</option>
<option value="25">25s</option>
</select>
</div>
<div class="setting-item">
<label>高清模式 (1080P)</label>
<label>{{ t('video.textToVideo.hdMode') }}</label>
<div class="hd-setting">
<el-switch v-model="hdMode" />
<span class="cost-text">开启消耗20积分</span>
<span class="cost-text">{{ t('video.textToVideo.hdModeCost') }}</span>
</div>
</div>
</div>
<!-- 生成按钮 -->
<div class="generate-section">
<button
class="generate-btn"
<button
class="generate-btn"
@click="startGenerate"
:disabled="!isAuthenticated"
:disabled="!isAuthenticated || inProgress || isCreatingTask"
>
{{ isAuthenticated ? '开始生成' : '请先登录' }}
{{ !isAuthenticated ? t('video.textToVideo.pleaseLogin') : (inProgress || isCreatingTask) ? t('video.textToVideo.taskInProgress') : t('video.textToVideo.startGenerate') }}
</button>
<div v-if="!isAuthenticated" class="login-tip">
<p>需要登录后才能提交任务</p>
<button class="login-link-btn" @click="goToLogin">立即登录</button>
<p>{{ t('video.textToVideo.loginRequired') }}</p>
<button class="login-link-btn" @click="goToLogin">{{ t('video.textToVideo.loginNow') }}</button>
</div>
</div>
</div>
@@ -99,20 +98,20 @@
<div class="task-status" v-if="currentTask">
<div class="status-header">
<h3>{{ getStatusText(taskStatus) }}</h3>
<div class="task-id">文生视频 {{ formatDate(currentTask.createdAt) }}</div>
<div class="task-id">{{ t('home.textToVideo') }} {{ formatDate(currentTask.createdAt) }}</div>
</div>
<!-- 任务描述 -->
<div class="task-description">
{{ inputText }}
</div>
<!-- 视频预览区域 -->
<div class="video-preview-container">
<!-- 生成中的状态 -->
<div v-if="inProgress" class="generating-container">
<div class="generating-placeholder">
<div class="generating-text">生成中</div>
<div class="generating-text">{{ t('video.generating') }}</div>
<div class="progress-bar-large">
<div class="progress-fill-large" :style="{ width: taskProgress + '%' }"></div>
</div>
@@ -125,50 +124,50 @@
<div class="task-info-header">
<div class="task-checkbox">
<input type="checkbox" id="inProgress" v-model="showInProgress">
<label for="inProgress">进行中</label>
<label for="inProgress">{{ t('video.textToVideo.inProgress') }}</label>
</div>
</div>
<!-- 视频播放区域 -->
<div class="video-player-container">
<div class="video-player">
<video
v-if="currentTask.resultUrl"
:src="currentTask.resultUrl"
controls
<video
v-if="currentTask.resultUrl"
:src="currentTask.resultUrl"
controls
class="result-video"
poster=""
></video>
<div v-else class="no-video-placeholder">
<div class="no-video-text">视频生成完成但未获取到视频链接</div>
<div class="no-video-text">{{ t('video.textToVideo.noVideoUrl') }}</div>
</div>
<!-- 水印选择覆盖层 -->
<div class="watermark-overlay">
<div class="watermark-options">
<div class="watermark-option">
<input type="radio" id="withWatermark" name="watermark" value="with" v-model="watermarkOption">
<label for="withWatermark">带水印</label>
<label for="withWatermark">{{ t('video.textToVideo.withWatermark') }}</label>
</div>
<div class="watermark-option">
<input type="radio" id="withoutWatermark" name="watermark" value="without" v-model="watermarkOption">
<label for="withoutWatermark">不带水印 会员专享</label>
<label for="withoutWatermark">{{ t('video.textToVideo.withoutWatermark') }}</label>
</div>
</div>
</div>
</div>
</div>
<!-- 操作按钮区域 -->
<div class="result-actions">
<button class="action-btn primary" @click="createSimilar">做同款</button>
<button class="action-btn primary" @click="createSimilar">{{ t('video.textToVideo.createSimilar') }}</button>
<div class="action-icons">
<button class="icon-btn" @click="downloadVideo" title="下载视频">
<button class="icon-btn" @click="downloadVideo" :title="t('video.textToVideo.downloadVideo')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
</button>
<button class="icon-btn" @click="deleteWork" title="删除作品">
<button class="icon-btn" @click="deleteWork" :title="t('video.textToVideo.deleteWork')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</svg>
@@ -181,64 +180,64 @@
<div v-else-if="taskStatus === 'FAILED'" class="failed-container">
<div class="failed-placeholder">
<div class="failed-icon"></div>
<div class="failed-text">生成失败</div>
<div class="failed-desc">请检查输入内容或重试</div>
<div class="failed-text">{{ t('video.textToVideo.generationFailed') }}</div>
<div class="failed-desc">{{ t('video.textToVideo.checkInputOrRetry') }}</div>
</div>
<div class="result-actions">
<button class="action-btn primary" @click="retryTask">重新生成</button>
<button class="action-btn primary" @click="retryTask">{{ t('video.textToVideo.regenerate') }}</button>
</div>
</div>
<!-- 其他状态 -->
<div v-else class="status-placeholder">
<div class="status-text">{{ getStatusText(taskStatus) }}</div>
</div>
</div>
</div>
<!-- 初始状态 -->
<div class="preview-content" v-else>
<div class="preview-placeholder">
<div class="placeholder-text">开始创作您的第一个作品吧!</div>
<div class="placeholder-text">{{ t('video.textToVideo.startCreating') }}</div>
</div>
</div>
<!-- 历史记录区域 -->
<div class="history-section" v-if="historyTasks.length > 0">
<div class="history-list">
<div
v-for="task in historyTasks"
:key="task.taskId"
<div
v-for="task in historyTasks"
:key="task.taskId"
class="history-item"
>
<!-- 顶部状态复选框 -->
<div class="history-status-checkbox" v-if="task.status === 'PENDING' || task.status === 'PROCESSING'">
<input type="checkbox" :checked="true" disabled>
<label>进行中</label>
<label>{{ t('video.textToVideo.inProgress') }}</label>
</div>
<!-- 头部信息 -->
<div class="history-item-header">
<span class="history-type">文生视频</span>
<span class="history-type">{{ t('home.textToVideo') }}</span>
<span class="history-date">{{ formatDate(task.createdAt) }}</span>
</div>
<!-- 描述文字 -->
<div class="history-prompt">{{ task.prompt || '无描述' }}</div>
<div class="history-prompt">{{ task.prompt || t('video.textToVideo.noDescription') }}</div>
<!-- 预览区域 -->
<div class="history-preview">
<div v-if="task.status === 'PENDING' || task.status === 'PROCESSING'" class="history-placeholder">
<div class="queue-text">排队中</div>
<div class="queue-link">订阅套餐以提升生成速度</div>
<button class="cancel-btn" @click="cancelTask(task.taskId)">取消</button>
<div class="queue-text">{{ t('video.textToVideo.queuing') }}</div>
<div class="queue-link">{{ t('video.textToVideo.subscribeToSpeedUp') }}</div>
<button class="cancel-btn" @click="cancelTask(task.taskId)">{{ t('common.cancel') }}</button>
</div>
<div v-else-if="task.status === 'COMPLETED' && task.resultUrl" class="history-video-thumbnail" @click="toggleHistoryVideo(task)">
<video
<video
:ref="el => setVideoRef(task.taskId, el)"
:src="processHistoryUrl(task.resultUrl)"
muted
:src="processHistoryUrl(task.resultUrl)"
muted
preload="metadata"
@loadedmetadata="handleVideoLoaded"
@error="handleVideoError"
@@ -249,13 +248,13 @@
</div>
</div>
<div v-else class="history-placeholder">
<div class="no-result-text">暂无结果</div>
<div class="no-result-text">{{ t('video.textToVideo.noResult') }}</div>
</div>
</div>
<!-- 做同款按钮 -->
<div class="history-actions">
<button class="similar-btn" @click="createSimilarFromHistory(task)">做同款</button>
<button class="similar-btn" @click="createSimilarFromHistory(task)">{{ t('video.textToVideo.createSimilar') }}</button>
</div>
</div>
</div>
@@ -269,24 +268,24 @@
<div v-if="showUserMenu" class="user-menu-teleport" :style="menuStyle">
<div class="menu-item" @click="goToProfile">
<el-icon><User /></el-icon>
<span>个人资料</span>
<span>{{ t('common.userProfile') }}</span>
</div>
<div class="menu-item" @click="goToMyWorks">
<el-icon><VideoCamera /></el-icon>
<span>我的作品</span>
<span>{{ t('profile.myWorks') }}</span>
</div>
<div class="menu-item" @click="goToSubscription">
<el-icon><Star /></el-icon>
<span>会员订阅</span>
<span>{{ t('profile.subscription') }}</span>
</div>
<div class="menu-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>系统设置</span>
<span>{{ t('common.settings') }}</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item logout" @click="logout">
<el-icon><SwitchButton /></el-icon>
<span>退出登录</span>
<span>{{ t('common.logout') }}</span>
</div>
</div>
</Teleport>
@@ -296,6 +295,7 @@
<script setup>
import { ref, onUnmounted, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { textToVideoApi } from '@/api/textToVideo'
import { useUserStore } from '@/stores/user'
import { User, VideoCamera, Star, Setting, SwitchButton } from '@element-plus/icons-vue'
@@ -306,6 +306,7 @@ import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
const userStore = useUserStore()
const { t } = useI18n()
// 计算是否已登录
const isAuthenticated = computed(() => userStore.isAuthenticated)
@@ -326,6 +327,8 @@ const optimizingPrompt = ref(false) // 优化提示词状态
const historyTasks = ref([]) // 历史记录
const playingVideos = ref({}) // 正在播放的视频
const videoRefs = ref({}) // 视频元素引用
const isCreatingTask = ref(false) // 标记是否正在创建任务,避免重复恢复
const hasRestoredTask = ref(false) // 标记是否已经恢复过任务
// 用户菜单相关
const showUserMenu = ref(false)
@@ -399,31 +402,33 @@ const logout = () => {
const startGenerate = async () => {
// 检查登录状态
if (!userStore.isAuthenticated) {
ElMessage.warning('请先登录后再提交任务')
ElMessage.warning(t('video.textToVideo.pleaseLoginFirst'))
goToLogin()
return
}
// 检查是否已有任务在进行中
if (inProgress.value) {
ElMessage.warning('已有任务在进行中,请等待完成或取消当前任务')
ElMessage.warning(t('video.textToVideo.taskInProgress'))
return
}
// 验证表单
if (!inputText.value.trim()) {
ElMessage.error('请输入文本描述')
ElMessage.error(t('video.textToVideo.pleaseEnterText'))
return
}
// 标记正在创建任务
isCreatingTask.value = true
// 显示加载状态
const loading = ElLoading.service({
lock: true,
text: '正在创建任务...',
text: t('video.textToVideo.creatingTask'),
background: 'rgba(0, 0, 0, 0.7)'
})
try {
// 调用API创建任务
const params = {
@@ -432,34 +437,55 @@ const startGenerate = async () => {
duration: parseInt(duration.value),
hdMode: hdMode.value
}
const response = await textToVideoApi.createTask(params)
if (response.data && response.data.success) {
currentTask.value = response.data.data
inProgress.value = true
taskProgress.value = 0
taskStatus.value = 'PENDING'
ElMessage.success('任务创建成功,开始处理...')
ElMessage.success(t('video.textToVideo.taskCreated'))
// 更新用户积分信息(任务创建后积分已被扣除)
try {
await userStore.fetchCurrentUser()
console.log('用户积分已更新')
} catch (error) {
console.error('更新用户积分失败:', error)
}
// 使用 setTimeout 确保不阻塞任务流程
setTimeout(async () => {
try {
await userStore.fetchCurrentUser()
console.log('[Points Update]', t('video.textToVideo.pointsUpdated'))
} catch (error) {
console.error('[Points Update Error]', t('video.textToVideo.pointsUpdateFailed'), error)
// 积分更新失败不影响任务流程
}
}, 0)
// 开始轮询任务状态
startPollingTask()
// 延迟重置创建标志,避免立即触发恢复逻辑
setTimeout(() => {
isCreatingTask.value = false
}, 2000)
} else {
ElMessage.error(response.data?.message || '创建任务失败')
// 任务创建失败,重置所有状态
ElMessage.error(response.data?.message || t('video.textToVideo.createTaskFailed'))
inProgress.value = false
isCreatingTask.value = false
currentTask.value = null
taskStatus.value = ''
taskProgress.value = 0
}
} catch (error) {
console.error('创建任务失败:', error)
ElMessage.error('创建任务失败,请重试')
console.error('[Create Task Error]', t('video.textToVideo.createTaskFailed'), error)
ElMessage.error(t('video.textToVideo.createTaskFailed'))
// 异常情况,重置所有状态
inProgress.value = false
isCreatingTask.value = false
currentTask.value = null
taskStatus.value = ''
taskProgress.value = 0
} finally {
loading.close()
}
@@ -482,9 +508,9 @@ const startPollingTask = () => {
// 更新resultUrl如果存在且不为空
if (progressData && progressData.resultUrl && progressData.resultUrl.trim() && currentTask.value) {
currentTask.value.resultUrl = progressData.resultUrl
console.log('更新resultUrl:', progressData.resultUrl.substring(0, 50) + '...')
console.log('[Result URL Updated]', progressData.resultUrl.substring(0, 50) + '...')
}
console.log('任务进度:', progressData)
console.log('[Task Progress]', progressData)
},
// 完成回调
async (taskData) => {
@@ -494,29 +520,32 @@ const startPollingTask = () => {
// 更新currentTask的resultUrl
if (taskData && taskData.resultUrl && taskData.resultUrl.trim() && currentTask.value) {
currentTask.value.resultUrl = taskData.resultUrl
console.log('任务完成resultUrl已更新:', taskData.resultUrl.substring(0, 50) + '...')
console.log('[Task Completed] Result URL:', taskData.resultUrl.substring(0, 50) + '...')
} else if (currentTask.value && !currentTask.value.resultUrl) {
console.warn('任务完成但未获取到resultUrl')
console.warn('[Task Completed] No result URL found')
}
ElMessage.success('视频生成完成!')
ElMessage.success(t('video.textToVideo.videoCompleted'))
// 更新用户积分信息
try {
await userStore.fetchCurrentUser()
console.log('用户积分已更新')
} catch (error) {
console.error('更新用户积分失败:', error)
}
// 更新用户积分信息(使用 setTimeout 确保不阻塞)
setTimeout(async () => {
try {
await userStore.fetchCurrentUser()
console.log('[Points Update]', t('video.textToVideo.pointsUpdated'))
} catch (error) {
console.error('[Points Update Error]', t('video.textToVideo.pointsUpdateFailed'), error)
// 积分更新失败不影响任务流程
}
}, 0)
// 可以在这里跳转到结果页面或显示结果
console.log('任务完成:', taskData)
console.log('[Task Completed]', taskData)
},
// 错误回调
(error) => {
inProgress.value = false
taskStatus.value = 'FAILED'
ElMessage.error('视频生成失败:' + error.message)
console.error('任务失败:', error)
ElMessage.error(t('video.textToVideo.videoFailed') + error.message)
console.error('[Task Failed]', error)
}
)
}
@@ -525,13 +554,13 @@ const startPollingTask = () => {
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
'PENDING': '等待中',
'PROCESSING': '处理中',
'COMPLETED': '已完成',
'FAILED': '失败',
'CANCELLED': '已取消'
'PENDING': t('video.textToVideo.statusPending'),
'PROCESSING': t('video.textToVideo.statusProcessing'),
'COMPLETED': t('video.completed'),
'FAILED': t('video.failed'),
'CANCELLED': t('video.textToVideo.statusCancelled')
}
return statusMap[status] || '未知'
return statusMap[status] || t('video.textToVideo.statusUnknown')
}
// 获取状态样式类
@@ -555,71 +584,71 @@ const formatDate = (dateString) => {
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}${month}${day}${hours}:${minutes}`
return t('video.textToVideo.dateFormat', { year, month, day, hours, minutes })
}
// 优化提示词
const optimizePromptHandler = async () => {
if (!inputText.value.trim()) {
ElMessage.warning('请输入提示词')
ElMessage.warning(t('video.textToVideo.pleaseEnterPrompt'))
return
}
// 长度检查
if (inputText.value.length > 2000) {
ElMessage.warning('提示词过长请控制在2000字符以内')
ElMessage.warning(t('video.textToVideo.promptTooLong'))
return
}
try {
optimizingPrompt.value = true
const loading = ElLoading.service({
lock: false,
text: '正在优化提示词,请稍候...',
text: t('video.textToVideo.optimizingPrompt'),
background: 'rgba(0, 0, 0, 0.3)'
})
const response = await optimizePrompt(inputText.value.trim(), 'text-to-video')
loading.close()
if (response.data && response.data.success) {
const data = response.data.data
const optimized = data.optimizedPrompt
// 检查是否真正优化了
if (data.optimized && optimized !== inputText.value.trim()) {
inputText.value = optimized
ElMessage.success('提示词优化成功!')
ElMessage.success(t('video.textToVideo.optimizeSuccess'))
} else {
ElMessage.warning('提示词已优化,但可能无明显变化')
ElMessage.warning(t('video.textToVideo.optimizeNoChange'))
}
} else {
ElMessage.error(response.data?.message || '优化失败')
ElMessage.error(response.data?.message || t('video.textToVideo.optimizeFailed'))
}
} catch (error) {
console.error('优化提示词失败:', error)
let errorMessage = '优化提示词失败'
console.error('[Optimize Prompt Error]', error)
let errorMessage = t('video.textToVideo.optimizeFailed')
if (error.response) {
const status = error.response.status
if (status === 400) {
errorMessage = error.response.data?.message || '请求参数错误'
errorMessage = error.response.data?.message || t('video.textToVideo.requestParamError')
} else if (status === 408 || error.code === 'ECONNABORTED') {
errorMessage = '请求超时,请稍后重试'
errorMessage = t('video.textToVideo.requestTimeout')
} else if (status >= 500) {
errorMessage = '服务器错误,请稍后重试'
errorMessage = t('video.textToVideo.serverError')
} else {
errorMessage = error.response.data?.message || '优化失败'
errorMessage = error.response.data?.message || t('video.textToVideo.optimizeFailed')
}
} else if (error.request) {
errorMessage = '网络错误,请检查网络连接'
errorMessage = t('video.textToVideo.networkError')
} else if (error.code === 'ERR_NETWORK') {
errorMessage = '网络连接错误,请检查您的网络'
errorMessage = t('video.textToVideo.networkConnectionError')
} else {
errorMessage = error.message || '优化失败'
errorMessage = error.message || t('video.textToVideo.optimizeFailed')
}
ElMessage.error(errorMessage)
} finally {
optimizingPrompt.value = false
@@ -641,9 +670,9 @@ const downloadVideo = () => {
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('开始下载视频')
ElMessage.success(t('video.textToVideo.downloadStarted'))
} else {
ElMessage.error('视频链接不可用')
ElMessage.error(t('video.textToVideo.videoUrlNotAvailable'))
}
}
@@ -664,22 +693,22 @@ const retryTask = () => {
// 删除作品
const deleteWork = () => {
if (!currentTask.value) {
ElMessage.error('没有可删除的作品')
ElMessage.error(t('video.textToVideo.noWorkToDelete'))
return
}
// 确认删除
ElMessage.confirm('确定要删除这个作品吗?', '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
ElMessage.confirm(t('video.textToVideo.deleteConfirm'), t('video.textToVideo.confirmDelete'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}).then(() => {
// 这里可以调用删除API
currentTask.value = null
taskStatus.value = ''
ElMessage.success('作品已删除')
ElMessage.success(t('video.textToVideo.workDeleted'))
}).catch(() => {
ElMessage.info('已取消删除')
ElMessage.info(t('video.textToVideo.deleteCancelled'))
})
}
@@ -717,10 +746,10 @@ const loadHistory = async () => {
resultUrl: task.resultUrl ? processHistoryUrl(task.resultUrl) : null
}))
console.log('历史记录加载成功:', historyTasks.value.length, '条')
console.log('[History Load]', t('video.textToVideo.historyLoadSuccess', { count: historyTasks.value.length }))
}
} catch (error) {
console.error('加载历史记录失败:', error)
console.error('[History Load Error]', t('video.textToVideo.historyLoadFailed'), error)
historyTasks.value = []
}
}
@@ -741,16 +770,16 @@ const createSimilarFromHistory = (task) => {
}
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
ElMessage.success('已填充历史记录参数,可以开始生成')
ElMessage.success(t('video.textToVideo.historyParamsFilled'))
}
// 取消任务
const cancelTask = async (taskId) => {
try {
ElMessage.info('取消功能待实现')
ElMessage.info(t('video.textToVideo.cancelFunctionTBD'))
// TODO: 实现取消任务API
} catch (error) {
console.error('取消任务失败:', error)
console.error('[Cancel Task Error]', error)
}
}
@@ -783,7 +812,7 @@ const handleVideoLoaded = (event) => {
// 处理视频加载错误
const handleVideoError = (event) => {
console.error('历史记录视频加载失败:', event.target.src)
console.error('[History Video Load Error]', event.target.src)
// 可以在这里添加错误处理逻辑,比如显示占位图
}
@@ -822,9 +851,15 @@ const toggleHistoryVideo = (task) => {
watch(() => userStore.isAuthenticated, (isAuth) => {
if (isAuth) {
loadHistory()
restoreProcessingTask()
// 延迟恢复任务,避免与创建任务冲突
setTimeout(() => {
if (!isCreatingTask.value) {
restoreProcessingTask()
}
}, 500)
} else {
historyTasks.value = []
hasRestoredTask.value = false
}
})
@@ -833,6 +868,18 @@ const restoreProcessingTask = async () => {
if (!userStore.isAuthenticated) {
return
}
// 如果正在创建任务,跳过恢复逻辑
if (isCreatingTask.value) {
console.log('[Task Restore] 跳过恢复:正在创建新任务')
return
}
// 如果已经恢复过任务且当前有任务在进行中,跳过
if (hasRestoredTask.value && currentTask.value) {
console.log('[Task Restore] 跳过恢复:已有任务在进行中')
return
}
try {
const response = await getProcessingWorks()
@@ -870,15 +917,18 @@ const restoreProcessingTask = async () => {
taskStatus.value = work.status || 'PROCESSING'
taskProgress.value = 50 // 初始进度设为50%
console.log('恢复正在进行中的任务:', work.taskId, '状态:', work.status)
ElMessage.info('检测到未完成的任务,继续处理中...')
console.log('[Task Restored]', work.taskId, 'Status:', work.status)
ElMessage.info(t('video.textToVideo.unfinishedTaskDetected'))
// 标记已恢复任务
hasRestoredTask.value = true
// 开始轮询任务状态
startPollingTask()
}
}
} catch (error) {
console.error('恢复任务失败:', error)
console.error('[Task Restore Error]', error)
}
}

View File

@@ -0,0 +1,369 @@
# TextToVideoCreate.vue 国际化支持完整报告
## 修改概述
已为 `TextToVideoCreate.vue`(文生视频创建页面)添加完整的国际化支持,将所有硬编码的中文文字替换为 `t()` 函数调用。
## 修改详情
### 1. 导入和初始化
**添加的导入:**
```javascript
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
```
### 2. 模板部分替换(共计 30+ 处)
#### 2.1 顶部导航栏
- `← 首页``{{ t('common.home') }}`
- `alt="用户头像"``:alt="t('video.textToVideo.userAvatar')"`
#### 2.2 创作模式标签
- `文生视频``{{ t('home.textToVideo') }}`
- `图生视频``{{ t('home.imageToVideo') }}`
- `分镜视频``{{ t('home.storyboardVideo') }}`
#### 2.3 文本输入区域
- `placeholder="输入文字,描述想要生成的内容"``:placeholder="t('video.textToVideo.textInputPlaceholder')"`
- `优化中...``{{ t('video.textToVideo.optimizing') }}`
- `一键优化``{{ t('video.textToVideo.oneClickOptimize') }}`
#### 2.4 视频设置
- `比例``{{ t('video.textToVideo.aspectRatio') }}`
- `时长``{{ t('video.duration') }}`
- `高清模式 (1080P)``{{ t('video.textToVideo.hdMode') }}`
- `开启消耗20积分``{{ t('video.textToVideo.hdModeCost') }}`
#### 2.5 生成按钮区域
- `开始生成``{{ t('video.textToVideo.startGenerate') }}`
- `请先登录``{{ t('video.textToVideo.pleaseLogin') }}`
- `需要登录后才能提交任务``{{ t('video.textToVideo.loginRequired') }}`
- `立即登录``{{ t('video.textToVideo.loginNow') }}`
#### 2.6 任务状态显示
- `文生视频 [日期]``{{ t('home.textToVideo') }} {{ formatDate(...) }}`
- `生成中``{{ t('video.generating') }}`
- `进行中``{{ t('video.textToVideo.inProgress') }}`
- `视频生成完成,但未获取到视频链接``{{ t('video.textToVideo.noVideoUrl') }}`
#### 2.7 水印选择
- `带水印``{{ t('video.textToVideo.withWatermark') }}`
- `不带水印 会员专享``{{ t('video.textToVideo.withoutWatermark') }}`
#### 2.8 操作按钮
- `做同款``{{ t('video.textToVideo.createSimilar') }}`
- `title="下载视频"``:title="t('video.textToVideo.downloadVideo')"`
- `title="删除作品"``:title="t('video.textToVideo.deleteWork')"`
#### 2.9 失败状态
- `生成失败``{{ t('video.textToVideo.generationFailed') }}`
- `请检查输入内容或重试``{{ t('video.textToVideo.checkInputOrRetry') }}`
- `重新生成``{{ t('video.textToVideo.regenerate') }}`
#### 2.10 初始状态
- `开始创作您的第一个作品吧!``{{ t('video.textToVideo.startCreating') }}`
#### 2.11 历史记录区域
- `进行中``{{ t('video.textToVideo.inProgress') }}`
- `文生视频``{{ t('home.textToVideo') }}`
- `无描述``{{ t('video.textToVideo.noDescription') }}`
- `排队中``{{ t('video.textToVideo.queuing') }}`
- `订阅套餐以提升生成速度``{{ t('video.textToVideo.subscribeToSpeedUp') }}`
- `取消``{{ t('common.cancel') }}`
- `暂无结果``{{ t('video.textToVideo.noResult') }}`
- `做同款``{{ t('video.textToVideo.createSimilar') }}`
#### 2.12 用户菜单
- `个人资料``{{ t('common.userProfile') }}`
- `我的作品``{{ t('profile.myWorks') }}`
- `会员订阅``{{ t('profile.subscription') }}`
- `系统设置``{{ t('common.settings') }}`
- `退出登录``{{ t('common.logout') }}`
### 3. JavaScript 部分替换(共计 30+ 处)
#### 3.1 startGenerate 函数
- `'请先登录后再提交任务'``t('video.textToVideo.pleaseLoginFirst')`
- `'已有任务在进行中,请等待完成或取消当前任务'``t('video.textToVideo.taskInProgress')`
- `'请输入文本描述'``t('video.textToVideo.pleaseEnterText')`
- `'正在创建任务...'``t('video.textToVideo.creatingTask')`
- `'任务创建成功,开始处理...'``t('video.textToVideo.taskCreated')`
- `'创建任务失败'``t('video.textToVideo.createTaskFailed')`
#### 3.2 startPollingTask 函数
- `'视频生成完成!'``t('video.textToVideo.videoCompleted')`
- `'视频生成失败:'``t('video.textToVideo.videoFailed')`
#### 3.3 getStatusText 函数
```javascript
const statusMap = {
'PENDING': t('video.textToVideo.statusPending'),
'PROCESSING': t('video.textToVideo.statusProcessing'),
'COMPLETED': t('video.completed'),
'FAILED': t('video.failed'),
'CANCELLED': t('video.textToVideo.statusCancelled')
}
return statusMap[status] || t('video.textToVideo.statusUnknown')
```
#### 3.4 optimizePromptHandler 函数
- `'请输入提示词'``t('video.textToVideo.pleaseEnterPrompt')`
- `'提示词过长请控制在2000字符以内'``t('video.textToVideo.promptTooLong')`
- `'正在优化提示词,请稍候...'``t('video.textToVideo.optimizingPrompt')`
- `'提示词优化成功!'``t('video.textToVideo.optimizeSuccess')`
- `'提示词已优化,但可能无明显变化'``t('video.textToVideo.optimizeNoChange')`
- `'优化失败'``t('video.textToVideo.optimizeFailed')`
- `'请求参数错误'``t('video.textToVideo.requestParamError')`
- `'请求超时,请稍后重试'``t('video.textToVideo.requestTimeout')`
- `'服务器错误,请稍后重试'``t('video.textToVideo.serverError')`
- `'网络错误,请检查网络连接'``t('video.textToVideo.networkError')`
- `'网络连接错误,请检查您的网络'``t('video.textToVideo.networkConnectionError')`
#### 3.5 downloadVideo 函数
- `'开始下载视频'``t('video.textToVideo.downloadStarted')`
- `'视频链接不可用'``t('video.textToVideo.videoUrlNotAvailable')`
#### 3.6 deleteWork 函数
- `'没有可删除的作品'``t('video.textToVideo.noWorkToDelete')`
- `'确定要删除这个作品吗?'``t('video.textToVideo.deleteConfirm')`
- `'确认删除'``t('video.textToVideo.confirmDelete')`
- `'确定'``t('common.confirm')`
- `'取消'``t('common.cancel')`
- `'作品已删除'``t('video.textToVideo.workDeleted')`
- `'已取消删除'``t('video.textToVideo.deleteCancelled')`
#### 3.7 createSimilarFromHistory 函数
- `'已填充历史记录参数,可以开始生成'``t('video.textToVideo.historyParamsFilled')`
#### 3.8 cancelTask 函数
- `'取消功能待实现'``t('video.textToVideo.cancelFunctionTBD')`
#### 3.9 restoreProcessingTask 函数
- `'检测到未完成的任务,继续处理中...'``t('video.textToVideo.unfinishedTaskDetected')`
## 需要新增的翻译键列表
### 在 `video.textToVideo.*` 命名空间下新增(共 40 个键):
```javascript
video: {
textToVideo: {
// 界面文本
userAvatar: '用户头像',
textInputPlaceholder: '输入文字,描述想要生成的内容',
optimizing: '优化中...',
oneClickOptimize: '一键优化',
aspectRatio: '比例',
hdMode: '高清模式 (1080P)',
hdModeCost: '开启消耗20积分',
startGenerate: '开始生成',
pleaseLogin: '请先登录',
loginRequired: '需要登录后才能提交任务',
loginNow: '立即登录',
// 任务状态
inProgress: '进行中',
noVideoUrl: '视频生成完成,但未获取到视频链接',
// 水印选项
withWatermark: '带水印',
withoutWatermark: '不带水印 会员专享',
// 操作按钮
createSimilar: '做同款',
downloadVideo: '下载视频',
deleteWork: '删除作品',
// 状态文本
generationFailed: '生成失败',
checkInputOrRetry: '请检查输入内容或重试',
regenerate: '重新生成',
startCreating: '开始创作您的第一个作品吧!',
// 历史记录
noDescription: '无描述',
queuing: '排队中',
subscribeToSpeedUp: '订阅套餐以提升生成速度',
noResult: '暂无结果',
// 消息提示
pleaseLoginFirst: '请先登录后再提交任务',
taskInProgress: '已有任务在进行中,请等待完成或取消当前任务',
pleaseEnterText: '请输入文本描述',
creatingTask: '正在创建任务...',
taskCreated: '任务创建成功,开始处理...',
createTaskFailed: '创建任务失败',
videoCompleted: '视频生成完成!',
videoFailed: '视频生成失败:',
// 状态映射
statusPending: '等待中',
statusProcessing: '处理中',
statusCancelled: '已取消',
statusUnknown: '未知',
// 优化提示词
pleaseEnterPrompt: '请输入提示词',
promptTooLong: '提示词过长请控制在2000字符以内',
optimizingPrompt: '正在优化提示词,请稍候...',
optimizeSuccess: '提示词优化成功!',
optimizeNoChange: '提示词已优化,但可能无明显变化',
optimizeFailed: '优化失败',
requestParamError: '请求参数错误',
requestTimeout: '请求超时,请稍后重试',
serverError: '服务器错误,请稍后重试',
networkError: '网络错误,请检查网络连接',
networkConnectionError: '网络连接错误,请检查您的网络',
// 下载和删除
downloadStarted: '开始下载视频',
videoUrlNotAvailable: '视频链接不可用',
noWorkToDelete: '没有可删除的作品',
deleteConfirm: '确定要删除这个作品吗?',
confirmDelete: '确认删除',
workDeleted: '作品已删除',
deleteCancelled: '已取消删除',
// 其他
historyParamsFilled: '已填充历史记录参数,可以开始生成',
cancelFunctionTBD: '取消功能待实现',
unfinishedTaskDetected: '检测到未完成的任务,继续处理中...'
}
}
```
### 英文翻译建议:
```javascript
video: {
textToVideo: {
// UI text
userAvatar: 'User Avatar',
textInputPlaceholder: 'Enter text to describe the content you want to generate',
optimizing: 'Optimizing...',
oneClickOptimize: 'One-Click Optimize',
aspectRatio: 'Aspect Ratio',
hdMode: 'HD Mode (1080P)',
hdModeCost: 'Enabling costs 20 points',
startGenerate: 'Start Generate',
pleaseLogin: 'Please Login',
loginRequired: 'Login required to submit task',
loginNow: 'Login Now',
// Task status
inProgress: 'In Progress',
noVideoUrl: 'Video generated but URL not available',
// Watermark options
withWatermark: 'With Watermark',
withoutWatermark: 'Without Watermark (Member Exclusive)',
// Action buttons
createSimilar: 'Create Similar',
downloadVideo: 'Download Video',
deleteWork: 'Delete Work',
// Status text
generationFailed: 'Generation Failed',
checkInputOrRetry: 'Please check input or retry',
regenerate: 'Regenerate',
startCreating: 'Start creating your first work!',
// History
noDescription: 'No description',
queuing: 'Queuing',
subscribeToSpeedUp: 'Subscribe to speed up generation',
noResult: 'No result',
// Messages
pleaseLoginFirst: 'Please login first to submit task',
taskInProgress: 'A task is already in progress, please wait or cancel current task',
pleaseEnterText: 'Please enter text description',
creatingTask: 'Creating task...',
taskCreated: 'Task created successfully, processing...',
createTaskFailed: 'Failed to create task',
videoCompleted: 'Video generation completed!',
videoFailed: 'Video generation failed: ',
// Status mapping
statusPending: 'Pending',
statusProcessing: 'Processing',
statusCancelled: 'Cancelled',
statusUnknown: 'Unknown',
// Optimize prompt
pleaseEnterPrompt: 'Please enter prompt',
promptTooLong: 'Prompt too long, please keep within 2000 characters',
optimizingPrompt: 'Optimizing prompt, please wait...',
optimizeSuccess: 'Prompt optimized successfully!',
optimizeNoChange: 'Prompt optimized but may have no obvious changes',
optimizeFailed: 'Optimization failed',
requestParamError: 'Request parameter error',
requestTimeout: 'Request timeout, please retry later',
serverError: 'Server error, please retry later',
networkError: 'Network error, please check connection',
networkConnectionError: 'Network connection error, please check your network',
// Download and delete
downloadStarted: 'Download started',
videoUrlNotAvailable: 'Video URL not available',
noWorkToDelete: 'No work to delete',
deleteConfirm: 'Are you sure you want to delete this work?',
confirmDelete: 'Confirm Delete',
workDeleted: 'Work deleted',
deleteCancelled: 'Delete cancelled',
// Others
historyParamsFilled: 'History parameters filled, ready to generate',
cancelFunctionTBD: 'Cancel function to be implemented',
unfinishedTaskDetected: 'Unfinished task detected, continuing processing...'
}
}
```
## 修改统计
- **模板替换:** 30+ 处
- **JavaScript 替换:** 30+ 处
- **新增翻译键:** 60 个(中英文各 60 个)
- **总计修改:** 60+ 处
## 使用的命名空间
1. **video.textToVideo.*** - 文生视频创建页面专用翻译(新增 60 个键)
2. **common.*** - 通用翻译(已存在)
- home, cancel, confirm, settings, userProfile, logout
3. **home.*** - 首页相关翻译(已存在)
- textToVideo, imageToVideo, storyboardVideo
4. **profile.*** - 个人主页相关翻译(已存在)
- myWorks, subscription
5. **video.*** - 视频相关通用翻译(已存在)
- duration, generating, completed, failed
## 注意事项
1. **日期格式化函数未国际化:** `formatDate` 函数中的日期格式(`年月日`)仍然是中文硬编码,可能需要根据语言环境动态调整
2. **ElMessage.confirm 方法:** 确认对话框已完全国际化
3. **所有用户可见文本已国际化:** 包括按钮、标签、提示信息、错误消息等
4. **控制台日志保持中文:** console.log 中的调试信息保持中文,不影响用户体验
## 验证建议
1. 切换语言后测试所有界面文本是否正确显示
2. 测试所有交互操作的提示消息是否正确国际化
3. 验证错误处理消息的国际化是否完整
4. 检查历史记录区域的动态内容显示
## 文件路径
- **修改的文件:** `C:\Users\UI\Desktop\AIGC\demo\frontend\src\views\TextToVideoCreate.vue`
- **需要添加翻译的文件:**
- `C:\Users\UI\Desktop\AIGC\demo\frontend\src\locales\zh.js`
- `C:\Users\UI\Desktop\AIGC\demo\frontend\src\locales\en.js`
---
**完成时间:** 2025-11-13
**修改者:** Claude Code Assistant

View File

@@ -0,0 +1,420 @@
<template>
<div class="agreement-page">
<div class="agreement-container">
<!-- 返回按钮 -->
<div class="back-button" @click="goBack">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 12H5M5 12L12 19M5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>返回</span>
</div>
<!-- 中文版本 -->
<div class="agreement-content">
<h1>最终用户许可协议(EULA)</h1>
<p class="intro">
本最终用户许可协议下称"协议"是您"用户" Vionow"Vionow""我们"之间订立的法律合同管辖您对我们基于网络的视频创作平台及相关服务"服务"的访问和使用
</p>
<p class="intro">
通过访问注册或使用Vionow即表示您同意受本协议条款的约束如果您不同意这些条款则不得使用本服务
</p>
<section>
<h2>1. 许可授予</h2>
<p>
Vionow 授予您一项有限的非排他性的不可转让且可撤销的许可允许您根据本协议和我们的服务条款为个人或专业目的使用本服务
</p>
<h3>您可以</h3>
<ul>
<li>通过网络浏览器访问和使用本服务</li>
<li>上传素材并创作您自己的视频</li>
<li>将处理后的结果用于商业或非商业目的根据您的订阅级别允许</li>
</ul>
<h3>您不可以</h3>
<ul>
<li>转售再许可或重新分发本服务或其输出内容</li>
<li>对平台进行逆向工程或修改</li>
<li>使用自动化系统如机器人脚本等访问或利用本服务</li>
</ul>
</section>
<section>
<h2>2. 所有权</h2>
<p>
本服务中的所有知识产权包括软件界面模型和人工智能技术均属于Vionow您保留您上传的内容及生成输出内容的所有权但须遵守本协议
</p>
</section>
<section>
<h2>3. 用户内容</h2>
<p>
上传内容即表示您确认您拥有该内容的使用权或已获得使用许可您授予Vionow 一项有限许可以处理和临时存储您的内容从而提供本服务
</p>
<p>
我们不对您的输入或输出内容主张所有权
</p>
</section>
<section>
<h2>4. 可接受的使用</h2>
<p>您同意不将本服务用于</p>
<ul>
<li>侵犯第三方权利例如版权商标权隐私权</li>
<li>非法有害或滥用活动</li>
<li>生成或传播攻击性色情或仇恨内容</li>
</ul>
<p>违反规定可能导致您的访问权限被暂停或终止</p>
</section>
<section>
<h2>5. 终止</h2>
<p>
如果您违反本协议我们可能会暂停或终止您的账户及对本服务的访问权限您可以随时取消您的订阅但须遵守我们的退款和计费政策
</p>
</section>
<section>
<h2>6. 免责声明</h2>
<p>
本服务按"原样"提供不附带任何形式的保证虽然我们力求准确和高质量但我们不保证特定的结果或不间断的访问
</p>
<p>我们对以下情况不承担责任</p>
<ul>
<li>数据丢失</li>
<li>输出质量问题</li>
<li>与您使用本服务相关的第三方索赔</li>
</ul>
</section>
<section>
<h2>7. 修改</h2>
<p>
我们保留随时修改本协议的权利更新内容将在公开发布后立即生效继续使用本服务即表示您接受修订后的条款
</p>
</section>
<section>
<h2>8. 联系方式</h2>
<p>
如果对本协议有任何疑问或疑虑请通过以下方式联系我们<br>
<a href="mailto:contact@vionow.com">contact@vionow.com</a>
</p>
</section>
<!-- 分隔线 -->
<div class="divider"></div>
<!-- English Version -->
<h1 class="english-title">End User License Agreement (EULA)</h1>
<p class="intro">
This End User License Agreement ("Agreement") is a legal contract between you (the "User") and Vionow ("Vionow", "we", or "us"), governing your access to and use of our web-based video creation platform and associated services (the "Service").
</p>
<p class="intro">
By accessing, registering for, or using Vionow, you agree to be bound by the terms of this Agreement. If you do not agree to the terms, you must not use the Service.
</p>
<section>
<h2>1. License Grant</h2>
<p>
Vionow grants you a limited, non-exclusive, non-transferable, and revocable license to use the Service for personal or professional purposes in accordance with this Agreement and our Terms of Service.
</p>
<h3>You may:</h3>
<ul>
<li>Access and use the Service via a web browser</li>
<li>Upload content and create your own videos</li>
<li>Use processed results for commercial or non-commercial purposes (as permitted by your subscription level)</li>
</ul>
<h3>You may not:</h3>
<ul>
<li>Resell, sublicense, or redistribute the Service or its output</li>
<li>Reverse engineer or modify the platform</li>
<li>Use automated systems (bots, scripts, etc.) to access or exploit the Service</li>
</ul>
</section>
<section>
<h2>2. Ownership</h2>
<p>
All intellectual property rights in the Service, including the software, interface, models, and AI technologies, belong to Vionow. You retain ownership of the content you upload and the outputs generated, subject to this Agreement.
</p>
</section>
<section>
<h2>3. User Content</h2>
<p>
By uploading content, you confirm that you own the rights or have permission to use it. You grant Vionow a limited license to process and temporarily store your content to provide the Service.
</p>
<p>
We do not claim ownership of your input or output content.
</p>
</section>
<section>
<h2>4. Acceptable Use</h2>
<p>You agree not to use the Service:</p>
<ul>
<li>To infringe upon third-party rights (e.g. copyright, trademark, privacy)</li>
<li>For unlawful, harmful, or abusive activities</li>
<li>To generate or distribute offensive, pornographic, or hateful content</li>
</ul>
<p>Violations may result in suspension or termination of access.</p>
</section>
<section>
<h2>5. Termination</h2>
<p>
We may suspend or terminate your account and access to the Service if you breach this Agreement. You may cancel your subscription at any time, subject to our refund and billing policy.
</p>
</section>
<section>
<h2>6. Disclaimers</h2>
<p>
The Service is provided "as is" without warranties of any kind. While we strive for accuracy and quality, we do not guarantee specific outcomes or uninterrupted access.
</p>
<p>We are not liable for:</p>
<ul>
<li>Loss of data</li>
<li>Output quality issues</li>
<li>Third-party claims related to your use of the Service</li>
</ul>
</section>
<section>
<h2>7. Modifications</h2>
<p>
We reserve the right to modify this Agreement at any time. Updates will be posted publicly and take effect upon publication. Continued use of the Service implies acceptance of the revised terms.
</p>
</section>
<section>
<h2>8. Contact</h2>
<p>
For any questions or concerns regarding this Agreement, please contact us at:<br>
<a href="mailto:contact@vionow.com">contact@vionow.com</a>
</p>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const goBack = () => {
router.back()
}
</script>
<style scoped>
.agreement-page {
min-height: 100vh;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
padding: 40px 20px;
overflow-y: auto;
}
.agreement-container {
max-width: 900px;
max-height: calc(100vh - 80px);
margin: 0 auto;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(20px);
border-radius: 20px;
padding: 60px 80px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
overflow-y: auto;
overflow-x: hidden;
}
/* 自定义滚动条样式 */
.agreement-container::-webkit-scrollbar {
width: 8px;
}
.agreement-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
}
.agreement-container::-webkit-scrollbar-thumb {
background: rgba(0, 212, 255, 0.3);
border-radius: 10px;
transition: background 0.3s ease;
}
.agreement-container::-webkit-scrollbar-thumb:hover {
background: rgba(0, 212, 255, 0.5);
}
/* Firefox 滚动条样式 */
.agreement-container {
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.3) rgba(255, 255, 255, 0.05);
}
.back-button {
display: inline-flex;
align-items: center;
gap: 8px;
color: #00D4FF;
font-size: 16px;
cursor: pointer;
margin-bottom: 30px;
transition: all 0.3s ease;
padding: 8px 16px;
border-radius: 8px;
}
.back-button:hover {
background: rgba(0, 212, 255, 0.1);
transform: translateX(-4px);
}
.back-button svg {
width: 20px;
height: 20px;
}
.agreement-content {
color: rgba(255, 255, 255, 0.9);
line-height: 1.8;
}
h1 {
font-size: 32px;
font-weight: 600;
color: #00D4FF;
margin-bottom: 30px;
text-align: center;
}
.english-title {
margin-top: 60px;
}
h2 {
font-size: 24px;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
margin-top: 40px;
margin-bottom: 16px;
}
h3 {
font-size: 18px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-top: 20px;
margin-bottom: 12px;
}
p {
font-size: 16px;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 16px;
}
.intro {
font-size: 16px;
line-height: 1.8;
margin-bottom: 20px;
}
section {
margin-bottom: 32px;
}
ul {
list-style: none;
padding-left: 0;
margin: 16px 0;
}
ul li {
position: relative;
padding-left: 24px;
margin-bottom: 12px;
color: rgba(255, 255, 255, 0.8);
font-size: 16px;
}
ul li::before {
content: "·";
position: absolute;
left: 8px;
color: #00D4FF;
font-size: 20px;
font-weight: bold;
}
a {
color: #00D4FF;
text-decoration: none;
transition: opacity 0.3s ease;
}
a:hover {
opacity: 0.8;
text-decoration: underline;
}
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
margin: 60px 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.agreement-container {
padding: 40px 30px;
}
h1 {
font-size: 26px;
}
h2 {
font-size: 20px;
}
h3 {
font-size: 16px;
}
p, ul li {
font-size: 14px;
}
}
@media (max-width: 480px) {
.agreement-page {
padding: 20px 10px;
}
.agreement-container {
padding: 30px 20px;
}
h1 {
font-size: 22px;
}
h2 {
font-size: 18px;
}
}
</style>

View File

@@ -82,11 +82,6 @@
<!-- 右上角操作按钮 -->
<div class="video-actions">
<el-tooltip content="分享" placement="bottom">
<button class="action-btn" @click="shareVideo">
<el-icon><Share /></el-icon>
</button>
</el-tooltip>
<el-tooltip content="下载" placement="bottom">
<button class="action-btn" @click="downloadVideo">
<el-icon><Download /></el-icon>
@@ -176,7 +171,6 @@ import {
User as VideoPlay,
User as VideoPause,
User as FullScreen,
User as Share,
User as Download,
User as Delete,
User,
@@ -332,10 +326,6 @@ const toggleFullscreen = () => {
}
// 操作功能
const shareVideo = () => {
ElMessage.info('分享功能开发中')
}
const downloadVideo = () => {
ElMessage.success('开始下载视频')
}

View File

@@ -10,9 +10,11 @@
<a href="#" class="nav-link" @click.prevent="goToTextToVideo">{{ $t('welcome.textToVideo') }}</a>
<a href="#" class="nav-link" @click.prevent="goToImageToVideo">{{ $t('welcome.imageToVideo') }}</a>
<a href="#" class="nav-link" @click.prevent="goToStoryboardVideo">{{ $t('welcome.storyboardVideo') }}</a>
<a href="#" class="nav-link" @click="scrollToSection('features')">{{ $t('welcome.pricing') }}</a>
</nav>
<button class="nav-button" @click="goToLogin">{{ $t('welcome.startExperience') }}</button>
<div class="nav-actions">
<LanguageSwitcher />
<button class="nav-button" @click="goToLogin">{{ $t('welcome.startExperience') }}</button>
</div>
</div>
</header>
@@ -20,46 +22,20 @@
<main class="content">
<h1 class="title">
<span class="title-line">
<span class="bright-text">{{ $t('welcome.title1') }}</span><span class="fade-text">{{ $t('welcome.title2') }}</span>
<span class="bright-text">智创</span><span class="gradient-text">无限</span>
</span>
<span class="title-line">
<span class="bright-text">{{ $t('welcome.title3') }}</span><span class="fade-text">{{ $t('welcome.title4') }}</span>
<span class="bright-text">灵感</span><span class="gradient-text">变现</span>
</span>
</h1>
<p class="subtitle">{{ $t('welcome.subtitle') }}</p>
<button class="main-button" @click="goToLogin">{{ $t('welcome.tryNow') }}</button>
<button class="main-button" @click="goToLogin">立即体验</button>
</main>
<!-- 功能说明 -->
<section id="features" class="features-section">
<div class="features-container">
<h2 class="features-title">{{ $t('welcome.coreFeatures') }}</h2>
<div class="features-grid">
<div class="feature-card" @click="goToTextToVideo" style="cursor: pointer;">
<h3>{{ $t('welcome.textToVideo') }}</h3>
<p>{{ $t('welcome.textToVideoDesc') }}</p>
</div>
<div class="feature-card" @click="goToImageToVideo" style="cursor: pointer;">
<h3>{{ $t('welcome.imageToVideo') }}</h3>
<p>{{ $t('welcome.imageToVideoDesc') }}</p>
</div>
<div class="feature-card" @click="goToStoryboardVideo" style="cursor: pointer;">
<h3>{{ $t('welcome.storyboardVideo') }}</h3>
<p>{{ $t('welcome.storyboardVideoDesc') }}</p>
</div>
<div class="feature-card">
<h3>{{ $t('welcome.pricing') }}</h3>
<p>{{ $t('welcome.pricingDesc') }}</p>
</div>
</div>
<button class="features-button" @click="goToLogin">{{ $t('welcome.startCreating') }}</button>
</div>
</section>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
@@ -82,25 +58,29 @@ const goToImageToVideo = () => {
const goToStoryboardVideo = () => {
router.push('/storyboard-video/create')
}
// 滚动到功能说明部分
const scrollToSection = (sectionId) => {
const element = document.getElementById(sectionId)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
}
</script>
<style scoped>
.welcome-page {
min-height: 100vh;
background: url('/images/backgrounds/welcome.jpg') center/cover no-repeat;
position: relative;
width: 100%;
min-height: 100vh;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.welcome-page::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 2156px;
height: 1394px;
background: url('/images/backgrounds/27.jpg') center/cover no-repeat;
z-index: -1;
}
/* 导航栏 */
.navbar {
position: fixed;
@@ -157,6 +137,12 @@ const scrollToSection = (sectionId) => {
color: #4a9eff;
}
.nav-actions {
display: flex;
align-items: center;
gap: 15px;
}
.nav-button {
background: rgba(74, 158, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.3);
@@ -219,17 +205,26 @@ const scrollToSection = (sectionId) => {
.bright-text {
color: white;
opacity: 1;
font-weight: 700;
}
.gradient-text {
color: rgba(255, 255, 255, 0.5);
font-weight: 700;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.fade-text {
color: white;
opacity: 0.6;
color: rgba(255, 255, 255, 0.5);
font-weight: 700;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.main-button {
background: linear-gradient(90deg, rgba(74, 158, 255, 0.8) 0%, rgba(255, 255, 255, 0.9) 100%);
background: rgba(255, 255, 255, 0.5);
border: none;
color: white;
padding: 22px 60px;
border-radius: 50px;
font-size: 22px;
@@ -238,8 +233,12 @@ const scrollToSection = (sectionId) => {
transition: all 0.3s ease;
box-shadow: 0 6px 25px rgba(74, 158, 255, 0.3);
position: relative;
overflow: hidden;
backdrop-filter: blur(10px);
color: #2563EB;
}
.main-button::after {
content: none;
}
.main-button:hover {
@@ -290,82 +289,4 @@ const scrollToSection = (sectionId) => {
font-size: 16px;
}
}
/* 功能说明部分 */
.features-section {
background: rgba(0, 0, 0, 0.8);
padding: 80px 20px;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.features-container {
max-width: 1200px;
width: 100%;
text-align: center;
}
.features-title {
font-size: 3rem;
color: white;
margin-bottom: 60px;
font-weight: 700;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 30px;
margin-bottom: 60px;
}
.feature-card {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 20px;
padding: 40px 30px;
transition: all 0.3s ease;
}
.feature-card:hover {
transform: translateY(-10px);
background: rgba(255, 255, 255, 0.15);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
.feature-card h3 {
font-size: 1.5rem;
color: white;
margin-bottom: 15px;
font-weight: 600;
}
.feature-card p {
color: rgba(255, 255, 255, 0.8);
line-height: 1.6;
font-size: 1rem;
}
.features-button {
background: linear-gradient(135deg, #4A9EFF 0%, #6B73FF 100%);
color: white;
border: none;
padding: 18px 40px;
font-size: 1.1rem;
font-weight: 600;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 10px 30px rgba(74, 158, 255, 0.3);
}
.features-button:hover {
background: linear-gradient(135deg, #6B73FF 0%, #4A9EFF 100%);
transform: translateY(-3px);
box-shadow: 0 15px 40px rgba(74, 158, 255, 0.4);
}
</style>