feat: 添加用户错误日志功能, 禁用Redis缓存, userId自动生成5位随机字符

This commit is contained in:
AIGC Developer
2025-12-11 13:32:24 +08:00
parent 3c37006ebd
commit 0933031b59
58 changed files with 4932 additions and 1144 deletions

View File

@@ -38,7 +38,7 @@ api.interceptors.request.use(
if (!isLoginRequest) {
// 非登录请求才添加Authorization头
const token = sessionStorage.getItem('token')
const token = localStorage.getItem('token')
if (token && token !== 'null' && token.trim() !== '') {
config.headers.Authorization = `Bearer ${token}`
console.log('请求拦截器添加Authorization头token长度:', token.length)
@@ -70,8 +70,8 @@ api.interceptors.response.use(
if (!isLoginRequest) {
// 清除无效的token并跳转到登录页
sessionStorage.removeItem('token')
sessionStorage.removeItem('user')
localStorage.removeItem('token')
localStorage.removeItem('user')
// 避免重复跳转
if (router.currentRoute.value.path !== '/login') {
ElMessage.error('认证失败,请重新登录')
@@ -91,8 +91,8 @@ api.interceptors.response.use(
const isLoginRequest = loginUrls.some(url => response.config.url.includes(url))
if (!isLoginRequest) {
sessionStorage.removeItem('token')
sessionStorage.removeItem('user')
localStorage.removeItem('token')
localStorage.removeItem('user')
if (router.currentRoute.value.path !== '/login') {
ElMessage.error('认证失败,请重新登录')
router.push('/login')
@@ -117,8 +117,8 @@ api.interceptors.response.use(
const isLoginRequest = loginUrls.some(url => error.config.url.includes(url))
if (!isLoginRequest) {
sessionStorage.removeItem('token')
sessionStorage.removeItem('user')
localStorage.removeItem('token')
localStorage.removeItem('user')
if (router.currentRoute.value.path !== '/login') {
ElMessage.error('认证失败,请重新登录')
router.push('/login')
@@ -136,8 +136,8 @@ api.interceptors.response.use(
if (!isLoginRequest) {
// 302也可能是认证失败导致的
sessionStorage.removeItem('token')
sessionStorage.removeItem('user')
localStorage.removeItem('token')
localStorage.removeItem('user')
if (router.currentRoute.value.path !== '/login') {
ElMessage.error('认证失败,请重新登录')
router.push('/login')

View File

@@ -37,4 +37,12 @@ export const startVideoGeneration = async (taskId, params = {}) => {
*/
export const mergeImagesToGrid = async (images, cols = 3) => {
return api.post('/image-grid/merge', { images, cols })
}
/**
* 重试失败的分镜视频任务
* @param {string} taskId - 任务ID
*/
export const retryStoryboardTask = async (taskId) => {
return api.post(`/storyboard-video/task/${taskId}/retry`)
}

View File

@@ -310,13 +310,15 @@ export default {
generateStoryboard: 'Generate Storyboard',
generateVideo: 'Generate Video',
uploadStoryboard: 'Upload Storyboard (can generate video directly)',
uploadHint: 'Upload 1-9 storyboard images, can generate video directly without text description',
uploadImage: 'Upload Image',
imageLabel: 'Image ',
uploadHint: 'Upload reference images, AI will generate 6-grid storyboard based on images',
addMore: 'Add More',
uploadedCount: 'Uploaded {count}/9',
uploadLimit: 'Limit reached',
uploadedImage: 'Uploaded image {index}',
maxImages: 'Maximum 9 images allowed',
maxImagesWarning: 'Maximum 9 images allowed, you have uploaded {current}, you can upload {remaining} more',
maxImages: 'Maximum 3 images allowed',
maxImagesWarning: 'Maximum 3 images allowed',
fileSizeLimit: 'Image file size cannot exceed 100MB',
invalidFileType: 'Please select valid image files',
uploadSuccess: 'Successfully uploaded {count} images',
@@ -365,6 +367,7 @@ export default {
generateVideoWithUpload: 'Generate Video with Uploaded Image',
startGenerateStoryboard: 'Start Generate Storyboard',
startGenerate: 'Start Generate',
generating: 'Generating...',
enterDescription: 'Please enter description',
enterDescriptionForImage: 'Please enter description, AI will generate storyboard based on reference image and description',
startingGenerate: 'Starting to generate storyboard...',
@@ -375,6 +378,7 @@ export default {
storyboardCompleted: 'Storyboard generation completed! Please click "Start Generate" button to generate video',
videoCompleted: 'Video generation completed!',
taskFailed: 'Task failed',
checkInputOrRetry: 'Please check input or retry',
unknownError: 'Unknown error',
startingVideoGenerate: 'Starting to generate video...',
videoTaskStarted: 'Video generation task started, please wait...',
@@ -390,7 +394,17 @@ export default {
taskCompleted: 'Task completed!',
resumingVideoTask: 'Detected unfinished video generation task, resuming...',
resumingStoryboardTask: 'Detected unfinished storyboard generation task, resuming...',
resumingTask: 'Detected unfinished task, resuming...'
resumingTask: 'Detected unfinished task, resuming...',
storyboardReady: 'Storyboard generated, click button below to generate video',
regenerate: 'Regenerate',
regenerateConfirm: 'Regenerating will consume points and create a new task. Continue?',
regenerateTitle: 'Regenerate Storyboard',
statusPending: 'Pending',
statusProcessing: 'Processing',
statusCompleted: 'Completed',
statusFailed: 'Failed',
statusCancelled: 'Cancelled',
statusUnknown: 'Unknown Status'
}
},
@@ -426,6 +440,7 @@ export default {
popular: 'Popular',
searchPlaceholder: 'Name/Prompt/ID',
selectItems: 'Select {count} items',
selectAll: 'Select All',
selectedCount: '{count} selected',
favorite: 'Favorite',
downloadWithWatermark: 'Download with Watermark',
@@ -788,9 +803,6 @@ export default {
promptOptimizationModelTip: 'Enter the model name for prompt optimization, e.g., gpt-4o, gemini-pro',
storyboardSystemPrompt: 'Storyboard System Prompt',
storyboardSystemPromptTip: 'This prompt will be prepended to user prompts for consistent storyboard generation style',
storyboardSystemPromptPlaceholder: 'E.g., high quality cinematic shot, professional photography, film tone...',
promptOptimizationSystemPrompt: 'Prompt Optimization System Instruction',
promptOptimizationSystemPromptTip: 'Custom system instruction for AI prompt optimization. Leave empty to use default. This instruction determines how AI understands and optimizes user prompts',
promptOptimizationSystemPromptPlaceholder: 'E.g., You are a professional AI prompt optimization expert, transform user descriptions into detailed, professional English prompts...'
storyboardSystemPromptPlaceholder: 'E.g., high quality cinematic shot, professional photography, film tone...'
}
}

View File

@@ -313,13 +313,15 @@ export default {
generateStoryboard: '生成分镜图',
generateVideo: '生成视频',
uploadStoryboard: '上传参考图片',
uploadHint: '上传一张参考图片AI将根据图片生成6宫格分镜图',
uploadImage: '上传图',
imageLabel: '图',
uploadHint: '上传参考图片AI将根据图片生成6宫格分镜图',
addMore: '重新上传',
uploadedCount: '已上传图片',
uploadLimit: '已上传',
uploadedImage: '参考图片',
maxImages: '只能上传1张参考图片',
maxImagesWarning: '只能上传1张参考图片',
maxImages: '最多只能上传3张参考图片',
maxImagesWarning: '最多只能上传3张参考图片',
fileSizeLimit: '图片文件大小不能超过100MB',
invalidFileType: '请选择有效的图片文件',
uploadSuccess: '成功上传 {count} 张图片',
@@ -369,6 +371,7 @@ export default {
generateStoryboardWithImage: '使用图片生成分镜图',
startGenerateStoryboard: '开始生成分镜图',
startGenerate: '开始生成',
generating: '生成中...',
enterDescription: '请输入描述文字',
enterDescriptionForImage: '请输入描述文字AI将根据参考图和描述生成分镜图',
startingGenerate: '开始生成分镜图...',
@@ -379,6 +382,7 @@ export default {
storyboardCompleted: '分镜图生成完成!请点击"开始生成"按钮生成视频',
videoCompleted: '视频生成完成!',
taskFailed: '任务失败',
checkInputOrRetry: '请检查输入或重新尝试',
unknownError: '未知错误',
startingVideoGenerate: '开始生成视频...',
videoTaskStarted: '视频生成任务已启动,请稍候...',
@@ -395,7 +399,16 @@ export default {
resumingVideoTask: '检测到未完成的视频生成任务,继续处理中...',
resumingStoryboardTask: '检测到未完成的分镜图生成任务,继续处理中...',
resumingTask: '检测到未完成的任务,继续处理中...',
storyboardReady: '分镜图已生成,点击下方按钮生成视频'
storyboardReady: '分镜图已生成,点击下方按钮生成视频',
regenerate: '重新生成',
regenerateConfirm: '重新生成将消耗积分并创建新任务,确定要继续吗?',
regenerateTitle: '重新生成分镜图',
statusPending: '排队中',
statusProcessing: '生成中',
statusCompleted: '已完成',
statusFailed: '生成失败',
statusCancelled: '已取消',
statusUnknown: '未知状态'
}
},
@@ -439,6 +452,7 @@ export default {
popular: '热门',
searchPlaceholder: '名字/提示词/ID',
selectItems: '选择{count}个项目',
selectAll: '全选',
selectedCount: '已选 {count} 个项目',
favorite: '收藏',
downloadWithWatermark: '带水印下载',
@@ -766,9 +780,6 @@ export default {
promptOptimizationModelTip: '输入用于优化提示词的模型名称,如 gpt-4o、gemini-pro 等',
storyboardSystemPrompt: '分镜图系统引导词',
storyboardSystemPromptTip: '此引导词会自动添加到用户提示词前面,用于统一分镜图生成风格',
storyboardSystemPromptPlaceholder: '例如:高质量电影级画面,专业摄影,电影色调...',
promptOptimizationSystemPrompt: '优化提示词系统指令',
promptOptimizationSystemPromptTip: '自定义AI优化提示词的系统指令留空则使用默认指令。该指令决定了AI如何理解和优化用户输入的提示词',
promptOptimizationSystemPromptPlaceholder: '例如你是一个专业的AI提示词优化专家将用户描述优化为详细、专业的英文提示词...'
storyboardSystemPromptPlaceholder: '例如:高质量电影级画面,专业摄影,电影色调...'
}
}

View File

@@ -3,21 +3,21 @@ import { ref, computed } from 'vue'
import { login, register, logout, getCurrentUser } from '@/api/auth'
export const useUserStore = defineStore('user', () => {
// 状态 - 从 sessionStorage 尝试恢复用户信息
// 状态 - 从 localStorage 尝试恢复用户信息
const user = ref(null)
const token = ref(null)
const loading = ref(false)
const initialized = ref(false)
try {
const cachedUser = sessionStorage.getItem('user')
const cachedToken = sessionStorage.getItem('token')
const cachedUser = localStorage.getItem('user')
const cachedToken = localStorage.getItem('token')
if (cachedUser && cachedToken) {
user.value = JSON.parse(cachedUser)
token.value = cachedToken
}
} catch (_) {
// ignore sessionStorage parse errors
// ignore localStorage parse errors
}
// 计算属性
@@ -45,9 +45,9 @@ export const useUserStore = defineStore('user', () => {
user.value = response.data.user
token.value = response.data.token
// 保存到sessionStorage关闭页面时自动清除
sessionStorage.setItem('token', response.data.token)
sessionStorage.setItem('user', JSON.stringify(user.value))
// 保存到localStorage关闭浏览器后仍保持登录
localStorage.setItem('token', response.data.token)
localStorage.setItem('user', JSON.stringify(user.value))
return { success: true }
} else {
return { success: false, message: response.message }
@@ -82,11 +82,11 @@ export const useUserStore = defineStore('user', () => {
// 登出
const logoutUser = async () => {
try {
// JWT无状态直接清除sessionStorage即可
// JWT无状态直接清除localStorage即可
token.value = null
user.value = null
sessionStorage.removeItem('token')
sessionStorage.removeItem('user')
localStorage.removeItem('token')
localStorage.removeItem('user')
} catch (error) {
console.error('Logout error:', error)
}
@@ -101,7 +101,7 @@ export const useUserStore = defineStore('user', () => {
if (data.success) {
user.value = data.data
sessionStorage.setItem('user', JSON.stringify(user.value))
localStorage.setItem('user', JSON.stringify(user.value))
} else {
console.warn('获取用户信息失败:', data.message)
// 不要立即清除用户数据,保持当前登录状态
@@ -117,9 +117,9 @@ export const useUserStore = defineStore('user', () => {
const clearUserData = () => {
token.value = null
user.value = null
// 清除 sessionStorage 中的用户数据
sessionStorage.removeItem('token')
sessionStorage.removeItem('user')
// 清除 localStorage 中的用户数据
localStorage.removeItem('token')
localStorage.removeItem('user')
}
// 初始化
@@ -128,9 +128,9 @@ export const useUserStore = defineStore('user', () => {
return
}
// 从sessionStorage恢复用户状态
const savedToken = sessionStorage.getItem('token')
const savedUser = sessionStorage.getItem('user')
// 从localStorage恢复用户状态
const savedToken = localStorage.getItem('token')
const savedUser = localStorage.getItem('user')
if (savedToken && savedUser) {
try {

View File

@@ -493,7 +493,7 @@ const fetchSystemStats = async () => {
try {
const response = await fetch('/api/admin/online-stats', {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
const data = await response.json()

View File

@@ -654,7 +654,7 @@ const fetchSystemStats = async () => {
try {
const response = await fetch('/api/admin/online-stats', {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
const data = await response.json()

View File

@@ -280,7 +280,7 @@ const fetchSystemStats = async () => {
try {
const response = await fetch('/api/admin/online-stats', {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
const data = await response.json()

View File

@@ -87,7 +87,7 @@ const loading = ref(false)
// 判断是否首次设置密码
const isFirstTimeSetup = computed(() => {
return sessionStorage.getItem('needSetPassword') === '1'
return localStorage.getItem('needSetPassword') === '1'
})
const form = reactive({
@@ -162,7 +162,7 @@ const handleSubmit = async () => {
ElMessage.success('密码修改成功')
// 清除首次设置标记
sessionStorage.removeItem('needSetPassword')
localStorage.removeItem('needSetPassword')
// 跳转到首页或之前的页面
const redirect = route.query.redirect || '/profile'

View File

@@ -522,7 +522,7 @@ const handleDelete = async (task) => {
)
// 调用后端API删除
const token = sessionStorage.getItem('token')
const token = localStorage.getItem('token')
if (!token) {
ElMessage.error('请先登录')
return
@@ -574,7 +574,7 @@ const handleBatchDelete = async () => {
)
// 调用后端API批量删除
const token = sessionStorage.getItem('token')
const token = localStorage.getItem('token')
if (!token) {
ElMessage.error('请先登录')
return
@@ -759,7 +759,7 @@ const fetchSystemStats = async () => {
try {
const response = await fetch('/api/admin/online-stats', {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
const data = await response.json()

View File

@@ -30,7 +30,6 @@
<div class="nav-item storyboard-item" @click="goToStoryboardVideo">
<el-icon><Film /></el-icon>
<span>分镜视频</span>
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
</div>
</nav>
</aside>

View File

@@ -1457,7 +1457,7 @@ onUnmounted(() => {
.main-content {
flex: 1;
display: grid;
grid-template-columns: 400px 1fr;
grid-template-columns: 520px 1fr;
gap: 0;
height: calc(100vh - 100px);
}
@@ -1505,33 +1505,38 @@ onUnmounted(() => {
/* 创作模式标签 */
.creation-tabs {
display: flex;
gap: 4px;
background: #0a0a0a;
padding: 4px;
border-radius: 12px;
gap: 8px;
padding: 0;
}
.tab {
flex: 1;
padding: 12px 16px;
border-radius: 8px;
width: 155px;
height: 44px;
padding: 0;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
color: #9ca3af;
font-size: 14px;
font-weight: 500;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.tab.active {
background: #3b82f6;
background: #313338;
color: #fff;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
border-color: transparent;
}
.tab:hover:not(.active) {
background: #2a2a2a;
background: rgba(49, 51, 56, 0.5);
color: #fff;
border-color: transparent;
}
/* 图片输入区域 */
@@ -2598,7 +2603,7 @@ onUnmounted(() => {
/* 响应式设计 */
@media (max-width: 1200px) {
.main-content {
grid-template-columns: 350px 1fr;
grid-template-columns: 420px 1fr;
}
.left-panel {

View File

@@ -308,17 +308,17 @@ const handleLogin = async () => {
const loginToken = response.data.data.token
const needsPasswordChange = response.data.data.needsPasswordChange // 后端直接返回是否需要修改密码
sessionStorage.setItem('token', loginToken)
sessionStorage.setItem('user', JSON.stringify(loginUser))
localStorage.setItem('token', loginToken)
localStorage.setItem('user', JSON.stringify(loginUser))
userStore.user = loginUser
userStore.token = loginToken
// 根据后端返回的标记设置是否需要修改密码
if (needsPasswordChange) {
sessionStorage.setItem('needSetPassword', '1')
localStorage.setItem('needSetPassword', '1')
console.log('新用户首次登录,需要设置密码')
} else {
sessionStorage.removeItem('needSetPassword')
localStorage.removeItem('needSetPassword')
}
console.log('登录成功,用户信息:', userStore.user, '需要设置密码:', needsPasswordChange)
@@ -328,7 +328,7 @@ const handleLogin = async () => {
await new Promise(resolve => setTimeout(resolve, 200))
// 如果需要设置密码,跳转到设置密码页面
const needSetPassword = sessionStorage.getItem('needSetPassword') === '1'
const needSetPassword = localStorage.getItem('needSetPassword') === '1'
const redirectPath = needSetPassword ? '/set-password' : (route.query.redirect || '/profile')
console.log('准备跳转到:', redirectPath, '需要设置密码:', needSetPassword)

View File

@@ -276,7 +276,7 @@ const currentPage = ref(1)
const pageSize = ref(10)
const totalMembers = ref(50)
// 当前用户角色(从 sessionStorage 或 API 获取)
// 当前用户角色(从 localStorage 或 API 获取)
const currentUserRole = ref('')
const isSuperAdmin = computed(() => currentUserRole.value === 'ROLE_SUPER_ADMIN')
@@ -702,7 +702,7 @@ const toggleRole = async (member) => {
const fetchCurrentUserRole = () => {
try {
// 登录时保存的是 'user' 而不是 'userInfo'
const userStr = sessionStorage.getItem('user')
const userStr = localStorage.getItem('user')
if (userStr) {
const user = JSON.parse(userStr)
currentUserRole.value = user.role || 'ROLE_USER'
@@ -727,7 +727,7 @@ const fetchSystemStats = async () => {
try {
const response = await fetch('/api/admin/online-stats', {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
const data = await response.json()

View File

@@ -41,7 +41,6 @@
<div class="nav-item" @click="goToStoryboardVideo">
<el-icon><Film /></el-icon>
<span>{{ t('works.storyboardVideo') }}</span>
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
</div>
</nav>
</aside>
@@ -133,7 +132,7 @@
</div>
<div class="select-row">
<el-checkbox v-model="multiSelect" size="small">{{ t('works.selectItems', { count: selectedIds.size || 6 }) }}</el-checkbox>
<el-checkbox :model-value="isAllSelected" @change="toggleSelectAll" size="small">{{ t('works.selectAll') }}</el-checkbox>
<template v-if="multiSelect && selectedIds.size">
<el-tag type="success" size="small">{{ t('works.selectedCount', { count: selectedIds.size }) }}</el-tag>
<el-button size="small" type="primary" @click="bulkDownload" plain>{{ t('video.download') }}</el-button>
@@ -227,6 +226,7 @@
<template #footer>
<el-space size="small">
<el-button text size="small" @click.stop="download(item)">{{ t('video.download') }}</el-button>
<el-button text size="small" type="danger" @click.stop="handleDeleteWork(item)">{{ t('common.delete') || '删除' }}</el-button>
</el-space>
</template>
</el-card>
@@ -489,7 +489,7 @@ const isVerticalVideo = computed(() => {
})
const page = ref(1)
const pageSize = ref(20)
const pageSize = ref(100)
const loading = ref(false)
const hasMore = ref(true)
const items = ref([])
@@ -663,12 +663,10 @@ const loadList = async () => {
const filteredItems = computed(() => {
let filtered = [...items.value]
// 过滤掉加载失败的作品URL失效的作品自动隐藏
// 过滤掉加载失败的作品
filtered = filtered.filter(item => {
// 检查 resultUrl 和 cover 是否在失败集合中
const resultUrlFailed = item.resultUrl && failedUrls.value.has(item.resultUrl)
const coverFailed = item.cover && failedUrls.value.has(item.cover)
// 如果任一URL失败则不显示该作品
return !resultUrlFailed && !coverFailed
})
@@ -1054,7 +1052,7 @@ const download = async (item) => {
// 备用方案:使用后端代理
const downloadUrl = getWorkFileUrl(item.id, true)
const token = sessionStorage.getItem('token')
const token = localStorage.getItem('token')
console.log('开始下载:', downloadUrl)
@@ -1151,6 +1149,36 @@ const moreCommand = async (cmd, item) => {
}
}
// 删除单个作品
const handleDeleteWork = async (item) => {
try {
await ElMessageBox.confirm(
t('works.deleteWorkConfirm') || `确定要删除作品"${item.title}"吗?`,
t('works.deleteConfirmTitle') || '删除确认',
{
type: 'warning',
confirmButtonText: t('common.delete') || '删除',
cancelButtonText: t('common.cancel') || '取消'
}
)
// 执行删除
const response = await deleteWork(item.id)
if (response.data.success) {
ElMessage.success(t('works.deleteSuccess') || '删除成功')
// 从列表中移除
items.value = items.value.filter(i => i.id !== item.id)
} else {
throw new Error(response.data.message || t('works.deleteFailed') || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除作品失败:', error)
ElMessage.error(error.message || t('works.deleteFailed') || '删除失败')
}
}
}
const toggleSelect = (id) => {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
@@ -1158,6 +1186,24 @@ const toggleSelect = (id) => {
selectedIds.value = next
}
// 全选/取消全选
const isAllSelected = computed(() => {
if (filteredItems.value.length === 0) return false
return filteredItems.value.every(item => selectedIds.value.has(item.id))
})
const toggleSelectAll = () => {
if (isAllSelected.value) {
// 取消全选
selectedIds.value = new Set()
multiSelect.value = false
} else {
// 全选
multiSelect.value = true
selectedIds.value = new Set(filteredItems.value.map(item => item.id))
}
}
const bulkDownload = async () => {
if (selectedIds.value.size === 0) {
ElMessage.warning(t('works.noItemsSelected'))

View File

@@ -41,7 +41,6 @@
<div class="nav-item">
<el-icon><Film /></el-icon>
<span @click="goToStoryboardVideo">{{ t('home.storyboardVideo') }}</span>
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
</div>
</nav>
</aside>
@@ -106,16 +105,22 @@
muted
preload="metadata"
@loadedmetadata="onVideoLoaded"
@error="onVideoError($event, video)"
></video>
<!-- 如果有封面图thumbnailUrl使用图片 -->
<!-- 如果有封面图使用图片 -->
<img
v-else-if="video.cover && video.cover !== video.resultUrl"
v-else-if="video.cover"
:src="video.cover"
:alt="video.title"
class="video-cover-img"
@error="onImageError"
/>
<!-- 否则使用占位符 -->
<div v-else class="figure"></div>
<!-- 否则使用视频图标占位符 -->
<div v-else class="video-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="rgba(255,255,255,0.5)">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
</div>
<div class="video-action">
<el-button v-if="index === 0 && video.status === 'COMPLETED'" type="primary" size="small" @click.stop="createSimilar(video)">{{ t('profile.createSimilar') }}</el-button>
@@ -549,8 +554,8 @@ const loadUserInfo = async () => {
console.log('设置后的用户信息:', userInfo.value)
// 检查用户是否需要设置密码(只有数据库中真正没有密码时才弹窗,且只弹一次)
const needSetPasswordFlag = sessionStorage.getItem('needSetPassword') === '1'
const hasShownPasswordDialog = sessionStorage.getItem('hasShownPasswordDialog') === '1'
const needSetPasswordFlag = localStorage.getItem('needSetPassword') === '1'
const hasShownPasswordDialog = localStorage.getItem('hasShownPasswordDialog') === '1'
// 检查后端返回的用户密码状态
const hasNoPassword = !user.passwordHash || String(user.passwordHash).trim() === ''
@@ -563,12 +568,12 @@ const loadUserInfo = async () => {
console.log('检测到用户没有设置密码,弹出修改密码弹窗')
openChangePasswordDialog()
// 清除标记,确保只弹一次
sessionStorage.removeItem('needSetPassword')
sessionStorage.setItem('hasShownPasswordDialog', '1')
localStorage.removeItem('needSetPassword')
localStorage.setItem('hasShownPasswordDialog', '1')
} else if (needSetPasswordFlag && !hasNoPassword) {
// 如果有标记但用户已经有密码了,清除标记
console.log('用户已有密码,清除 needSetPassword 标记')
sessionStorage.removeItem('needSetPassword')
localStorage.removeItem('needSetPassword')
}
} else {
console.error('获取用户信息失败:', response?.data?.message || '未知错误')
@@ -640,6 +645,19 @@ const onVideoLoaded = (event) => {
}
}
// 视频加载失败处理
const onVideoError = (event, video) => {
console.warn('视频加载失败:', video.resultUrl)
// 隐藏video元素显示占位符
event.target.style.display = 'none'
}
// 图片加载失败处理
const onImageError = (event) => {
console.warn('图片加载失败:', event.target.src)
event.target.style.display = 'none'
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
loadUserInfo()
@@ -1062,6 +1080,15 @@ onUnmounted(() => {
object-fit: cover;
}
.video-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
}
.figure {
width: 80px;
height: 80px;

View File

@@ -139,7 +139,7 @@ const handleSubmit = async () => {
ElMessage.success('密码设置成功')
// 清除首次设置标记
sessionStorage.removeItem('needSetPassword')
localStorage.removeItem('needSetPassword')
// 跳转到首页或之前的页面
const redirect = route.query.redirect || '/'
@@ -159,7 +159,7 @@ const handleSubmit = async () => {
// 跳过
const handleSkip = () => {
// 清除首次设置标记
sessionStorage.removeItem('needSetPassword')
localStorage.removeItem('needSetPassword')
// 跳转到首页
const redirect = route.query.redirect || '/'

View File

@@ -30,7 +30,6 @@
<div class="nav-item storyboard-item" @click="goToStoryboardVideoCreate">
<el-icon><Film /></el-icon>
<span>分镜视频</span>
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
</div>
</nav>
</aside>

File diff suppressed because one or more lines are too long

View File

@@ -41,7 +41,6 @@
<div class="nav-item storyboard-item" @click="goToStoryboardVideo">
<el-icon><Film /></el-icon>
<span>{{ $t('home.storyboardVideo') }}</span>
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
</div>
</nav>
</aside>
@@ -409,7 +408,7 @@ const loadUserSubscriptionInfo = async () => {
}
// 检查token是否存在
const token = userStore.token || sessionStorage.getItem('token')
const token = userStore.token || localStorage.getItem('token')
if (!token || token === 'null' || token.trim() === '') {
console.warn('未找到有效的token跳转到登录页')
ElMessage.warning(t('subscription.pleaseLogin'))

View File

@@ -268,16 +268,6 @@
</el-input>
<div class="model-tip">{{ $t('systemSettings.storyboardSystemPromptTip') }}</div>
</el-form-item>
<el-form-item :label="$t('systemSettings.promptOptimizationSystemPrompt')">
<el-input
v-model="promptOptimizationSystemPrompt"
type="textarea"
:rows="6"
style="width: 500px;"
:placeholder="$t('systemSettings.promptOptimizationSystemPromptPlaceholder')">
</el-input>
<div class="model-tip">{{ $t('systemSettings.promptOptimizationSystemPromptTip') }}</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@@ -510,7 +500,6 @@ const cleanupConfig = reactive({
const promptOptimizationModel = ref('gpt-5.1-thinking')
const promptOptimizationApiUrl = ref('https://ai.comfly.chat')
const storyboardSystemPrompt = ref('')
const promptOptimizationSystemPrompt = ref('')
const savingAiModel = ref(false)
const goToDashboard = () => {
@@ -664,23 +653,24 @@ const loadMembershipLevels = async () => {
// 任务清理相关方法
const getAuthHeaders = () => {
const token = sessionStorage.getItem('token')
const token = localStorage.getItem('token')
return token ? { 'Authorization': `Bearer ${token}` } : {}
}
const refreshStats = async () => {
const refreshStats = async (showMessage = true) => {
loadingStats.value = true
try {
const response = await cleanupApi.getCleanupStats()
cleanupStats.value = response.data
ElMessage.success(t('systemSettings.statsRefreshSuccess'))
if (showMessage) {
ElMessage.success(t('systemSettings.statsRefreshSuccess'))
}
} catch (error) {
console.error('Get statistics failed:', error)
ElMessage.error(t('systemSettings.statsRefreshFailed'))
} finally {
loadingStats.value = false
}
}
const performFullCleanup = async () => {
@@ -779,7 +769,7 @@ const loadAiModelSettings = async () => {
try {
const response = await fetch('/api/admin/settings', {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
if (response.ok) {
@@ -793,9 +783,6 @@ const loadAiModelSettings = async () => {
if (data.storyboardSystemPrompt !== undefined) {
storyboardSystemPrompt.value = data.storyboardSystemPrompt
}
if (data.promptOptimizationSystemPrompt !== undefined) {
promptOptimizationSystemPrompt.value = data.promptOptimizationSystemPrompt
}
}
} catch (error) {
console.error('Load AI model settings failed:', error)
@@ -810,13 +797,12 @@ const saveAiModelSettings = async () => {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
promptOptimizationModel: promptOptimizationModel.value,
promptOptimizationApiUrl: promptOptimizationApiUrl.value,
storyboardSystemPrompt: storyboardSystemPrompt.value,
promptOptimizationSystemPrompt: promptOptimizationSystemPrompt.value
storyboardSystemPrompt: storyboardSystemPrompt.value
})
})
if (response.ok) {
@@ -834,7 +820,7 @@ const saveAiModelSettings = async () => {
// 页面加载时获取统计信息和会员等级配置
onMounted(() => {
refreshStats()
refreshStats(false) // 初始加载不显示成功提示
loadMembershipLevels()
fetchSystemStats()
loadAiModelSettings()
@@ -845,7 +831,7 @@ const fetchSystemStats = async () => {
try {
const response = await fetch('/api/admin/online-stats', {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
const data = await response.json()

View File

@@ -30,7 +30,6 @@
<div class="nav-item storyboard-item" @click="goToStoryboardVideo">
<el-icon><Film /></el-icon>
<span>分镜视频</span>
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
</div>
</nav>
</aside>

View File

@@ -1283,7 +1283,7 @@ onUnmounted(() => {
.main-content {
flex: 1;
display: grid;
grid-template-columns: 400px 1fr;
grid-template-columns: 520px 1fr;
gap: 0;
height: calc(100vh - 100px);
}
@@ -1302,33 +1302,38 @@ onUnmounted(() => {
/* 创作模式标签 */
.creation-tabs {
display: flex;
gap: 4px;
background: #0a0a0a;
padding: 4px;
border-radius: 12px;
gap: 8px;
padding: 0;
}
.tab {
flex: 1;
padding: 12px 16px;
border-radius: 8px;
width: 155px;
height: 44px;
padding: 0;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
color: #9ca3af;
font-size: 14px;
font-weight: 500;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.tab.active {
background: #3b82f6;
background: #313338;
color: #fff;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
border-color: transparent;
}
.tab:hover:not(.active) {
background: #2a2a2a;
background: rgba(49, 51, 56, 0.5);
color: #fff;
border-color: transparent;
}
/* 文本输入区域 */
@@ -1607,7 +1612,7 @@ onUnmounted(() => {
/* 响应式设计 */
@media (max-width: 1200px) {
.main-content {
grid-template-columns: 350px 1fr;
grid-template-columns: 420px 1fr;
}
.left-panel {