This commit is contained in:
hrx 2026-06-04 11:17:47 +08:00
parent 5df9a86325
commit 7b6d76a351
26 changed files with 3381 additions and 796 deletions

View File

@ -8,6 +8,39 @@
</head>
<body>
<div id="app"></div>
<script>
;(function () {
var syncPath = '#/getCookie'
var reloadPrefix = 'yuanjing-get-cookie-reload:'
function isGetCookieRoute() {
return window.location.hash.indexOf(syncPath) === 0
}
function reloadOnce(reason) {
if (!isGetCookieRoute()) return
var key = reloadPrefix + reason + ':' + window.location.hash
if (window.sessionStorage.getItem(key)) return
window.sessionStorage.setItem(key, '1')
var url = new URL(window.location.href)
url.searchParams.set('_syncReload', Date.now().toString())
window.location.replace(url.toString())
}
window.addEventListener('hashchange', function () {
reloadOnce('hashchange')
})
window.setTimeout(function () {
var app = document.getElementById('app')
if (app && app.childElementCount === 0) {
reloadOnce('empty-app')
}
}, 1200)
})()
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -6,6 +6,9 @@ export const textvideo = <T,D>(data:D):Promise<T> =>post('/llmage/vidu_inference
// 图生视频
export const imagetovideo = <T,D>(data:D):Promise<T> =>post('/llmage/vidu_inference.dspy',data)
// 参考生视频
export const referencevideo = <T,D>(data:D):Promise<T> =>post('/llmage/video',data)
// 获取所有模型
export const AllModel = <T,D>(data:D):Promise<T> =>get('/llmage/get_type_llms.dspy',data)

Binary file not shown.

View File

@ -368,7 +368,7 @@
.custom-dialog {
width: 100%;
max-width: 420px;
max-width: 600px;
background: linear-gradient(180deg, rgba(23, 26, 37, 0.98), rgba(15, 18, 27, 0.99));
backdrop-filter: blur(18px);
border-radius: 24px;
@ -381,7 +381,7 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 30px;
margin: 20px 30px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
h3 {

View File

@ -0,0 +1,426 @@
.reference-to-video {
height: auto;
min-height: 100%;
display: flex;
flex-direction: column;
gap: 16px!important;
overflow: visible;
}
:deep(.el-select__wrapper) {
width: 100% !important;
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 0 !important;
font-size: 14px;
color: #fff !important;
resize: vertical;
outline: none !important;
border-radius: 10px !important;
color: #fff;
}
:deep(.el-select__selected-item){
color: #fff!important;
}
:deep(.el-textarea) {
.el-textarea__inner {
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 0 !important;
padding: 12px 0 !important;
font-size: 14px;
line-height: 1.5;
color: #fff !important;
resize: vertical;
outline: none !important;
border-radius: 10px !important;
color: #fff;
backdrop-filter: blur(30px);
padding: 14px 16px !important;
&:focus {
border: none !important;
box-shadow: none !important;
outline: none !important;
}
&::placeholder {
color: #ffffff7a !important;
font-size: 14px;
}
}
// 确保没有任何附加的边框或装饰出现
.el-input__count {
background: transparent;
color: #ffffff7a;
}
}
.card {
width: 100%;
background-color: #1a1c20;
border: none;
flex: 0 0 auto;
min-height: fit-content;
margin-bottom: 16px;
padding: 16px;
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: visible;
:deep(.el-card__body) {
height: auto;
padding: 12px 16px;
overflow: visible;
}
}
.reference-container {
flex: none;
gap: 20px;
color: #fff;
overflow: visible;
}
.reference-header {
display: flex;
flex-direction: column;
gap: 18px;
padding: 8px 0 4px;
flex-shrink: 0;
}
.reference-header__copy {
display: flex;
flex-direction: column;
gap: 8px;
}
.reference-header__title {
font-size: 22px;
line-height: 1.35;
font-weight: 700;
color: #fff;
}
.reference-header__desc {
font-size: 15px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.72);
}
.reference-header__actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.hidden-upload-input {
display: none;
}
.reference-action-btn {
display: inline-flex;
align-items: center;
gap: 10px;
height: 46px;
padding: 0 22px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
color: #fff;
font-size: 16px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.18);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.reference-action-btn__icon {
font-size: 26px;
line-height: 1;
font-weight: 300;
}
.reference-assets {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.reference-asset-card {
width: 180px;
overflow: hidden;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.reference-asset-card__image {
display: block;
width: 100%;
height: 110px;
object-fit: cover;
background: rgba(0, 0, 0, 0.24);
}
.reference-asset-card--audio {
display: flex;
flex-direction: column;
justify-content: space-between;
}
.reference-asset-card__audio {
display: flex;
align-items: center;
gap: 12px;
min-height: 110px;
padding: 16px;
}
.reference-asset-card__audio-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
font-weight: 600;
}
.reference-asset-card__audio-info {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.reference-asset-card__audio-title {
font-size: 14px;
color: #fff;
}
.reference-asset-card__audio-name {
max-width: 100px;
font-size: 12px;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dialog-preview-media {
display: block;
width: 100%;
max-height: 70vh;
object-fit: contain;
}
.dialog-preview-audio {
display: block;
width: 100%;
}
.reference-asset-card__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
}
.reference-asset-card__label {
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
}
.reference-asset-card__actions {
display: flex;
align-items: center;
gap: 8px;
}
.input {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
:deep(.el-textarea) {
flex: 1;
display: flex;
flex-direction: column;
}
:deep(.el-textarea__inner) {
flex: 1;
min-height: 150px;
}
:deep(.el-input__inner) {
background-color: rgba(0, 0, 0, 0.32);
border: none;
border-radius: 10px;
color: #fff;
backdrop-filter: blur(30px);
padding: 14px 16px;
font-size: 14px;
line-height: 24px;
&::placeholder {
color: rgba(255, 255, 255, 0.36);
}
&:focus {
box-shadow: none;
border-color: transparent;
}
}
}
.btmcard {
width: 100%;
background-color: #1a1c20;
border: none;
flex-shrink: 0;
padding: 16px;
border-radius: 12px;
overflow: visible;
:deep(.el-card__body) {
padding: 12px 16px;
}
}
.select {
display: flex;
flex-direction: column;
gap: 16px;
}
.select-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}
.select-title {
width: 50%;
font-size: 14px;
color: #fff;
padding-right: 12px;
}
.required-star {
margin-left: 4px;
color: #ff8f8f;
}
.select-content {
width: 50%;
display: flex;
gap: 8px;
}
:deep(.el-input-number) {
width: 100%;
.el-input__wrapper {
background-color: rgba(0, 0, 0, 0.32) !important;
border: none !important;
box-shadow: none !important;
border-radius: 10px !important;
}
.el-input__inner {
color: #fff !important;
background-color: transparent !important;
}
.el-input-number__decrease,
.el-input-number__increase {
background-color: rgba(255, 255, 255, 0.08);
color: #fff;
border: none;
}
}
.radio-group {
width: 50%;
display: flex;
background-color: rgba(255, 255, 255, 0.04);
border-radius: 8px;
overflow: hidden;
}
.and {
color: #ffffff66;
margin: 0 4px;
}
.radio-item {
flex: 1;
height: 36px;
border: none;
background: transparent;
color: white;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.radio-item.selected {
background-color: rgba(255, 255, 255, 0.08);
position: relative;
}
.radio-item.premium {
background-clip: text;
-webkit-background-clip: text;
color: transparent;
background-image: linear-gradient(to bottom, #7280FF, #DADFFF);
font-weight: 600;
}
.uploaded-action {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.12);
color: #fff;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.24);
}
.el-icon {
font-size: 16px;
}
}

View File

@ -43,10 +43,10 @@
<span class="item-label">用户名:</span>
<span class="item-value">{{ loginStore.userInfo?.user?.name || loginStore.userInfo?.user?.username }}</span>
</div>
<div class="dropdown-item user-id">
<!-- <div class="dropdown-item user-id">
<span class="item-label">用户ID:</span>
<span class="item-value">{{ loginStore.userInfo?.user?.id }}</span>
</div>
</div> -->
<div class="dropdown-divider"></div>
<div class="dropdown-item logout" @click="handleLogout">
<svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -62,9 +62,9 @@
</template>
<!-- 未登录登录按钮 -->
<div v-else class="btn" @click="$router.push('/login')">
<!-- <div v-else class="btn" @click="$router.push('/login')">
登录
</div>
</div> -->
</div>
</div>

View File

@ -16,11 +16,11 @@
<el-select v-model="statusFilter" placeholder="全部状态" style="width: 240px" popper-class="custom-select-popup" @change="handleFilterChange">
<el-option label="全部状态" value="" />
<el-option label="已创建" value="created" />
<el-option label="处理中" value="processing" />
<el-option label="排队中" value="queueing" />
<el-option label="已完成" value="succeeded" />
<el-option label="失败" value="failed" />
<el-option label="已创建" value="CREATED" />
<el-option label="运行中" value="RUNNING" />
<el-option label="排队中" value="PENDING" />
<el-option label="成功" value="SUCCEEDED" />
<el-option label="失败" value="FAILED" />
</el-select>
</div>
@ -88,7 +88,7 @@
<div class="video-player">
<div class="player-container">
<!-- 视频生成中提示 -->
<div v-if="!task.video_url && (task.status === 'created' || task.status === 'processing' || task.status === 'queueing' || task.status === 'running' || task.status === 'PENDING')" class="generating-status">
<div v-if="!task.video_url && isInProgressStatus(task.status)" class="generating-status">
<div class="spinner"></div>
<div class="generating-text">{{ getStatusText(task.status) }}</div>
</div>
@ -189,7 +189,7 @@ const filteredTasks = computed(() => {
//
if (showProcessing.value) {
tasks = tasks.filter(task => task.status === 'processing');
tasks = tasks.filter(task => isInProgressStatus(task.status));
}
//
@ -213,6 +213,27 @@ const filteredTasks = computed(() => {
*/
const handleFilterChange = () => {};
const normalizeTaskStatus = (status?: string): string => {
const upperStatus = String(status || '').toUpperCase();
const statusMap: Record<string, string> = {
CREATED: 'CREATED',
PENDING: 'PENDING',
QUEUEING: 'PENDING',
RUNNING: 'RUNNING',
PROCESSING: 'RUNNING',
SUCCEEDED: 'SUCCEEDED',
COMPLETED: 'SUCCEEDED',
FAILED: 'FAILED'
};
return statusMap[upperStatus] || upperStatus;
};
const isInProgressStatus = (status?: string) => {
const normalizedStatus = normalizeTaskStatus(status);
return normalizedStatus === 'CREATED' || normalizedStatus === 'PENDING' || normalizedStatus === 'RUNNING';
};
const getPreviewBgStyle = (coverUrl?: string) => {
if (!coverUrl) {
return {};
@ -229,16 +250,14 @@ const getPreviewBgStyle = (coverUrl?: string) => {
*/
const getStatusText = (status: string): string => {
const statusMap: Record<string, string> = {
'created': '已创建',
'processing': '处理中',
'running': '处理中',
'queueing': '排队中',
'succeeded': '已完成',
'completed': '已完成',
'failed': '失败',
'PENDING': '处理中'
'CREATED': '已创建',
'PENDING': '排队中',
'RUNNING': '运行中',
'SUCCEEDED': '成功',
'FAILED': '失败'
};
return statusMap[status] || status;
const normalizedStatus = normalizeTaskStatus(status);
return statusMap[normalizedStatus] || normalizedStatus;
};
/**
@ -247,16 +266,14 @@ const getStatusText = (status: string): string => {
*/
const getStatusClass = (status: string): string => {
const classMap: Record<string, string> = {
'created': 'status-created',
'processing': 'status-processing',
'running': 'status-processing',
'queueing': 'status-queueing',
'succeeded': 'status-succeeded',
'completed': 'status-completed',
'failed': 'status-failed',
'PENDING': 'status-processing'
'CREATED': 'status-created',
'PENDING': 'status-queueing',
'RUNNING': 'status-processing',
'SUCCEEDED': 'status-succeeded',
'FAILED': 'status-failed'
};
return classMap[status] || '';
const normalizedStatus = normalizeTaskStatus(status);
return classMap[normalizedStatus] || '';
};
/**
@ -369,7 +386,7 @@ const updateTaskStatus = async (taskId: string) => {
const taskIndex = taskList.value.findIndex(t => t.taskid === taskId);
if (taskIndex !== -1) {
// res.data.resposne.status
const newStatus = res.data.resposne.status?.toLowerCase() || taskList.value[taskIndex].status;
const newStatus = normalizeTaskStatus(res.data.resposne.status || taskList.value[taskIndex].status);
taskList.value[taskIndex].status = newStatus;
// res.data.resposne
@ -391,13 +408,7 @@ const updateTaskStatus = async (taskId: string) => {
*/
const pollTasksStatus = () => {
//
const processingTasks = taskList.value.filter(task =>
task.status === 'created' ||
task.status === 'processing' ||
task.status === 'queueing' ||
task.status === 'running' ||
task.status === 'PENDING'
);
const processingTasks = taskList.value.filter(task => isInProgressStatus(task.status));
if (processingTasks.length === 0) {
//
@ -457,7 +468,7 @@ const loadTaskList = async () => {
return {
taskid: task.taskid,
status: task.status?.toLowerCase() || finalOutput.status?.toLowerCase() || 'created',
status: normalizeTaskStatus(task.status || finalOutput.status || 'CREATED'),
prompt: input.prompt,
cover_url: finalOutput.image || undefined,
video_url: finalOutput.video || undefined,
@ -471,13 +482,7 @@ const loadTaskList = async () => {
});
//
const hasProcessingTasks = taskList.value.some(task =>
task.status === 'created' ||
task.status === 'processing' ||
task.status === 'queueing' ||
task.status === 'running' ||
task.status === 'PENDING'
);
const hasProcessingTasks = taskList.value.some(task => isInProgressStatus(task.status));
if (hasProcessingTasks) {
startPolling();

View File

@ -73,6 +73,8 @@ const handleTabClick = (type: string) => {
.stencil-item {
cursor: pointer;
position: relative;
padding-bottom: 8px;
}
.stencil-item.active .item-title {
@ -80,6 +82,18 @@ const handleTabClick = (type: string) => {
font-weight: bold;
}
.stencil-item.active::after {
content: '';
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
width: 24px;
height: 2px;
border-radius: 999px;
background-color: #fff;
}
.item-title {
font-size: 16px;
color: rgba(255, 255, 255, 0.7);

View File

@ -3,7 +3,7 @@
<div class="content">
<div ref="container" class="waterfall-container" :style="{
width: '100%',
height: 'auto',
height: '100%',
overflow: 'hidden auto',
position: 'relative',
willChange: 'transform',
@ -29,7 +29,7 @@
left: `${item.left}px`,
}">
<!-- 卡片内容保持不变 -->
<div class="card-wrapper">
<div class="card-wrapper" @click="openDetail(item)">
<div class="video-container" :style="{ aspectRatio: item.aspectRatio }"
@mouseenter="handleMouseEnter(index)" @mouseleave="handleMouseLeave(index)">
<video :ref="(el) => setVideoRef(index, el as HTMLVideoElement | null)" :src="item.src"
@ -63,6 +63,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getVideos } from '@/apis/home'
import { transformFeedItem, transformShareElementItem } from '@/utlis/transform'
import type { FeedResponse, ShareElementsResponse } from '@/types/home'
@ -81,6 +82,9 @@ const emit = defineEmits<{
(e: 'loadMore'): void
}>()
const router = useRouter()
const DETAIL_STORAGE_KEY = 'material-square-detail'
// ==================== ====================
const items = ref<StencilItem[]>([])
const loading = ref(false)
@ -112,6 +116,25 @@ const handleMouseLeave = (index: number) => {
}
}
const openDetail = (item: StencilItem) => {
sessionStorage.setItem(
DETAIL_STORAGE_KEY,
JSON.stringify({
selectedId: item.id,
items: items.value,
activeTab: props.activeTab,
})
)
router.push({
path: '/material-square/detail',
query: {
id: item.id,
tab: props.activeTab,
},
})
}
// ==================== ====================
const container = ref<HTMLElement>()
const innerScrollContainer = ref<HTMLElement>()
@ -396,6 +419,7 @@ defineExpose({ refreshLayout: layout })
.card-wrapper {
width: 100%;
position: relative;
cursor: pointer;
}
/* 视频容器 */

View File

@ -3,14 +3,20 @@
<div class="top">
<!-- 左侧菜单浮层半透明 -->
<div class="top-left">
<div class="left-item">
<div
class="left-item"
:class="{ 'is-active': currentFeatureType === 'reference' }"
>
<div class="title">参考生视频</div>
<div class="detail">万物皆可自由组合搭配无限创意</div>
<div class="item-btn">
<div class="btn" @click="$router.push('/videoGenerate/reference')">立即使用</div>
</div>
</div>
<div class="left-item">
<div
class="left-item"
:class="{ 'is-active': currentFeatureType === 'image' }"
>
<div class="title">图生视频</div>
<div class="detail">20s让图片动起来首尾帧无缝融合过渡丝滑自然</div>
<div class="item-btn">
@ -73,7 +79,7 @@
</template>
<script setup lang="ts">
import { ref, onBeforeUnmount } from 'vue'
import { computed, ref, onBeforeUnmount } from 'vue'
import { Swiper, SwiperSlide } from 'swiper/vue'
import { Autoplay, Pagination } from 'swiper/modules'
import 'swiper/swiper-bundle.css'
@ -188,6 +194,11 @@ const AUTO_PLAY_INTERVAL = 5000
//
const currentIndex = ref(0)
const currentFeatureType = computed<'reference' | 'image'>(() => {
const currentSlide = slides[currentIndex.value]
return currentSlide?.subtitle.includes('图生视频') ? 'image' : 'reference'
})
// Swiper
const swiperInstance = ref<SwiperInstance | null>(null)
@ -284,6 +295,14 @@ onBeforeUnmount(() => {
background-color: rgba(255, 255, 255, 0.08);
}
&.is-active {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.04) 100%);
}
&.is-active .detail {
opacity: 0.72;
}
.title {
font-size: 16px;
font-weight: 600;
@ -308,10 +327,18 @@ onBeforeUnmount(() => {
border-radius: 16px;
line-height: 24px;
display: inline-block;
transition: background-color 0.2s, color 0.2s;
}
}
}
.left-item.is-active {
.btn {
background-color: rgba(255, 255, 255, 0.28);
color: #fff;
}
}
.top-right {
position: absolute;
top: 0;
@ -366,7 +393,7 @@ onBeforeUnmount(() => {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
gap: 4px;
pointer-events: none;
.thumbnail-img {
@ -381,10 +408,13 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 0.2s;
transition: opacity 0.2s, transform 0.2s;
position: relative;
z-index: 1;
&:hover {
opacity: 0.8;
transform: translateX(-2px);
}
img {
@ -405,6 +435,9 @@ onBeforeUnmount(() => {
backdrop-filter: blur(10px);
color: white;
font-size: 16px;
margin: -8px 0;
z-index: 2;
// border: 2px solid rgba(18, 20, 24, 0.72);
&.play-btn {
background-color: rgba(255, 255, 255, 0.24);
@ -414,6 +447,11 @@ onBeforeUnmount(() => {
width: 16px;
height: 16px;
}
&.play-btn svg {
transform: rotate(90deg);
transform-origin: center;
}
}
}

View File

@ -18,12 +18,6 @@
<span class="menu-text">首页</span>
</div>
</el-menu-item>
<el-menu-item index="bill">
<div class="menu-item-content">
<i class="iconfont icon-xiaofeijilu"></i>
<span class="menu-text">消费记录</span>
</div>
</el-menu-item>
</el-menu>
</div>
@ -52,25 +46,10 @@
<span class="menu-text">图片生成</span>
</div>
</el-menu-item>
</el-menu>
</div>
<!-- 创作工具分组 -->
<div class="tool-navigation mb-8">
<div class="menu-title">创作工具</div>
<el-menu
:default-active="activeIndex"
class="custom-menu"
background-color="transparent"
text-color="#fff"
active-text-color="#00FFCC"
@select="handleSelect"
>
<el-menu-item index="material-square">
<el-menu-item index="/TextGenerate">
<div class="menu-item-content">
<i class="iconfont icon-image-edit"></i>
<span class="menu-text">素材广场</span>
<i class="iconfont icon-apikey"></i>
<span class="menu-text">文本生成</span>
</div>
</el-menu-item>
</el-menu>
@ -86,7 +65,13 @@
active-text-color="#00FFCC"
@select="handleSelect"
>
<el-menu-item index="apikey">
<el-menu-item index="bill">
<div class="menu-item-content">
<i class="iconfont icon-xiaofeijilu"></i>
<span class="menu-text">消费记录</span>
</div>
</el-menu-item>
<el-menu-item index="apikey">
<div class="menu-item-content">
<i class="iconfont icon-apikey"></i>
<span class="menu-text">令牌管理</span>
@ -139,6 +124,9 @@ const handleSelect = (index: string) => {
case '/ImgGenerate':
router.push('/ImgGenerate');
break;
case '/TextGenerate':
router.push('/TextGenerate');
break;
case 'digital-human':
router.push('/digital-human');
break;
@ -170,6 +158,8 @@ const updateActiveIndex = () => {
activeIndex.value = '/videoGenerate';
} else if (path.includes('/ImgGenerate')) {
activeIndex.value = '/ImgGenerate';
} else if (path.includes('/TextGenerate')) {
activeIndex.value = '/TextGenerate';
} else if (path.includes('/digital-human')) {
activeIndex.value = 'digital-human';
} else if (path.includes('/material-square')) {

View File

@ -78,7 +78,7 @@
<el-option
v-for="model in models"
:key="model.id"
:label="model.name"
:label="model.model"
:value="model.id"
/>
</el-select>
@ -135,15 +135,54 @@
<!-- 音频上传示例 -->
<div v-else-if="field.uitype === 'audio'" class="audio-wrapper">
<el-input
v-model="configData[field.name]"
placeholder="请输入音频文件URL或点击上传"
clearable
:disabled="generating"
<input
:ref="(el) => setAudioInputRef(field.name, el)"
class="hidden-upload-input"
type="file"
accept="audio/*"
@change="(event) => handleAudioSelect(event, field.name)"
/>
<el-button size="small" @click="simulateUpload" :disabled="generating" class="upload-btn">
上传
</el-button>
<div class="audio-upload-card" :class="{ 'has-file': !!audioFileNames[field.name] }">
<div class="audio-upload-main">
<div class="audio-file-copy">
<div class="audio-file-name" :title="audioFileNames[field.name] || ''">
{{ audioFileNames[field.name] || '未上传音频文件' }}
</div>
<div class="audio-file-tip">
{{
audioFileNames[field.name]
? '可试听或重新上传'
: '支持 MP3、WAV、M4A 等格式'
}}
</div>
</div>
<div class="audio-actions">
<el-button
size="small"
@click="triggerAudioUpload(field.name)"
:disabled="generating"
class="audio-action-btn primary"
>
{{ audioFileNames[field.name] ? '重新上传' : '上传' }}
</el-button>
<button
v-if="audioFileNames[field.name]"
type="button"
class="audio-text-btn"
@click="removeAudioFile(field.name)"
:disabled="generating"
>
删除
</button>
</div>
</div>
<audio
v-if="audioPreviewUrls[field.name]"
class="audio-preview"
:src="audioPreviewUrls[field.name]"
controls
/>
</div>
</div>
</div>
</div>
@ -198,6 +237,10 @@ const uploadedFiles = ref<Record<string, string>>({});
const uploadedFileNames = ref<Record<string, string>>({});
/** 其他配置项数据(除 prompt 和图片外) */
const configData = ref<Record<string, ConfigValue>>({});
const audioFiles = ref<Record<string, File | null>>({});
const audioFileNames = ref<Record<string, string>>({});
const audioPreviewUrls = ref<Record<string, string>>({});
const audioInputRefs = ref<Record<string, HTMLInputElement | null>>({});
/** 是否正在生成视频(用于禁用交互) */
const generating = ref(false);
@ -211,6 +254,68 @@ const dialogVisible = ref(false);
/** 价格查询防抖计时器 */
let priceTimer: ReturnType<typeof setTimeout> | null = null;
const setAudioInputRef = (fieldName: string, el: unknown) => {
if (el instanceof HTMLInputElement) {
audioInputRefs.value[fieldName] = el;
return;
}
delete audioInputRefs.value[fieldName];
};
const triggerAudioUpload = (fieldName: string) => {
audioInputRefs.value[fieldName]?.click();
};
const resetAudioInputValue = (fieldName: string) => {
const input = audioInputRefs.value[fieldName];
if (input) {
input.value = '';
}
};
const revokeAudioPreviewUrl = (fieldName: string) => {
const url = audioPreviewUrls.value[fieldName];
if (url) {
URL.revokeObjectURL(url);
}
};
const removeAudioFile = (fieldName: string, silent = false) => {
revokeAudioPreviewUrl(fieldName);
delete audioFiles.value[fieldName];
delete audioFileNames.value[fieldName];
delete audioPreviewUrls.value[fieldName];
configData.value[fieldName] = '';
resetAudioInputValue(fieldName);
if (!silent) {
ElMessage.info('已移除音频');
}
};
const handleAudioSelect = (event: Event, fieldName: string) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!file.type.startsWith('audio/')) {
ElMessage.warning('请上传音频文件');
resetAudioInputValue(fieldName);
return;
}
if (audioPreviewUrls.value[fieldName]) {
revokeAudioPreviewUrl(fieldName);
}
audioFiles.value[fieldName] = file;
audioFileNames.value[fieldName] = file.name;
audioPreviewUrls.value[fieldName] = URL.createObjectURL(file);
configData.value[fieldName] = file.name;
resetAudioInputValue(fieldName);
ElMessage.success('音频上传成功');
};
// ==================== ====================
/** 图片类型的字段列表 */
const imageFields = computed(() => {
@ -241,6 +346,13 @@ const promptPlaceholder = computed(() => {
return promptField?.required ? '请输入提示词(必填)' : '请输入提示词';
});
const audioFieldNames = computed(() => {
if (!currentModel.value) return [];
return currentModel.value.input_fields
.filter(field => field.uitype === 'audio')
.map(field => field.name);
});
// ==================== ====================
/**
* 根据当前模型初始化 configData 对象
@ -396,6 +508,7 @@ const loadModels = async () => {
const handleModelChange = (modelId: string) => {
const selected = models.value.find(m => m.id === modelId);
if (selected) {
audioFieldNames.value.forEach(fieldName => removeAudioFile(fieldName, true));
currentModel.value = selected;
//
uploadedFiles.value = {};
@ -476,19 +589,12 @@ const handlePictureCardPreview = (file: UploadFile) => {
dialogVisible.value = true;
};
/**
* 模拟音频上传示例
*/
const simulateUpload = () => {
ElMessage.info('此处可对接实际音频上传服务');
configData.value.audio_file = 'https://example.com/demo_audio.mp3';
};
// ==================== ====================
/**
* 构建提交给后端的参数对象
*/
const buildSubmitParams = (): Record<string, unknown> => {
const buildSubmitParams = (): Record<string, unknown> | FormData => {
const hasAudioUpload = audioFieldNames.value.some(fieldName => !!audioFiles.value[fieldName]);
const baseParams: Record<string, unknown> = {
llmid: currentModel.value!.id,
};
@ -506,18 +612,37 @@ const buildSubmitParams = (): Record<string, unknown> => {
});
//
Object.keys(configData.value).forEach(key => {
if (configData.value[key] !== undefined) {
const value = configData.value[key];
if (key === 'audio_file') {
baseParams.audio = Boolean(value);
} else {
baseParams[key] = value;
}
currentModel.value?.input_fields.forEach(field => {
if (field.uitype === 'image' || field.uitype === 'audio' || field.name === 'prompt') {
return;
}
const value = configData.value[field.name];
if (value !== undefined && value !== null && value !== '') {
baseParams[field.name] = field.uitype === 'int' ? Number(value) : value;
}
});
return baseParams;
baseParams.audio = hasAudioUpload;
if (!hasAudioUpload) {
return baseParams;
}
const formData = new FormData();
Object.entries(baseParams).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
formData.append(key, String(value));
});
audioFieldNames.value.forEach(fieldName => {
const file = audioFiles.value[fieldName];
if (file) {
formData.append(fieldName, file, file.name);
}
});
return formData;
};
/**
@ -525,6 +650,10 @@ const buildSubmitParams = (): Record<string, unknown> => {
* @returns 是否成功创建任务
*/
const generate = async (): Promise<boolean> => {
if (generating.value) {
return false;
}
if (!currentModel.value) {
ElMessage.error('请选择模型');
return false;
@ -559,9 +688,9 @@ const generate = async (): Promise<boolean> => {
const taskid = res.data?.taskid || res.taskid;
if (res.status === 'created' || res.status === 'processing' || res.status === 'queueing' || res.status === 'CREATED') {
if (res.status === 'created' ||res.status ==='PENDING' || res.status === 'processing' || res.status === 'queueing' || res.status === 'CREATED') {
const statusMsg =
res.status === 'created'
res.status === 'created' || res.status === 'PENDING' || res.status === 'CREATED'
? '视频生成任务已提交'
: res.status === 'processing'
? '进行中'
@ -593,6 +722,7 @@ const resetForm = () => {
prompt.value = '';
uploadedFiles.value = {};
uploadedFileNames.value = {};
audioFieldNames.value.forEach(fieldName => removeAudioFile(fieldName, true));
initFormData();
price.value = 0;
emit('price-update', 0);
@ -616,6 +746,9 @@ onBeforeUnmount(() => {
clearTimeout(priceTimer);
priceTimer = null;
}
Object.keys(audioPreviewUrls.value).forEach(fieldName => {
revokeAudioPreviewUrl(fieldName);
});
});
</script>
@ -735,4 +868,112 @@ onBeforeUnmount(() => {
.custom-select-popup .el-select-dropdown__item:hover {
background-color: #3a3d46 !important;
}
.hidden-upload-input {
display: none;
}
.audio-wrapper {
width: 100%;
}
.audio-upload-card {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
box-sizing: border-box;
}
.audio-upload-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.audio-upload-card {
padding: 10px 12px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.32);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.audio-upload-card.has-file {
border-color: rgba(148, 216, 255, 0.22);
background: rgba(255, 255, 255, 0.05);
}
.audio-file-copy {
min-width: 0;
flex: 1;
}
.audio-file-name {
color: #fff;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audio-file-tip {
margin-top: 4px;
color: rgba(255, 255, 255, 0.56);
font-size: 12px;
line-height: 1.2;
}
.audio-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
align-items: center;
}
.audio-action-btn {
height: 30px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 12px;
}
.audio-action-btn:hover {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.audio-action-btn.primary {
border-color: rgba(148, 216, 255, 0.28);
background: rgba(122, 164, 198, 0.32);
color: #fff;
}
.audio-text-btn {
padding: 0;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.62);
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.audio-text-btn:hover {
color: #fff;
}
.audio-text-btn:disabled {
cursor: not-allowed;
color: rgba(255, 255, 255, 0.32);
}
.audio-preview {
width: 100%;
height: 32px;
opacity: 0.9;
}
@media (max-width: 768px) {
.audio-upload-main {
flex-direction: column;
align-items: stretch;
}
.audio-actions {
width: 100%;
justify-content: space-between;
}
.audio-action-btn {
flex: 0 0 auto;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -33,7 +33,7 @@
<el-option
v-for="model in models"
:key="model.id"
:label="model.name"
:label="model.model"
:value="model.id"
/>
</el-select>
@ -90,15 +90,54 @@
<!-- 音频上传 -->
<div v-else-if="field.uitype === 'audio'" class="audio-wrapper">
<el-input
v-model="configData[field.name]"
placeholder="请输入音频文件URL或点击上传"
clearable
:disabled="generating"
<input
:ref="(el) => setAudioInputRef(field.name, el)"
class="hidden-upload-input"
type="file"
accept="audio/*"
@change="(event) => handleAudioSelect(event, field.name)"
/>
<el-button size="small" @click="simulateUpload" :disabled="generating" class="upload-btn">
上传
</el-button>
<div class="audio-upload-card" :class="{ 'has-file': !!audioFileNames[field.name] }">
<div class="audio-upload-main">
<div class="audio-file-copy">
<div class="audio-file-name" :title="audioFileNames[field.name] || ''">
{{ audioFileNames[field.name] || '未上传音频文件' }}
</div>
<div class="audio-file-tip">
{{
audioFileNames[field.name]
? '可试听或重新上传'
: '支持 MP3、WAV、M4A 等格式'
}}
</div>
</div>
<div class="audio-actions">
<el-button
size="small"
@click="triggerAudioUpload(field.name)"
:disabled="generating"
class="audio-action-btn primary"
>
{{ audioFileNames[field.name] ? '重新上传' : '上传' }}
</el-button>
<button
v-if="audioFileNames[field.name]"
type="button"
class="audio-text-btn"
@click="removeAudioFile(field.name)"
:disabled="generating"
>
删除
</button>
</div>
</div>
<audio
v-if="audioPreviewUrls[field.name]"
class="audio-preview"
:src="audioPreviewUrls[field.name]"
controls
/>
</div>
</div>
<!-- 其他未知类型 -->
@ -118,7 +157,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { textvideo, AllModel, queryModelPrice } from '../../apis/Model';
import type {
@ -152,6 +191,10 @@ const prompt = ref('');
/** 其他配置字段的集合 */
const configData = ref<Record<string, ConfigValue>>({});
const audioFiles = ref<Record<string, File | null>>({});
const audioFileNames = ref<Record<string, string>>({});
const audioPreviewUrls = ref<Record<string, string>>({});
const audioInputRefs = ref<Record<string, HTMLInputElement | null>>({});
const generating = ref(false);
const price = ref(0);
@ -169,6 +212,13 @@ const promptPlaceholder = computed(() => {
return '请输入提示词';
});
const audioFieldNames = computed(() => {
if (!currentModel.value) return [];
return currentModel.value.input_fields
.filter(field => field.uitype === 'audio')
.map(field => field.name);
});
// ==================== ====================
const initFormData = () => {
if (!currentModel.value) return;
@ -230,6 +280,68 @@ const buildPriceParams = (): PriceQueryParams | null => {
let priceTimer: ReturnType<typeof setTimeout> | null = null;
const setAudioInputRef = (fieldName: string, el: unknown) => {
if (el instanceof HTMLInputElement) {
audioInputRefs.value[fieldName] = el;
return;
}
delete audioInputRefs.value[fieldName];
};
const triggerAudioUpload = (fieldName: string) => {
audioInputRefs.value[fieldName]?.click();
};
const resetAudioInputValue = (fieldName: string) => {
const input = audioInputRefs.value[fieldName];
if (input) {
input.value = '';
}
};
const revokeAudioPreviewUrl = (fieldName: string) => {
const url = audioPreviewUrls.value[fieldName];
if (url) {
URL.revokeObjectURL(url);
}
};
const removeAudioFile = (fieldName: string, silent = false) => {
revokeAudioPreviewUrl(fieldName);
delete audioFiles.value[fieldName];
delete audioFileNames.value[fieldName];
delete audioPreviewUrls.value[fieldName];
configData.value[fieldName] = '';
resetAudioInputValue(fieldName);
if (!silent) {
ElMessage.info('已移除音频');
}
};
const handleAudioSelect = (event: Event, fieldName: string) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!file.type.startsWith('audio/')) {
ElMessage.warning('请上传音频文件');
resetAudioInputValue(fieldName);
return;
}
if (audioPreviewUrls.value[fieldName]) {
revokeAudioPreviewUrl(fieldName);
}
audioFiles.value[fieldName] = file;
audioFileNames.value[fieldName] = file.name;
audioPreviewUrls.value[fieldName] = URL.createObjectURL(file);
configData.value[fieldName] = file.name;
resetAudioInputValue(fieldName);
ElMessage.success('音频上传成功');
};
const fetchPrice = () => {
if (priceTimer) clearTimeout(priceTimer);
@ -287,6 +399,7 @@ const loadModels = async () => {
const handleModelChange = (modelId: string) => {
const selected = models.value.find(m => m.id === modelId);
if (selected) {
audioFieldNames.value.forEach(fieldName => removeAudioFile(fieldName, true));
currentModel.value = selected;
initFormData();
fetchPrice();
@ -317,37 +430,54 @@ watch(
{ immediate: true }
);
// ==================== ====================
const simulateUpload = () => {
ElMessage.info('此处可对接实际音频上传服务');
configData.value.audio_file = 'https://example.com/demo_audio.mp3';
};
// ==================== ====================
const buildSubmitParams = (): TextVideoParams => {
const buildSubmitParams = (): TextVideoParams | FormData => {
const hasAudioUpload = audioFieldNames.value.some(fieldName => !!audioFiles.value[fieldName]);
const baseParams: TextVideoParams = {
llmid: currentModel.value!.id,
prompt: prompt.value, // string
};
if (configData.value.duration !== undefined) {
baseParams.duration = Number(configData.value.duration);
}
if (configData.value.ratio !== undefined) {
baseParams.ratio = String(configData.value.ratio);
}
if (configData.value.resolution !== undefined) {
baseParams.resolution = String(configData.value.resolution);
}
if (configData.value.size !== undefined && !baseParams.resolution) {
currentModel.value?.input_fields.forEach(field => {
if (field.name === 'prompt' || field.uitype === 'audio') return;
const value = configData.value[field.name];
if (value === undefined || value === null || value === '') return;
baseParams[field.name] = field.uitype === 'int' ? Number(value) : value;
});
if (baseParams.resolution === undefined && configData.value.size !== undefined) {
baseParams.resolution = String(configData.value.size);
}
baseParams.audio = Boolean(configData.value.audio_file);
return baseParams;
baseParams.audio = hasAudioUpload;
if (!hasAudioUpload) {
return baseParams;
}
const formData = new FormData();
Object.entries(baseParams).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
formData.append(key, String(value));
});
audioFieldNames.value.forEach(fieldName => {
const file = audioFiles.value[fieldName];
if (file) {
formData.append(fieldName, file, file.name);
}
});
return formData;
};
const generate = async (): Promise<boolean> => {
if (generating.value) {
return false;
}
if (!currentModel.value) {
ElMessage.error('请选择模型');
return false;
@ -379,8 +509,8 @@ const generate = async (): Promise<boolean> => {
// taskid
let taskid = res.data?.taskid || res.taskid;
if (res.status === 'created' || res.status === 'processing' || res.status === 'queueing') {
const statusMsg = res.status === 'created' ? '视频生成任务已提交' : (res.status === 'processing' ? '进行中' : '排队中');
if (res.status === 'created' ||res.status === 'PENDING' || res.status === 'CREATED' || res.status === 'processing' || res.status === 'queueing') {
const statusMsg = res.status === 'created' || res.status === 'PENDING' || res.status === 'CREATED' ? '视频生成任务已提交' : (res.status === 'processing' ? '进行中' : '排队中');
ElMessage.success(`${statusMsg},请稍后在任务列表中查看`);
if (taskid) {
@ -407,6 +537,7 @@ const generate = async (): Promise<boolean> => {
const resetForm = () => {
prompt.value = '';
audioFieldNames.value.forEach(fieldName => removeAudioFile(fieldName, true));
initFormData();
price.value = 0;
emit('price-update', 0);
@ -423,6 +554,16 @@ defineExpose({
onMounted(() => {
loadModels();
});
onBeforeUnmount(() => {
if (priceTimer) {
clearTimeout(priceTimer);
priceTimer = null;
}
Object.keys(audioPreviewUrls.value).forEach(fieldName => {
revokeAudioPreviewUrl(fieldName);
});
});
</script>
<style scoped lang="less">
@ -461,4 +602,112 @@ onMounted(() => {
background: transparent;
color: #ffffff7a;
}
.hidden-upload-input {
display: none;
}
.audio-wrapper {
width: 100%;
}
.audio-upload-card {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
box-sizing: border-box;
}
.audio-upload-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.audio-upload-card {
padding: 10px 12px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.32);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.audio-upload-card.has-file {
border-color: rgba(148, 216, 255, 0.22);
background: rgba(255, 255, 255, 0.05);
}
.audio-file-copy {
min-width: 0;
flex: 1;
}
.audio-file-name {
color: #fff;
font-size: 14px;
font-weight: 500;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audio-file-tip {
margin-top: 4px;
color: rgba(255, 255, 255, 0.56);
font-size: 12px;
line-height: 1.2;
}
.audio-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
align-items: center;
}
.audio-action-btn {
height: 30px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 12px;
}
.audio-action-btn:hover {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.audio-action-btn.primary {
border-color: rgba(148, 216, 255, 0.28);
background: rgba(122, 164, 198, 0.32);
color: #fff;
}
.audio-text-btn {
padding: 0;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.62);
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.audio-text-btn:hover {
color: #fff;
}
.audio-text-btn:disabled {
cursor: not-allowed;
color: rgba(255, 255, 255, 0.32);
}
.audio-preview {
width: 100%;
height: 32px;
opacity: 0.9;
}
@media (max-width: 768px) {
.audio-upload-main {
flex-direction: column;
align-items: stretch;
}
.audio-actions {
width: 100%;
justify-content: space-between;
}
.audio-action-btn {
flex: 0 0 auto;
}
}
</style>

View File

@ -11,6 +11,7 @@
v-if="activeTab === 'reference'"
@price-update="handleReferencePriceUpdate"
@prompt-change="handlePromptChange"
@task-created="handleTaskCreated"
/>
<ImgVideo
ref="imgVideoRef"
@ -36,7 +37,7 @@
>
创作
<span v-if="currentPrice > 0" class="price-info">
$ {{ currentPrice }}
{{ currentPrice }}
</span>
</div>
</div>
@ -53,6 +54,7 @@ import Referencevideo from './ReferenceVideo.vue';
import TextVideo from './TextVideo.vue';
import ImgVideo from './ImgVideo.vue';
import { debounce } from '@/utlis/debounce';
import { useLoginStore } from '@/store/LoginStore';
// ==================== ====================
/** 文生视频组件暴露的方法 */
@ -81,6 +83,7 @@ const textVideoRef = ref<TextVideoExposed | null>(null);
// ==================== ====================
const route = useRoute();
const router = useRouter();
const loginStore = useLoginStore();
const activeTab = ref<'reference' | 'image' | 'text'>('reference');
const referencePrice = ref(0);
@ -89,6 +92,7 @@ const imagePrice = ref(0);
const isPromptEmpty = ref(true);
const isTextPromptEmpty = ref(true);
const isImagePromptEmpty = ref(true);
const isCreating = ref(false);
//
const handleReferencePriceUpdate = (price: number) => { referencePrice.value = price; };
@ -111,6 +115,10 @@ const currentPrice = computed(() => {
});
const isCreateButtonDisabled = computed(() => {
if (!loginStore.isLoggedIn || isCreating.value) {
return true;
}
switch (activeTab.value) {
case 'reference': return isPromptEmpty.value;
case 'text': return isTextPromptEmpty.value;
@ -127,35 +135,49 @@ const handleTabChange = debounce((tab: 'reference' | 'image' | 'text') => {
//
const handleCreate = debounce(async () => {
if (!loginStore.isLoggedIn) {
ElMessage.warning('请先登录后再创作');
return;
}
if (isCreating.value) {
return;
}
if (isCreateButtonDisabled.value) {
ElMessage.warning('请先填写必要信息');
return;
}
isCreating.value = true;
let result = false;
switch (activeTab.value) {
case 'text':
if (textVideoRef.value?.generate) {
result = await textVideoRef.value.generate();
}
break;
case 'image':
if (imgVideoRef.value?.generate) {
result = await imgVideoRef.value.generate();
}
break;
case 'reference':
if (referenceVideoRef.value?.generate) {
result = await referenceVideoRef.value.generate();
} else {
ElMessage.info('参考生视频功能暂未开放');
}
break;
default:
ElMessage.info('该功能暂未开放');
break;
try {
switch (activeTab.value) {
case 'text':
if (textVideoRef.value?.generate) {
result = await textVideoRef.value.generate();
}
break;
case 'image':
if (imgVideoRef.value?.generate) {
result = await imgVideoRef.value.generate();
}
break;
case 'reference':
if (referenceVideoRef.value?.generate) {
result = await referenceVideoRef.value.generate();
} else {
ElMessage.info('参考生视频功能暂未开放');
}
break;
default:
ElMessage.info('该功能暂未开放');
break;
}
return result;
} finally {
isCreating.value = false;
}
return result;
}, 500);
const emit = defineEmits<{ (e: 'task-created', taskid: string): void }>();

View File

@ -20,9 +20,12 @@ const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
const app = createApp(App)
app.use(pinia)
app.use(router)
app.use(ElementPlus, {
locale: zhCn,
})
app.use(pinia)
app.mount('#app')
router.isReady().then(() => {
app.mount('#app')
})

View File

@ -1,4 +1,5 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import GetCookie from '@/views/Cookie/getCookie.vue'
const router = createRouter({
history: createWebHashHistory(),
@ -18,6 +19,11 @@ const router = createRouter({
name: 'login',
component: () => import('@/views/login/Login.vue')
},
{
path: '/getCookie',
name: 'getCookie',
component: GetCookie
},
// 首页
{
path: '/Homeindex',
@ -53,6 +59,11 @@ const router = createRouter({
}
]
},
{
path: '/TextGenerate',
name: 'TextGenerate',
component: () => import('@/views/TextGenerate/TextGenerate.vue')
},
// videoGenerate
{
path: '/videoGenerate',
@ -84,7 +95,6 @@ const router = createRouter({
name: 'material-square',
component: () => import('@/views/videoGenerate/videoGenerate.vue')
},
// apikey
{
path: '/apikey',
@ -93,6 +103,11 @@ const router = createRouter({
}
]
},
{
path: '/material-square/detail',
name: 'material-square-detail',
component: () => import('@/views/materialSquare/Detail.vue')
},
]
})

View File

@ -5,6 +5,7 @@ import router from '@/router';
import type { LoginResult, LoginParams, LoginUser } from '@/types/login';
import { LoginApi } from '@/apis/login';
import { openAccount } from '@/apis/home';
import { clearApiBaseURL } from '@/utlis/request';
export const useLoginStore = defineStore(
'loginStore',
@ -106,7 +107,54 @@ export const useLoginStore = defineStore(
showAccountSelect.value = false;
return doLogin({ ...params, selected_id: selectedId });
};
const logoutS = () => {
// 第三方同步用户成功后,直接写入登录态
const syncLogin = (data: { user: LoginUser } | LoginUser) => {
const nextUserInfo = 'user' in data ? data : { user: data };
openAccounts();
isLoggedIn.value = true;
userInfo.value = nextUserInfo;
accountList.value = [];
showAccountSelect.value = false;
loginTimestamp.value = Date.now();
startAutoLogoutTimer();
const path = redirectPath.value || '/';
redirectPath.value = null;
router.push(path);
};
// 外部接口只同步 cookie 时,先恢复前端登录态
const syncCookieLogin = (user?: Partial<LoginUser>) => {
openAccounts();
isLoggedIn.value = true;
userInfo.value = {
user: {
id: user?.id || '',
username: user?.username || user?.name || '已登录用户',
password: '',
email: user?.email || '',
orgid: user?.orgid || '',
nick_name: user?.nick_name || null,
address: user?.address || null,
mobile: user?.mobile || user?.cellphone || '',
user_status: user?.user_status || '',
name: user?.name,
cellphone: user?.cellphone,
},
};
accountList.value = [];
showAccountSelect.value = false;
loginTimestamp.value = Date.now();
startAutoLogoutTimer();
const path = redirectPath.value || '/';
redirectPath.value = null;
router.push(path);
};
const logoutS = () => {
// 保存当前路径
redirectPath.value = router.currentRoute.value.path;
clearAutoLogoutTimer();
@ -115,6 +163,7 @@ export const useLoginStore = defineStore(
accountList.value = [];
showAccountSelect.value = false;
loginTimestamp.value = null;
clearApiBaseURL();
router.push('/login');
ElMessage.error('身份验证过期请重新登录');
};
@ -126,6 +175,7 @@ export const useLoginStore = defineStore(
accountList.value = [];
showAccountSelect.value = false;
loginTimestamp.value = null;
clearApiBaseURL();
router.push('/login');
};
@ -148,6 +198,8 @@ export const useLoginStore = defineStore(
redirectPath,
Login,
loginWithSelectedAccount,
syncLogin,
syncCookieLogin,
logout,
logoutS,
initAutoLogout,

View File

@ -273,6 +273,14 @@ export interface StencilItem {
avatar: string;
userName: string;
likes: number;
title: string;
description: string;
createdAt: string;
contentType: string;
displayTag?: string;
collectCount?: number;
recreateCount?: number;
creatorId?: string;
averageColor?: string;
}

View File

@ -3,12 +3,24 @@ import axios from 'axios'
import { ElMessage } from 'element-plus'
// import { ElMessage } from 'element-plus'
const apiBaseURL = import.meta.env.VITE_API_BASE_URL
const defaultApiBaseURL = import.meta.env.VITE_API_BASE_URL
const apiBaseURLStorageKey = 'yuanjing_api_base_url'
export const setApiBaseURL = (url: string) => {
localStorage.setItem(apiBaseURLStorageKey, url)
}
export const clearApiBaseURL = () => {
localStorage.removeItem(apiBaseURLStorageKey)
}
const getApiBaseURL = () => localStorage.getItem(apiBaseURLStorageKey) || defaultApiBaseURL
const shouldIncludeCredentials = (url?: string, baseURL?: string) => {
if (!url) return true
try {
const apiBaseURL = getApiBaseURL()
const apiOrigin = new URL(apiBaseURL).origin
const requestOrigin = new URL(url, baseURL || apiBaseURL).origin
return requestOrigin === apiOrigin
@ -21,6 +33,7 @@ const shouldIncludeCredentials = (url?: string, baseURL?: string) => {
// 添加请求拦截器
axios.interceptors.request.use(
function (config) {
const apiBaseURL = getApiBaseURL()
config.baseURL = apiBaseURL
config.withCredentials = shouldIncludeCredentials(config.url, config.baseURL)
return config
@ -36,7 +49,22 @@ axios.interceptors.request.use(
axios.interceptors.response.use(
function (response) {
// 放行的状态列表
const allowedStatuses = ['ok', 'PENDING', 'created', 'processing', 'queueing', 'succeeded', 'completed', 'failed'];
const allowedStatuses = [
'ok',
'CREATED',
'PENDING',
'RUNNING',
'SUCCEEDED',
'FAILED',
'created',
'processing',
'queueing',
'running',
'succeeded',
'completed',
'failed',
'PENDING'
];
// 检查响应数据中的状态
const responseData = response.data;

View File

@ -34,6 +34,14 @@ export function transformFeedItem(item: FeedItem): StencilItem | null {
avatar: data.creator?.avatar ?? '',
userName: data.creator?.nick_name ?? '',
likes: data.react_info?.vote_count ?? 0,
title: 'title' in data ? data.title : data.tag || data.brief || '未命名作品',
description: data.detail || ('brief' in data ? data.brief : '') || '',
createdAt: data.created_at || '',
contentType: item.type,
displayTag: 'tag' in data ? data.tag : data.type,
collectCount: data.react_info?.collect_count ?? 0,
recreateCount: 'recreate_count' in data ? data.recreate_count : undefined,
creatorId: data.creator?.id ?? '',
};
}
@ -95,5 +103,12 @@ export function transformShareElementItem(item: ShareElementItem): StencilItem |
avatar: element.creator?.avatar ?? '',
userName: element.creator?.nick_name ?? '',
likes: share.react_info?.vote_count ?? 0,
title: element.name || '未命名作品',
description: share.introduce || element.recaption?.description || '',
createdAt: share.created_at || element.created_at || '',
contentType: 'share_elements',
displayTag: share.category_display?.[0] || element.type || '素材',
collectCount: share.react_info?.collect_count ?? 0,
creatorId: element.creator?.id ?? '',
};
}

View File

@ -57,7 +57,7 @@
<div class="order-header">
<div class="order-model">
<span class="label">模型</span>
<span class="value">{{ order.name || order.model }}</span>
<span class="value">{{ order.model }}</span>
</div>
<div class="order-status">
<span :class="['status-badge', getStatusClass(order.status)]">

View File

@ -0,0 +1,307 @@
<template>
<div class="sync-page">
<div class="sync-card">
<div class="sync-icon" :class="status">
<el-icon v-if="status === 'loading'" class="is-loading">
<Loading />
</el-icon>
<el-icon v-else-if="status === 'success'">
<CircleCheckFilled />
</el-icon>
<el-icon v-else>
<CircleCloseFilled />
</el-icon>
</div>
<h2>{{ title }}</h2>
<p>{{ message }}</p>
<div v-if="status === 'loading'" class="sync-progress">
<span></span>
</div>
<el-button v-if="status === 'error'" type="primary" @click="router.replace('/login')">
返回登录
</el-button>
</div>
</div>
</template>
<script setup lang='ts'>
import { computed, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { CircleCheckFilled, CircleCloseFilled, Loading } from '@element-plus/icons-vue';
import { useRoute, useRouter } from 'vue-router';
import { useLoginStore } from '@/store/LoginStore';
import { setApiBaseURL } from '@/utlis/request';
import type { LoginUser } from '@/types/login';
interface SyncUserResponse {
status?: string;
message?: string;
cookie?: string | string[] | Record<string, string>;
cookies?: string | string[] | Record<string, string>;
user?: Partial<LoginUser>;
data?: {
user?: Partial<LoginUser>;
cookie?: string | string[] | Record<string, string>;
cookies?: string | string[] | Record<string, string>;
} | Partial<LoginUser>;
}
const route = useRoute();
const router = useRouter();
const loginStore = useLoginStore();
const cookieApiBaseURL = 'https://token.opencomputing.cn' ;
const status = ref<'loading' | 'success' | 'error'>('loading');
const message = ref('同步用户信息中...');
const syncing = ref(false);
const title = computed(() => {
if (status.value === 'success') return '同步成功';
if (status.value === 'error') return '同步失败';
return '正在同步登录';
});
const goLoginLater = () => {
window.setTimeout(() => {
router.replace('/login');
}, 1500);
};
const getQueryValue = (key: string) => {
const routeValue = route.query[key];
if (typeof routeValue === 'string' && routeValue) return routeValue;
const searchParams = new URLSearchParams(window.location.search);
const searchValue = searchParams.get(key);
if (searchValue) return searchValue;
const hashQuery = window.location.hash.split('?')[1] || '';
return new URLSearchParams(hashQuery).get(key);
};
const parseResponse = (text: string): SyncUserResponse | string | null => {
if (!text) return null;
try {
return JSON.parse(text) as SyncUserResponse;
} catch {
return text;
}
};
const setCookieValue = (cookie: string) => {
if (!cookie.includes('=')) return;
document.cookie = cookie;
};
const persistCookies = (cookies?: string | string[] | Record<string, string>) => {
if (!cookies) return;
if (typeof cookies === 'string') {
setCookieValue(cookies);
return;
}
if (Array.isArray(cookies)) {
cookies.forEach(setCookieValue);
return;
}
Object.entries(cookies).forEach(([name, value]) => {
document.cookie = `${name}=${encodeURIComponent(value)}; path=/`;
});
};
const isUserData = (data: SyncUserResponse['data']): data is Partial<LoginUser> => {
return Boolean(data && ('id' in data || 'username' in data || 'mobile' in data || 'name' in data));
};
const getSyncedUser = (result: SyncUserResponse | string | null) => {
if (!result || typeof result === 'string') return undefined;
if (result.user) return result.user;
if (result.data && 'user' in result.data) return result.data.user;
if (isUserData(result.data)) return result.data;
return undefined;
};
const persistResponseCookies = (result: SyncUserResponse | string | null) => {
if (!result) return;
if (typeof result === 'string') {
persistCookies(result);
return;
}
persistCookies(result.cookie);
persistCookies(result.cookies);
if (result.data && 'cookie' in result.data) persistCookies(result.data.cookie);
if (result.data && 'cookies' in result.data) persistCookies(result.data.cookies);
};
const getErrorMessage = (result: SyncUserResponse | string | null) => {
if (!result || typeof result === 'string') return '同步用户失败';
return result.message || '同步用户失败';
};
const getSyncUrl = (deerer: string) => {
const url = new URL('/dapi/deerer_user.dspy', cookieApiBaseURL);
url.searchParams.set('deerer', deerer);
return url.toString();
};
const syncUser = async () => {
if (syncing.value) return;
const deerer = getQueryValue('deerer');
if (!deerer) {
status.value = 'error';
message.value = '缺少 deerer 参数';
ElMessage.error(message.value);
goLoginLater();
return;
}
syncing.value = true;
status.value = 'loading';
message.value = '同步用户信息中...';
try {
const res = await fetch(
getSyncUrl(deerer),
{
credentials: 'include',
}
);
const result = parseResponse(await res.text());
persistResponseCookies(result);
const responseStatus = typeof result === 'string' ? undefined : result?.status;
const syncSuccess = res.ok && (!responseStatus || ['ok', 'success'].includes(responseStatus));
if (syncSuccess) {
status.value = 'success';
message.value = '同步登录成功,正在进入系统...';
setApiBaseURL(cookieApiBaseURL);
loginStore.syncCookieLogin(getSyncedUser(result));
ElMessage.success('同步登录成功');
return;
}
status.value = 'error';
message.value = getErrorMessage(result);
ElMessage.error(message.value);
goLoginLater();
} catch (error) {
console.error('同步用户失败:', error);
status.value = 'error';
message.value = '同步用户失败,请稍后重试';
ElMessage.error(message.value);
goLoginLater();
} finally {
syncing.value = false;
}
};
watch(
() => route.fullPath,
() => {
syncUser();
},
{ immediate: true }
);
</script>
<style scoped lang='less'>
.sync-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(circle at 20% 20%, rgba(64, 158, 255, 0.18), transparent 28%),
radial-gradient(circle at 80% 10%, rgba(103, 194, 58, 0.14), transparent 26%),
linear-gradient(135deg, #eef5ff 0%, #f8fbff 48%, #eef7f3 100%);
}
.sync-card {
width: 420px;
max-width: 100%;
padding: 44px 36px;
text-align: center;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 24px;
box-shadow: 0 20px 60px rgba(30, 64, 110, 0.12);
backdrop-filter: blur(16px);
h2 {
margin: 20px 0 10px;
color: #1f2d3d;
font-size: 24px;
font-weight: 600;
}
p {
min-height: 24px;
margin: 0 0 28px;
color: #6b778c;
font-size: 15px;
line-height: 1.6;
}
}
.sync-icon {
width: 76px;
height: 76px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 38px;
&.loading {
color: #409eff;
background: rgba(64, 158, 255, 0.12);
}
&.success {
color: #67c23a;
background: rgba(103, 194, 58, 0.12);
}
&.error {
color: #f56c6c;
background: rgba(245, 108, 108, 0.12);
}
}
.sync-progress {
height: 6px;
overflow: hidden;
background: #e8f1ff;
border-radius: 999px;
span {
display: block;
width: 45%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #79bbff, #409eff);
animation: progress 1.2s ease-in-out infinite;
}
}
@keyframes progress {
0% {
transform: translateX(-110%);
}
100% {
transform: translateX(230%);
}
}
</style>

View File

@ -0,0 +1,319 @@
<template>
<div class="text-chat-page">
<aside class="chat-sidebar">
<div class="brand">
<div class="brand-mark">YJ</div>
<span>元境文本</span>
</div>
<button class="new-chat-btn">+ 开启新对话</button>
<div class="history-section">
<div class="history-title">今天</div>
<div class="history-item active">设置默认浏览器方法</div>
</div>
<div class="history-section">
<div class="history-title">昨天</div>
<div class="history-item">简化模型编辑保存代码</div>
<div class="history-item">AI准备平台分类介绍</div>
</div>
<div class="history-section">
<div class="history-title">7 天内</div>
<div class="history-item">利用模板页面代码优化</div>
<div class="history-item">用户发送数字1寻求帮助</div>
</div>
</aside>
<main class="chat-main">
<header class="chat-header">
<div>
<h2>设置默认浏览器方法</h2>
<p>快速生成结构清晰可直接使用的文本内容</p>
</div>
<button class="share-btn">分享</button>
</header>
<section class="answer-area">
<div class="answer-meta">
<span class="spark"></span>
已思考用时 3
</div>
<article class="answer-card">
<p>
通用中操作提供步骤设置默认浏览器的方法取决于你使用的操作系统
下面列出常见系统的步骤
</p>
<h3>Windows 10 / 11</h3>
<ol>
<li>打开设置 Win + I</li>
<li>进入应用 默认应用</li>
<li>找到 Web 浏览器点击当前默认浏览器图标</li>
<li>从列表中选择你想设为默认的浏览器</li>
<li>关闭设置窗口即可</li>
</ol>
<h3>macOS</h3>
<ol>
<li>打开系统设置</li>
<li>点击桌面与程序坞</li>
<li>找到默认网页浏览器旁边的下拉菜单</li>
<li>选择你喜欢的浏览器</li>
<li>关闭设置窗口</li>
</ol>
</article>
</section>
<div class="composer-wrap">
<div class="composer">
<el-input
v-model="prompt"
class="composer-input"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
placeholder="给元境发送消息"
/>
<div class="composer-footer">
<div class="composer-tools">
<button>深度思考</button>
<button>智能搜索</button>
</div>
<button class="send-btn"></button>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const prompt = ref('');
</script>
<style scoped lang="less">
.text-chat-page {
height: calc(100vh - 80px);
min-height: 0;
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
overflow: hidden;
color: #fff;
background: #0f0f19;
}
.chat-sidebar {
min-height: 0;
padding: 18px 12px;
background: #141621;
border-right: 1px solid rgba(255, 255, 255, 0.06);
overflow-y: auto;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 18px;
color: #00ffcc;
font-size: 16px;
font-weight: 700;
}
.brand-mark {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: linear-gradient(135deg, rgba(148, 216, 255, 0.22), rgba(0, 255, 204, 0.16));
color: #fff;
font-size: 12px;
}
.new-chat-btn {
width: 100%;
height: 38px;
margin-bottom: 20px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.06);
color: #fff;
cursor: pointer;
}
.history-section {
margin-bottom: 20px;
}
.history-title {
margin: 0 8px 8px;
color: rgba(255, 255, 255, 0.42);
font-size: 13px;
}
.history-item {
padding: 10px 12px;
border-radius: 10px;
color: rgba(255, 255, 255, 0.68);
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.active,
&:hover {
background: linear-gradient(90deg, #2cfff84d, #2c68ff4d);
color: #00ffcc;
}
}
.chat-main {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: radial-gradient(circle at 50% 0%, rgba(44, 104, 255, 0.14), transparent 36%), #0f0f19;
}
.chat-header {
flex-shrink: 0;
height: 72px;
padding: 14px 28px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: rgba(255, 255, 255, 0.48);
font-size: 12px;
}
}
.share-btn {
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.64);
cursor: pointer;
}
.answer-area {
flex: 1;
min-height: 0;
max-width: 760px;
width: 100%;
margin: 0 auto;
padding: 32px 20px 170px;
overflow-y: auto;
}
.answer-meta {
margin-bottom: 18px;
color: rgba(255, 255, 255, 0.58);
font-size: 14px;
}
.spark {
color: #00ffcc;
}
.answer-card {
padding: 24px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.86);
font-size: 15px;
line-height: 1.9;
h3 {
margin: 28px 0 12px;
font-size: 20px;
line-height: 1.4;
}
ol {
padding-left: 22px;
margin: 0;
}
}
.composer-wrap {
position: absolute;
right: 32px;
bottom: 22px;
left: calc(220px + 32px);
display: flex;
justify-content: center;
pointer-events: none;
}
.composer {
width: min(680px, 100%);
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 22px;
background: rgba(26, 28, 32, 0.96);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.32);
backdrop-filter: blur(18px);
pointer-events: auto;
}
.composer-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.composer-tools {
display: flex;
gap: 8px;
button {
height: 28px;
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.74);
cursor: pointer;
}
}
.send-btn {
width: 34px;
height: 34px;
border: none;
border-radius: 50%;
background: linear-gradient(270deg, #94d8ff 11.11%, #e0ddff);
color: #0f0f19;
font-size: 18px;
cursor: pointer;
}
:deep(.composer-input .el-textarea__inner) {
border: none;
box-shadow: none;
resize: none;
color: #fff;
background: transparent;
}
:deep(.composer-input .el-textarea__inner::placeholder) {
color: rgba(255, 255, 255, 0.42);
}
</style>

View File

@ -9,7 +9,7 @@
loop
playsinline
crossorigin="anonymous"
src="../../assets/images/idfile.mp4"
src="../../assets/images/loginBg.mp4"
></video>
</div>

View File

@ -0,0 +1,529 @@
<template>
<div class="material-detail">
<div v-if="currentItem" class="detail-layout">
<section class="player-panel">
<button class="back-btn" type="button" @click="goBack">
<i class="iconfont icon-zuo"></i>
返回
</button>
<div class="video-card">
<video
:key="currentItem.id"
:src="currentItem.src"
:poster="currentItem.poster"
class="main-video"
controls
autoplay
playsinline
></video>
</div>
</section>
<aside class="side-panel">
<div class="info-card">
<div class="meta-row">
<!-- <span class="type-tag">{{ currentItem.displayTag || getTypeLabel(currentItem.contentType) }}</span> -->
<span class="publish-time">{{ formatDate(currentItem.createdAt) }}</span>
</div>
<div class="creator">
<img
v-if="currentItem.avatar"
:src="currentItem.avatar"
:alt="currentItem.userName"
class="creator-avatar"
/>
<div class="creator-info">
<div class="creator-name">{{ currentItem.userName || '匿名创作者' }}</div>
<div class="creator-subtitle">{{ getTypeLabel(currentItem.contentType) }}</div>
</div>
</div>
<h1 class="title">{{ currentItem.title || '未命名作品' }}</h1>
<p class="description">
{{ currentItem.description || '暂无作品描述' }}
</p>
<div class="stats">
<div class="stat-item">
<span class="stat-label">点赞</span>
<span class="stat-value">{{ currentItem.likes }}</span>
</div>
<div class="stat-item">
<span class="stat-label">收藏</span>
<span class="stat-value">{{ currentItem.collectCount ?? 0 }}</span>
</div>
<div v-if="currentItem.recreateCount !== undefined" class="stat-item">
<span class="stat-label">复用</span>
<span class="stat-value">{{ currentItem.recreateCount }}</span>
</div>
</div>
</div>
<div v-if="relatedItems.length" class="recommend-card">
<div class="recommend-title">相关内容</div>
<button
v-for="item in relatedItems"
:key="item.id"
type="button"
class="recommend-item"
:class="{ active: item.id === selectedId }"
@click="selectItem(item)"
>
<img :src="item.poster" :alt="item.title" class="recommend-cover" />
<div class="recommend-info">
<div class="recommend-name">{{ item.title || item.userName }}</div>
<div class="recommend-author">{{ item.userName || '匿名创作者' }}</div>
</div>
</button>
</div>
</aside>
</div>
<div v-else class="empty-state">
<div class="empty-title">暂无详情数据</div>
<div class="empty-desc">请从素材瀑布流列表中点击任意卡片进入详情页</div>
<button class="back-home-btn" type="button" @click="goBack">返回列表</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { StencilItem } from '@/types/home'
interface DetailCache {
selectedId?: string
items?: StencilItem[]
activeTab?: string
}
const DETAIL_STORAGE_KEY = 'material-square-detail'
const route = useRoute()
const router = useRouter()
const items = ref<StencilItem[]>([])
const selectedId = ref('')
const readCache = (): DetailCache | null => {
const raw = sessionStorage.getItem(DETAIL_STORAGE_KEY)
if (!raw) {
return null
}
try {
return JSON.parse(raw) as DetailCache
} catch (error) {
console.error('读取详情缓存失败', error)
return null
}
}
const writeCache = () => {
sessionStorage.setItem(
DETAIL_STORAGE_KEY,
JSON.stringify({
selectedId: selectedId.value,
items: items.value,
})
)
}
const syncFromCache = () => {
const cache = readCache()
const routeId = typeof route.query.id === 'string' ? route.query.id : ''
items.value = cache?.items ?? []
selectedId.value = routeId || cache?.selectedId || items.value[0]?.id || ''
}
const currentItem = computed(() => {
return items.value.find((item) => item.id === selectedId.value) ?? null
})
const relatedItems = computed(() => {
return items.value.filter((item) => item.id !== selectedId.value).slice(0, 12)
})
const getTypeLabel = (type: string) => {
const typeMap: Record<string, string> = {
short_film: '短片',
tutorial: '教程',
media_asset: '素材',
share_elements: '参考素材',
}
return typeMap[type] || '作品'
}
const formatDate = (value: string) => {
if (!value) {
return '未知时间'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
const selectItem = (item: StencilItem) => {
selectedId.value = item.id
writeCache()
router.replace({
path: '/material-square/detail',
query: {
...route.query,
id: item.id,
},
})
}
const goBack = () => {
if (window.history.length > 1) {
router.back()
return
}
router.push('/index')
}
watch(
() => route.query.id,
() => {
syncFromCache()
},
{ immediate: true }
)
</script>
<style scoped lang="less">
.material-detail {
width: 100%;
height: 100vh;
padding: 24px;
box-sizing: border-box;
background: #0f0f19;
color: #fff;
overflow: hidden;
}
.detail-layout {
display: flex;
gap: 24px;
height: calc(100vh - 48px);
}
.player-panel {
flex: 1;
min-width: 0;
min-height: 0;
}
.back-btn,
.back-home-btn {
display: flex;
align-items: center;
gap: 4px;
justify-content: center;
.iconfont{
font-size: 12px;
}
height: 40px;
padding: 0 18px;
margin-bottom: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
color: #fff;
cursor: pointer;
}
.video-card,
.info-card,
.recommend-card {
background: linear-gradient(180deg, rgba(26, 28, 35, 0.98), rgba(13, 14, 19, 0.98));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
overflow: hidden;
}
.video-card {
height: calc(100% - 56px);
min-height: 640px;
padding: 16px;
}
.main-video {
width: 100%;
height: 100%;
display: block;
border-radius: 18px;
background: #000;
object-fit: contain;
}
.side-panel {
width: 500px;
display: flex;
gap: 16px;
flex-shrink: 0;
height: 100%;
overflow: hidden;
}
.info-card {
flex: 1;
padding: 20px;
height: 100%;
box-sizing: border-box;
overflow: hidden;
}
.meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.type-tag {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 12px;
border-radius: 999px;
background: rgba(71, 117, 255, 0.22);
color: #b9ccff;
font-size: 13px;
}
.publish-time {
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
}
.title {
margin: 0 0 16px;
font-size: 20px;
line-height: 1.4;
}
.creator {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.creator-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
}
.creator-name {
font-size: 15px;
font-weight: 600;
}
.creator-subtitle {
margin-top: 4px;
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
}
.description {
margin: 0;
color: rgba(255, 255, 255, 0.82);
font-size: 14px;
line-height: 1.75;
white-space: pre-wrap;
}
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 20px;
}
.stat-item {
padding: 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.05);
}
.stat-label {
display: block;
margin-bottom: 6px;
color: rgba(255, 255, 255, 0.56);
font-size: 12px;
}
.stat-value {
font-size: 18px;
font-weight: 600;
}
.recommend-card {
width: 120px;
height: 100%;
padding: 12px 10px;
box-sizing: border-box;
overflow-y: auto;
position: relative;
right: -4px;
scrollbar-width: thin;
scrollbar-color: rgba(122, 144, 214, 0.18) transparent;
}
.recommend-card::-webkit-scrollbar {
width: 4px;
}
.recommend-card::-webkit-scrollbar-track {
background: transparent;
border-radius: 999px;
}
.recommend-card::-webkit-scrollbar-thumb {
background: rgba(132, 158, 255, 0.14);
border-radius: 999px;
border: none;
}
.recommend-card:hover::-webkit-scrollbar {
margin-left: 8px;
width: 6px;
}
.recommend-card:hover::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.03);
}
.recommend-card:hover::-webkit-scrollbar-thumb {
background: rgba(132, 158, 255, 0.38);
}
.recommend-card::-webkit-scrollbar-thumb:hover {
background: rgba(154, 178, 255, 0.58);
}
.recommend-title {
margin-bottom: 12px;
color: rgba(255, 255, 255, 0.68);
font-size: 12px;
text-align: center;
}
.recommend-item {
width: 100%;
height: 50px;
padding: 0;
margin-bottom: 10px;
border: 0;
background: transparent;
color: #fff;
cursor: pointer;
}
.recommend-item.active .recommend-cover,
.recommend-item:hover .recommend-cover {
border-color: rgba(130, 161, 255, 0.9);
}
.recommend-cover {
width: 100%;
height: 80px;
// aspect-ratio: 9 / 16;
display: block;
border: 2px solid transparent;
border-radius: 14px;
object-fit: cover;
background: #111;
img{
width: 100%;
height: 100%;
}
}
.recommend-info {
margin-top: 6px;
text-align: left;
}
.recommend-name,
.recommend-author {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recommend-name {
font-size: 12px;
}
.recommend-author {
margin-top: 2px;
color: rgba(255, 255, 255, 0.55);
font-size: 11px;
}
.empty-state {
height: calc(100vh - 48px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.empty-title {
font-size: 24px;
font-weight: 600;
}
.empty-desc {
color: rgba(255, 255, 255, 0.6);
}
@media (max-width: 1280px) {
.detail-layout {
flex-direction: column;
}
.side-panel {
width: 100%;
}
.recommend-card {
width: 100%;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 12px;
}
.recommend-item {
margin-bottom: 0;
}
}
</style>