1
This commit is contained in:
parent
5df9a86325
commit
7b6d76a351
33
index.html
33
index.html
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
BIN
src/assets/images/loginBg.mp4
Normal file
BIN
src/assets/images/loginBg.mp4
Normal file
Binary file not shown.
@ -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 {
|
||||
|
||||
426
src/assets/less/Video/refernce/refernce.less
Normal file
426
src/assets/less/Video/refernce/refernce.less
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/* 视频容器 */
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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')) {
|
||||
|
||||
@ -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
@ -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>
|
||||
@ -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 }>();
|
||||
|
||||
@ -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')
|
||||
})
|
||||
@ -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')
|
||||
},
|
||||
|
||||
]
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 ?? '',
|
||||
};
|
||||
}
|
||||
@ -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)]">
|
||||
|
||||
307
src/views/Cookie/getCookie.vue
Normal file
307
src/views/Cookie/getCookie.vue
Normal 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>
|
||||
319
src/views/TextGenerate/TextGenerate.vue
Normal file
319
src/views/TextGenerate/TextGenerate.vue
Normal 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>
|
||||
@ -9,7 +9,7 @@
|
||||
loop
|
||||
playsinline
|
||||
crossorigin="anonymous"
|
||||
src="../../assets/images/idfile.mp4"
|
||||
src="../../assets/images/loginBg.mp4"
|
||||
></video>
|
||||
</div>
|
||||
|
||||
|
||||
529
src/views/materialSquare/Detail.vue
Normal file
529
src/views/materialSquare/Detail.vue
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user