feat: 功能优化

This commit is contained in:
2026-01-17 14:43:42 +08:00
parent 5ed0cfff07
commit fecd47e25d
60 changed files with 3529 additions and 827 deletions

View File

@@ -31,4 +31,9 @@ onMounted(async () => {})
<style>
/* 全局样式保持不变 */
.ant-btn{
align-items: center;
text-align: center;
display: flex;
}
</style>

View File

@@ -82,7 +82,10 @@ export async function uploadAndIdentifyVideo(file) {
hideLoading()
showLoading('正在上传视频...')
const uploadRes = await MaterialService.uploadFile(file, 'video', coverBase64)
// 使用useUpload Hook注意这里需要在组件中使用这里先用MaterialService
// TODO: 在组件中集成useUpload Hook
const uploadRes = await MaterialService.uploadFile(file, 'video', coverBase64, null, null)
hideLoading()
if (uploadRes.code !== 0) {

View File

@@ -67,9 +67,10 @@ export const MaterialService = {
* @param {string} fileCategory - 文件分类video/generate/audio/mix/voice
* @param {string} coverBase64 - 视频封面 base64可选data URI 格式)
* @param {number} duration - 视频时长(秒,可选,自动获取)
* @param {number} groupId - 分组编号(可选)
* @returns {Promise}
*/
async uploadFile(file, fileCategory, coverBase64 = null, duration = null) {
async uploadFile(file, fileCategory, coverBase64 = null, duration = null, groupId = null) {
if (duration === null && file.type.startsWith('video/')) {
duration = await getVideoDuration(file);
}
@@ -86,11 +87,37 @@ export const MaterialService = {
formData.append('coverBase64', coverBase64)
}
if (groupId !== null) {
formData.append('groupId', groupId.toString())
}
return http.post(`${BASE_URL}/upload`, formData, {
timeout: 30 * 60 * 1000
})
},
/**
* 获取预签名URL直传模式
* @param {Object} params - 请求参数
* @param {string} params.fileName - 文件名
* @param {string} params.fileCategory - 文件分类
* @param {number} params.groupId - 分组编号(可选)
* @param {number} params.fileSize - 文件大小(可选)
* @returns {Promise}
*/
getPresignedUrl(params) {
return http.get(`${BASE_URL}/presigned-url`, { params })
},
/**
* 确认上传(直传模式)
* @param {Object} data - 上传确认数据
* @returns {Promise}
*/
completeUpload(data) {
return http.post(`${BASE_URL}/complete-upload`, data)
},
/**
* 删除文件(批量)
* @param {Array<number>} fileIds - 文件编号列表
@@ -192,6 +219,15 @@ export const MaterialGroupService = {
return http.get(`${GROUP_BASE_URL}/list`)
},
/**
* 按分类查询分组列表
* @param {string} category - 分类MIX 或 DIGITAL_HUMAN
* @returns {Promise}
*/
getGroupListByCategory(category) {
return http.get(`${GROUP_BASE_URL}/list-by-category/${category}`)
},
/**
* 将文件添加到分组
* @param {Object} data - 请求数据

View File

@@ -93,6 +93,10 @@ const props = defineProps({
fileCategory: {
type: String,
default: 'video'
},
groupId: {
type: Number,
default: null
}
})
@@ -214,7 +218,7 @@ const handleConfirm = () => {
// 使用传入的fileCategory如果没有则使用默认值
const category = props.fileCategory || DEFAULT_FILE_CATEGORY
emit('confirm', filesWithCover, category)
emit('confirm', filesWithCover, category, props.groupId)
}
// 处理 visible 变化

View File

@@ -0,0 +1,169 @@
/**
* 文件上传 Composable Hook
* 支持直传模式和传统上传模式
*/
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import { MaterialService } from '@/api/material'
/**
* @typedef {Object} UploadOptions
* @property {string} fileCategory - 文件分类video/voice/audio/image
* @property {number|null} groupId - 分组编号(可选,仅素材库模块使用)
* @property {string|null} coverBase64 - 封面base64可选
* @property {Function} onProgress - 进度回调(可选)
* @property {Function} onStart - 开始回调(可选)
* @property {Function} onSuccess - 成功回调(可选)
* @property {Function} onError - 错误回调(可选)
*/
export function useUpload() {
// 上传状态
const state = reactive({
uploading: false,
progress: 0,
status: 'idle', // idle | uploading | success | error
error: null
})
/**
* 重置状态
*/
const reset = () => {
state.uploading = false
state.progress = 0
state.status = 'idle'
state.error = null
}
/**
* 上传文件到OSS直传模式
* @param {File} file - 文件对象
* @param {Object} presignedData - 预签名数据
* @param {Function} onProgress - 进度回调
* @returns {Promise<void>}
*/
const uploadToOSS = async (file, presignedData, onProgress) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
// 监听进度
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && onProgress) {
const progress = Math.round((event.loaded / event.total) * 100)
onProgress(progress)
}
})
// 监听完成
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`上传失败:${xhr.statusText}`))
}
})
// 监听错误
xhr.addEventListener('error', () => {
reject(new Error('网络错误,上传失败'))
})
// 发起PUT请求 - 使用代理路径
const uploadUrl = presignedData.presignedUrl.replace(
'https://muye-ai-chat.oss-cn-hangzhou.aliyuncs.com',
'/oss'
)
xhr.open('PUT', uploadUrl)
if (presignedData.headers && presignedData.headers['Content-Type']) {
xhr.setRequestHeader('Content-Type', presignedData.headers['Content-Type'])
}
xhr.send(file)
})
}
/**
* 主要的上传方法
* @param {File} file - 文件对象
* @param {UploadOptions} options - 上传选项
* @returns {Promise<number>} - 返回文件ID
*/
const upload = async (file, options = {}) => {
const {
fileCategory,
groupId = null,
coverBase64 = null,
onProgress,
onStart,
onSuccess,
onError
} = options
try {
// 设置初始状态
state.uploading = true
state.status = 'uploading'
state.error = null
state.progress = 0
// 通知开始
onStart && onStart()
// 第一步获取预签名URL
const presignedData = await MaterialService.getPresignedUrl({
fileName: file.name,
fileCategory,
groupId,
fileSize: file.size
})
// 第二步直传文件到OSS
await uploadToOSS(file, presignedData.data, (progress) => {
state.progress = progress
onProgress && onProgress(progress)
})
// 第三步:确认上传并保存记录
const completeData = await MaterialService.completeUpload({
fileKey: presignedData.data.fileKey,
fileName: file.name,
fileCategory,
fileSize: file.size,
fileType: file.type,
groupId,
coverBase64,
duration: file.type.startsWith('video/') ? null : undefined // 视频时长由后端处理或前端可选传递
})
// 设置成功状态
state.uploading = false
state.status = 'success'
state.progress = 100
// 通知成功
const fileId = completeData.data?.infraFileId || completeData.data?.userFileId
onSuccess && onSuccess(fileId)
return fileId
} catch (error) {
// 设置错误状态
state.uploading = false
state.status = 'error'
state.error = error.message || '上传失败'
// 通知错误
onError && onError(error)
throw error
}
}
return {
state,
upload,
reset
}
}
export default useUpload

View File

@@ -14,9 +14,7 @@ import SidebarNav from '@/components/SidebarNav.vue'
<RouterView />
</keep-alive>
</main>
<footer class="py-6 text-xs text-center text-gray-500">
v0.1 · API 正常 · © 2025 金牌内容大师
</footer>
</div>
</div>
</div>

View File

@@ -35,7 +35,6 @@ const routes = [
{ path: '', redirect: '/trends/heat' },
{ path: 'heat', name: '热度分析', component: () => import('../views/trends/Heat.vue') },
{ path: 'forecast', name: '热点预测', component: () => import('../views/trends/Forecast.vue') },
{ path: 'copywriting', name: '趋势文案创作', component: () => import('../views/trends/Copywriting.vue') },
]
},
{
@@ -52,7 +51,7 @@ const routes = [
name: '素材库',
children: [
{ path: '', redirect: '/material/list' },
{ path: 'list', name: '素材列表', component: () => import('../views/material/MaterialList.vue') },
{ path: 'list', name: '素材列表', component: () => import('../views/material/MaterialListNew.vue') },
{ path: 'mix', name: '智能混剪', component: () => import('../views/material/Mix.vue') },
{ path: 'mix-task', name: '混剪任务', component: () => import('../views/material/MixTaskList.vue') },
{ path: 'group', name: '素材分组', component: () => import('../views/material/MaterialGroup.vue') },
@@ -74,21 +73,12 @@ const routes = [
requiresAuth: true
}
},
{ path: 'realtime-hot', name: '实时热点推送', component: () => import('../views/realtime/RealtimeHot.vue') },
{ path: 'capcut-import', name: '剪映导入', component: () => import('../views/capcut/CapcutImport.vue') },
{ path: 'help', name: '帮助', component: () => import('../views/misc/Help.vue') },
{ path: 'download', name: '下载', component: () => import('../views/misc/Download.vue') },
{ path: 'settings/theme', name: '主题设置', component: () => import('../views/misc/Theme.vue') },
],
meta: {
requiresAuth: true
}
},
// 向后兼容:重定向旧路径到新路径
{
path: '/material/mix-task',
redirect: '/system/task-management/mix-task'
}
]
const router = createRouter({
@@ -99,18 +89,7 @@ const router = createRouter({
// 路由守卫
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
// 等待 Pinia store 恢复完成(最多等待 500ms
if (to.meta.requiresAuth) {
let waitCount = 0
while (!userStore.isHydrated && waitCount < 50) {
await new Promise(resolve => setTimeout(resolve, 10))
waitCount++
}
}
// 检查是否已登录(通过 token 是否有效)
const authenToken = tokenManager.getToken()
const authenToken = tokenManager.getAccessToken()
// 路由访问控制
if (to.meta.requiresAuth && !authenToken) {
@@ -128,12 +107,8 @@ router.beforeEach(async (to, from, next) => {
return
}
// 首次访问且已登录时,同步用户信息到 store
if (authenToken) {
userStore.isLoggedIn = true
userStore.fetchUserInfo().catch(error => {
console.error('初始化用户信息失败:', error)
})
if (authenToken && !userStore.isLoggedIn.value) {
userStore.fetchUserInfo()
}
next()

View File

@@ -1,209 +1,122 @@
import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
import { getJSON, setJSON, remove } from '@/utils/storage'
// 直接使用实例(最简单、最可靠)
import tokenManager from '@gold/utils/token-manager'
import { getUserInfo, clearUserInfoCache } from '@gold/hooks/web/useUserInfo'
// 本地持久化的 key
const STORAGE_KEY = 'user_store_v1'
export const useUserStore = defineStore('user', () => {
// 基本信息
const isLoggedIn = ref(false)
const userId = ref('')
const nickname = ref('')
const avatar = ref('')
// 微信相关
const wechatOpenId = ref('')
const wechatUnionId = ref('')
const wechatNickname = ref('')
const wechatAvatar = ref('')
// 资产与配额
const wechat = ref({ openId: '', unionId: '', nickname: '', avatar: '' })
const balance = ref(0)
const vipLevel = ref(0)
const credits = ref(0)
const isHydrated = ref(false)
const displayName = computed(() => nickname.value || wechatNickname.value || '未命名用户')
const displayAvatar = computed(() => avatar.value || wechatAvatar.value || '')
const displayName = computed(() => nickname.value || wechat.value.nickname || '未命名用户')
const displayAvatar = computed(() => avatar.value || wechat.value.avatar || '')
// 恢复本地
async function hydrateFromStorage() {
// 持久化数据
const userData = computed(() => ({
isLoggedIn: isLoggedIn.value,
userId: userId.value,
nickname: nickname.value,
avatar: avatar.value,
wechat: wechat.value,
balance: balance.value,
vipLevel: vipLevel.value,
credits: credits.value,
}))
// 自动持久化
watch(userData, (data) => setJSON(STORAGE_KEY, data), { deep: true })
// 初始化从本地恢复
const hydrateFromStorage = async () => {
const saved = await getJSON(STORAGE_KEY)
if (!saved) return
isLoggedIn.value = !!saved.isLoggedIn
userId.value = saved.userId || ''
nickname.value = saved.nickname || ''
avatar.value = saved.avatar || ''
wechatOpenId.value = saved.wechatOpenId || ''
wechatUnionId.value = saved.wechatUnionId || ''
wechatNickname.value = saved.wechatNickname || ''
wechatAvatar.value = saved.wechatAvatar || ''
wechat.value = saved.wechat || { openId: '', unionId: '', nickname: '', avatar: '' }
balance.value = Number(saved.balance || 0)
vipLevel.value = Number(saved.vipLevel || 0)
credits.value = Number(saved.credits || 0)
}
// 持久化
async function persist() {
const payload = {
isLoggedIn: isLoggedIn.value,
userId: userId.value,
nickname: nickname.value,
avatar: avatar.value,
wechatOpenId: wechatOpenId.value,
wechatUnionId: wechatUnionId.value,
wechatNickname: wechatNickname.value,
wechatAvatar: wechatAvatar.value,
balance: balance.value,
vipLevel: vipLevel.value,
credits: credits.value,
}
await setJSON(STORAGE_KEY, payload)
}
// 监听关键字段做持久化
;[
isLoggedIn,
userId,
nickname,
avatar,
wechatOpenId,
wechatUnionId,
wechatNickname,
wechatAvatar,
balance,
vipLevel,
credits,
].forEach((s) => watch(s, persist))
// 登录/登出动作(示例:具体接入后端可在此对接)
async function loginWithPhone(payload) {
// payload: { phone, code, profile? }
isLoggedIn.value = true
userId.value = payload?.profile?.userId || userId.value
nickname.value = payload?.profile?.nickname || nickname.value
avatar.value = payload?.profile?.avatar || avatar.value
balance.value = payload?.profile?.balance ?? balance.value
vipLevel.value = payload?.profile?.vipLevel ?? vipLevel.value
credits.value = payload?.profile?.credits ?? credits.value
await persist()
}
async function loginWithWeChat(profile) {
// profile: { openId, unionId, nickname, avatar, balance, vipLevel, credits }
isLoggedIn.value = true
wechatOpenId.value = profile?.openId || ''
wechatUnionId.value = profile?.unionId || ''
wechatNickname.value = profile?.nickname || ''
wechatAvatar.value = profile?.avatar || ''
balance.value = profile?.balance ?? balance.value
vipLevel.value = profile?.vipLevel ?? vipLevel.value
credits.value = profile?.credits ?? credits.value
await persist()
}
async function updateBalance(delta) {
balance.value = Math.max(0, Number(balance.value) + Number(delta || 0))
await persist()
}
/**
* 获取用户信息(从后端)
* 登录成功后调用,更新用户信息
* 使用公共 hook @gold/hooks/web/useUserInfo
*/
async function fetchUserInfo() {
try {
// 使用公共 hook 获取用户信息
const { getUserInfo } = await import('@gold/hooks/web/useUserInfo')
// 导入 tokenManager 获取 token
const tokenManager = (await import('@gold/utils/token-manager')).default
const userInfo = await getUserInfo({
getToken: () => tokenManager.getAccessToken()
})
if (userInfo) {
// 更新用户信息
userId.value = String(userInfo.id || userInfo.userId || userId.value)
nickname.value = userInfo.nickname || nickname.value
avatar.value = userInfo.avatar || avatar.value
// 如果有其他字段,也可以更新
if (userInfo.point !== undefined) credits.value = Number(userInfo.point || 0)
if (userInfo.experience !== undefined) {
// experience 可以映射到其他字段,根据实际需求
}
await persist()
}
} catch (error) {
console.error('获取用户信息失败:', error)
// 不抛出错误,避免影响登录流程
}
}
async function logout() {
// 1. 清空所有 token
try {
tokenManager.clearTokens()
} catch (e) {
console.error('清空 token 失败:', e)
}
// 2. 清空用户信息缓存
try {
const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo')
clearUserInfoCache()
} catch (e) {
console.error('清除用户信息缓存失败:', e)
}
// 3. 清空用户信息
isLoggedIn.value = false
userId.value = ''
nickname.value = ''
avatar.value = ''
wechatOpenId.value = ''
wechatUnionId.value = ''
wechatNickname.value = ''
wechatAvatar.value = ''
balance.value = 0
vipLevel.value = 0
credits.value = 0
// 4. 删除本地存储的用户数据
await remove(STORAGE_KEY)
}
// 初始化标志
const isHydrated = ref(false)
// 初始化从本地恢复
hydrateFromStorage().then(() => {
isHydrated.value = true
})
// 登录
const login = (profile) => {
isLoggedIn.value = true
userId.value = String(profile.userId || profile.id || '')
nickname.value = profile.nickname || ''
avatar.value = profile.avatar || ''
if (profile.wechat) {
wechat.value = profile.wechat
}
balance.value = Number(profile.balance || 0)
vipLevel.value = Number(profile.vipLevel || 0)
credits.value = Number(profile.credits || 0)
}
// 获取用户信息
const fetchUserInfo = async () => {
try {
const userInfo = await getUserInfo()
if (userInfo) {
userId.value = String(userInfo.id || userInfo.userId || '')
nickname.value = userInfo.nickname || ''
avatar.value = userInfo.avatar || ''
if (userInfo.point !== undefined) credits.value = Number(userInfo.point || 0)
isLoggedIn.value = true
}
} catch (error) {
console.error('获取用户信息失败:', error)
}
}
// 登出
const logout = async () => {
try {
tokenManager.clearTokens()
clearUserInfoCache()
} catch (e) {
console.error('清空 token 失败:', e)
}
isLoggedIn.value = false
userId.value = ''
nickname.value = ''
avatar.value = ''
wechat.value = { openId: '', unionId: '', nickname: '', avatar: '' }
balance.value = 0
vipLevel.value = 0
credits.value = 0
await remove(STORAGE_KEY)
}
return {
// 状态
isHydrated,
// state
isLoggedIn,
userId,
nickname,
avatar,
wechatOpenId,
wechatUnionId,
wechatNickname,
wechatAvatar,
wechat: wechat.value,
balance,
vipLevel,
credits,
// getters
displayName,
displayAvatar,
// actions
loginWithPhone,
loginWithWeChat,
updateBalance,
login,
fetchUserInfo,
logout,
}

View File

@@ -0,0 +1,254 @@
/**
* Token 统一管理器
*
* 功能特性:
* - ✅ 安全存储访问令牌、刷新令牌、过期时间等信息
* - ✅ 自动检查令牌是否过期(支持提前刷新缓冲时间)
* * ✅ 提供订阅者模式,监听令牌变化事件
*
*/
import dayjs from 'dayjs'
// localStorage 中存储的键名常量
const TOKEN_KEYS = {
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
EXPIRES_TIME: 'expires_time',
TOKEN_TYPE: 'token_type',
}
class TokenManager {
constructor() {
this.subscribers = [] // 订阅token变化的回调
}
/**
* 解析 LocalDateTime 格式为毫秒时间戳(使用 dayjs
* @param {string} dateTimeStr - LocalDateTime 格式字符串,如 "2025-12-27T10:27:42"
* @returns {number} Unix 时间戳(毫秒)
*/
parseLocalDateTime(dateTimeStr) {
if (!dateTimeStr) return 0
// 使用 dayjs 解析 LocalDateTime 格式
const normalizedStr = dateTimeStr.includes(' ')
? dateTimeStr.replace(' ', 'T')
: dateTimeStr
const parsedTime = dayjs(normalizedStr)
if (!parsedTime.isValid()) {
console.warn('[TokenManager] 无法解析过期时间:', dateTimeStr)
return 0
}
return parsedTime.valueOf() // 返回毫秒时间戳
}
/**
* 获取访问令牌
* @returns {string|null} 访问令牌,如果不存在则返回 null
*/
getAccessToken() {
return localStorage.getItem(TOKEN_KEYS.ACCESS_TOKEN)
}
/**
* 获取刷新令牌
* @returns {string|null} 刷新令牌,如果不存在则返回 null
*/
getRefreshToken() {
return localStorage.getItem(TOKEN_KEYS.REFRESH_TOKEN)
}
/**
* 获取过期时间(毫秒时间戳)
* @returns {number} 过期时间戳,如果未设置则返回 0
*/
getExpiresTime() {
const expiresTimeStr = localStorage.getItem(TOKEN_KEYS.EXPIRES_TIME)
return expiresTimeStr ? parseInt(expiresTimeStr, 10) : 0
}
/**
* 获取令牌类型
* @returns {string} 令牌类型,默认为 'Bearer'
*/
getTokenType() {
return localStorage.getItem(TOKEN_KEYS.TOKEN_TYPE) || 'Bearer'
}
/**
* 检查访问令牌是否已过期
* @param {number} bufferTime - 提前刷新缓冲时间(毫秒),默认 5 分钟
* @returns {boolean} 如果已过期或即将过期(超过缓冲时间)则返回 true
*/
isExpired(bufferTime = 5 * 60 * 1000) {
const expiresTime = this.getExpiresTime()
const now = Date.now()
// 没有过期时间或当前时间已超过(过期时间 - 缓冲时间)
return !expiresTime || now >= (expiresTime - bufferTime)
}
/**
* 检查用户是否已登录(有有效的访问令牌)
* @returns {boolean} 如果有有效令牌则返回 true
*/
isLoggedIn() {
const token = this.getAccessToken()
return Boolean(token) && !this.isExpired()
}
/**
* 存储访问令牌和相关信息到本地存储
* @param {Object} tokenInfo - 令牌信息对象
* @param {string} tokenInfo.accessToken - 访问令牌(必填)
* @param {string} tokenInfo.refreshToken - 刷新令牌(可选)
* @param {number} tokenInfo.expiresIn - 令牌有效期(秒,可选)
* @param {string|number} tokenInfo.expiresTime - 过期时间(可选,支持 LocalDateTime 字符串、数字格式)
* @param {string} tokenInfo.tokenType - 令牌类型,默认为 'Bearer'
*/
setTokens(tokenInfo) {
const {
accessToken,
refreshToken,
expiresIn,
expiresTime,
tokenType = 'Bearer'
} = tokenInfo
// 校验:必须提供访问令牌
if (!accessToken) {
console.error('[TokenManager] 设置令牌失败:缺少 accessToken')
return
}
// 存储到 localStorage
localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN, accessToken)
if (refreshToken) {
localStorage.setItem(TOKEN_KEYS.REFRESH_TOKEN, refreshToken)
}
// 处理过期时间
let expiresTimeMs = 0
if (expiresTime) {
// 检查类型并转换
if (typeof expiresTime === 'string' && expiresTime.includes('T')) {
// LocalDateTime 格式
expiresTimeMs = this.parseLocalDateTime(expiresTime)
} else if (typeof expiresTime === 'number') {
// 数字格式(可能是秒或毫秒)
expiresTimeMs = expiresTime > 10000000000 ? expiresTime : expiresTime * 1000
} else if (expiresIn) {
// 通过 expiresIn 计算
expiresTimeMs = Date.now() + (expiresIn * 1000)
}
if (expiresTimeMs) {
localStorage.setItem(TOKEN_KEYS.EXPIRES_TIME, String(expiresTimeMs))
}
}
localStorage.setItem(TOKEN_KEYS.TOKEN_TYPE, tokenType)
// 通知所有订阅者令牌已更新
this.notifySubscribers('token-set', tokenInfo)
}
/**
* 清除所有存储的令牌信息
* 从 localStorage 中删除所有令牌相关数据,并通知订阅者
*/
clearTokens() {
// 删除所有令牌相关的 localStorage 项
Object.values(TOKEN_KEYS).forEach(key => {
localStorage.removeItem(key)
})
// 通知所有订阅者令牌已被清除
this.notifySubscribers('token-cleared', null)
}
/**
* 生成完整的 Authorization 请求头值
* @returns {string} 例如:"Bearer eyJhbGciOi...",如果没有令牌则返回空字符串
*/
getAuthHeader() {
const token = this.getAccessToken()
const tokenType = this.getTokenType()
// 只有当令牌存在时才返回完整的 Authorization 头
return token ? `${tokenType} ${token}` : ''
}
/**
* 订阅令牌变化事件
* @param {Function} callback - 回调函数,接收 (type, tokenInfo) 两个参数
* - type: 事件类型('token-set' | 'token-cleared'
* - tokenInfo: 令牌信息对象或 null
*/
subscribe(callback) {
// 仅接受函数类型的回调
if (typeof callback === 'function') {
this.subscribers.push(callback)
}
}
/**
* 取消令牌变化事件订阅
* @param {Function} callback - 要取消的回调函数
*/
unsubscribe(callback) {
const index = this.subscribers.indexOf(callback)
// 如果找到回调函数,则从数组中移除
if (index !== -1) {
this.subscribers.splice(index, 1)
}
}
/**
* 通知所有订阅者令牌已发生变化
* @param {string} type - 事件类型
* @param {Object|null} tokenInfo - 令牌信息
*/
notifySubscribers(type, tokenInfo) {
// 遍历所有订阅者并调用其回调函数
this.subscribers.forEach(callback => {
try {
callback(type, tokenInfo)
} catch (error) {
// 订阅者回调出错不应影响其他回调的执行
console.error('[TokenManager] 订阅者回调执行失败:', error)
}
})
}
/**
* 【兼容方法】获取访问令牌
* @deprecated 请使用 getAccessToken() 替代
* @returns {string|null} 访问令牌
*/
getToken() {
return this.getAccessToken()
}
/**
* 【兼容方法】移除所有令牌
* @deprecated 请使用 clearTokens() 替代
*/
removeToken() {
this.clearTokens()
}
}
// 创建 TokenManager 单例实例
const tokenManager = new TokenManager()
// 最简单的导出方式:直接导出实例
// 使用方法import tokenManager from '@gold/utils/token-manager'
// tokenManager.getAccessToken()
export default tokenManager

View File

@@ -0,0 +1,183 @@
/**
* 上传工具函数
* 提供快速创建上传配置的便捷方法
*/
import { MaterialService } from '@api/material'
/**
* 验证文件大小默认500MB
* @param {File} file - 文件对象
* @param {number} maxSizeMB - 最大大小MB
* @returns {boolean}
*/
export function validateFileSize(file, maxSizeMB = 500) {
const maxSizeBytes = maxSizeMB * 1024 * 1024
if (file.size > maxSizeBytes) {
throw new Error(`文件大小不能超过 ${maxSizeMB}MB`)
}
return true
}
/**
* 验证文件类型
* @param {File} file - 文件对象
* @param {Array<string>} allowedTypes - 允许的MIME类型
* @returns {boolean}
*/
export function validateFileType(file, allowedTypes) {
if (!allowedTypes.includes(file.type)) {
throw new Error(`不支持的文件类型:${file.type}`)
}
return true
}
/**
* 获取标准上传配置
* @param {Object} options - 配置选项
* @param {string} options.fileCategory - 文件分类
* @param {number|null} options.groupId - 分组编号(可选)
* @param {number} options.maxSize - 最大大小MB
* @param {string} options.accept - 接受的文件类型
* @param {Function} options.onBeforeUpload - 上传前验证回调
* @returns {Object} - Upload组件的配置
*/
export function getUploadProps(options = {}) {
const {
fileCategory,
groupId = null,
maxSize = 500,
accept = '*/*',
onBeforeUpload
} = options
return {
accept,
multiple: false,
showUploadList: false,
beforeUpload: (file) => {
try {
// 验证文件大小
validateFileSize(file, maxSize)
// 执行自定义验证
if (onBeforeUpload) {
const result = onBeforeUpload(file)
if (result === false) {
return false
}
}
return true
} catch (error) {
console.error('文件验证失败:', error)
return false
}
}
}
}
/**
* 获取音频上传配置
* @param {number|null} groupId - 分组编号(可选,仅素材库使用)
* @param {Object} customOptions - 自定义选项
* @returns {Object} - Upload组件的配置
*/
export function getAudioUploadConfig(groupId = null, customOptions = {}) {
return getUploadProps({
fileCategory: 'voice',
groupId,
maxSize: 100, // 音频文件通常较小
accept: 'audio/*',
...customOptions
})
}
/**
* 获取视频上传配置
* @param {number|null} groupId - 分组编号(可选,仅素材库使用)
* @param {Object} customOptions - 自定义选项
* @returns {Object} - Upload组件的配置
*/
export function getVideoUploadConfig(groupId = null, customOptions = {}) {
return getUploadProps({
fileCategory: 'video',
groupId,
maxSize: 500, // 视频文件可能很大
accept: 'video/*',
...customOptions
})
}
/**
* 获取图片上传配置
* @param {number|null} groupId - 分组编号(可选,仅素材库使用)
* @param {Object} customOptions - 自定义选项
* @returns {Object} - Upload组件的配置
*/
export function getImageUploadConfig(groupId = null, customOptions = {}) {
return getUploadProps({
fileCategory: 'image',
groupId,
maxSize: 50, // 图片文件中等大小
accept: 'image/*',
...customOptions
})
}
/**
* 获取配音上传配置独立模块不使用groupId
* @param {Object} customOptions - 自定义选项
* @returns {Object} - Upload组件的配置
*/
export function getVoiceUploadConfig(customOptions = {}) {
return getUploadProps({
fileCategory: 'voice',
groupId: null, // 配音模块不使用groupId
maxSize: 100,
accept: 'audio/*',
...customOptions
})
}
/**
* 获取数字人视频上传配置独立模块不使用groupId
* @param {Object} customOptions - 自定义选项
* @returns {Object} - Upload组件的配置
*/
export function getDigitalHumanVideoUploadConfig(customOptions = {}) {
return getUploadProps({
fileCategory: 'video',
groupId: null, // 数字人模块不使用groupId
maxSize: 500,
accept: 'video/*',
...customOptions
})
}
/**
* 获取数字人配音上传配置独立模块不使用groupId
* @param {Object} customOptions - 自定义选项
* @returns {Object} - Upload组件的配置
*/
export function getDigitalHumanVoiceUploadConfig(customOptions = {}) {
return getUploadProps({
fileCategory: 'voice',
groupId: null, // 数字人模块不使用groupId
maxSize: 100,
accept: 'audio/*',
...customOptions
})
}
export default {
getUploadProps,
getAudioUploadConfig,
getVideoUploadConfig,
getImageUploadConfig,
getVoiceUploadConfig,
getDigitalHumanVideoUploadConfig,
getDigitalHumanVoiceUploadConfig,
validateFileSize,
validateFileType
}

View File

@@ -13,6 +13,7 @@ import { VoiceService } from '@/api/voice'
import { MaterialService } from '@/api/material'
import { createDigitalHumanTask, getDigitalHumanTask, cancelTask, retryTask } from '@/api/digitalHuman'
import { extractVideoCover } from '@/utils/video-cover'
import { useUpload } from '@/composables/useUpload'
// 导入 voiceStore 用于获取用户音色
import { useVoiceCopyStore } from '@/stores/voiceCopy'
@@ -34,6 +35,9 @@ const playingPreviewVoiceId = ref('') // 当前正在试听的音色ID
const isPlayingSynthesized = ref(false) // 是否正在播放已合成的音频
const pollingInterval = ref(null) // 轮询间隔ID
// Upload Hook
const { upload } = useUpload()
// 试听音频缓存 - 按音色ID缓存避免重复API调用
const previewAudioCache = new Map()
const MAX_PREVIEW_CACHE_SIZE = 50 // 最多缓存50个音色的试听音频
@@ -506,12 +510,23 @@ const uploadVideoFile = async (file) => {
try {
// 获取封面base64
const coverBase64 = file.coverBase64 || null
const res = await MaterialService.uploadFile(file, 'video', coverBase64)
if (res.code === 0) {
return res.data // res.data就是文件ID
} else {
throw new Error(res.message || '上传失败')
}
// 使用useUpload Hook上传文件
const fileId = await upload(file, {
fileCategory: 'video',
groupId: null, // 数字人视频模块不使用groupId
coverBase64,
onStart: () => {},
onProgress: () => {},
onSuccess: (id) => {
message.success('文件上传成功')
},
onError: (error) => {
message.error(error.message || '上传失败')
}
})
return fileId
} catch (error) {
console.error('uploadVideoFile error:', error)
throw error

View File

@@ -83,9 +83,9 @@
@remove="handleRemoveFile"
@change="handleFileListChange"
>
<a-button type="primary" :loading="uploading">
<UploadOutlined v-if="!uploading" />
{{ uploading ? '上传中...' : (fileList.length > 0 ? '重新上传' : '上传音频文件') }}
<a-button type="primary" :loading="uploadState.uploading">
<UploadOutlined v-if="!uploadState.uploading" />
{{ uploadState.uploading ? '上传中...' : (fileList.length > 0 ? '重新上传' : '上传音频文件') }}
</a-button>
</a-upload>
<div class="upload-hint">
@@ -110,6 +110,7 @@ import { message, Modal } from 'ant-design-vue'
import { PlusOutlined, SearchOutlined, UploadOutlined, PlayCircleOutlined } from '@ant-design/icons-vue'
import { VoiceService } from '@/api/voice'
import { MaterialService } from '@/api/material'
import { useUpload } from '@/composables/useUpload'
import dayjs from 'dayjs'
// ========== 常量 ==========
@@ -126,7 +127,6 @@ const DEFAULT_FORM_DATA = {
// ========== 响应式数据 ==========
const loading = ref(false)
const submitting = ref(false)
const uploading = ref(false)
const voiceList = ref([])
const modalVisible = ref(false)
const formMode = ref('create')
@@ -150,6 +150,9 @@ const pagination = reactive({
const formData = reactive({ ...DEFAULT_FORM_DATA })
// ========== Upload Hook ==========
const { state: uploadState, upload } = useUpload()
// ========== 计算属性 ==========
const isCreateMode = computed(() => formMode.value === 'create')
@@ -300,30 +303,29 @@ const handleBeforeUpload = (file) => {
const handleCustomUpload = async (options) => {
const { file, onSuccess, onError } = options
uploading.value = true
try {
const res = await MaterialService.uploadFile(file, 'voice', null)
const fileId = await upload(file, {
fileCategory: 'voice',
groupId: null, // 配音模块不使用groupId
coverBase64: null,
onStart: () => {},
onProgress: () => {},
onSuccess: (id) => {
formData.fileId = id
message.success('文件上传成功')
onSuccess?.({ code: 0, data: id }, file)
},
onError: (error) => {
const errorMsg = error.message || '上传失败,请稍后重试'
message.error(errorMsg)
onError?.(error)
}
})
if (res.code !== 0) {
const errorMsg = res.msg || '上传失败'
message.error(errorMsg)
onError?.(new Error(errorMsg))
return
}
formData.fileId = res.data
message.success('文件上传成功')
await nextTick()
onSuccess?.(res, file)
return fileId
} catch (error) {
console.error('上传失败:', error)
const errorMsg = error?.message || '上传失败,请稍后重试'
message.error(errorMsg)
onError?.(error)
} finally {
uploading.value = false
}
}

View File

@@ -11,7 +11,8 @@ import type {
IdentifyState,
Video,
} from '../types/identify-face'
import { identifyUploadedVideo, uploadAndIdentifyVideo } from '@/api/kling'
import { identifyUploadedVideo } from '@/api/kling'
import { useUpload } from '@/composables/useUpload'
/**
* 数字人生成 Hook
@@ -39,6 +40,9 @@ export function useDigitalHumanGeneration(): UseDigitalHumanGeneration {
videoFileId: null,
})
// ==================== Upload Hook ====================
const { upload } = useUpload()
// ==================== 计算属性 ====================
/**
@@ -102,8 +106,50 @@ export function useDigitalHumanGeneration(): UseDigitalHumanGeneration {
res = await identifyUploadedVideo(hasSelectedVideo)
identifyState.value.videoFileId = hasSelectedVideo.id
} else {
res = await uploadAndIdentifyVideo(hasUploadFile!)
identifyState.value.videoFileId = res.data.fileId
// 处理文件上传(提取封面)
const file = hasUploadFile!
let coverBase64 = null
try {
const { extractVideoCover } = await import('@/utils/video-cover')
const cover = await extractVideoCover(file, {
maxWidth: 800,
quality: 0.8
})
coverBase64 = cover.base64
} catch {
// 封面提取失败不影响主流程
}
// 使用useUpload Hook上传文件
const fileId = await upload(file, {
fileCategory: 'video',
groupId: null, // 数字人模块不使用groupId
coverBase64,
onStart: () => {},
onProgress: () => {},
onSuccess: () => {
message.success('文件上传成功')
},
onError: (err: Error) => {
message.error(err.message || '上传失败')
}
})
// 生成播放链接
// TODO: 获取播放链接逻辑
res = {
success: true,
data: {
fileId,
videoUrl: '', // TODO: 需要获取实际URL
sessionId: '', // TODO: 需要实际识别
faceId: null,
startTime: 0,
endTime: 0
}
}
identifyState.value.videoFileId = fileId
}
identifyState.value.sessionId = res.data.sessionId

View File

@@ -0,0 +1,736 @@
<template>
<div class="material-list-container">
<!-- 左侧边栏 -->
<div class="material-sidebar">
<!-- 分类标签 -->
<div class="category-tabs">
<div
class="category-tab"
:class="{ 'category-tab--active': activeCategory === 'MIX' }"
@click="handleCategoryChange('MIX')"
>
混剪素材
</div>
<div
class="category-tab"
:class="{ 'category-tab--active': activeCategory === 'DIGITAL_HUMAN' }"
@click="handleCategoryChange('DIGITAL_HUMAN')"
>
数字人素材
</div>
</div>
<!-- 分组列表 -->
<div class="sidebar-section">
<div class="sidebar-section__header">
<span>分组列表</span>
</div>
<div class="sidebar-group-list">
<div
v-for="group in groupList"
:key="group.id"
class="sidebar-group-item"
:class="{ 'sidebar-group-item--active': selectedGroupId === group.id }"
@click="handleSelectGroup(group)"
>
<div class="sidebar-group-item__content">
<FolderOutlined class="sidebar-group-item__icon" />
<span class="sidebar-group-item__name">{{ group.name }}</span>
</div>
<span class="sidebar-group-item__count">{{ group.fileCount }}</span>
</div>
</div>
</div>
<!-- 新建分组按钮 -->
<div class="sidebar-actions">
<a-button
type="primary"
block
@click="handleOpenCreateGroupModal"
>
<template #icon>
<PlusOutlined />
</template>
新建分组
</a-button>
</div>
</div>
<!-- 右侧内容区域 -->
<div class="material-content">
<!-- 头部操作栏 -->
<div class="material-content__header">
<h2 class="material-content__title">
{{ getContentTitle() }}
</h2>
<div class="material-content__actions">
<a-button type="primary" @click="handleOpenUploadModal">
<template #icon>
<UploadOutlined />
</template>
上传素材
</a-button>
<a-button
v-if="selectedFileIds.length > 0"
type="primary"
status="danger"
@click="handleBatchDelete"
>
批量删除 ({{ selectedFileIds.length }})
</a-button>
<a-button
type="primary"
ghost
@click="$router.push('/material/mix')"
>
素材混剪
</a-button>
</div>
</div>
<!-- 搜索栏 -->
<div class="material-content__search">
<a-input
v-model="searchKeyword"
placeholder="搜索文件名"
style="width: 300px"
allow-clear
@press-enter="handleSearch"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<a-button type="primary" @click="handleSearch">搜索</a-button>
</div>
<!-- 文件列表 -->
<div class="material-content__list">
<a-spin :spinning="loading" tip="加载中..." style="width: 100%; min-height: 400px;">
<template v-if="fileList.length > 0">
<div class="material-grid">
<div
v-for="file in fileList"
:key="file.id"
class="material-item"
:class="{ 'material-item--selected': selectedFileIds.includes(file.id) }"
@click="handleFileClick(file)"
>
<div class="material-item__content">
<!-- 预览图 -->
<div class="material-item__preview">
<img
v-if="file.coverBase64"
:src="file.coverBase64"
:alt="file.fileName"
@error="handleImageError"
/>
<div v-else class="material-item__preview-placeholder">
<FileOutlined />
</div>
</div>
<!-- 文件信息 -->
<div class="material-item__info">
<div class="material-item__name" :title="file.fileName">
{{ file.fileName }}
</div>
<div class="material-item__meta">
<span class="material-item__size">{{ formatFileSize(file.fileSize) }}</span>
<span class="material-item__time">{{ formatDate(file.createTime) }}</span>
</div>
</div>
</div>
<!-- 选择框 -->
<div class="material-item__select">
<a-checkbox
:checked="selectedFileIds.includes(file.id)"
@change="(checked) => handleFileSelectChange(file.id, checked)"
@click.stop
/>
</div>
</div>
</div>
</template>
<a-empty v-else description="暂无素材" />
</a-spin>
</div>
<!-- 分页 -->
<div class="material-content__pagination">
<a-pagination
v-model:current="pagination.current"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:show-size-changer="true"
:show-quick-jumper="true"
:show-total="(total, range) => `${range[0]}-${range[1]} 条,共 ${total}`"
@change="handlePageChange"
@show-size-change="handlePageSizeChange"
/>
</div>
</div>
<!-- 上传素材弹窗 -->
<MaterialUploadModal
v-model:visible="uploadModalVisible"
:group-id="selectedGroupId"
@confirm="handleFileUpload"
/>
<!-- 新建分组弹窗 -->
<a-modal
v-model:visible="createGroupModalVisible"
title="新建分组"
@ok="handleCreateGroup"
@cancel="createGroupModalVisible = false"
>
<a-form :model="createGroupForm" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<a-form-item label="分组名称" name="name" :rules="[{ required: true, message: '请输入分组名称' }]">
<a-input v-model:value="createGroupForm.name" placeholder="请输入分组名称" />
</a-form-item>
<a-form-item label="分组分类">
<a-input v-model:value="createGroupForm.category" disabled />
</a-form-item>
<a-form-item label="分组描述">
<a-textarea v-model:value="createGroupForm.description" placeholder="请输入分组描述可选" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue';
import {
UploadOutlined,
SearchOutlined,
FileOutlined,
FolderOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue';
import MaterialService, { MaterialGroupService } from '@/api/material';
import { useUpload } from '@/composables/useUpload';
// 响应式数据
const loading = ref(false);
const activeCategory = ref('MIX'); // MIX 或 DIGITAL_HUMAN
const selectedGroupId = ref(null);
const groupList = ref([]);
const fileList = ref([]);
const totalFileCount = ref(0);
const searchKeyword = ref('');
const uploadModalVisible = ref(false);
const createGroupModalVisible = ref(false);
// Upload Hook
const { upload } = useUpload()
const createGroupForm = reactive({
name: '',
description: '',
category: 'MIX'
});
// 选中的文件ID列表
const selectedFileIds = ref([]);
// 分页信息
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0
});
// 方法
const handleCategoryChange = async (category) => {
activeCategory.value = category;
selectedGroupId.value = null;
createGroupForm.category = category;
await loadGroupList();
await loadFileList();
};
const handleSelectGroup = (group) => {
selectedGroupId.value = group.id;
loadFileList();
};
const handleOpenCreateGroupModal = () => {
createGroupForm.name = '';
createGroupForm.description = '';
createGroupForm.category = activeCategory.value;
createGroupModalVisible.value = true;
};
const handleCreateGroup = async () => {
try {
await MaterialGroupService.createGroup(createGroupForm);
message.success('分组创建成功');
createGroupModalVisible.value = false;
await loadGroupList();
} catch (error) {
message.error('分组创建失败');
}
};
const loadGroupList = async () => {
try {
const result = await MaterialGroupService.getGroupListByCategory(activeCategory.value);
groupList.value = result.data || result || [];
// 默认选中第一个分组
if (groupList.value.length > 0 && selectedGroupId.value === null) {
selectedGroupId.value = groupList.value[0].id;
}
await loadFileList();
} catch (error) {
console.error('加载分组列表失败:', error);
// 如果按分类查询失败,尝试加载所有分组
try {
const allGroups = await MaterialGroupService.getGroupList();
groupList.value = allGroups.data || allGroups || [];
// 默认选中第一个分组
if (groupList.value.length > 0 && selectedGroupId.value === null) {
selectedGroupId.value = groupList.value[0].id;
}
await loadFileList();
} catch (fallbackError) {
console.error('加载所有分组也失败:', fallbackError);
}
}
};
const loadFileList = async () => {
loading.value = true;
try {
const params = {
pageNo: pagination.current,
pageSize: pagination.pageSize,
fileCategory: 'video', // 只查询视频文件
fileName: searchKeyword.value || undefined,
groupId: selectedGroupId.value || undefined
};
const result = await MaterialService.getFilePage(params);
fileList.value = result.data?.list || result.list || [];
pagination.total = result.data?.total || result.total || 0;
totalFileCount.value = result.data?.total || result.total || 0;
} catch (error) {
console.error('加载文件列表失败:', error);
message.error('加载文件列表失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.current = 1;
loadFileList();
};
const handlePageChange = (page, pageSize) => {
pagination.current = page;
pagination.pageSize = pageSize;
loadFileList();
};
const handlePageSizeChange = (current, size) => {
pagination.current = 1;
pagination.pageSize = size;
loadFileList();
};
const handleFileClick = (file) => {
// 切换文件选中状态
const isSelected = selectedFileIds.value.includes(file.id);
if (isSelected) {
// 如果已选中,则取消选中
selectedFileIds.value = selectedFileIds.value.filter(id => id !== file.id);
} else {
// 如果未选中,则添加到选中列表
if (!selectedFileIds.value.includes(file.id)) {
selectedFileIds.value.push(file.id);
}
}
};
const handleFileSelectChange = (fileId, checked) => {
if (checked) {
if (!selectedFileIds.value.includes(fileId)) {
selectedFileIds.value.push(fileId);
}
} else {
selectedFileIds.value = selectedFileIds.value.filter(id => id !== fileId);
}
};
const handleOpenUploadModal = () => {
uploadModalVisible.value = true;
};
const handleFileUpload = async (filesWithCover, category, groupId) => {
try {
// 上传每个文件
for (const fileWithCover of filesWithCover) {
await upload(fileWithCover.file, {
fileCategory: category,
groupId, // 素材库模块使用groupId
coverBase64: fileWithCover.coverBase64,
onStart: () => {},
onProgress: () => {},
onSuccess: (id) => {
console.log('文件上传成功:', id)
},
onError: (error) => {
message.error(error.message || '上传失败')
}
})
}
message.success(`成功上传 ${filesWithCover.length} 个文件`);
uploadModalVisible.value = false;
await loadFileList();
await loadGroupList();
} catch (error) {
console.error("文件上传失败:", error);
message.error("文件上传失败: " + (error.message || "未知错误"));
}
};
const handleBatchDelete = async () => {
// TODO: 实现批量删除
message.info('批量删除功能待实现');
};
const getContentTitle = () => {
if (selectedGroupId.value === null) {
return activeCategory.value === 'MIX' ? '混剪素材' : '数字人素材';
}
const group = groupList.value.find(g => g.id === selectedGroupId.value);
return group ? group.name : '素材列表';
};
const formatFileSize = (size) => {
if (size < 1024) return size + ' B';
if (size < 1024 * 1024) return (size / 1024).toFixed(2) + ' KB';
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(2) + ' MB';
return (size / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
};
const formatDate = (date) => {
return new Date(date).toLocaleDateString();
};
const handleImageError = (e) => {
e.target.style.display = 'none';
};
// 监听分类变化
watch(activeCategory, () => {
selectedFileIds.value = [];
});
// 初始化
onMounted(() => {
loadGroupList();
});
</script>
<style scoped lang="less">
.material-list-container {
display: flex;
height: 100%;
}
.material-sidebar {
width: 256px;
background: #fff;
border-right: 1px solid #e8e8e8;
display: flex;
flex-direction: column;
padding: 16px;
overflow-y: auto;
}
.category-tabs {
display: flex;
background: #f5f5f5;
border-radius: 8px;
padding: 3px;
margin-bottom: 16px;
border: 1px solid #e8e8e8;
}
.category-tab {
flex: 1;
padding: 8px 16px;
text-align: center;
cursor: pointer;
background: transparent;
border-radius: 6px;
font-weight: 400;
font-size: 13px;
color: #666;
border: 1px solid transparent;
&:hover {
color: #1890ff;
background: rgba(24, 144, 255, 0.05);
}
&--active {
background: #fff;
color: #1890ff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #d9d9d9;
}
}
.sidebar-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
cursor: pointer;
border-radius: 6px;
margin-bottom: 4px;
transition: all 0.3s;
&:hover {
background: #f5f5f5;
}
&--active {
background: #e6f7ff;
color: #1890ff;
}
&__content {
display: flex;
align-items: center;
}
&__icon {
margin-right: 8px;
font-size: 16px;
}
&__name {
font-size: 14px;
}
&__count {
font-size: 12px;
color: #999;
}
}
.sidebar-section {
margin-top: 16px;
&__header {
padding: 8px 12px;
font-weight: 500;
color: #666;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
.sidebar-group-list {
max-height: 300px;
overflow-y: auto;
}
.sidebar-group-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
cursor: pointer;
border-radius: 6px;
margin-bottom: 2px;
transition: all 0.3s;
&:hover {
background: #f5f5f5;
}
&--active {
background: #e6f7ff;
color: #1890ff;
}
&__content {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
}
&__icon {
margin-right: 8px;
font-size: 14px;
flex-shrink: 0;
}
&__name {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__count {
font-size: 12px;
color: #999;
margin-left: 8px;
}
}
.sidebar-actions {
margin-top: auto;
padding-top: 16px;
}
.material-content {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
margin: 16px;
border-radius: 8px;
overflow: hidden;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #e8e8e8;
}
&__title {
margin: 0;
font-size: 18px;
font-weight: 500;
}
&__actions {
display: flex;
gap: 8px;
}
&__search {
padding: 16px 24px;
border-bottom: 1px solid #e8e8e8;
display: flex;
gap: 8px;
}
&__list {
flex: 1;
padding: 24px;
overflow-y: auto;
}
&__pagination {
padding: 16px 24px;
border-top: 1px solid #e8e8e8;
text-align: right;
}
}
.material-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.material-item {
position: relative;
border: 1px solid #e8e8e8;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&--selected {
border-color: #1890ff;
background: #e6f7ff;
}
&__content {
padding: 12px;
}
&__preview {
width: 100%;
height: 120px;
background: #f5f5f5;
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__preview-placeholder {
font-size: 48px;
color: #d9d9d9;
}
&__info {
display: flex;
flex-direction: column;
gap: 4px;
}
&__name {
font-size: 14px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #999;
}
&__select {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255, 255, 255, 0.9);
border-radius: 4px;
padding: 4px;
}
}
</style>

View File

@@ -1,15 +0,0 @@
<script setup>
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">下载</h2>
<div class="bg-white p-4 rounded shadow">提供Windows与Mac安装包下载占位</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -1,15 +0,0 @@
<script setup>
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">帮助与指南</h2>
<div class="bg-white p-4 rounded shadow">常见问题操作指引与联系支持</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -1,15 +0,0 @@
<script setup>
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">主题设置</h2>
<div class="bg-white p-4 rounded shadow">浅色/深色切换与品牌色配置占位</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -1,15 +0,0 @@
<script setup>
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">实时热点推送</h2>
<div class="bg-white p-4 rounded shadow">榜单看板订阅管理趋势联动</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -1,16 +0,0 @@
<script setup>
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">热点-文案创作</h2>
<p class="text-gray-600 text-sm">入口参数预填自热度/预测面板</p>
<div class="bg-white p-4 rounded shadow">结果编辑器与模块一共享逻辑</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -3,9 +3,12 @@
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
@@ -19,13 +22,18 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@gold/*": ["../../*"],
"@/types/*": ["./src/types/*"]
"@/*": [
"./src/*"
],
"@gold/*": [
"../../*"
],
"@/types/*": [
"./src/types/*"
]
}
},
"include": [
@@ -40,6 +48,9 @@
"node_modules",
"dist"
],
"references": [{ "path": "./tsconfig.node.json" }]
}
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@@ -30,28 +30,7 @@ export default defineConfig(({ mode }) => {
UnoCSS(),
vueDevTools(),
tailwindcss()
// electron({
// main: {
// // Shortcut of `build.lib.entry`.
// entry: 'electron/main.ts',
// vite: {
// build: {
// rollupOptions: {
// external: ['better-sqlite3'],
// },
// },
// },
// },
// preload: {
// input: fileURLToPath(new URL('./electron/preload.ts', import.meta.url)),
// },
// // See 👉 https://github.com/electron-vite/vite-plugin-electron-renderer
// renderer:
// process.env.NODE_ENV === 'test'
// ? // https://github.com/electron-vite/vite-plugin-electron-renderer/issues/78#issuecomment-2053600808
// undefined
// : {},
// }),
],
resolve: {
alias: {
@@ -82,6 +61,17 @@ export default defineConfig(({ mode }) => {
})
},
},
// OSS 代理 - 用于直传上传
'/oss': {
target: 'https://muye-ai-chat.oss-cn-hangzhou.aliyuncs.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/oss/, ''),
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': '*',
},
},
},
},
}