This commit is contained in:
hrx 2026-09-20 13:49:49 +08:00
parent 7b6d76a351
commit 97b4b215fb
45 changed files with 6939 additions and 1119 deletions

View File

@ -1 +1 @@
VITE_API_BASE_URL = 'https://opencomputing.ai'
VITE_API_BASE_URL=https://token.opencomputing.cn

View File

@ -1 +1 @@
VITE_API_BASE_URL = 'https://opencomputing.ai'
VITE_API_BASE_URL=https://token.opencomputing.cn

1
.env.test Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE_URL=https://tokentest.opencomputing.cn

20
package-lock.json generated
View File

@ -23,7 +23,7 @@
"vue-waterfall-plugin-next": "^3.0.1"
},
"devDependencies": {
"@types/node": "^25.4.0",
"@types/node": "^25.9.2",
"@types/swiper": "^6.0.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.0",
@ -1712,12 +1712,13 @@
}
},
"node_modules/@types/node": {
"version": "25.4.0",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-25.4.0.tgz",
"integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==",
"version": "25.9.2",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-25.9.2.tgz",
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/swiper": {
@ -3432,10 +3433,11 @@
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true
"version": "7.24.6",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
"node_modules/unplugin": {
"version": "3.0.0",

View File

@ -5,7 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"build": "vite build",
"build:test": "vite build --mode test",
"build:prod": "vite build --mode production",
"preview": "vite preview"
},
"dependencies": {

View File

@ -1,4 +1,5 @@
import {get, post} from '../utlis/request'
import { get, getApiBaseURL, post } from '../utlis/request'
import type { TextToTextParams } from '@/types/model'
// 文生视频
export const textvideo = <T,D>(data:D):Promise<T> =>post('/llmage/vidu_inference.dspy',data)
@ -9,6 +10,184 @@ export const imagetovideo = <T,D>(data:D):Promise<T> =>post('/llmage/vidu_infere
// 参考生视频
export const referencevideo = <T,D>(data:D):Promise<T> =>post('/llmage/video',data)
// 文生图
export const texttoimage = <T,D>(data:D):Promise<T> =>post('/llmage/v1/image/generations',data)
// 文生文
export const texttoText = <T,D>(data:D):Promise<T> =>post('/llmage/v1/chat/completions',data)
const isDonePayload = (payload: string): boolean => /^\[\s*DONE\s*\]$/i.test(payload.trim())
const isStructuredPayload = (payload: string): boolean => {
const firstChar = payload.trim().charAt(0)
return firstChar === '{' || firstChar === '['
}
const trimEventPayload = (payload: string): string => payload
.replace(/(?:\\r\\n|\\n|\r?\n)+$/g, '')
.trim()
const isCompletePayload = (payload: string): boolean => {
const text = payload.trim()
if (!text) return false
if (isDonePayload(text) || !isStructuredPayload(text)) return true
let depth = 0
let inString = false
let escaped = false
for (const char of text) {
if (escaped) {
escaped = false
continue
}
if (char === '\\') {
escaped = inString
continue
}
if (char === '"') {
inString = !inString
continue
}
if (inString) continue
if (char === '{' || char === '[') {
depth += 1
} else if (char === '}' || char === ']') {
depth -= 1
}
}
return depth === 0 && !inString
}
const extractTextFromPayload = (payload: string): string => {
const normalizedPayload = trimEventPayload(payload)
if (!normalizedPayload || isDonePayload(normalizedPayload)) {
return ''
}
try {
const parsed = JSON.parse(normalizedPayload)
const contentCandidates = [
parsed?.choices?.[0]?.delta?.content,
parsed?.choices?.[0]?.message?.content,
parsed?.data?.content,
parsed?.data?.answer,
parsed?.content,
parsed?.answer,
parsed?.text,
]
const content = contentCandidates.find((item): item is string => typeof item === 'string' && item.length > 0)
return content || ''
} catch {
return isStructuredPayload(normalizedPayload) ? '' : normalizedPayload
}
}
const readPayloadsFromBuffer = (buffer: string, flush = false): { payloads: string[]; rest: string } => {
const payloads: string[] = []
let rest = buffer
const dataMarkerPattern = /(?:^|\r?\n|\\n)data:\s*/
const nextDataMarkerPattern = /(?:\r?\n|\\n)data:\s*/
while (rest) {
const startMatch = dataMarkerPattern.exec(rest)
if (!startMatch) {
return {
payloads: flush && rest.trim() ? [...payloads, rest] : payloads,
rest: flush ? '' : rest,
}
}
const payloadStart = startMatch.index + startMatch[0].length
const candidateText = rest.slice(payloadStart)
const nextMatch = nextDataMarkerPattern.exec(candidateText)
if (nextMatch) {
payloads.push(trimEventPayload(candidateText.slice(0, nextMatch.index)))
rest = candidateText.slice(nextMatch.index)
continue
}
const candidatePayload = trimEventPayload(candidateText)
if (flush || isCompletePayload(candidatePayload)) {
payloads.push(candidatePayload)
rest = ''
} else {
rest = rest.slice(startMatch.index)
}
break
}
return { payloads, rest }
}
const extractTextFromChunk = (chunk: string): string => {
const { payloads } = readPayloadsFromBuffer(chunk, true)
return payloads.reduce((result, payload) => result + extractTextFromPayload(payload), '')
}
// 文生文流式输出
export const textToTextStream = async (
data: TextToTextParams,
onMessage: (content: string) => void,
signal?: AbortSignal
) => {
const response = await fetch(`${getApiBaseURL()}/llmage/v1/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(data),
signal,
})
if (!response.ok) {
throw new Error(`文生文请求失败:${response.status}`)
}
if (!response.body) {
const text = await response.text()
onMessage(extractTextFromChunk(text))
return
}
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let pendingText = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
pendingText += decoder.decode(value, { stream: true })
const { payloads, rest } = readPayloadsFromBuffer(pendingText)
pendingText = rest
payloads.forEach((payload) => {
const content = extractTextFromPayload(payload)
if (content) {
onMessage(content)
}
})
}
pendingText += decoder.decode()
const { payloads } = readPayloadsFromBuffer(pendingText, true)
payloads.forEach((payload) => {
const content = extractTextFromPayload(payload)
if (content) {
onMessage(content)
}
})
}
// 获取所有模型
export const AllModel = <T,D>(data:D):Promise<T> =>get('/llmage/get_type_llms.dspy',data)
@ -16,7 +195,10 @@ export const AllModel = <T,D>(data:D):Promise<T> =>get('/llmage/get_type_llms.ds
export const queryModelPrice = <T,D>(data:D):Promise<T> =>post('/platformbiz/product_query_price.dspy',data)
// 获取当天任务列表
export const getTodayTask = <T>():Promise<T> =>get('/llmage/get_my_asynctasks.dspy')
export const getTodayTask = <T,D = undefined>(data?:D):Promise<T> =>get('/llmage/get_my_asynctasks.dspy',data)
// 获取任务状态
export const getAsyncTaskStatus = <T,D>(data:D):Promise<T> =>get('/llmage/get_asynctask_status.dspy',data)
export const getAsyncTaskStatus = <T,D>(data:D):Promise<T> =>get('/llmage/get_asynctask_status.dspy',data)
// 历史记录
export const getHistoryRecord = <T,D>(data:D):Promise<T> =>get('/llmage/api/get_inference_history.dspy',data)

View File

@ -54,6 +54,54 @@
<div class="content unicode" style="display: block;">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont">&#xe621;</span>
<div class="name">下载</div>
<div class="code-name">&amp;#xe621;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xeb90;</span>
<div class="name">箭头_上下切换_o</div>
<div class="code-name">&amp;#xeb90;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe619;</span>
<div class="name">搜索</div>
<div class="code-name">&amp;#xe619;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe61e;</span>
<div class="name">我的创作</div>
<div class="code-name">&amp;#xe61e;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe62f;</span>
<div class="name"></div>
<div class="code-name">&amp;#xe62f;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe611;</span>
<div class="name"></div>
<div class="code-name">&amp;#xe611;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe8bd;</span>
<div class="name">消息</div>
<div class="code-name">&amp;#xe8bd;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe67d;</span>
<div class="name">17A发送</div>
<div class="code-name">&amp;#xe67d;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe606;</span>
<div class="name">414126695</div>
@ -192,9 +240,9 @@
<pre><code class="language-css"
>@font-face {
font-family: 'iconfont';
src: url('iconfont.woff2?t=1776413178216') format('woff2'),
url('iconfont.woff?t=1776413178216') format('woff'),
url('iconfont.ttf?t=1776413178216') format('truetype');
src: url('iconfont.woff2?t=1780995416168') format('woff2'),
url('iconfont.woff?t=1780995416168') format('woff'),
url('iconfont.ttf?t=1780995416168') format('truetype');
}
</code></pre>
<h3 id="-iconfont-">第二步:定义使用 iconfont 的样式</h3>
@ -220,6 +268,78 @@
<div class="content font-class">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont icon-xiazai"></span>
<div class="name">
下载
</div>
<div class="code-name">.icon-xiazai
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-jiantou_shangxiaqiehuan_o"></span>
<div class="name">
箭头_上下切换_o
</div>
<div class="code-name">.icon-jiantou_shangxiaqiehuan_o
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-sousuo"></span>
<div class="name">
搜索
</div>
<div class="code-name">.icon-sousuo
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-wodechuangzuo"></span>
<div class="name">
我的创作
</div>
<div class="code-name">.icon-wodechuangzuo
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-shang"></span>
<div class="name">
</div>
<div class="code-name">.icon-shang
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-xia"></span>
<div class="name">
</div>
<div class="code-name">.icon-xia
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-xiaoxi"></span>
<div class="name">
消息
</div>
<div class="code-name">.icon-xiaoxi
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-a-17Afasong"></span>
<div class="name">
17A发送
</div>
<div class="code-name">.icon-a-17Afasong
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-a-414126695"></span>
<div class="name">
@ -427,6 +547,70 @@
<div class="content symbol">
<ul class="icon_lists dib-box">
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-xiazai"></use>
</svg>
<div class="name">下载</div>
<div class="code-name">#icon-xiazai</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-jiantou_shangxiaqiehuan_o"></use>
</svg>
<div class="name">箭头_上下切换_o</div>
<div class="code-name">#icon-jiantou_shangxiaqiehuan_o</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-sousuo"></use>
</svg>
<div class="name">搜索</div>
<div class="code-name">#icon-sousuo</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-wodechuangzuo"></use>
</svg>
<div class="name">我的创作</div>
<div class="code-name">#icon-wodechuangzuo</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-shang"></use>
</svg>
<div class="name"></div>
<div class="code-name">#icon-shang</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-xia"></use>
</svg>
<div class="name"></div>
<div class="code-name">#icon-xia</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-xiaoxi"></use>
</svg>
<div class="name">消息</div>
<div class="code-name">#icon-xiaoxi</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-a-17Afasong"></use>
</svg>
<div class="name">17A发送</div>
<div class="code-name">#icon-a-17Afasong</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-a-414126695"></use>

View File

@ -1,8 +1,8 @@
@font-face {
font-family: "iconfont"; /* Project id 5136943 */
src: url('iconfont.woff2?t=1776413178216') format('woff2'),
url('iconfont.woff?t=1776413178216') format('woff'),
url('iconfont.ttf?t=1776413178216') format('truetype');
src: url('iconfont.woff2?t=1780995416168') format('woff2'),
url('iconfont.woff?t=1780995416168') format('woff'),
url('iconfont.ttf?t=1780995416168') format('truetype');
}
.iconfont {
@ -13,6 +13,38 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-xiazai:before {
content: "\e621";
}
.icon-jiantou_shangxiaqiehuan_o:before {
content: "\eb90";
}
.icon-sousuo:before {
content: "\e619";
}
.icon-wodechuangzuo:before {
content: "\e61e";
}
.icon-shang:before {
content: "\e62f";
}
.icon-xia:before {
content: "\e611";
}
.icon-xiaoxi:before {
content: "\e8bd";
}
.icon-a-17Afasong:before {
content: "\e67d";
}
.icon-a-414126695:before {
content: "\e606";
}

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,62 @@
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "25554760",
"name": "下载",
"font_class": "xiazai",
"unicode": "e621",
"unicode_decimal": 58913
},
{
"icon_id": "5387926",
"name": "箭头_上下切换_o",
"font_class": "jiantou_shangxiaqiehuan_o",
"unicode": "eb90",
"unicode_decimal": 60304
},
{
"icon_id": "9306187",
"name": "搜索",
"font_class": "sousuo",
"unicode": "e619",
"unicode_decimal": 58905
},
{
"icon_id": "5826834",
"name": "我的创作",
"font_class": "wodechuangzuo",
"unicode": "e61e",
"unicode_decimal": 58910
},
{
"icon_id": "1526208",
"name": "上",
"font_class": "shang",
"unicode": "e62f",
"unicode_decimal": 58927
},
{
"icon_id": "14313193",
"name": "下",
"font_class": "xia",
"unicode": "e611",
"unicode_decimal": 58897
},
{
"icon_id": "11372722",
"name": "消息",
"font_class": "xiaoxi",
"unicode": "e8bd",
"unicode_decimal": 59581
},
{
"icon_id": "29522659",
"name": "17A发送",
"font_class": "a-17Afasong",
"unicode": "e67d",
"unicode_decimal": 59005
},
{
"icon_id": "47386728",
"name": "414126695",

Binary file not shown.

View File

@ -118,6 +118,14 @@
line-height: 1.1;
}
.balance-unit {
margin-left: 3px;
font-size: 12px;
font-weight: 500;
// color: rgba(255, 255, 255, 0.72);
color: #fff;
}
.combo-divider {
width: 1px;
height: 24px;

View File

@ -256,23 +256,3 @@
justify-content: center;
align-items: center;
}
.login-require-content {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 24px;
}
.login-require-content .message {
font-size: 16px;
color: rgba(255, 255, 255, 0.7);
}
.login-require-content .login-btn {
width: 140px;
height: 44px;
border-radius: 12px;
font-size: 16px;
background: linear-gradient(270deg, #94d8ff 11.11%, #e0ddff);
border: none;
color: #000;
}

View File

@ -310,29 +310,6 @@
align-items: center;
}
.login-require-content {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 24px;
.message {
font-size: 16px;
color: rgba(255, 255, 255, 0.7);
}
.login-btn {
width: 140px;
height: 44px;
border-radius: 12px;
font-size: 16px;
background: linear-gradient(270deg, #94d8ff 11.11%, #e0ddff);
border: none;
color: #000;
}
}
/* 状态徽章样式 */
.status-badge {
padding: 4px 12px;

View File

@ -83,6 +83,7 @@
}
.reference-container {
padding: 12px;
flex: none;
gap: 20px;
color: #fff;
@ -261,9 +262,10 @@
.input {
flex: 1;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
margin-top: 10px;
:deep(.el-textarea) {
flex: 1;
display: flex;
@ -314,6 +316,7 @@
display: flex;
flex-direction: column;
gap: 16px;
padding: 12px;
}
.select-item {

View File

@ -35,6 +35,8 @@
gap: 20px;
color: #fff;
overflow: hidden;
padding: 12px;
}
.input {
@ -75,6 +77,7 @@
display: flex;
flex-direction: column;
gap: 16px;
padding: 12px;
}
.select-item {

View File

@ -78,30 +78,22 @@
.guide-list li + li {
margin-top: 4px;
}
.login-require,
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.login-require-content,
.empty-state {
flex-direction: column;
text-align: center;
}
.login-require-content img,
.empty-state img {
width: 220px;
height: 220px;
object-fit: contain;
opacity: 0.55;
}
.login-require-content {
display: flex;
gap: 12px;
}
.login-title,
.empty-title {
font-size: 20px;
color: #fff;

View File

@ -94,7 +94,6 @@
}
}
.login-require,
.empty-state {
flex: 1;
display: flex;
@ -102,7 +101,6 @@
justify-content: center;
}
.login-require-content,
.empty-state {
flex-direction: column;
text-align: center;
@ -115,12 +113,6 @@
}
}
.login-require-content {
display: flex;
gap: 12px;
}
.login-title,
.empty-title {
font-size: 20px;
color: #fff;

View File

@ -19,7 +19,10 @@
</span>
<div class="balance-copy">
<span class="balance-label">账户余额</span>
<span class="balance-text">{{ formattedBalance }}</span>
<span class="balance-text">
{{ formattedBalance }}
<span class="balance-unit"></span>
</span>
</div>
</div>
<span class="combo-divider"></span>

View File

@ -3,7 +3,7 @@
<div class="login-prompt__content">
<div class="login-prompt__title">{{ title }}</div>
<div v-if="description" class="login-prompt__description">{{ description }}</div>
<el-button type="primary" class="login-prompt__button" @click="handleLogin">
<el-button class="login-prompt__button" @click="handleLogin">
{{ buttonText }}
</el-button>
</div>
@ -22,8 +22,8 @@ interface Props {
withDefaults(defineProps<Props>(), {
title: '请先登录',
description: '',
buttonText: '登录',
description: '登录后即可继续使用当前功能。',
buttonText: '立即登录',
fullHeight: false,
});
@ -40,7 +40,7 @@ const handleLogin = () => {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
padding: 40px 24px;
box-sizing: border-box;
&.is-full-height {
@ -49,26 +49,48 @@ const handleLogin = () => {
}
.login-prompt__content {
width: min(460px, 100%);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
gap: 18px;
padding: 34px 36px;
// border: 1px solid rgba(148, 216, 255, 0.08);
border-radius: 18px;
// background:
// linear-gradient(180deg, rgba(18, 28, 42, 0.42), rgba(12, 18, 28, 0.18));
text-align: center;
}
.login-prompt__title {
font-size: 18px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
font-size: 24px;
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
}
.login-prompt__description {
max-width: 520px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.65);
max-width: 390px;
line-height: 1.75;
color: rgba(235, 244, 255, 0.58);
font-size: 16px;
}
.login-prompt__button {
min-width: 120px;
min-width: 148px;
height: 44px;
margin-top: 8px;
border: none;
border-radius: 12px;
color: #07101a;
background: linear-gradient(270deg, #94d8ff 11.11%, #e0ddff);
font-size: 15px;
font-weight: 700;
box-shadow: 0 12px 28px rgba(148, 216, 255, 0.18);
&:hover,
&:focus {
color: #07101a;
background: linear-gradient(270deg, #a8e0ff 11.11%, #efedff);
}
}
</style>

View File

@ -5,8 +5,8 @@
<LoginPrompt
v-if="!isLoggedIn"
class="login-require"
title="请登录后再进行创作哦~"
button-text="登录"
title="请登录"
description="登录后可查看生成任务和创作结果。"
/>
<!-- 已登录状态 -->
@ -49,12 +49,7 @@
<path d="M20.3996 11.3997L20.3996 6.59971C20.3996 5.27422 19.3251 4.19971 17.9996 4.19971H5.99961C4.67413 4.19971 3.59961 5.27422 3.59961 6.59971V17.3997C3.59961 18.7252 4.67413 19.7997 5.99961 19.7997L12.5996 19.7997" stroke="currentColor" stroke-linecap="round" stroke-width="1.5"></path>
<path d="M14.3996 11.9999L10.7996 14.0784V9.92144L14.3996 11.9999Z" fill="currentColor" stroke="currentColor" stroke-width="1.2"></path>
</svg>
<span>{{ task.action === 't2v' ? '文生视频' : task.action === 'i2v' ? '图生视频' : '视频生成' }}</span>
</div>
<div class="task-status">
<span :class="['status-badge', getStatusClass(task.status)]">
{{ getStatusText(task.status) }}
</span>
<span>{{ getTaskTypeText(task) }}</span>
</div>
</div>
@ -62,8 +57,15 @@
<div class="task-prompt-wrapper">
<div class="prompt-content">
<div class="video-preview">
<div class="preview-bg" :style="getPreviewBgStyle(task.cover_url)">
<div class="preview-bg" :style="getPreviewBgStyle(task.cover_url || task.image_url)">
<img
v-if="isImageTask(task)"
:src="task.image_url || task.cover_url"
alt="图片预览"
class="preview-video"
/>
<video
v-else
:src="task.video_url"
:poster="task.cover_url || ''"
class="preview-video"
@ -88,12 +90,24 @@
<div class="video-player">
<div class="player-container">
<!-- 视频生成中提示 -->
<div v-if="!task.video_url && isInProgressStatus(task.status)" class="generating-status">
<div v-if="!getTaskMediaUrl(task) && isInProgressStatus(task.status)" class="generating-status">
<div class="spinner"></div>
<div class="generating-text">{{ getStatusText(task.status) }}</div>
<div class="progress-wrap">
<div class="progress-track">
<div class="progress-fill" :style="{ width: `${getTaskProgress(task.status)}%` }"></div>
</div>
<div class="progress-value">{{ getTaskProgress(task.status) }}%</div>
</div>
</div>
<!-- 视频播放器 -->
<video
<img
v-else-if="isImageTask(task)"
:src="getTaskMediaUrl(task)"
alt="生成图片"
class="task-video task-image"
/>
<video
v-else
:src="task.video_url"
:poster="task.cover_url"
@ -113,9 +127,9 @@
<!-- 下载按钮 -->
<div
class="icon-btn"
:class="{ disabled: !task.video_url || downloadingTaskIds.has(task.taskid) }"
@click="task.video_url && !downloadingTaskIds.has(task.taskid) ? downloadVideo(task) : null"
:title="task.video_url ? '下载视频' : '暂无可下载的视频'"
:class="{ disabled: !getTaskMediaUrl(task) || downloadingTaskIds.has(task.taskid) }"
@click="getTaskMediaUrl(task) && !downloadingTaskIds.has(task.taskid) ? downloadVideo(task) : null"
:title="getTaskMediaUrl(task) ? '下载作品' : '暂无可下载的作品'"
>
<template v-if="downloadingTaskIds.has(task.taskid)">
<el-icon class="is-loading">
@ -151,6 +165,9 @@ import LoginPrompt from '@/components/LoginPrompt/LoginPrompt.vue';
// ==================== ====================
const loginStore = useLoginStore();
const props = defineProps<{
llmcatelogid?: string;
}>();
// ==================== ====================
/** 登录状态 */
@ -234,6 +251,35 @@ const isInProgressStatus = (status?: string) => {
return normalizedStatus === 'CREATED' || normalizedStatus === 'PENDING' || normalizedStatus === 'RUNNING';
};
const isImageTask = (task: TaskItem) => {
return task.action === 't2i' || (!!task.image_url && !task.video_url);
};
const getTaskMediaUrl = (task: TaskItem) => {
return task.video_url || task.image_url || '';
};
const getTaskTypeText = (task: TaskItem) => {
if (task.action === 't2v') return '文生视频';
if (task.action === 'i2v') return '图生视频';
if (task.action === 'r2v') return '参考生视频';
if (task.action === 't2i' || isImageTask(task)) return '文生图';
return '视频生成';
};
const getTaskProgress = (status?: string): number => {
const normalizedStatus = normalizeTaskStatus(status);
const progressMap: Record<string, number> = {
CREATED: 15,
PENDING: 35,
RUNNING: 70,
SUCCEEDED: 100,
FAILED: 100
};
return progressMap[normalizedStatus] ?? 0;
};
const getPreviewBgStyle = (coverUrl?: string) => {
if (!coverUrl) {
return {};
@ -244,6 +290,57 @@ const getPreviewBgStyle = (coverUrl?: string) => {
};
};
const readStringField = (source: unknown, keys: string[]) => {
if (!source || typeof source !== 'object') return '';
const record = source as Record<string, unknown>;
for (const key of keys) {
const value = record[key];
if (typeof value === 'string' && value) return value;
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return '';
};
const getOutputImageUrl = (output: unknown) => {
const directUrl = readStringField(output, [
'image',
'image_url',
'url',
'imageUrl',
'output_image',
'output_url',
'b64_json',
'images',
]);
if (directUrl) return directUrl;
if (output && typeof output === 'object') {
const record = output as Record<string, unknown>;
const dataUrl = readStringField(record.data, ['image', 'image_url', 'url', 'b64_json', 'images']);
if (dataUrl) return dataUrl;
}
return '';
};
const getTaskAction = (input: Record<string, unknown>, output: { video?: string; usage?: { action?: string } }) => {
// output usage action
if (output.usage?.action) return output.usage.action;
// catelogid
const catelogid = input.catelogid as string;
if (catelogid === 'r2v') return 'r2v';
if (catelogid === 'i2v') return 'i2v';
if (catelogid === 't2v') return 't2v';
if (catelogid === 't2i') return 't2i';
//
if (output.video) return 't2v';
//
if (getOutputImageUrl(output)) return 't2i';
return undefined;
};
/**
* 获取状态显示文本
* @param status 状态值
@ -260,22 +357,6 @@ const getStatusText = (status: string): string => {
return statusMap[normalizedStatus] || normalizedStatus;
};
/**
* 获取状态样式类名
* @param status 状态值
*/
const getStatusClass = (status: string): string => {
const classMap: Record<string, string> = {
'CREATED': 'status-created',
'PENDING': 'status-queueing',
'RUNNING': 'status-processing',
'SUCCEEDED': 'status-succeeded',
'FAILED': 'status-failed'
};
const normalizedStatus = normalizeTaskStatus(status);
return classMap[normalizedStatus] || '';
};
/**
* 下载视频
* @param task 任务对象
@ -287,9 +368,11 @@ const downloadVideo = async (task: TaskItem) => {
return;
}
//
if (!task.video_url) {
ElMessage.error('视频地址不存在,无法下载');
const mediaUrl = getTaskMediaUrl(task);
//
if (!mediaUrl) {
ElMessage.error('作品地址不存在,无法下载');
return;
}
@ -309,7 +392,7 @@ const downloadVideo = async (task: TaskItem) => {
try {
// 1使 fetch CORS
const response = await fetch(task.video_url);
const response = await fetch(mediaUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
@ -323,7 +406,7 @@ const downloadVideo = async (task: TaskItem) => {
// _ID_.mp4
const timestamp = new Date().getTime();
const prompt = task.prompt?.substring(0, 15) || 'video';
const fileName = `${prompt}_${task.taskid}_${timestamp}.mp4`;
const fileName = `${prompt}_${task.taskid}_${timestamp}.${isImageTask(task) ? 'png' : 'mp4'}`;
link.download = fileName;
document.body.appendChild(link);
@ -340,8 +423,8 @@ const downloadVideo = async (task: TaskItem) => {
// 2 -
try {
const link = document.createElement('a');
link.href = task.video_url;
link.download = `video_${task.taskid}_${new Date().getTime()}.mp4`;
link.href = mediaUrl;
link.download = `${isImageTask(task) ? 'image' : 'video'}_${task.taskid}_${new Date().getTime()}.${isImageTask(task) ? 'png' : 'mp4'}`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
@ -371,6 +454,14 @@ const updateTaskStatus = async (taskId: string) => {
status: string;
video?: string;
image?: string;
image_url?: string;
url?: string;
images?: string[];
imageUrl?: string;
output_image?: string;
output_url?: string;
b64_json?: string;
data?: unknown;
}
interface Data {
resposne: Response;
@ -389,12 +480,17 @@ const updateTaskStatus = async (taskId: string) => {
const newStatus = normalizeTaskStatus(res.data.resposne.status || taskList.value[taskIndex].status);
taskList.value[taskIndex].status = newStatus;
// res.data.resposne
// / res.data.resposne
if (res.data.resposne.video) {
taskList.value[taskIndex].video_url = res.data.resposne.video;
}
if (res.data.resposne.image) {
taskList.value[taskIndex].cover_url = res.data.resposne.image;
const imageUrl = getOutputImageUrl(res.data.resposne);
if (imageUrl) {
taskList.value[taskIndex].cover_url = imageUrl;
taskList.value[taskIndex].image_url = imageUrl;
if (!taskList.value[taskIndex].video_url) {
taskList.value[taskIndex].action = 't2i';
}
}
}
}
@ -456,28 +552,35 @@ const loadTaskList = async () => {
isLoading.value = true;
try {
const taskParams = props.llmcatelogid ? { llmcatelogid: props.llmcatelogid } : undefined;
//
const res = await getTodayTask<TaskResult>();
const res = await getTodayTask<TaskResult, { llmcatelogid: string } | undefined>(taskParams);
if (res.status === 'ok' && res.data?.tasks && Array.isArray(res.data.tasks)) {
//
taskList.value = res.data.tasks.map((task) => {
const output = task.ioinfo?.output || [];
const finalOutput = output.find((o) => o.image && o.video) || output[output.length - 1] || {};
const firstOutput = output[0] || {};
// video
const finalOutput = output.find((o) => o.video) || output.find((o) => getOutputImageUrl(o)) || firstOutput;
const input = task.ioinfo?.input || {};
const imageUrl = getOutputImageUrl(finalOutput);
const action = getTaskAction(input as unknown as Record<string, unknown>, finalOutput) || props.llmcatelogid;
const usageOutput = finalOutput?.usage as { model?: string; resolution?: string } | undefined;
return {
taskid: task.taskid,
status: normalizeTaskStatus(task.status || finalOutput.status || 'CREATED'),
prompt: input.prompt,
cover_url: finalOutput.image || undefined,
cover_url: imageUrl || undefined,
image_url: imageUrl && !finalOutput.video ? imageUrl : undefined,
video_url: finalOutput.video || undefined,
duration: input.duration,
resolution: input.resolution,
resolution: input.resolution || input.size || usageOutput?.resolution || '',
llmid: input.llmid,
model: input.model,
model: input.model || usageOutput?.model || '',
use_time: task.use_time,
action: finalOutput.usage?.action,
action,
} as TaskItem;
});
@ -528,6 +631,16 @@ watch(() => loginStore.isLoggedIn, (newValue) => {
}
});
watch(
() => props.llmcatelogid,
() => {
if (isLoggedIn.value) {
stopPolling();
loadTaskList();
}
}
);
// ==================== ====================
/**
* 向父组件暴露的方法
@ -604,6 +717,37 @@ defineExpose({
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
.progress-wrap {
width: min(320px, 68%);
display: flex;
align-items: center;
gap: 12px;
}
.progress-track {
flex: 1;
height: 8px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #2cfff8 0%, #94d8ff 48%, #e0ddff 100%);
box-shadow: 0 0 18px rgba(44, 255, 248, 0.34);
transition: width 0.35s ease;
}
.progress-value {
min-width: 38px;
color: rgba(255, 255, 255, 0.72);
font-size: 13px;
text-align: right;
}
</style>
<style>

View File

@ -0,0 +1,665 @@
<template>
<div class="img-img-panel">
<div v-if="!selectedModel" class="model-loading">
{{ modelLoading ? '模型加载中...' : '暂无配置模型,请等待管理员配置' }}
</div>
<template v-else>
<el-card class="prompt-card">
<div class="upload-area">
<input
ref="imageInputRef"
class="hidden-upload-input"
type="file"
accept="image/*"
@change="handleImageSelect"
/>
<div v-if="imagePreviewUrl" class="image-preview">
<img :src="imagePreviewUrl" alt="参考图片" />
<button type="button" class="remove-image" @click="removeImage">删除</button>
</div>
<button v-else type="button" class="upload-card" @click="triggerImageUpload">
<span class="upload-plus">+</span>
<span>上传参考图片</span>
</button>
</div>
<div class="prompt-editor">
<el-input
v-model="prompt"
class="prompt-input"
:autosize="{ minRows: 8, maxRows: 8 }"
type="textarea"
maxlength="3000"
show-word-limit
:placeholder="promptPlaceholder"
/>
</div>
</el-card>
<div v-if="configPanelVisible" class="config-panel">
<div
v-for="field in configFields"
:key="field.name"
class="config-section"
>
<div class="config-title">
{{ field.displayLabel }}
<span v-if="field.required" class="required-star">*</span>
</div>
<div class="config-content">
<div v-if="field.uitype === 'code'" class="option-grid">
<button
v-for="option in field.data"
:key="option.value"
type="button"
class="option-btn"
:class="{ active: configData[field.name] === option.value }"
@click="configData[field.name] = option.value"
>
{{ option.text || option.value }}
</button>
</div>
<div v-else-if="field.isQuantity" class="option-grid">
<button
v-for="num in field.quantityOptions"
:key="num"
type="button"
class="option-btn"
:class="{ active: Number(configData[field.name]) === num }"
@click="configData[field.name] = num"
>
{{ num }}
</button>
</div>
<el-input-number
v-else-if="field.uitype === 'int'"
v-model="configData[field.name]"
:min="1"
:max="field.max || 99"
:step="1"
controls-position="right"
style="width: 100%"
/>
<el-input
v-else
v-model="configData[field.name]"
:placeholder="field.placeholder || `请输入${field.displayLabel}`"
clearable
/>
</div>
</div>
</div>
<div class="control-row">
<el-select
v-model="currentModelId"
class="model-select"
placeholder="请选择模型"
popper-class="custom-select-popup"
@change="onModelChange"
>
<el-option
v-for="item in modelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<button
type="button"
class="config-trigger"
:class="{ active: configPanelVisible }"
@click="configPanelVisible = !configPanelVisible"
>
<span
v-for="item in summaryItems"
:key="item.name"
class="summary-item"
>
{{ item.text }}
</span>
<span class="trigger-arrow">
<i :class="['iconfont', configPanelVisible ? 'icon-shang' : 'icon-xia']"></i>
</span>
</button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { AllModel, queryModelPrice, texttoimage } from '@/apis/Model';
import type {
AllModelResponse,
InputField,
Model,
PriceQueryParams,
PriceQueryResponse,
TextToImageParams,
TextToImageResponse,
} from '@/types/model';
import { ElMessage } from 'element-plus';
import { computed, onMounted, ref, watch } from 'vue';
type ConfigValue = string | number | boolean;
const emit = defineEmits<{
(e: 'price-update', price: number): void;
(e: 'prompt-change', isEmpty: boolean): void;
(e: 'task-created', taskid: string): void;
(e: 'image-generated', result: TextToImageResponse): void;
}>();
const prompt = ref('');
const models = ref<Model[]>([]);
const currentModelId = ref('');
const configData = ref<Record<string, ConfigValue>>({});
const configPanelVisible = ref(false);
const generating = ref(false);
const modelLoading = ref(true);
const imageInputRef = ref<HTMLInputElement | null>(null);
const imageFileField = ref('image_file');
const imageData = ref('');
const imagePreviewUrl = ref('');
let priceTimer: ReturnType<typeof setTimeout> | null = null;
const selectedModel = computed(() => models.value.find((item) => item.id === currentModelId.value));
const modelOptions = computed(() => models.value.map((item) => ({
label: item.model || item.name,
value: item.id,
})));
const promptPlaceholder = computed(() => {
const promptField = selectedModel.value?.input_fields?.find((field) => field.name === 'prompt');
return promptField?.placeholder || promptField?.label || '结合参考图片,描述你想生成的图片内容';
});
const isImageField = (field: InputField) => {
const name = field.name.toLowerCase();
return field.uitype === 'image' || name.includes('image') || name.includes('img') || name.includes('file');
};
const configFields = computed(() => {
return (selectedModel.value?.input_fields || [])
.filter((field) => field.name !== 'prompt' && !isImageField(field))
.map((field) => {
const value = configData.value[field.name];
const isQuantity = isQuantityName(field.name) && field.uitype === 'int';
const displayLabel = field.label || field.lable || field.name;
const selectedOption = field.data?.find((option) => option.value === value);
return {
...field,
displayLabel,
isQuantity,
quantityOptions: isQuantity
? Array.from({ length: Math.min(field.max || 4, 4) }, (_, index) => index + 1)
: [],
summaryText: selectedOption?.text || (isQuantity ? `${value || 1}` : String(value || displayLabel)),
};
});
});
const summaryItems = computed(() => configFields.value.slice(0, 4).map((field) => ({
name: field.name,
text: field.summaryText,
})));
const isQuantityName = (name: string) => {
const lowerName = name.toLowerCase();
return ['num', 'count', 'quantity', 'batch_size', 'n'].some((key) => lowerName.includes(key));
};
const defaultConfigValue = (field: InputField): ConfigValue => {
if (field.defaultvalue !== undefined && field.defaultvalue !== null) return field.defaultvalue;
if (field.uitype === 'code' && field.data?.length) return field.data[0].value;
if (field.uitype === 'int') return 1;
return '';
};
const resetConfig = () => {
const nextConfig: Record<string, ConfigValue> = {};
const imageField = selectedModel.value?.input_fields?.find(isImageField);
imageFileField.value = imageField?.name || 'image_file';
(selectedModel.value?.input_fields || [])
.filter((field) => field.name !== 'prompt' && !isImageField(field))
.forEach((field) => {
nextConfig[field.name] = defaultConfigValue(field);
});
configData.value = nextConfig;
};
const onModelChange = () => {
resetConfig();
configPanelVisible.value = false;
fetchPrice();
};
const loadImageToImageModels = async () => {
interface ModelParams {
llmcatelogid: string;
}
modelLoading.value = true;
try {
const res = await AllModel<AllModelResponse, ModelParams>({
llmcatelogid: 'i2i',
});
if (res.status === 'ok' && res.data?.length) {
models.value = res.data;
currentModelId.value = res.data[0]?.id || '';
resetConfig();
fetchPrice();
} else {
models.value = [];
currentModelId.value = '';
}
} finally {
modelLoading.value = false;
}
};
const triggerImageUpload = () => {
imageInputRef.value?.click();
};
const handleImageSelect = (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
ElMessage.warning('请上传图片文件');
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
imageData.value = String(reader.result || '');
imagePreviewUrl.value = imageData.value;
emitPromptState();
};
reader.readAsDataURL(file);
};
const removeImage = () => {
imageData.value = '';
imagePreviewUrl.value = '';
if (imageInputRef.value) imageInputRef.value.value = '';
emitPromptState();
};
const emitPromptState = () => {
emit('prompt-change', !prompt.value.trim() || !imageData.value);
};
const buildPriceParams = (): PriceQueryParams | null => {
if (!selectedModel.value) return null;
return {
product_type: 'llm',
product_id: selectedModel.value.id,
config_data: {
action: selectedModel.value.apiname,
has_image: !!imageData.value,
...configData.value,
},
};
};
const buildGenerateParams = (): TextToImageParams | null => {
if (!selectedModel.value) return null;
return {
model: selectedModel.value.model,
catelogid: 'i2i',
prompt: prompt.value.trim(),
[imageFileField.value]: imageData.value,
...configData.value,
};
};
const fetchPrice = () => {
if (priceTimer) clearTimeout(priceTimer);
priceTimer = setTimeout(async () => {
const params = buildPriceParams();
if (!params) {
emit('price-update', 0);
return;
}
try {
const res = await queryModelPrice<PriceQueryResponse, PriceQueryParams>(params);
emit('price-update', res.status === 'ok' && res.data ? res.data.amount : 0);
} catch (error) {
console.error('图生图价格查询失败:', error);
emit('price-update', 0);
}
}, 500);
};
watch(
() => [currentModelId.value, configData.value],
() => fetchPrice(),
{ deep: true }
);
watch(
() => [prompt.value, imageData.value],
() => emitPromptState(),
{ immediate: true }
);
const generate = async () => {
if (!imageData.value) {
ElMessage.warning('请上传参考图片');
return false;
}
if (!prompt.value.trim()) {
ElMessage.warning('请输入提示词');
return false;
}
const params = buildGenerateParams();
if (!params) {
ElMessage.warning('模型未加载完成');
return false;
}
generating.value = true;
try {
const res = await texttoimage<TextToImageResponse, TextToImageParams>(params);
const taskid = res.data?.taskid || res.taskid;
const images = res.image || res.data?.image || [];
if (String(res.status).toUpperCase() === 'SUCCEEDED' && images.length) {
ElMessage.success('图生图生成成功');
emit('image-generated', res);
resetForm();
return true;
}
if (['CREATED', 'PENDING', 'RUNNING'].includes(String(res.status).toUpperCase())) {
ElMessage.success('图生图任务已提交,请稍后在任务列表中查看');
if (taskid) emit('task-created', taskid);
resetForm();
return true;
}
ElMessage.error(res.message || '生成失败,请重试');
return false;
} catch (error) {
console.error('图生图生成失败:', error);
ElMessage.error(error instanceof Error ? error.message : '生成失败,请重试');
return false;
} finally {
generating.value = false;
}
};
const resetForm = () => {
prompt.value = '';
removeImage();
resetConfig();
emit('price-update', 0);
emit('prompt-change', true);
};
defineExpose({
generate,
resetForm,
});
onMounted(() => {
loadImageToImageModels();
});
</script>
<style scoped lang="less">
.img-img-panel {
position: relative;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 16px;
}
.model-loading {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
color: rgba(255, 255, 255, 0.62);
background: #1a1c20;
}
.prompt-card {
flex: 1;
min-height: 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
background: #1a1c20;
overflow: hidden;
:deep(.el-card__body) {
height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
}
}
.hidden-upload-input {
display: none;
}
.upload-area {
flex-shrink: 0;
}
.upload-card,
.image-preview {
width: 160px;
height: 160px;
border-radius: 12px;
}
.upload-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px dashed rgba(255, 255, 255, 0.24);
color: rgba(255, 255, 255, 0.72);
background: rgba(0, 0, 0, 0.22);
cursor: pointer;
}
.upload-plus {
font-size: 34px;
line-height: 1;
}
.image-preview {
position: relative;
overflow: hidden;
background: rgba(0, 0, 0, 0.22);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.remove-image {
position: absolute;
right: 8px;
bottom: 8px;
height: 28px;
padding: 0 10px;
border: none;
border-radius: 8px;
color: #fff;
background: rgba(0, 0, 0, 0.62);
cursor: pointer;
}
.prompt-editor {
flex: 1;
min-height: 0;
display: flex;
}
.prompt-input {
flex: 1;
:deep(.el-textarea) {
height: 100%;
}
:deep(.el-textarea__inner) {
height: 100% !important;
resize: none;
border: none !important;
border-radius: 10px !important;
color: #fff !important;
background: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
padding: 14px 16px !important;
line-height: 1.7;
}
:deep(.el-input__count) {
background: transparent;
color: rgba(255, 255, 255, 0.48);
}
}
.config-panel {
position: absolute;
left: 16px;
right: 16px;
bottom: 64px;
z-index: 10;
overflow-y: auto;
padding: 16px;
border-radius: 12px;
background: #2b2d30;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.32);
}
.config-section + .config-section {
margin-top: 18px;
}
.config-title {
margin-bottom: 10px;
color: rgba(255, 255, 255, 0.64);
font-size: 14px;
}
.required-star {
margin-left: 4px;
color: #f56c6c;
}
.option-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
gap: 8px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.18);
padding: 6px;
}
.option-btn {
height: 34px;
border: none;
border-radius: 8px;
color: rgba(255, 255, 255, 0.76);
background: transparent;
cursor: pointer;
&.active {
color: #fff;
background: rgba(255, 255, 255, 0.14);
}
}
.control-row {
flex-shrink: 0;
display: flex;
gap: 8px;
min-height: 52px;
}
.model-select {
flex: 0 0 160px;
}
.config-trigger {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
color: #fff;
background: #252729;
cursor: pointer;
&.active {
border-color: rgba(69, 238, 120, 0.8);
}
}
.summary-item {
position: relative;
color: rgba(255, 255, 255, 0.82);
font-size: 14px;
white-space: nowrap;
& + .summary-item {
padding-left: 12px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.12);
transform: translateY(-50%);
}
}
}
.trigger-arrow {
margin-left: auto;
color: rgba(255, 255, 255, 0.72);
}
</style>

View File

@ -5,15 +5,41 @@
<ImgTap :activeTab="activeTab" @tabChange="handleTabChange" />
</div>
<div class="content">
<ReferenceImg v-if="activeTab === 'reference'" />
<TextImg v-else-if="activeTab === 'text'" />
<ReferenceImg
ref="referenceImgRef"
v-if="activeTab === 'reference'"
@price-update="handleReferencePriceUpdate"
@prompt-change="handleReferencePromptChange"
@task-created="handleTaskCreated"
@image-generated="handleImageGenerated"
/>
<TextImg
ref="textImgRef"
v-else-if="activeTab === 'text'"
@price-update="handleTextPriceUpdate"
@prompt-change="handleTextPromptChange"
@task-created="handleTaskCreated"
@image-generated="handleImageGenerated"
/>
<ImgImg
ref="imgImgRef"
v-else-if="activeTab === 'image'"
@price-update="handleImagePriceUpdate"
@prompt-change="handleImagePromptChange"
@task-created="handleTaskCreated"
@image-generated="handleImageGenerated"
/>
</div>
<div className="create-button-container">
<div class="create-button-container">
<div
type="primary"
className="create-button"
class="create-button"
:class="{ disabled: isCreateButtonDisabled }"
@click="handleCreate"
>
创作
<span v-if="currentPrice > 0" class="price-info">
消耗 {{ currentPrice }}
</span>
</div>
</div>
</div>
@ -21,18 +47,130 @@
</div>
</template>
<script setup lang='ts'>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import ImgTap from './ImgTap.vue';
import ReferenceImg from './ReferenceImg.vue';
import TextImg from './TextImg.vue';
import ImgImg from './ImgImg.vue';
import { debounce } from '@/utlis/debounce';
import type { TextToImageResponse } from '@/types/model';
const route = useRoute();
const router = useRouter();
interface TextImgExposed {
generate: () => Promise<boolean>;
resetForm: () => void;
}
interface ImgImgExposed {
generate: () => Promise<boolean>;
resetForm: () => void;
}
interface ReferenceImgExposed {
generate: () => Promise<boolean>;
resetForm: () => void;
}
// tab
const activeTab = ref('reference');
const referenceImgRef = ref<ReferenceImgExposed | null>(null);
const textImgRef = ref<TextImgExposed | null>(null);
const imgImgRef = ref<ImgImgExposed | null>(null);
const textPrice = ref(0);
const imagePrice = ref(0);
const referencePrice = ref(0);
const isReferencePromptEmpty = ref(true);
const isTextPromptEmpty = ref(true);
const isImagePromptEmpty = ref(true);
const isCreating = ref(false);
const handleTextPriceUpdate = (price: number) => {
textPrice.value = price;
};
const handleTextPromptChange = (isEmpty: boolean) => {
isTextPromptEmpty.value = isEmpty;
};
const handleReferencePriceUpdate = (price: number) => {
referencePrice.value = price;
};
const handleReferencePromptChange = (isEmpty: boolean) => {
isReferencePromptEmpty.value = isEmpty;
};
const handleImagePriceUpdate = (price: number) => {
imagePrice.value = price;
};
const handleImagePromptChange = (isEmpty: boolean) => {
isImagePromptEmpty.value = isEmpty;
};
const currentPrice = computed(() => {
if (activeTab.value === 'text') {
return textPrice.value;
}
if (activeTab.value === 'image') {
return imagePrice.value;
}
return referencePrice.value;
});
const isCreateButtonDisabled = computed(() => {
if (isCreating.value) return true;
if (activeTab.value === 'reference') return isReferencePromptEmpty.value;
if (activeTab.value === 'text') return isTextPromptEmpty.value;
if (activeTab.value === 'image') return isImagePromptEmpty.value;
return true;
});
const emit = defineEmits<{
(e: 'task-created', taskid: string): void;
(e: 'image-generated', result: TextToImageResponse): void;
(e: 'generation-start'): void;
}>();
const handleTaskCreated = (taskid: string) => {
emit('task-created', taskid);
if (activeTab.value === 'text') {
textImgRef.value?.resetForm?.();
}
if (activeTab.value === 'image') {
imgImgRef.value?.resetForm?.();
}
if (activeTab.value === 'reference') {
referenceImgRef.value?.resetForm?.();
}
};
const handleImageGenerated = (result: TextToImageResponse) => {
emit('image-generated', result);
};
const handleCreate = debounce(async () => {
if (isCreateButtonDisabled.value) return;
isCreating.value = true;
emit('generation-start');
try {
if (activeTab.value === 'text') {
await textImgRef.value?.generate?.();
}
if (activeTab.value === 'image') {
await imgImgRef.value?.generate?.();
}
if (activeTab.value === 'reference') {
await referenceImgRef.value?.generate?.();
}
} finally {
isCreating.value = false;
}
}, 500);
// tab
const handleTabChange = debounce((tab: string) => {
@ -48,6 +186,8 @@ const updateActiveTabFromRoute = () => {
activeTab.value = 'reference';
} else if (path.includes('/ImgGenerate/text')) {
activeTab.value = 'text';
} else if (path.includes('/ImgGenerate/image')) {
activeTab.value = 'image';
} else {
// reference
activeTab.value = 'reference';
@ -66,12 +206,13 @@ onMounted(() => {
onUnmounted(() => {
handleTabChange.cancel();
handleCreate.cancel();
});
</script>
<style scoped lang='less'>
.left{
width: 100%;
height: 100%;
height: calc(100vh - 80px);
// overflow-y: auto;
position: relative;
display: flex;
@ -105,9 +246,21 @@ z-index: 999;
color: #000 !important;
font-size: 18px !important;
font-weight: 600 !important;
gap: 8px;
cursor: pointer;
&:hover {
background-color: rgba(255, 255, 255, 0.02) !important;
}
.price-info {
font-size: 14px;
font-weight: 500;
}
&.disabled {
cursor: not-allowed;
opacity: 0.55;
}
}
}
</style>

View File

@ -15,6 +15,13 @@
>
文生图
</div>
<div
class="tab-item"
:class="{ active: activeTab === 'image' }"
@click="switchTab('image')"
>
图生图
</div>
</div>
</div>
</template>
@ -47,7 +54,6 @@ const switchTab = (tab: string) => {
}
.tab-item {
width: 50%;
flex: 1;
padding: 12px 0;
background: transparent;

View File

@ -1,170 +1,784 @@
<template>
<div class="container">
<el-card class="Topcard">
<div class="card-top">
<div class="top-left">
<h2>参考生图</h2>
<div class="desc">参考多图元素生成全新图片</div>
<div class="add">
<div class="add-item">
<i class="iconfont icon-jia"></i>
<span>添加图片</span>
</div>
<div class="add-item">
<i class="iconfont icon-jia"></i>
<span>添加主体</span>
</div>
</div>
</div>
<div class="top-right">
<img src="https://image01.vidu.zone/vidu/media-asset/20250808-140539-3230cb2d.webp" alt="">
</div>
</div>
<div class="card-bottom">
<el-input v-model="textarea2" class="input" :autosize="{ minRows: 10, maxRows: 10 }" type="textarea"
placeholder="结合图片,描述想要生成的内容" />
</div>
</el-card>
<el-card class="Bottomcard">
<div class="select">
<div class="select-item">
<div class="select-item">
<div class="title">
模型
</div>
<div class="content">
<el-select v-model="value" placeholder="Select" popper-class="custom-select-popup">
<el-option v-for="item in options" :key="item.value" :label="item.label"
:value="item.value" />
</el-select>
</div>
</div>
</div>
</div>
<div class="border"></div>
<div class="select">
<div class="select-item">
<div class="select-item">
<div class="title">
清晰度
</div>
<div class="content">
<el-select v-model="value" placeholder="Select" popper-class="custom-select-popup">
<el-option v-for="item in options" :key="item.value" :label="item.label"
:value="item.value" />
</el-select>
</div>
</div>
</div>
</div>
<div class="border"></div>
<div class="select">
<div class="select-item">
<div class="select-item">
<div class="title">
宽高比
</div>
<div class="content">
<el-select v-model="value" placeholder="Select" popper-class="custom-select-popup">
<el-option v-for="item in options" :key="item.value" :label="item.label"
:value="item.value" />
</el-select>
</div>
</div>
</div>
</div>
<div class="border"></div>
<div class="select">
<div class="select-item">
<div class="select-item">
<div class="title">
数量
</div>
<div class="content">
<div class="quantity-buttons">
<button
v-for="num in [1, 2, 3, 4]"
:key="num"
class="quantity-btn"
:class="{ active: selectedQuantity === num }"
@click="selectedQuantity = num"
>
{{ num }}
</button>
</div>
</div>
</div>
</div>
</div>
</el-card>
<div class="reference-img-panel">
<div v-if="!selectedModel" class="model-loading">
{{ modelLoading ? '模型加载中...' : '暂无配置模型,请等待管理员配置' }}
</div>
<template v-else>
<el-card class="prompt-card">
<div class="reference-upload">
<div class="reference-copy">
<div class="reference-title">参考生图</div>
<div class="reference-desc">上传参考图片结合提示词生成全新图片</div>
</div>
<input
ref="imageInputRef"
class="hidden-upload-input"
type="file"
accept="image/*"
multiple
@change="handleImageSelect"
/>
<button type="button" class="upload-action" @click="triggerImageUpload">
<span class="upload-action__icon">+</span>
<span>添加图片</span>
</button>
</div>
<div v-if="referenceImages.length" class="image-list">
<div
v-for="image in referenceImages"
:key="image.id"
class="image-card"
>
<img :src="image.url" :alt="image.name" />
<button type="button" class="remove-image" @click="removeImage(image.id)">删除</button>
</div>
</div>
<div class="prompt-editor">
<el-input
v-model="prompt"
class="prompt-input"
:autosize="{ minRows: 10, maxRows: 10 }"
type="textarea"
maxlength="3000"
show-word-limit
:placeholder="promptPlaceholder"
/>
</div>
</el-card>
<div v-if="configPanelVisible" class="config-panel">
<div
v-for="field in configFields"
:key="field.name"
class="config-section"
>
<div class="config-title">
{{ field.displayLabel }}
<span v-if="field.required" class="required-star">*</span>
</div>
<div class="config-content">
<div v-if="field.uitype === 'code'" class="option-grid">
<button
v-for="option in field.data"
:key="option.value"
type="button"
class="option-btn"
:class="{ active: configData[field.name] === option.value }"
@click="configData[field.name] = option.value"
>
{{ option.text || option.value }}
</button>
</div>
<div v-else-if="field.isQuantity" class="option-grid">
<button
v-for="num in field.quantityOptions"
:key="num"
type="button"
class="option-btn"
:class="{ active: Number(configData[field.name]) === num }"
@click="configData[field.name] = num"
>
{{ num }}
</button>
</div>
<el-input-number
v-else-if="field.uitype === 'int'"
v-model="configData[field.name]"
:min="1"
:max="field.max || 99"
:step="1"
controls-position="right"
style="width: 100%"
/>
<el-input
v-else
v-model="configData[field.name]"
:placeholder="field.placeholder || `请输入${field.displayLabel}`"
clearable
/>
</div>
</div>
</div>
<div class="control-row">
<el-select
v-model="currentModelId"
class="model-select"
placeholder="请选择模型"
popper-class="custom-select-popup"
@change="onModelChange"
>
<el-option
v-for="item in modelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<button
type="button"
class="config-trigger"
:class="{ active: configPanelVisible }"
@click="configPanelVisible = !configPanelVisible"
>
<span
v-for="item in summaryItems"
:key="item.name"
class="summary-item"
>
{{ item.text }}
</span>
<span class="trigger-arrow">
<i :class="['iconfont', configPanelVisible ? 'icon-shang' : 'icon-xia']"></i>
</span>
</button>
</div>
</template>
</div>
</template>
<script setup lang='ts'>
import { ref } from 'vue';
const textarea2 = ref('')
const value = ref('')
const selectedQuantity = ref(1)
const options = [
{
value: 'Option1',
label: 'Option1',
},
{
value: 'Option2',
label: 'Option2',
},
{
value: 'Option3',
label: 'Option3',
},
{
value: 'Option4',
label: 'Option4',
},
{
value: 'Option5',
label: 'Option5',
<script setup lang="ts">
import { AllModel, queryModelPrice, texttoimage } from '@/apis/Model';
import type {
AllModelResponse,
InputField,
Model,
PriceQueryParams,
PriceQueryResponse,
TextToImageParams,
TextToImageResponse,
} from '@/types/model';
import { ElMessage } from 'element-plus';
import { computed, onMounted, ref, watch } from 'vue';
type ConfigValue = string | number | boolean;
interface ReferenceImage {
id: string;
name: string;
url: string;
}
const emit = defineEmits<{
(e: 'price-update', price: number): void;
(e: 'prompt-change', isEmpty: boolean): void;
(e: 'task-created', taskid: string): void;
(e: 'image-generated', result: TextToImageResponse): void;
}>();
const prompt = ref('');
const models = ref<Model[]>([]);
const currentModelId = ref('');
const configData = ref<Record<string, ConfigValue>>({});
const configPanelVisible = ref(false);
const generating = ref(false);
const modelLoading = ref(true);
const imageInputRef = ref<HTMLInputElement | null>(null);
const referenceImages = ref<ReferenceImage[]>([]);
let priceTimer: ReturnType<typeof setTimeout> | null = null;
const selectedModel = computed(() => models.value.find((item) => item.id === currentModelId.value));
const modelOptions = computed(() => models.value.map((item) => ({
label: item.model || item.name,
value: item.id,
})));
const promptPlaceholder = computed(() => {
const promptField = selectedModel.value?.input_fields?.find((field) => field.name === 'prompt');
return promptField?.placeholder || promptField?.label || '结合参考图片,描述想要生成的内容';
});
const isReferenceImageField = (field: InputField) => {
const name = field.name.toLowerCase();
return field.uitype === 'image'
|| name.includes('image')
|| name.includes('img')
|| name.includes('reference')
|| name.includes('subject');
};
const configFields = computed(() => {
return (selectedModel.value?.input_fields || [])
.filter((field) => field.name !== 'prompt' && !isReferenceImageField(field))
.map((field) => {
const value = configData.value[field.name];
const isQuantity = isQuantityName(field.name) && field.uitype === 'int';
const displayLabel = field.label || field.lable || field.name;
const selectedOption = field.data?.find((option) => option.value === value);
return {
...field,
displayLabel,
placeholder: field.placeholder,
isQuantity,
quantityOptions: isQuantity
? Array.from({ length: Math.min(field.max || 4, 4) }, (_, index) => index + 1)
: [],
summaryText: selectedOption?.text || (isQuantity ? `${value || 1}` : String(value || displayLabel)),
};
});
});
const summaryItems = computed(() => configFields.value.slice(0, 4).map((field) => ({
name: field.name,
text: field.summaryText,
})));
const isQuantityName = (name: string) => {
const lowerName = name.toLowerCase();
return ['num', 'count', 'quantity', 'batch_size', 'n'].some((key) => lowerName.includes(key));
};
const defaultConfigValue = (field: InputField): ConfigValue => {
if (field.defaultvalue !== undefined && field.defaultvalue !== null) return field.defaultvalue;
if (field.uitype === 'code' && field.data?.length) return field.data[0].value;
if (field.uitype === 'int') return 1;
return '';
};
const resetConfig = () => {
const nextConfig: Record<string, ConfigValue> = {};
(selectedModel.value?.input_fields || [])
.filter((field) => field.name !== 'prompt' && !isReferenceImageField(field))
.forEach((field) => {
nextConfig[field.name] = defaultConfigValue(field);
});
configData.value = nextConfig;
};
const onModelChange = () => {
resetConfig();
configPanelVisible.value = false;
fetchPrice();
};
const loadReferenceImageModels = async () => {
interface ModelParams {
llmcatelogid: string;
}
modelLoading.value = true;
try {
const res = await AllModel<AllModelResponse, ModelParams>({
llmcatelogid: 'ref2i',
});
if (res.status === 'ok' && res.data?.length) {
models.value = res.data;
currentModelId.value = res.data[0]?.id || '';
resetConfig();
fetchPrice();
} else {
models.value = [];
currentModelId.value = '';
}
]
} finally {
modelLoading.value = false;
}
};
const triggerImageUpload = () => {
imageInputRef.value?.click();
};
const handleImageSelect = async (event: Event) => {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files || []);
if (!files.length) return;
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
if (imageFiles.length !== files.length) {
ElMessage.warning('已忽略非图片文件');
}
const nextImages = await Promise.all(imageFiles.map(readImageFile));
referenceImages.value = [...referenceImages.value, ...nextImages];
input.value = '';
emitPromptState();
};
const readImageFile = (file: File): Promise<ReferenceImage> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve({
id: `${file.name}-${file.size}-${file.lastModified}`,
name: file.name,
url: String(reader.result || ''),
});
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
};
const removeImage = (id: string) => {
referenceImages.value = referenceImages.value.filter((image) => image.id !== id);
emitPromptState();
};
const emitPromptState = () => {
emit('prompt-change', !prompt.value.trim() || referenceImages.value.length === 0);
};
const buildPriceParams = (): PriceQueryParams | null => {
if (!selectedModel.value) return null;
return {
product_type: 'llm',
product_id: selectedModel.value.id,
config_data: {
action: selectedModel.value.apiname,
image_count: referenceImages.value.length,
...configData.value,
},
};
};
const imageFieldNames = computed(() => {
return (selectedModel.value?.input_fields || [])
.filter(isReferenceImageField)
.map((field) => field.name);
});
const buildImagePayload = () => {
const payload: Record<string, unknown> = {};
const fields = imageFieldNames.value;
if (fields.length <= 1) {
payload[fields[0] || 'images'] = referenceImages.value.map((image) => image.url);
return payload;
}
referenceImages.value.forEach((image, index) => {
payload[fields[index] || `image${index + 1}_file`] = image.url;
});
return payload;
};
const buildGenerateParams = (): TextToImageParams | null => {
if (!selectedModel.value) return null;
return {
model: selectedModel.value.model,
catelogid: 'r2i',
prompt: prompt.value.trim(),
...buildImagePayload(),
...configData.value,
};
};
const fetchPrice = () => {
if (priceTimer) clearTimeout(priceTimer);
priceTimer = setTimeout(async () => {
const params = buildPriceParams();
if (!params) {
emit('price-update', 0);
return;
}
try {
const res = await queryModelPrice<PriceQueryResponse, PriceQueryParams>(params);
emit('price-update', res.status === 'ok' && res.data ? res.data.amount : 0);
} catch (error) {
console.error('参考生图价格查询失败:', error);
emit('price-update', 0);
}
}, 500);
};
watch(
() => [currentModelId.value, configData.value],
() => fetchPrice(),
{ deep: true }
);
watch(
() => [prompt.value, referenceImages.value.length],
() => emitPromptState(),
{ immediate: true }
);
const generate = async () => {
if (!referenceImages.value.length) {
ElMessage.warning('请上传参考图片');
return false;
}
if (!prompt.value.trim()) {
ElMessage.warning('请输入提示词');
return false;
}
const params = buildGenerateParams();
if (!params) {
ElMessage.warning('模型未加载完成');
return false;
}
generating.value = true;
try {
const res = await texttoimage<TextToImageResponse, TextToImageParams>(params);
const taskid = res.data?.taskid || res.taskid;
const images = res.image || res.data?.image || [];
if (String(res.status).toUpperCase() === 'SUCCEEDED' && images.length) {
ElMessage.success('参考生图生成成功');
emit('image-generated', res);
resetForm();
return true;
}
if (['CREATED', 'PENDING', 'RUNNING'].includes(String(res.status).toUpperCase())) {
ElMessage.success('参考生图任务已提交,请稍后在任务列表中查看');
if (taskid) emit('task-created', taskid);
resetForm();
return true;
}
ElMessage.error(res.message || '生成失败,请重试');
return false;
} catch (error) {
console.error('参考生图生成失败:', error);
ElMessage.error(error instanceof Error ? error.message : '生成失败,请重试');
return false;
} finally {
generating.value = false;
}
};
const resetForm = () => {
prompt.value = '';
referenceImages.value = [];
resetConfig();
emit('price-update', 0);
emit('prompt-change', true);
};
defineExpose({
generate,
resetForm,
});
onMounted(() => {
loadReferenceImageModels();
});
</script>
<style scoped lang='less'>
@import url('../../assets/less/Img/ImgBox.less');
<style scoped lang="less">
.reference-img-panel {
position: relative;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 16px;
}
.model-loading {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
color: rgba(255, 255, 255, 0.62);
background: #1a1c20;
}
.prompt-card {
flex: 1;
min-height: 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
background: #1a1c20;
overflow: hidden;
:deep(.el-card__body) {
height: 100%;
display: flex;
flex-direction: column;
gap: 14px;
padding: 12px;
}
}
.reference-upload {
flex-shrink: 0;
display: flex;
justify-content: space-between;
gap: 16px;
padding: 8px 4px 4px;
}
.reference-title {
color: #fff;
font-size: 22px;
font-weight: 700;
line-height: 1.35;
}
.reference-desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.62);
font-size: 14px;
}
.hidden-upload-input {
display: none;
}
.upload-action {
display: inline-flex;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #fff;
background: rgba(255, 255, 255, 0.06);
cursor: pointer;
}
.upload-action__icon {
font-size: 20px;
line-height: 1;
}
.image-list {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
max-height: 180px;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.image-card {
position: relative;
width: 118px;
height: 118px;
overflow: hidden;
border-radius: 10px;
background: rgba(0, 0, 0, 0.24);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.remove-image {
position: absolute;
right: 6px;
bottom: 6px;
height: 26px;
padding: 0 8px;
border: none;
border-radius: 8px;
color: #fff;
background: rgba(0, 0, 0, 0.62);
cursor: pointer;
}
.prompt-editor {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.prompt-input {
flex: 1;
:deep(.el-textarea) {
height: 100%;
}
:deep(.el-textarea__inner) {
height: 100% !important;
resize: none;
border: none !important;
border-radius: 10px !important;
color: #fff !important;
background: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
padding: 14px 16px !important;
line-height: 1.7;
}
:deep(.el-textarea__inner::placeholder) {
color: rgba(255, 255, 255, 0.42);
}
:deep(.el-input__count) {
background: transparent;
color: rgba(255, 255, 255, 0.48);
}
}
.config-panel {
position: absolute;
left: 16px;
right: 16px;
bottom: 64px;
z-index: 10;
overflow-y: auto;
padding: 16px;
border-radius: 12px;
background: #2b2d30;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.32);
}
.config-section + .config-section {
margin-top: 18px;
}
.config-title {
margin-bottom: 10px;
color: rgba(255, 255, 255, 0.64);
font-size: 14px;
}
.required-star {
margin-left: 4px;
color: #f56c6c;
}
.option-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
gap: 8px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.18);
padding: 6px;
}
.option-btn {
height: 34px;
border: none;
border-radius: 8px;
color: rgba(255, 255, 255, 0.76);
background: transparent;
cursor: pointer;
&.active {
color: #fff;
background: rgba(255, 255, 255, 0.14);
}
}
.control-row {
flex-shrink: 0;
display: flex;
gap: 8px;
min-height: 52px;
}
.model-select {
flex: 0 0 160px;
}
.config-trigger {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
color: #fff;
background: #252729;
cursor: pointer;
&.active {
border-color: rgba(69, 238, 120, 0.8);
}
}
.summary-item {
position: relative;
color: rgba(255, 255, 255, 0.82);
font-size: 14px;
white-space: nowrap;
& + .summary-item {
padding-left: 12px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.12);
transform: translateY(-50%);
}
}
}
.trigger-arrow {
margin-left: auto;
color: rgba(255, 255, 255, 0.72);
}
:deep(.el-select__wrapper),
:deep(.el-input__wrapper),
:deep(.el-input-number .el-input__wrapper) {
width: 100% !important;
min-height: 52px;
border: none !important;
border-radius: 12px !important;
color: #fff !important;
background: #252729 !important;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12) inset !important;
}
:deep(.el-select__selected-item),
:deep(.el-input__inner) {
color: #fff !important;
}
</style>
<style>
.custom-select-popup {
background-color: #2A2D34 !important;
background-color: #2a2d34 !important;
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
border: none !important;
.el-select-dropdown__item {
color: #ffffff !important;
background-color: transparent !important;
font-size: 14px !important;
}
.el-select-dropdown__item.selected,
.el-select-dropdown__item:hover {
background-color: #3a3d46 !important;
}
}
.el-select {
.el-input__inner {
background-color: #35383F !important;
border: none !important;
color: #fff !important;
}
.el-input__suffix-inner {
color: #fff !important;
}
.custom-select-popup .el-select-dropdown__item {
color: #ffffff !important;
background-color: transparent !important;
font-size: 14px !important;
}
</style>
.custom-select-popup .el-select-dropdown__item.selected,
.custom-select-popup .el-select-dropdown__item:hover {
background-color: #3a3d46 !important;
}
</style>

View File

@ -1,156 +1,595 @@
<template>
<div class="container">
<el-card class="Topcard">
<div class="card-bottom">
<el-input v-model="textarea2" class="input" :autosize="{ minRows: 10, maxRows: 10 }" type="textarea"
placeholder="结合图片,描述想要生成的内容" />
</div>
</el-card>
<el-card class="Bottomcard">
<div class="select">
<div class="select-item">
<div class="select-item">
<div class="title">
模型
</div>
<div class="content">
<el-select v-model="value" placeholder="Select" popper-class="custom-select-popup">
<el-option v-for="item in options" :key="item.value" :label="item.label"
:value="item.value" />
</el-select>
</div>
</div>
</div>
</div>
<div class="border"></div>
<div class="select">
<div class="select-item">
<div class="select-item">
<div class="title">
清晰度
</div>
<div class="content">
<el-select v-model="value" placeholder="Select" popper-class="custom-select-popup">
<el-option v-for="item in options" :key="item.value" :label="item.label"
:value="item.value" />
</el-select>
</div>
</div>
</div>
</div>
<div class="border"></div>
<div class="select">
<div class="select-item">
<div class="select-item">
<div class="title">
宽高比
</div>
<div class="content">
<el-select v-model="value" placeholder="Select" popper-class="custom-select-popup">
<el-option v-for="item in options" :key="item.value" :label="item.label"
:value="item.value" />
</el-select>
</div>
</div>
</div>
</div>
<div class="border"></div>
<div class="select">
<div class="select-item">
<div class="select-item">
<div class="title">
数量
</div>
<div class="content">
<div class="quantity-buttons">
<button
v-for="num in [1, 2, 3, 4]"
:key="num"
class="quantity-btn"
:class="{ active: selectedQuantity === num }"
@click="selectedQuantity = num"
>
{{ num }}
</button>
</div>
</div>
</div>
</div>
</div>
</el-card>
<div class="text-img-panel">
<div v-if="!selectedModel" class="model-loading">
{{ modelLoading ? '模型加载中...' : '暂无配置模型,请等待管理员配置' }}
</div>
<template v-else>
<el-card class="prompt-card">
<div class="prompt-editor">
<el-input
v-model="prompt"
class="prompt-input"
:autosize="{ minRows: 12, maxRows: 12 }"
type="textarea"
maxlength="3000"
show-word-limit
:placeholder="promptPlaceholder"
/>
</div>
</el-card>
<div v-if="configPanelVisible" class="config-panel">
<div
v-for="field in configFields"
:key="field.name"
class="config-section"
>
<div class="config-title">
{{ field.displayLabel }}
<span v-if="field.required" class="required-star">*</span>
</div>
<div class="config-content">
<div v-if="field.uitype === 'code'" class="option-grid">
<button
v-for="option in field.data"
:key="option.value"
type="button"
class="option-btn"
:class="{ active: configData[field.name] === option.value }"
@click="configData[field.name] = option.value"
>
{{ option.text || option.value }}
</button>
</div>
<div v-else-if="field.isQuantity" class="option-grid">
<button
v-for="num in field.quantityOptions"
:key="num"
type="button"
class="option-btn"
:class="{ active: Number(configData[field.name]) === num }"
@click="configData[field.name] = num"
>
{{ num }}
</button>
</div>
<el-input-number
v-else-if="field.uitype === 'int'"
v-model="configData[field.name]"
:min="1"
:max="field.max || 99"
:step="1"
controls-position="right"
style="width: 100%"
/>
<el-input
v-else
v-model="configData[field.name]"
:placeholder="field.placeholder || `请输入${field.displayLabel}`"
clearable
/>
</div>
</div>
</div>
<div class="control-row">
<el-select
v-model="currentModelId"
class="model-select"
placeholder="请选择模型"
popper-class="custom-select-popup"
@change="onModelChange"
>
<el-option
v-for="item in modelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<button
type="button"
class="config-trigger"
:class="{ active: configPanelVisible }"
@click="configPanelVisible = !configPanelVisible"
>
<span
v-for="item in summaryItems"
:key="item.name"
class="summary-item"
>
{{ item.text }}
</span>
<span class="trigger-arrow">
<i :class="['iconfont', configPanelVisible ? 'icon-shang' : 'icon-xia']"></i>
</span>
</button>
</div>
</template>
</div>
</template>
<script setup lang='ts'>
import { ref } from 'vue';
const textarea2 = ref('')
const value = ref('')
const selectedQuantity = ref(1)
<script setup lang="ts">
import { AllModel, queryModelPrice, texttoimage } from '@/apis/Model';
import type {
AllModelResponse,
InputField,
Model,
PriceQueryParams,
PriceQueryResponse,
TextToImageParams,
TextToImageResponse,
} from '@/types/model';
import { ElMessage } from 'element-plus';
import { computed, onMounted, ref, watch } from 'vue';
const options = [
{
value: 'Option1',
label: 'Option1',
},
{
value: 'Option2',
label: 'Option2',
},
{
value: 'Option3',
label: 'Option3',
},
{
value: 'Option4',
label: 'Option4',
},
{
value: 'Option5',
label: 'Option5',
type ConfigValue = string | number | boolean;
const emit = defineEmits<{
(e: 'price-update', price: number): void;
(e: 'prompt-change', isEmpty: boolean): void;
(e: 'task-created', taskid: string): void;
(e: 'image-generated', result: TextToImageResponse): void;
}>();
const prompt = ref('');
const models = ref<Model[]>([]);
const currentModelId = ref('');
const configData = ref<Record<string, ConfigValue>>({});
const configPanelVisible = ref(false);
const generating = ref(false);
const modelLoading = ref(true);
let priceTimer: ReturnType<typeof setTimeout> | null = null;
const modelOptions = computed(() => models.value.map((item) => ({
label: item.model || item.name,
value: item.id,
})));
const selectedModel = computed(() => models.value.find((item) => item.id === currentModelId.value));
const promptPlaceholder = computed(() => {
const promptField = selectedModel.value?.input_fields?.find((field) => field.name === 'prompt');
return promptField?.placeholder || promptField?.label || '描述你想要生成的图片内容';
});
const configFields = computed(() => {
return (selectedModel.value?.input_fields || [])
.filter((field) => field.name !== 'prompt')
.map((field) => {
const value = configData.value[field.name];
const isQuantity = isQuantityName(field.name) && field.uitype === 'int';
const displayLabel = field.label || field.lable || field.name;
const selectedOption = field.data?.find((option) => option.value === value);
return {
...field,
displayLabel,
placeholder: field.placeholder,
isQuantity,
quantityOptions: isQuantity
? Array.from({ length: Math.min(field.max || 4, 4) }, (_, index) => index + 1)
: [],
summaryText: selectedOption?.text || (isQuantity ? `${value || 1}` : String(value || displayLabel)),
};
});
});
const summaryItems = computed(() => configFields.value.slice(0, 4).map((field) => ({
name: field.name,
text: field.summaryText,
})));
const defaultConfigValue = (field: InputField): ConfigValue => {
if (field.defaultvalue !== undefined && field.defaultvalue !== null) {
return field.defaultvalue;
}
if (field.uitype === 'code' && field.data?.length) {
return field.data[0].value;
}
if (field.uitype === 'int') {
return 1;
}
return '';
};
const resetConfig = () => {
const nextConfig: Record<string, ConfigValue> = {};
(selectedModel.value?.input_fields || [])
.filter((field) => field.name !== 'prompt')
.forEach((field) => {
nextConfig[field.name] = defaultConfigValue(field);
});
configData.value = nextConfig;
};
const onModelChange = () => {
resetConfig();
configPanelVisible.value = false;
fetchPrice();
};
const isQuantityName = (name: string) => {
const lowerName = name.toLowerCase();
return ['num', 'count', 'quantity', 'batch_size', 'n'].some((key) => lowerName.includes(key));
}
const loadTextToImageModels = async () => {
interface ModelParams {
llmcatelogid: string;
}
modelLoading.value = true;
try {
const res = await AllModel<AllModelResponse, ModelParams>({
llmcatelogid: 't2i',
});
if (res.status === 'ok' && res.data?.length) {
models.value = res.data;
currentModelId.value = res.data[0]?.id || '';
resetConfig();
fetchPrice();
} else {
models.value = [];
currentModelId.value = '';
}
]
} finally {
modelLoading.value = false;
}
};
const buildPriceParams = (): PriceQueryParams | null => {
if (!selectedModel.value) return null;
return {
product_type: 'llm',
product_id: selectedModel.value.id,
config_data: {
action: selectedModel.value.apiname,
...configData.value,
},
};
}
const buildGenerateParams = (): TextToImageParams | null => {
if (!selectedModel.value) return null;
return {
model: selectedModel.value.model,
catelogid: 't2i',
prompt: prompt.value.trim(),
...configData.value,
};
}
const fetchPrice = () => {
if (priceTimer) clearTimeout(priceTimer);
priceTimer = setTimeout(async () => {
const params = buildPriceParams();
if (!params) {
emit('price-update', 0);
return;
}
try {
const res = await queryModelPrice<PriceQueryResponse, PriceQueryParams>(params);
emit('price-update', res.status === 'ok' && res.data ? res.data.amount : 0);
} catch (error) {
console.error('文生图价格查询失败:', error);
emit('price-update', 0);
}
}, 500);
}
watch(
() => [currentModelId.value, configData.value],
() => fetchPrice(),
{ deep: true }
);
watch(
() => prompt.value,
(value) => {
emit('prompt-change', !value.trim());
},
{ immediate: true }
);
const generate = async () => {
if (!prompt.value.trim()) {
ElMessage.warning('请输入提示词');
return false;
}
const params = buildGenerateParams();
if (!params) {
ElMessage.warning('模型未加载完成');
return false;
}
generating.value = true;
try {
const res = await texttoimage<TextToImageResponse, TextToImageParams>(params);
const taskid = res.data?.taskid || res.taskid;
const images = res.image || res.data?.image || [];
if (String(res.status).toUpperCase() === 'SUCCEEDED' && images.length) {
ElMessage.success('图片生成成功');
emit('image-generated', res);
resetForm();
return true;
}
if (['CREATED', 'PENDING', 'RUNNING'].includes(String(res.status).toUpperCase())) {
ElMessage.success('图片生成任务已提交,请稍后在任务列表中查看');
if (taskid) {
emit('task-created', taskid);
}
resetForm();
return true;
}
ElMessage.error(res.message || '生成失败,请重试');
return false;
} catch (error) {
console.error('文生图生成失败:', error);
ElMessage.error(error instanceof Error ? error.message : '生成失败,请重试');
return false;
} finally {
generating.value = false;
}
}
const resetForm = () => {
prompt.value = '';
resetConfig();
emit('price-update', 0);
emit('prompt-change', true);
}
defineExpose({
generate,
resetForm,
});
onMounted(() => {
loadTextToImageModels();
});
</script>
<style scoped lang='less'>
@import url('../../assets/less/Img/ImgBox.less');
.card-bottom{
margin: 0;
<style scoped lang="less">
.text-img-panel {
position: relative;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 16px;
}
.model-loading {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
color: rgba(255, 255, 255, 0.62);
background: #1a1c20;
}
.prompt-card {
flex: 1;
min-height: 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
background: #1a1c20;
overflow: hidden;
:deep(.el-card__body) {
height: 100%;
padding: 12px;
}
}
.prompt-editor {
height: 100%;
display: flex;
flex-direction: column;
}
.prompt-input {
flex: 1;
:deep(.el-textarea) {
height: 100%;
}
:deep(.el-textarea__inner) {
height: 100% !important;
resize: none;
border: none !important;
border-radius: 10px !important;
color: #fff !important;
background: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
padding: 14px 16px !important;
line-height: 1.7;
}
:deep(.el-textarea__inner::placeholder) {
color: rgba(255, 255, 255, 0.42);
}
:deep(.el-input__count) {
background: transparent;
color: rgba(255, 255, 255, 0.48);
}
}
.config-panel {
position: absolute;
left: 16px;
right: 16px;
bottom: 64px;
z-index: 10;
// max-height: 48%;
overflow-y: auto;
padding: 16px;
border-radius: 12px;
background: #2b2d30;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.32);
}
.config-section + .config-section {
margin-top: 18px;
}
.config-title {
margin-bottom: 10px;
color: rgba(255, 255, 255, 0.64);
font-size: 14px;
}
.required-star {
margin-left: 4px;
color: #f56c6c;
}
.config-content {
width: 100%;
}
.option-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
gap: 8px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.18);
padding: 6px;
}
.option-btn {
min-height: 42px;
border: none;
border-radius: 8px;
color: rgba(255, 255, 255, 0.86);
background: transparent;
cursor: pointer;
transition: all 0.2s;
&:hover,
&.active {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
}
.control-row {
flex-shrink: 0;
display: flex;
gap: 8px;
min-height: 52px;
}
.model-select {
flex: 0 0 160px;
}
.config-trigger {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
color: #fff;
background: #252729;
cursor: pointer;
&.active {
border-color: rgba(69, 238, 120, 0.8);
}
}
.summary-item {
position: relative;
min-width: 0;
color: rgba(255, 255, 255, 0.82);
font-size: 14px;
white-space: nowrap;
& + .summary-item {
padding-left: 12px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.12);
transform: translateY(-50%);
}
}
}
.trigger-arrow {
margin-left: auto;
color: rgba(255, 255, 255, 0.72);
}
:deep(.el-select__wrapper),
:deep(.el-input__wrapper),
:deep(.el-input-number .el-input__wrapper) {
width: 100% !important;
min-height: 52px;
border: none !important;
border-radius: 12px !important;
color: #fff !important;
background: #252729 !important;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12) inset !important;
}
:deep(.el-select__selected-item),
:deep(.el-input__inner) {
color: #fff !important;
}
</style>
<style>
.custom-select-popup {
background-color: #2A2D34 !important;
background-color: #2a2d34 !important;
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
border: none !important;
.el-select-dropdown__item {
color: #ffffff !important;
background-color: transparent !important;
font-size: 14px !important;
}
.el-select-dropdown__item.selected,
.el-select-dropdown__item:hover {
background-color: #3a3d46 !important;
}
}
.el-select {
.el-input__inner {
background-color: #35383F !important;
border: none !important;
color: #fff !important;
}
.custom-select-popup .el-select-dropdown__item {
color: #ffffff !important;
background-color: transparent !important;
font-size: 14px !important;
}
.el-input__suffix-inner {
color: #fff !important;
}
.custom-select-popup .el-select-dropdown__item.selected,
.custom-select-popup .el-select-dropdown__item.is-selected,
.custom-select-popup .el-select-dropdown__item.is-hovering,
.custom-select-popup .el-select-dropdown__item:hover {
color: #fff !important;
background-color: #3a3d46 !important;
}
</style>

View File

@ -18,6 +18,7 @@
<span class="menu-text">首页</span>
</div>
</el-menu-item>
</el-menu>
</div>
@ -48,7 +49,7 @@
</el-menu-item>
<el-menu-item index="/TextGenerate">
<div class="menu-item-content">
<i class="iconfont icon-apikey"></i>
<i class="iconfont icon-xiaoxi"></i>
<span class="menu-text">文本生成</span>
</div>
</el-menu-item>
@ -65,13 +66,19 @@
active-text-color="#00FFCC"
@select="handleSelect"
>
<el-menu-item index="bill">
<!-- <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="history-creation">
<div class="menu-item-content">
<i class="iconfont icon-wodechuangzuo"></i>
<span class="menu-text">历史创作</span>
</div>
</el-menu-item>
<el-menu-item index="apikey">
<el-menu-item index="apikey">
<div class="menu-item-content">
<i class="iconfont icon-apikey"></i>
<span class="menu-text">令牌管理</span>
@ -109,6 +116,9 @@ const handleSelect = (index: string) => {
case 'bill':
router.push('/bill');
break;
case 'history-creation':
router.push('/history-creation');
break;
case 'discover':
router.push('/discover');
break;
@ -146,6 +156,8 @@ const updateActiveIndex = () => {
const path = route.path
if (path.includes('/index')) {
activeIndex.value = 'index';
} else if (path.includes('/history-creation')) {
activeIndex.value = 'history-creation';
} else if (path.includes('/bill')) {
activeIndex.value = 'bill';
} else if (path.includes('/discover')) {

View File

@ -1,5 +1,10 @@
<template>
<div class="reference-to-video">
<div ref="panelRef" class="reference-to-video">
<div v-if="!currentModel" class="model-loading">
{{ modelLoading ? '模型加载中...' : '暂无配置模型,请等待管理员配置' }}
</div>
<template v-else>
<!-- 顶部卡片图片上传和提示词输入区域 -->
<el-card class="card">
<div class="reference-container">
@ -7,13 +12,13 @@
<div v-if="currentModel" class="upload-section">
<div v-for="field in imageFields" :key="field.name" class="upload-item">
<div class="upload-label">
{{ field.label }}
{{ getFieldLabel(field) }}
<span v-if="field.required" class="required-star">*</span>
</div>
<!-- 已上传图片时显示预览卡片 -->
<div v-if="uploadedFiles[field.name]" class="image-preview-container">
<img :src="uploadedFiles[field.name]" :alt="field.label" class="preview-image" />
<img :src="uploadedFiles[field.name]" :alt="getFieldLabel(field)" class="preview-image" />
<div class="preview-actions">
<span class="preview-action" @click="handlePictureCardPreview({ url: uploadedFiles[field.name] } as UploadFile)">
<el-icon><ZoomIn /></el-icon>
@ -61,8 +66,8 @@
<img w-full :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
<!-- 底部配置卡片模型选择及其他动态配置项 -->
<el-card class="btmcard">
<!-- 参数面板由底部摘要条展开 -->
<el-card v-if="configPanelVisible" class="btmcard">
<div class="select">
<!-- 模型选择下拉框动态从接口加载 -->
<div class="select-item">
@ -89,7 +94,7 @@
<template v-for="field in visibleFields" :key="field.name">
<div class="select-item">
<div class="select-title">
{{ field.label }}
{{ getFieldLabel(field) }}
<span v-if="field.required" class="required-star">*</span>
</div>
<div class="select-content">
@ -99,7 +104,7 @@
v-model="configData[field.name]"
style="width: 100%"
popper-class="custom-select-popup"
:placeholder="`请选择${field.label}`"
:placeholder="`请选择${getFieldLabel(field)}`"
:disabled="generating"
>
<el-option
@ -126,7 +131,7 @@
<el-input
v-else-if="field.uitype === 'text'"
v-model="configData[field.name]"
:placeholder="`请输入${field.label}`"
:placeholder="`请输入${getFieldLabel(field)}`"
:type="field.name === 'negative_prompt' ? 'textarea' : 'text'"
:rows="field.name === 'negative_prompt' ? 3 : 1"
clearable
@ -189,6 +194,43 @@
</template>
</div>
</el-card>
<div class="control-row">
<el-select
v-model="currentModelId"
class="model-select"
placeholder="请选择模型"
popper-class="custom-select-popup"
@change="handleModelChange"
:disabled="generating"
>
<el-option
v-for="item in modelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<button
type="button"
class="config-trigger"
:class="{ active: configPanelVisible }"
@click="configPanelVisible = !configPanelVisible"
>
<span
v-for="item in summaryItems"
:key="item.name"
class="summary-item"
>
{{ item.text }}
</span>
<span class="trigger-arrow">
<i :class="['iconfont', configPanelVisible ? 'icon-shang' : 'icon-xia']"></i>
</span>
</button>
</div>
</template>
</div>
</template>
@ -220,6 +262,54 @@ const emit = defineEmits<{
// ==================== ====================
/** 配置项可能的值类型(用于表单双向绑定) */
type ConfigValue = string | number | boolean;
type GenerationStatus = 'CREATED' | 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | string;
const CREATED_STATUSES = new Set(['CREATED', 'PENDING', 'RUNNING', 'SUCCEEDED']);
const STATUS_MESSAGE_MAP: Record<string, string> = {
CREATED: '视频生成任务已提交',
PENDING: '排队中',
RUNNING: '进行中',
SUCCEEDED: '视频生成成功',
};
const normalizeGenerationStatus = (status?: string): GenerationStatus => {
const normalizedStatus = String(status || '').toUpperCase();
const statusAliases: Record<string, GenerationStatus> = {
CREATED: 'CREATED',
CREATEDD: 'CREATED',
PENDING: 'PENDING',
QUEUEING: 'PENDING',
PROCESSING: 'RUNNING',
RUNNING: 'RUNNING',
SUCCEEDED: 'SUCCEEDED',
COMPLETED: 'SUCCEEDED',
FAILED: 'FAILED',
};
return statusAliases[normalizedStatus] || normalizedStatus;
};
const getFieldLabel = (field: { label?: string; lable?: string; name: string }) => {
return field.label || field.lable || field.name;
};
const resolveDefaultValue = (field: Model['input_fields'][number]): ConfigValue => {
if (field.defaultvalue !== undefined && field.defaultvalue !== null) {
return field.defaultvalue;
}
if (field.uitype === 'code' && field.data?.length) {
return field.data[0].value;
}
if (field.uitype === 'int') {
return 1;
}
return '';
};
const isEmptyValue = (value: unknown) => value === '' || value === undefined || value === null;
// ==================== ====================
/** 模型列表 */
@ -228,6 +318,9 @@ const models = ref<Model[]>([]);
const currentModelId = ref('');
/** 当前选中的模型对象(从 models 中查找) */
const currentModel = ref<Model | null>(null);
const modelLoading = ref(true);
const configPanelVisible = ref(false);
const panelRef = ref<HTMLElement | null>(null);
/** 提示词内容 */
const prompt = ref('');
@ -339,6 +432,25 @@ const visibleFields = computed(() => {
);
});
const modelOptions = computed(() => models.value.map(model => ({
label: model.model || model.name,
value: model.id,
})));
const summaryItems = computed(() => visibleFields.value.slice(0, 4).map(field => ({
name: field.name,
text: getFieldSummaryText(field),
})));
const getFieldSummaryText = (field: Model['input_fields'][number]) => {
const value = configData.value[field.name];
if (value === undefined || value === null || value === '') return getFieldLabel(field);
if (field.uitype === 'code') {
return field.data?.find(option => option.value === value)?.text || String(value);
}
return String(value);
};
/** prompt 输入框的占位文本(根据必填状态动态生成) */
const promptPlaceholder = computed(() => {
if (!currentModel.value) return '请输入提示词';
@ -370,18 +482,7 @@ const initFormData = () => {
return;
}
let defaultValue: ConfigValue = field.defaultvalue ?? '';
if (defaultValue === undefined || defaultValue === null) {
if (field.uitype === 'code' && field.data && field.data.length > 0) {
defaultValue = field.data[0].value;
} else if (field.uitype === 'int') {
defaultValue = 1;
} else {
defaultValue = '';
}
}
newConfigData[field.name] = defaultValue;
newConfigData[field.name] = resolveDefaultValue(field);
});
configData.value = newConfigData;
@ -479,8 +580,9 @@ const checkRequiredFields = (): boolean => {
* 加载模型列表仅过滤 type '图生视频'
*/
const loadModels = async () => {
modelLoading.value = true;
try {
const params: ModelParams = { type: '图生视频' };
const params: ModelParams = { llmcatelogid: 'i2v' };
const res = await AllModel<AllModelResponse, ModelParams>(params);
if (res.status === 'ok' && Array.isArray(res.data)) {
models.value = res.data;
@ -497,6 +599,8 @@ const loadModels = async () => {
} catch (error) {
console.error('加载模型失败:', error);
ElMessage.error('加载模型列表失败,请刷新重试');
} finally {
modelLoading.value = false;
}
};
@ -515,9 +619,17 @@ const handleModelChange = (modelId: string) => {
uploadedFileNames.value = {};
initFormData();
fetchPrice();
configPanelVisible.value = false;
}
};
const handleDocumentClick = (event: MouseEvent) => {
if (!configPanelVisible.value) return;
const target = event.target as Node | null;
if (target && panelRef.value?.contains(target)) return;
configPanelVisible.value = false;
};
// ==================== ====================
//
watch(
@ -664,12 +776,12 @@ const generate = async (): Promise<boolean> => {
currentModel.value.input_fields.forEach(field => {
if (field.required) {
if (field.name === 'prompt') {
if (!prompt.value?.trim()) missingFields.push(field.label);
if (!prompt.value?.trim()) missingFields.push(getFieldLabel(field));
} else if (field.uitype === 'image') {
if (!uploadedFileNames.value[field.name]?.trim()) missingFields.push(field.label);
if (!uploadedFileNames.value[field.name]?.trim()) missingFields.push(getFieldLabel(field));
} else {
const val = configData.value[field.name];
if (!val) missingFields.push(field.label);
if (isEmptyValue(val)) missingFields.push(getFieldLabel(field));
}
}
});
@ -688,14 +800,15 @@ const generate = async (): Promise<boolean> => {
const taskid = res.data?.taskid || res.taskid;
if (res.status === 'created' ||res.status ==='PENDING' || res.status === 'processing' || res.status === 'queueing' || res.status === 'CREATED') {
const statusMsg =
res.status === 'created' || res.status === 'PENDING' || res.status === 'CREATED'
? '视频生成任务已提交'
: res.status === 'processing'
? '进行中'
: '排队中';
ElMessage.success(`${statusMsg},请稍后在任务列表中查看`);
const normalizedStatus = normalizeGenerationStatus(res.status);
if (CREATED_STATUSES.has(normalizedStatus)) {
const statusMsg = STATUS_MESSAGE_MAP[normalizedStatus] || '视频生成任务已提交';
ElMessage.success(
normalizedStatus === 'SUCCEEDED'
? statusMsg
: `${statusMsg},请稍后在任务列表中查看`
);
if (taskid) {
emit('task-created', taskid);
@ -738,10 +851,12 @@ defineExpose({
// ==================== ====================
onMounted(() => {
loadModels();
document.addEventListener('click', handleDocumentClick);
});
//
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick);
if (priceTimer) {
clearTimeout(priceTimer);
priceTimer = null;
@ -756,7 +871,7 @@ onBeforeUnmount(() => {
@import url('../../assets/less/Video/text/text.less');
.reference-to-video {
height: auto;
height: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
@ -778,8 +893,19 @@ onBeforeUnmount(() => {
}
}
.model-loading {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
color: rgba(255, 255, 255, 0.62);
background: #1a1c20;
}
.card :deep(.el-card__body) {
height: auto;
// height: auto;
overflow: visible;
}
@ -847,6 +973,240 @@ onBeforeUnmount(() => {
}
}
}
.reference-to-video {
position: relative;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 16px;
.card {
flex: 1;
min-height: 0;
margin-bottom: 0;
overflow: hidden;
}
.btmcard {
position: absolute;
left: 16px;
right: 16px;
bottom: 64px;
z-index: 10;
max-height: 48%;
overflow-y: auto;
background: #2b2d30;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.32);
:deep(.el-card__body) {
padding: 16px;
}
.select > .select-item:first-child {
display: none;
}
}
}
.control-row {
flex-shrink: 0;
display: flex;
gap: 8px;
min-height: 52px;
}
.model-select {
flex: 0 0 160px;
}
.config-trigger {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
color: #fff;
background: #252729;
cursor: pointer;
&.active {
border-color: rgba(69, 238, 120, 0.8);
}
}
.summary-item {
position: relative;
min-width: 0;
color: rgba(255, 255, 255, 0.82);
font-size: 14px;
white-space: nowrap;
& + .summary-item {
padding-left: 12px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.12);
transform: translateY(-50%);
}
}
}
.trigger-arrow {
margin-left: auto;
color: rgba(255, 255, 255, 0.72);
}
.reference-to-video {
box-sizing: border-box;
overflow: hidden;
gap: 10px;
padding: 0 12px;
background: #0f1114;
.card {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
background: #1a1c20;
padding: 0;
:deep(.el-card__body) {
height: 100%;
display: flex;
flex-direction: column;
padding: 12px;
overflow: hidden;
}
}
.btmcard {
max-height: min(48%, 420px);
border: none;
border-radius: 12px;
padding: 0;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}
.reference-container,
.input {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.reference-container {
gap: 12px;
overflow: hidden;
}
.upload-section {
flex-shrink: 0;
}
.input {
:deep(.el-textarea) {
height: 100%;
display: flex;
}
:deep(.el-textarea__inner) {
height: 100% !important;
resize: none !important;
border: none !important;
border-radius: 10px !important;
background: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
color: #fff !important;
padding: 14px 16px !important;
line-height: 1.7;
}
}
.select {
gap: 12px;
}
.select-item {
align-items: flex-start;
gap: 14px;
padding: 8px 0;
}
.select-title {
width: 34%;
min-width: 96px;
color: rgba(255, 255, 255, 0.82);
line-height: 36px;
}
.select-content {
flex: 1;
width: auto;
min-width: 0;
}
.control-row {
min-height: 58px;
gap: 10px;
}
.model-select {
flex: 0 0 180px;
:deep(.el-select__wrapper) {
width: 100% !important;
min-height: 52px;
border: none !important;
border-radius: 12px !important;
color: #fff !important;
background: #252729 !important;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12) inset !important;
}
:deep(.el-select__selected-item),
:deep(.el-select__placeholder),
:deep(.el-select__caret) {
color: #fff !important;
}
}
.config-trigger {
min-height: 58px;
border-color: rgba(255, 255, 255, 0.1);
background: #242629;
overflow: hidden;
}
.summary-item {
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.el-select__wrapper),
:deep(.el-input__wrapper),
:deep(.el-textarea__inner) {
background-color: rgba(0, 0, 0, 0.32) !important;
border: none !important;
box-shadow: none !important;
color: #fff !important;
}
</style>
<style>

View File

@ -1,5 +1,10 @@
<template>
<div class="reference-to-video">
<div ref="panelRef" class="reference-to-video">
<div v-if="!currentModel" class="model-loading">
{{ modelLoading ? '模型加载中...' : '暂无配置模型,请等待管理员配置' }}
</div>
<template v-else>
<!-- 顶部卡片放参考素材上传区和提示词输入区 -->
<el-card class="card">
<div class="reference-container">
@ -157,92 +162,143 @@
</div>
</el-card>
<!-- 底部卡片模型与动态配置参数 -->
<el-card class="btmcard">
<div class="select">
<!-- 模型选择切换后重新初始化参数默认值 -->
<div class="select-item">
<div class="select-title">模型</div>
<div class="select-content">
<el-select
v-model="currentModelId"
style="width: 100%"
popper-class="custom-select-popup"
:disabled="generating"
@change="handleModelChange"
>
<el-option
v-for="model in models"
:key="model.id"
:label="model.model"
:value="model.id"
/>
</el-select>
</div>
</div>
<!-- 动态字段区根据当前模型的 input_fields 自动渲染 -->
<template v-for="field in visibleFields" :key="field.name">
<!-- 参数面板由底部摘要条展开 -->
<el-card v-if="configPanelVisible" class="btmcard">
<div class="select">
<!-- 模型选择切换后重新初始化参数默认值 -->
<div class="select-item">
<div class="select-title">
{{ field.label }}
<span v-if="field.required" class="required-star">*</span>
</div>
<div class="select-title">模型</div>
<div class="select-content">
<!-- 下拉型字段 -->
<el-select
v-if="field.uitype === 'code'"
v-model="configData[field.name]"
v-model="currentModelId"
style="width: 100%"
popper-class="custom-select-popup"
:placeholder="`请选择${field.label}`"
:disabled="generating"
@change="handleModelChange"
>
<el-option
v-for="option in field.data"
:key="option.value"
:label="option.text || option.value"
:value="option.value"
v-for="model in models"
:key="model.id"
:label="model.model"
:value="model.id"
/>
</el-select>
<!-- 文本型字段 -->
<el-input
v-else-if="field.uitype === 'text'"
v-model="configData[field.name]"
:placeholder="`请输入${field.label}`"
:type="field.name === 'negative_prompt' ? 'textarea' : 'text'"
:rows="field.name === 'negative_prompt' ? 3 : 1"
clearable
:disabled="generating"
/>
<!-- 数值型字段 -->
<el-input-number
v-else-if="field.uitype === 'int'"
v-model="configData[field.name]"
:min="1"
:max="field.max || 15"
:step="1"
controls-position="right"
style="width: 100%"
:disabled="generating"
/>
<!-- 兜底输入框处理未显式分类的字段类型 -->
<el-input
v-else
v-model="configData[field.name]"
:placeholder="`请输入${field.label}`"
clearable
:disabled="generating"
/>
</div>
</div>
</template>
</div>
<!-- 动态字段区根据当前模型的 input_fields 自动渲染 -->
<template v-for="field in visibleFields" :key="field.name">
<div class="select-item">
<div class="select-title">
{{ getFieldLabel(field) }}
<span v-if="field.required" class="required-star">*</span>
</div>
<div class="select-content">
<!-- 布尔字段用按钮组展示是/ -->
<div v-if="isBooleanOptionField(field)" class="boolean-option-grid">
<button
v-for="option in field.data"
:key="option.value"
type="button"
class="boolean-option-btn"
:class="{ active: configData[field.name] === option.value }"
:disabled="generating"
@click="configData[field.name] = option.value"
>
{{ option.text || option.value }}
</button>
</div>
<!-- 下拉型字段 -->
<el-select
v-else-if="field.uitype === 'code'"
v-model="configData[field.name]"
style="width: 100%"
popper-class="custom-select-popup"
:placeholder="`请选择${getFieldLabel(field)}`"
:disabled="generating"
>
<el-option
v-for="option in field.data"
:key="option.value"
:label="option.text || option.value"
:value="option.value"
/>
</el-select>
<!-- 文本型字段 -->
<el-input
v-else-if="field.uitype === 'text'"
v-model="configData[field.name]"
:placeholder="`请输入${getFieldLabel(field)}`"
:type="field.name === 'negative_prompt' ? 'textarea' : 'text'"
:rows="field.name === 'negative_prompt' ? 3 : 1"
clearable
:disabled="generating"
/>
<!-- 数值型字段 -->
<el-input-number
v-else-if="field.uitype === 'int'"
v-model="configData[field.name]"
:min="1"
:max="field.max || 15"
:step="1"
controls-position="right"
style="width: 100%"
:disabled="generating"
/>
<!-- 兜底输入框处理未显式分类的字段类型 -->
<el-input
v-else
v-model="configData[field.name]"
:placeholder="`请输入${getFieldLabel(field)}`"
clearable
:disabled="generating"
/>
</div>
</div>
</template>
</div>
</el-card>
<div class="control-row">
<el-select
v-model="currentModelId"
class="model-select"
placeholder="请选择模型"
popper-class="custom-select-popup"
:disabled="generating"
@change="handleModelChange"
>
<el-option
v-for="item in modelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<button
type="button"
class="config-trigger"
:class="{ active: configPanelVisible }"
@click="configPanelVisible = !configPanelVisible"
>
<span
v-for="item in summaryItems"
:key="item.name"
class="summary-item"
>
{{ item.text }}
</span>
<span class="trigger-arrow">
<i :class="['iconfont', configPanelVisible ? 'icon-shang' : 'icon-xia']"></i>
</span>
</button>
</div>
<!-- 预览弹窗根据当前预览素材类型切换不同的展示组件 -->
<el-dialog v-model="dialogVisible">
<video
@ -261,6 +317,7 @@
/>
<img v-else class="dialog-preview-media" :src="dialogPreviewUrl" alt="Preview Image" />
</el-dialog>
</template>
</div>
</template>
@ -271,6 +328,7 @@ import { Delete, ZoomIn } from '@element-plus/icons-vue';
import { referencevideo, AllModel, queryModelPrice } from '../../apis/Model';
import type {
AllModelResponse,
InputField,
Model,
ModelParams,
PriceQueryParams,
@ -293,6 +351,78 @@ type ConfigValue = string | number | boolean;
//
type AssetType = 'image' | 'video' | 'audio';
type SchemaField = {
type?: string;
required?: boolean;
title?: string;
default?: string | number | boolean | null;
defaultvalue?: string | number | boolean | null;
enum?: Array<string | number | boolean>;
data?: Array<{ value: string; text?: string }>;
};
const schemaTypeToUiType = (name: string, field: SchemaField) => {
const fieldName = name.toLowerCase();
const fieldType = String(field.type || '').toLowerCase();
if (fieldName.includes('audio') && fieldType !== 'boolean') return 'audio';
if (['images', 'subjects'].includes(fieldName) || fieldName.includes('image')) return 'image';
if (fieldName === 'videos' || fieldName.includes('video')) return 'video';
if (field.enum?.length || field.data?.length || fieldType === 'boolean') return 'code';
if (fieldType === 'integer' || fieldType === 'number') return 'int';
return 'text';
};
const schemaToInputFields = (schema: Record<string, SchemaField>): InputField[] => {
return Object.entries(schema).map(([name, field]) => {
const data = field.data || (
String(field.type || '').toLowerCase() === 'boolean'
? [
{ value: 'true', text: '是' },
{ value: 'false', text: '否' },
]
: field.enum?.map(value => ({
value: String(value),
text: typeof value === 'boolean' ? (value ? '是' : '否') : String(value),
}))
);
const defaultValue = field.defaultvalue ?? field.default;
return {
name,
label: field.title || name,
required: field.required,
uitype: schemaTypeToUiType(name, field),
defaultvalue: typeof defaultValue === 'boolean' ? String(defaultValue) : defaultValue ?? '',
data,
};
});
};
const parseInputFields = (inputFields: unknown): InputField[] => {
if (Array.isArray(inputFields)) return inputFields as InputField[];
if (typeof inputFields === 'string') {
try {
const parsed = JSON.parse(inputFields);
return parseInputFields(parsed);
} catch {
return [];
}
}
if (inputFields && typeof inputFields === 'object') {
return schemaToInputFields(inputFields as Record<string, SchemaField>);
}
return [];
};
const normalizeModel = (model: Model): Model => ({
...model,
input_fields: parseInputFields((model as unknown as { input_fields?: unknown }).input_fields),
});
//
// - file FormData
// - previewUrl
@ -313,6 +443,9 @@ const MAX_VIDEO_DURATION_SECONDS = 11;
const models = ref<Model[]>([]);
const currentModelId = ref('');
const currentModel = ref<Model | null>(null);
const modelLoading = ref(true);
const configPanelVisible = ref(false);
const panelRef = ref<HTMLElement | null>(null);
//
const prompt = ref('');
@ -365,14 +498,62 @@ const normalizeReferenceStatus = (status?: string) => {
//
const dialogPreviewUrl = computed(() => dialogPreviewAsset.value?.previewUrl || '');
const getFieldLabel = (field: { label?: string; lable?: string; name: string }) => {
return field.label || field.lable || field.name;
};
const resolveDefaultValue = (field: Model['input_fields'][number]): ConfigValue => {
if (field.defaultvalue !== undefined && field.defaultvalue !== null) {
return field.defaultvalue;
}
if (field.uitype === 'code' && field.data?.length) {
return field.data[0].value;
}
if (field.uitype === 'int') {
return 1;
}
return '';
};
const isEmptyValue = (value: unknown) => value === '' || value === undefined || value === null;
// prompt
const visibleFields = computed(() => {
if (!currentModel.value) return [];
return currentModel.value.input_fields.filter(
field => !isUploadField(field) && field.name !== 'prompt'
field => !isUploadField(field) && field.name !== 'prompt' && field.name !== 'model'
);
});
const modelOptions = computed(() => models.value.map(model => ({
label: model.model || model.name,
value: model.id,
})));
const summaryItems = computed(() => visibleFields.value.slice(0, 4).map(field => ({
name: field.name,
text: getFieldSummaryText(field),
})));
const getFieldSummaryText = (field: Model['input_fields'][number]) => {
const value = configData.value[field.name];
if (value === undefined || value === null || value === '') return getFieldLabel(field);
if (field.uitype === 'code') {
return field.data?.find(option => option.value === value)?.text || String(value);
}
return String(value);
};
const isBooleanOptionField = (field: Model['input_fields'][number]) => {
if (field.uitype !== 'code' || !field.data?.length) return false;
const values = field.data.map(option => String(option.value).toLowerCase());
return values.every(value => ['true', 'false', '1', '0'].includes(value))
|| field.data.every(option => ['是', '否'].includes(String(option.text || option.value)));
};
// prompt
const promptPlaceholder = computed(() => {
if (!currentModel.value) return REFERENCE_PROMPT_PLACEHOLDER;
@ -382,19 +563,15 @@ const promptPlaceholder = computed(() => {
: REFERENCE_PROMPT_PLACEHOLDER;
});
const audioUploadFieldNames = computed(() => {
if (!currentModel.value) return [];
return currentModel.value.input_fields
.filter(field => isAudioField(field))
.map(field => field.name);
});
// /
const isAssetField = (field: { name: string; uitype: string }) => {
const fieldName = field.name.toLowerCase();
return (
field.uitype === 'image' ||
field.uitype === 'video' ||
fieldName === 'images' ||
fieldName === 'subjects' ||
fieldName === 'videos' ||
fieldName.includes('image') ||
fieldName.includes('video')
);
@ -402,7 +579,8 @@ const isAssetField = (field: { name: string; uitype: string }) => {
//
const isAudioField = (field: { name: string; uitype: string }) => {
return field.uitype === 'audio' || field.name.toLowerCase().includes('audio');
const fieldName = field.name.toLowerCase();
return field.uitype === 'audio' || fieldName.includes('audio_file');
};
//
@ -410,6 +588,30 @@ const isUploadField = (field: { name: string; uitype: string }) => {
return isAssetField(field) || isAudioField(field);
};
const getUploadFieldsByType = (type: AssetType) => {
if (!currentModel.value) return [];
return currentModel.value.input_fields.filter(field => {
if (type === 'audio') return isAudioField(field);
if (type === 'image') {
const fieldName = field.name.toLowerCase();
return field.uitype === 'image' || fieldName === 'images' || fieldName === 'subjects' || fieldName.includes('image');
}
return field.uitype === 'video' || field.name.toLowerCase() === 'videos' || field.name.toLowerCase().includes('video');
});
};
const getUploadFieldNamesByType = (type: AssetType) => {
return getUploadFieldsByType(type).map(field => field.name);
};
const getUploadAssetByIndex = (type: AssetType, index: number) => {
if (type === 'audio') return audioAsset.value;
if (type === 'image') return imageAssets.value[index] || null;
return videoAssets.value[index] || null;
};
//
const hasVisualAssets = () => imageAssets.value.length > 0 || videoAssets.value.length > 0;
@ -425,19 +627,9 @@ const initFormData = () => {
const newConfigData: Record<string, ConfigValue> = {};
currentModel.value.input_fields.forEach(field => {
if (field.name === 'prompt' || isUploadField(field)) return;
if (field.name === 'prompt' || field.name === 'model' || isUploadField(field)) return;
let defaultValue: ConfigValue = field.defaultvalue ?? '';
if (defaultValue === undefined || defaultValue === null) {
if (field.uitype === 'code' && field.data && field.data.length > 0) {
defaultValue = field.data[0].value;
} else if (field.uitype === 'int') {
defaultValue = 1;
} else {
defaultValue = '';
}
}
newConfigData[field.name] = defaultValue;
newConfigData[field.name] = resolveDefaultValue(field);
});
configData.value = newConfigData;
@ -453,7 +645,7 @@ const buildPriceParams = (): PriceQueryParams | null => {
};
currentModel.value.input_fields.forEach(field => {
if (field.name === 'prompt' || isUploadField(field)) return;
if (field.name === 'prompt' || field.name === 'model' || isUploadField(field)) return;
const value = configData.value[field.name];
if (value !== undefined) {
@ -511,15 +703,16 @@ const fetchPrice = () => {
//
// 退
const loadModels = async () => {
const candidateTypes: ModelParams['type'][] = ['参考生视频'];
const candidateTypes: ModelParams['llmcatelogid'][] = ['r2v'];
modelLoading.value = true;
try {
for (const type of candidateTypes) {
const res = await AllModel<AllModelResponse, ModelParams>({ type });
for (const llmcatelogid of candidateTypes) {
const res = await AllModel<AllModelResponse, ModelParams>({ llmcatelogid });
if (res.status === 'ok' && Array.isArray(res.data) && res.data.length > 0) {
models.value = res.data;
currentModelId.value = res.data[0].id;
currentModel.value = res.data[0];
models.value = res.data.map(normalizeModel);
currentModelId.value = models.value[0].id;
currentModel.value = models.value[0];
initFormData();
fetchPrice();
return;
@ -530,6 +723,8 @@ const loadModels = async () => {
} catch (error) {
console.error('加载模型失败:', error);
ElMessage.error('加载模型列表失败,请刷新重试');
} finally {
modelLoading.value = false;
}
};
@ -538,10 +733,11 @@ const handleModelChange = (modelId: string) => {
const selected = models.value.find(model => model.id === modelId);
if (!selected) return;
currentModel.value = selected;
currentModel.value = normalizeModel(selected);
initFormData();
fetchPrice();
};
configPanelVisible.value = false;
};
//
watch(
@ -574,6 +770,13 @@ const triggerImageUpload = () => {
imageInputRef.value?.click();
};
const handleDocumentClick = (event: MouseEvent) => {
if (!configPanelVisible.value) return;
const target = event.target as Node | null;
if (target && panelRef.value?.contains(target)) return;
configPanelVisible.value = false;
};
const triggerVideoUpload = () => {
videoInputRef.value?.click();
};
@ -812,6 +1015,7 @@ const buildSubmitParams = (): FormData => {
// id
formData.append('llmid', currentModel.value!.id);
formData.append('model', currentModel.value!.model);
// prompt
if (prompt.value.trim()) {
@ -824,18 +1028,20 @@ const buildSubmitParams = (): FormData => {
formData.append(key, String(value));
});
// /
imageAssets.value.forEach(asset => {
formData.append('image_files', asset.file, asset.name);
});
// input_fields image1_file / video1_file
const appendAssetsByField = (assets: UploadedAsset[], fieldNames: string[], fallbackName: string) => {
assets.forEach((asset, index) => {
const fieldName = fieldNames[index] || fallbackName;
formData.append(fieldName, asset.file, asset.name);
});
};
videoAssets.value.forEach(asset => {
formData.append('video_files', asset.file, asset.name);
});
appendAssetsByField(imageAssets.value, getUploadFieldNamesByType('image'), 'image_files');
appendAssetsByField(videoAssets.value, getUploadFieldNamesByType('video'), 'video_files');
if (audioAsset.value) {
const targetFieldNames = audioUploadFieldNames.value.length > 0
? audioUploadFieldNames.value
const targetFieldNames = getUploadFieldNamesByType('audio').length > 0
? getUploadFieldNamesByType('audio')
: ['audio_files'];
targetFieldNames.forEach(fieldName => {
@ -890,27 +1096,31 @@ const generate = async (): Promise<boolean> => {
const missingFields: string[] = [];
currentModel.value.input_fields.forEach(field => {
if (!field.required) return;
if (field.name === 'model') return;
if (field.name === 'prompt') {
if (!prompt.value.trim()) missingFields.push(field.label);
if (!prompt.value.trim()) missingFields.push(getFieldLabel(field));
return;
}
if (isAudioField(field)) {
if (!audioAsset.value) missingFields.push(field.label);
if (!audioAsset.value) missingFields.push(getFieldLabel(field));
return;
}
if (isAssetField(field)) {
const isImageField = field.name.toLowerCase().includes('image') || field.uitype === 'image';
const hasAsset = isImageField ? imageAssets.value.length > 0 : videoAssets.value.length > 0;
if (!hasAsset) missingFields.push(field.label);
const uploadType: AssetType = field.uitype === 'video' || field.name.toLowerCase().includes('video')
? 'video'
: 'image';
const fieldIndex = getUploadFieldsByType(uploadType).findIndex(item => item.name === field.name);
const hasAsset = !!getUploadAssetByIndex(uploadType, Math.max(fieldIndex, 0));
if (!hasAsset) missingFields.push(getFieldLabel(field));
return;
}
const value = configData.value[field.name];
if (value === '' || value === undefined || value === null) {
missingFields.push(field.label);
if (isEmptyValue(value)) {
missingFields.push(getFieldLabel(field));
}
});
@ -924,8 +1134,9 @@ const generate = async (): Promise<boolean> => {
// multipart/form-data
const params = buildSubmitParams();
// FormData 便
logFormDataEntries(params);
if (import.meta.env.DEV) {
logFormDataEntries(params);
}
//
const res = (await referencevideo(params)) as TextVideoResponse;
@ -1003,10 +1214,12 @@ defineExpose({
//
onMounted(() => {
loadModels();
document.addEventListener('click', handleDocumentClick);
});
//
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick);
imageAssets.value.forEach(asset => revokeAssetPreviewUrl(asset));
videoAssets.value.forEach(asset => revokeAssetPreviewUrl(asset));
revokeAssetPreviewUrl(audioAsset.value);
@ -1020,12 +1233,285 @@ onBeforeUnmount(() => {
<style scoped lang="less">
@import url('../../assets/less/Video/refernce/refernce.less');
.model-loading {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
color: rgba(255, 255, 255, 0.62);
background: #1a1c20;
}
.reference-to-video {
position: relative;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 16px;
.card {
flex: 1;
min-height: 0;
margin-bottom: 0;
overflow: hidden;
}
.btmcard {
position: absolute;
left: 16px;
right: 16px;
bottom: 64px;
z-index: 10;
max-height: 48%;
overflow-y: auto;
background: #2b2d30;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.32);
:deep(.el-card__body) {
padding: 16px;
}
.select > .select-item:first-child {
display: none;
}
}
}
.control-row {
flex-shrink: 0;
display: flex;
gap: 8px;
min-height: 52px;
}
.model-select {
flex: 0 0 160px;
}
.config-trigger {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
color: #fff;
background: #252729;
cursor: pointer;
&.active {
border-color: rgba(69, 238, 120, 0.8);
}
}
.summary-item {
position: relative;
min-width: 0;
color: rgba(255, 255, 255, 0.82);
font-size: 14px;
white-space: nowrap;
& + .summary-item {
padding-left: 12px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.12);
transform: translateY(-50%);
}
}
}
.trigger-arrow {
margin-left: auto;
color: rgba(255, 255, 255, 0.72);
}
.boolean-option-grid {
width: 100%;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
padding: 6px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.18);
}
.boolean-option-btn {
height: 44px;
border: none;
border-radius: 8px;
color: #fff;
background: transparent;
font-size: 15px;
cursor: pointer;
&.active {
background: rgba(255, 255, 255, 0.14);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.reference-to-video {
box-sizing: border-box;
overflow: hidden;
gap: 10px;
padding: 0 12px;
background: #0f1114;
.card {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
background: #1a1c20;
padding: 0;
:deep(.el-card__body) {
height: 100%;
display: flex;
flex-direction: column;
padding: 12px;
overflow: hidden;
}
}
.btmcard {
max-height: min(48%, 420px);
border: none;
border-radius: 12px;
padding: 0;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}
.reference-container,
.input {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.reference-container {
gap: 12px;
overflow: hidden;
}
.reference-header {
flex-shrink: 0;
}
.input {
:deep(.el-textarea) {
height: 100%;
display: flex;
}
:deep(.el-textarea__inner) {
height: 100% !important;
resize: none !important;
border: none !important;
border-radius: 10px !important;
background: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
color: #fff !important;
padding: 14px 16px !important;
line-height: 1.7;
}
}
.select {
gap: 12px;
}
.select-item {
align-items: flex-start;
gap: 14px;
padding: 8px 0;
}
.select-title {
width: 34%;
min-width: 96px;
color: rgba(255, 255, 255, 0.82);
line-height: 36px;
}
.select-content {
flex: 1;
width: auto;
min-width: 0;
}
.control-row {
min-height: 58px;
gap: 10px;
}
.model-select {
flex: 0 0 180px;
:deep(.el-select__wrapper) {
width: 100% !important;
min-height: 52px;
border: none !important;
border-radius: 12px !important;
color: #fff !important;
background: #252729 !important;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12) inset !important;
}
:deep(.el-select__selected-item),
:deep(.el-select__placeholder),
:deep(.el-select__caret) {
color: #fff !important;
}
}
.config-trigger {
min-height: 58px;
border-color: rgba(255, 255, 255, 0.1);
background: #242629;
overflow: hidden;
}
.summary-item {
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.el-select__wrapper),
:deep(.el-input__wrapper),
:deep(.el-textarea__inner) {
background-color: rgba(0, 0, 0, 0.32) !important;
border: none !important;
box-shadow: none !important;
color: #fff !important;
}
</style>
<style>
.reference-to-video {
gap: 16px !important;
gap: 12px !important;
}
/* 全局样式:自定义下拉框弹出层 */
.custom-select-popup {

View File

@ -1,5 +1,10 @@
<template>
<div class="reference-to-video">
<div ref="panelRef" class="reference-to-video">
<div v-if="!currentModel" class="model-loading">
{{ modelLoading ? '模型加载中...' : '暂无配置模型,请等待管理员配置' }}
</div>
<template v-else>
<!-- 顶部卡片提示词输入区 -->
<el-card class="card">
<div class="reference-container">
@ -16,143 +21,180 @@
</div>
</el-card>
<!-- 底部卡片模型及配置项 -->
<el-card class="btmcard">
<div class="select">
<!-- 模型选择 -->
<div class="select-item">
<div class="select-title">模型</div>
<div class="select-content">
<el-select
v-model="currentModelId"
style="width: 100%"
popper-class="custom-select-popup"
@change="handleModelChange"
:disabled="generating"
>
<el-option
v-for="model in models"
:key="model.id"
:label="model.model"
:value="model.id"
/>
</el-select>
</div>
</div>
<!-- 动态渲染除 prompt 以外的其他配置字段 -->
<template v-for="field in visibleFields" :key="field.name">
<!-- 参数面板由底部摘要条展开 -->
<el-card v-if="configPanelVisible" class="btmcard">
<div class="select">
<!-- 模型选择 -->
<div class="select-item">
<div class="select-title">
{{ field.label }}
<span v-if="field.required" class="required-star">*</span>
</div>
<div class="select-title">模型</div>
<div class="select-content">
<!-- 下拉选择 -->
<el-select
v-if="field.uitype === 'code'"
v-model="configData[field.name]"
v-model="currentModelId"
style="width: 100%"
popper-class="custom-select-popup"
:placeholder="`请选择${field.label}`"
@change="handleModelChange"
:disabled="generating"
>
<el-option
v-for="option in field.data"
:key="option.value"
:label="option.text || option.value"
:value="option.value"
v-for="model in models"
:key="model.id"
:label="model.model"
:value="model.id"
/>
</el-select>
<!-- 文本输入 -->
<el-input
v-else-if="field.uitype === 'text'"
v-model="configData[field.name]"
:placeholder="`请输入${field.label}`"
:type="field.name === 'negative_prompt' ? 'textarea' : 'text'"
:rows="field.name === 'negative_prompt' ? 3 : 1"
clearable
:disabled="generating"
/>
<!-- 整数输入 -->
<el-input-number
v-else-if="field.uitype === 'int'"
v-model="configData[field.name]"
:min="1"
:max="field.max || 15"
:step="1"
controls-position="right"
style="width: 100%"
:disabled="generating"
/>
<!-- 音频上传 -->
<div v-else-if="field.uitype === 'audio'" class="audio-wrapper">
<input
:ref="(el) => setAudioInputRef(field.name, el)"
class="hidden-upload-input"
type="file"
accept="audio/*"
@change="(event) => handleAudioSelect(event, field.name)"
/>
<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>
<!-- 其他未知类型 -->
<el-input
v-else
v-model="configData[field.name]"
:placeholder="`请输入${field.label}`"
clearable
:disabled="generating"
/>
</div>
</div>
</template>
</div>
<!-- 动态渲染除 prompt 以外的其他配置字段 -->
<template v-for="field in visibleFields" :key="field.name">
<div class="select-item">
<div class="select-title">
{{ getFieldLabel(field) }}
<span v-if="field.required" class="required-star">*</span>
</div>
<div class="select-content">
<!-- 下拉选择 -->
<el-select
v-if="field.uitype === 'code'"
v-model="configData[field.name]"
style="width: 100%"
popper-class="custom-select-popup"
:placeholder="`请选择${getFieldLabel(field)}`"
:disabled="generating"
>
<el-option
v-for="option in field.data"
:key="option.value"
:label="option.text || option.value"
:value="option.value"
/>
</el-select>
<!-- 文本输入 -->
<el-input
v-else-if="field.uitype === 'text'"
v-model="configData[field.name]"
:placeholder="`请输入${getFieldLabel(field)}`"
:type="field.name === 'negative_prompt' ? 'textarea' : 'text'"
:rows="field.name === 'negative_prompt' ? 3 : 1"
clearable
:disabled="generating"
/>
<!-- 整数输入 -->
<el-input-number
v-else-if="field.uitype === 'int'"
v-model="configData[field.name]"
:min="1"
:max="field.max || 15"
:step="1"
controls-position="right"
style="width: 100%"
:disabled="generating"
/>
<!-- 音频上传 -->
<div v-else-if="field.uitype === 'audio'" class="audio-wrapper">
<input
:ref="(el) => setAudioInputRef(field.name, el)"
class="hidden-upload-input"
type="file"
accept="audio/*"
@change="(event) => handleAudioSelect(event, field.name)"
/>
<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>
<!-- 其他未知类型 -->
<el-input
v-else
v-model="configData[field.name]"
:placeholder="`请输入${getFieldLabel(field)}`"
clearable
:disabled="generating"
/>
</div>
</div>
</template>
</div>
</el-card>
<div class="control-row">
<el-select
v-model="currentModelId"
class="model-select"
placeholder="请选择模型"
popper-class="custom-select-popup"
@change="handleModelChange"
:disabled="generating"
>
<el-option
v-for="item in modelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<button
type="button"
class="config-trigger"
:class="{ active: configPanelVisible }"
@click="configPanelVisible = !configPanelVisible"
>
<span
v-for="item in summaryItems"
:key="item.name"
class="summary-item"
>
{{ item.text }}
</span>
<span class="trigger-arrow">
<i :class="['iconfont', configPanelVisible ? 'icon-shang' : 'icon-xia']"></i>
</span>
</button>
</div>
</template>
</div>
</template>
@ -180,11 +222,62 @@ const emit = defineEmits<{
// ==================== ====================
/** 配置字段的值类型(不包括 prompt */
type ConfigValue = string | number | boolean;
type GenerationStatus = 'CREATED' | 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | string;
const CREATED_STATUSES = new Set(['CREATED', 'PENDING', 'RUNNING', 'SUCCEEDED']);
const STATUS_MESSAGE_MAP: Record<string, string> = {
CREATED: '视频生成任务已提交',
PENDING: '排队中',
RUNNING: '进行中',
SUCCEEDED: '视频生成成功',
};
const normalizeGenerationStatus = (status?: string): GenerationStatus => {
const normalizedStatus = String(status || '').toUpperCase();
const statusAliases: Record<string, GenerationStatus> = {
CREATED: 'CREATED',
CREATEDD: 'CREATED',
PENDING: 'PENDING',
QUEUEING: 'PENDING',
PROCESSING: 'RUNNING',
RUNNING: 'RUNNING',
SUCCEEDED: 'SUCCEEDED',
COMPLETED: 'SUCCEEDED',
FAILED: 'FAILED',
};
return statusAliases[normalizedStatus] || normalizedStatus;
};
const getFieldLabel = (field: { label?: string; lable?: string; name: string }) => {
return field.label || field.lable || field.name;
};
const resolveDefaultValue = (field: Model['input_fields'][number]): ConfigValue => {
if (field.defaultvalue !== undefined && field.defaultvalue !== null) {
return field.defaultvalue;
}
if (field.uitype === 'code' && field.data?.length) {
return field.data[0].value;
}
if (field.uitype === 'int') {
return 1;
}
return '';
};
const isEmptyValue = (value: unknown) => value === '' || value === undefined || value === null;
// ==================== ====================
const models = ref<Model[]>([]);
const currentModelId = ref('');
const currentModel = ref<Model | null>(null);
const modelLoading = ref(true);
const configPanelVisible = ref(false);
const panelRef = ref<HTMLElement | null>(null);
/** 单独的提示词字段,确保是 string 类型 */
const prompt = ref('');
@ -205,6 +298,25 @@ const visibleFields = computed(() => {
return currentModel.value.input_fields.filter(field => field.name !== 'prompt');
});
const modelOptions = computed(() => models.value.map(model => ({
label: model.model || model.name,
value: model.id,
})));
const summaryItems = computed(() => visibleFields.value.slice(0, 4).map(field => ({
name: field.name,
text: getFieldSummaryText(field),
})));
const getFieldSummaryText = (field: Model['input_fields'][number]) => {
const value = configData.value[field.name];
if (value === undefined || value === null || value === '') return getFieldLabel(field);
if (field.uitype === 'code') {
return field.data?.find(option => option.value === value)?.text || String(value);
}
return String(value);
};
const promptPlaceholder = computed(() => {
if (!currentModel.value) return '请输入提示词';
const promptField = currentModel.value.input_fields.find(f => f.name === 'prompt');
@ -230,18 +342,7 @@ const initFormData = () => {
return;
}
let defaultValue: ConfigValue = field.defaultvalue ?? '';
if (defaultValue === undefined || defaultValue === null) {
if (field.uitype === 'code' && field.data && field.data.length > 0) {
defaultValue = field.data[0].value;
} else if (field.uitype === 'int') {
defaultValue = 1;
} else {
defaultValue = '';
}
}
newConfigData[field.name] = defaultValue;
newConfigData[field.name] = resolveDefaultValue(field);
});
configData.value = newConfigData;
@ -374,13 +475,15 @@ const fetchPrice = () => {
}
}, 500);
};
// ==================== ====================
const loadModels = async () => {
const Props = {
type:'文生视频'
}
const Props = {
llmcatelogid:'t2v'
}
modelLoading.value = true;
try {
const res = await AllModel<AllModelResponse,ModelParams>(Props);
if (res.status === 'ok' && Array.isArray(res.data)) {
models.value = res.data;
@ -393,7 +496,9 @@ const loadModels = async () => {
} else {
ElMessage.error('获取模型列表失败');
}
} finally {
modelLoading.value = false;
}
};
const handleModelChange = (modelId: string) => {
@ -403,9 +508,17 @@ const handleModelChange = (modelId: string) => {
currentModel.value = selected;
initFormData();
fetchPrice();
configPanelVisible.value = false;
}
};
const handleDocumentClick = (event: MouseEvent) => {
if (!configPanelVisible.value) return;
const target = event.target as Node | null;
if (target && panelRef.value?.contains(target)) return;
configPanelVisible.value = false;
};
// ==================== ====================
watch(
() => [configData.value, currentModelId.value],
@ -487,10 +600,10 @@ const generate = async (): Promise<boolean> => {
currentModel.value.input_fields.forEach(field => {
if (field.required) {
if (field.name === 'prompt') {
if (!prompt.value?.trim()) missingFields.push(field.label);
if (!prompt.value?.trim()) missingFields.push(getFieldLabel(field));
} else {
const val = configData.value[field.name];
if (!val) missingFields.push(field.label);
if (isEmptyValue(val)) missingFields.push(getFieldLabel(field));
}
}
});
@ -506,12 +619,16 @@ const generate = async (): Promise<boolean> => {
const params = buildSubmitParams();
const res = (await textvideo(params)) as TextVideoResponse;
// taskid
let taskid = res.data?.taskid || res.taskid;
const taskid = res.data?.taskid || res.taskid;
const normalizedStatus = normalizeGenerationStatus(res.status);
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 (CREATED_STATUSES.has(normalizedStatus)) {
const statusMsg = STATUS_MESSAGE_MAP[normalizedStatus] || '视频生成任务已提交';
ElMessage.success(
normalizedStatus === 'SUCCEEDED'
? statusMsg
: `${statusMsg},请稍后在任务列表中查看`
);
if (taskid) {
emit('task-created', taskid);
@ -553,9 +670,11 @@ defineExpose({
// ==================== ====================
onMounted(() => {
loadModels();
document.addEventListener('click', handleDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick);
if (priceTimer) {
clearTimeout(priceTimer);
priceTimer = null;
@ -568,6 +687,242 @@ onBeforeUnmount(() => {
<style scoped lang="less">
@import url('../../assets/less/Video/text/text.less');
.model-loading {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
color: rgba(255, 255, 255, 0.62);
background: #1a1c20;
}
.reference-to-video {
position: relative;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 16px;
.card {
flex: 1;
min-height: 0;
margin-bottom: 0;
overflow: hidden;
}
.btmcard {
position: absolute;
left: 16px;
right: 16px;
bottom: 64px;
z-index: 10;
max-height: 48%;
overflow-y: auto;
background: #2b2d30;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.32);
:deep(.el-card__body) {
padding: 16px;
}
.select > .select-item:first-child {
display: none;
}
}
}
.control-row {
flex-shrink: 0;
display: flex;
gap: 8px;
min-height: 52px;
}
.model-select {
flex: 0 0 160px;
}
.config-trigger {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
color: #fff;
background: #252729;
cursor: pointer;
&.active {
border-color: rgba(69, 238, 120, 0.8);
}
}
.summary-item {
position: relative;
min-width: 0;
color: rgba(255, 255, 255, 0.82);
font-size: 14px;
white-space: nowrap;
& + .summary-item {
padding-left: 12px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.12);
transform: translateY(-50%);
}
}
}
.trigger-arrow {
margin-left: auto;
color: rgba(255, 255, 255, 0.72);
}
.reference-to-video {
box-sizing: border-box;
overflow: hidden;
gap: 10px;
padding: 0 12px;
background: #0f1114;
.card {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
background: #1a1c20;
padding: 0;
:deep(.el-card__body) {
height: 100%;
display: flex;
flex-direction: column;
padding: 12px;
overflow: hidden;
}
}
.btmcard {
max-height: min(48%, 420px);
border: none;
border-radius: 12px;
padding: 0;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}
.reference-container,
.input {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.input {
:deep(.el-textarea) {
height: 100%;
display: flex;
}
:deep(.el-textarea__inner) {
height: 100% !important;
resize: none !important;
border: none !important;
border-radius: 10px !important;
background: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
color: #fff !important;
padding: 14px 16px !important;
line-height: 1.7;
}
}
.select {
gap: 12px;
}
.select-item {
align-items: flex-start;
gap: 14px;
padding: 8px 0;
}
.select-title {
width: 34%;
min-width: 96px;
color: rgba(255, 255, 255, 0.82);
line-height: 36px;
}
.select-content {
flex: 1;
width: auto;
min-width: 0;
}
.control-row {
min-height: 58px;
gap: 10px;
}
.model-select {
flex: 0 0 180px;
:deep(.el-select__wrapper) {
width: 100% !important;
min-height: 52px;
border: none !important;
border-radius: 12px !important;
color: #fff !important;
background: #252729 !important;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12) inset !important;
}
:deep(.el-select__selected-item),
:deep(.el-select__placeholder),
:deep(.el-select__caret) {
color: #fff !important;
}
}
.config-trigger {
min-height: 58px;
border-color: rgba(255, 255, 255, 0.1);
background: #242629;
overflow: hidden;
}
.summary-item {
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.el-select__wrapper),
:deep(.el-input__wrapper),
:deep(.el-textarea__inner) {
background-color: rgba(0, 0, 0, 0.32) !important;
border: none !important;
box-shadow: none !important;
color: #fff !important;
}
</style>
<style>

View File

@ -35,12 +35,28 @@ const router = createRouter({
name: 'index',
component: () => import('@/views/index/index.vue')
},
{
path: '/homePage/index',
name: 'homePageIndex',
component: () => import('@/views/index/index.vue')
},
// 消费记录
{
path: '/bill',
name: 'bill',
component: () => import('@/views/Bill/Bill.vue')
},
// 历史创作
{
path: '/history-creation',
name: 'history-creation',
component: () => import('@/views/HistoryCreation.vue')
},
{
path: '/history-creation/detail/:id',
name: 'history-creation-detail',
component: () => import('@/views/HistoryCreationDetail.vue')
},
// ImgGenerate
{
path: '/ImgGenerate',
@ -56,6 +72,11 @@ const router = createRouter({
path: 'text',
name: 'ImgGenerateText',
component: () => import('@/views/ImgGenerate/ImgGenerate.vue')
},
{
path: 'image',
name: 'ImgGenerateImage',
component: () => import('@/views/ImgGenerate/ImgGenerate.vue')
}
]
},

View File

@ -48,9 +48,11 @@ export interface Model {
export interface InputField {
name: string; // 字段名
label: string; // 显示标签
lable?: string; // 兼容后端历史拼写
uitype: string; // 控件类型: code, text, int, audio 等
required?: boolean; // 是否必填
defaultvalue?: string | number | boolean | null; // 默认值
placeholder?: string; // 输入提示
data?: Array<{ value: string; text?: string }>; // 下拉选项
max?: number; // 最大值int 类型)
textField?: string; // 下拉选项显示字段
@ -132,6 +134,36 @@ export interface TextVideoResponse {
data?: { taskid: string } | null;
}
export interface TextToImageParams {
model: string;
catelogid?: string;
prompt: string;
negative_prompt?: string;
[key: string]: unknown;
}
export interface TextToImageResponse {
taskid?: string;
status: string;
message?: string;
image_count?: number;
image?: string[];
usage?: {
image_count?: number;
input_tokens?: number;
output_tokens?: number;
size?: string;
total_tokens?: number;
[key: string]: unknown;
};
data?: {
taskid?: string;
image?: string[];
image_count?: number;
usage?: TextToImageResponse['usage'];
} | null;
}
/**
* -
*/
@ -172,9 +204,11 @@ export interface Ioinfo {
export interface Input {
llmid: string;
prompt: string;
catelogid?: string;
duration: number;
ratio: string;
resolution: string;
size?: string;
audio: boolean;
transno: string;
model: string;
@ -198,6 +232,9 @@ export interface Output {
usage?: Usage;
type?: string;
image?: string;
image_url?: string;
url?: string;
images?: string[];
video?: string;
}
@ -220,6 +257,7 @@ export interface TaskItem {
created_at?: string;
updated_at?: string;
video_url?: string;
image_url?: string;
cover_url?: string;
duration?: number;
resolution?: string;
@ -231,5 +269,41 @@ export interface TaskItem {
export interface ModelParams {
type: string;
}
llmcatelogid: string;
}
// 文生文
export interface TextToTextParams {
model: string;
catelogid?: string;
prompt: string;
sys_prompt?: string;
stream: boolean;
}
export interface TextToTextChoice {
delta?: {
content?: string;
};
message?: {
content?: string;
};
}
export interface TextToTextResponse {
status?: string;
content?: string;
answer?: string;
text?: string;
data?: {
content?: string;
answer?: string;
};
choices?: TextToTextChoice[];
}
// 历史记录
export interface HistoryRecordParams {
page: number;
pageSize: number;
llmcatelogid: string;
}

View File

@ -1,9 +1,31 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
// import { ElMessage } from 'element-plus'
const defaultApiBaseURL = import.meta.env.VITE_API_BASE_URL
// 获取环境变量,添加默认值
const getDefaultApiBaseURL = () => {
const envUrl = import.meta.env.VITE_API_BASE_URL
// 开发环境如果没有配置,使用本地地址
if (import.meta.env.DEV && !envUrl) {
console.warn('⚠️ 未配置 VITE_API_BASE_URL使用默认本地地址')
return 'http://localhost:5173' // Vite 默认端口
}
return envUrl || 'https://token.opencomputing.cn'
}
const defaultApiBaseURL = getDefaultApiBaseURL()
// 调试信息(只在开发环境显示)
if (import.meta.env.DEV) {
console.group('🔧 API 配置信息')
console.log('环境模式:', import.meta.env.MODE)
console.log('VITE_API_BASE_URL:', import.meta.env.VITE_API_BASE_URL)
console.log('最终使用的 API 地址:', defaultApiBaseURL)
console.log('是否开发环境:', import.meta.env.DEV)
console.groupEnd()
}
const apiBaseURLStorageKey = 'yuanjing_api_base_url'
export const setApiBaseURL = (url: string) => {
@ -14,7 +36,13 @@ export const clearApiBaseURL = () => {
localStorage.removeItem(apiBaseURLStorageKey)
}
const getApiBaseURL = () => localStorage.getItem(apiBaseURLStorageKey) || defaultApiBaseURL
export const getApiBaseURL = () => {
if (import.meta.env.DEV) {
return defaultApiBaseURL
}
return localStorage.getItem(apiBaseURLStorageKey) || defaultApiBaseURL
}
const shouldIncludeCredentials = (url?: string, baseURL?: string) => {
if (!url) return true
@ -30,7 +58,6 @@ const shouldIncludeCredentials = (url?: string, baseURL?: string) => {
}
// 请求拦截器
// 添加请求拦截器
axios.interceptors.request.use(
function (config) {
const apiBaseURL = getApiBaseURL()
@ -44,8 +71,7 @@ axios.interceptors.request.use(
},
)
// 添加响应拦截器
// 响应拦截器
axios.interceptors.response.use(
function (response) {
// 放行的状态列表
@ -77,7 +103,8 @@ axios.interceptors.response.use(
// 如果状态不在放行列表中,也返回(兼容其他状态)
return response;
}, function (error) {
},
function (error) {
// 只有在确实有错误消息时才显示 ElMessage
if (error?.response?.data?.message) {
ElMessage({
@ -87,7 +114,9 @@ axios.interceptors.response.use(
})
}
return Promise.reject(error);
});
}
);
const http = <T, D, P>(url: string, data?: D, params?: P, method: 'get' | 'PUT' | 'Delete' | 'post' = 'get'): Promise<T> => {
return new Promise((resolve, reject) => {
axios({
@ -99,7 +128,6 @@ const http = <T, D, P>(url: string, data?: D, params?: P, method: 'get' | 'PUT'
.then((response) => {
resolve(response.data as T)
})
.catch(function (error) {
reject(error)
})
@ -109,6 +137,7 @@ const http = <T, D, P>(url: string, data?: D, params?: P, method: 'get' | 'PUT'
export const get = <T, P>(url: string, params?: P): Promise<T> => {
return http<T, undefined, P>(url, undefined, params, 'get')
}
export const post = <T, D>(url: string, data?: D): Promise<T> => {
return http<T, D, undefined>(url, data, undefined, 'post')
}
@ -116,18 +145,11 @@ export const post = <T, D>(url: string, data?: D): Promise<T> => {
export const Delete = <T, D>(url: string, data?: D): Promise<T> => {
return http<T, D, undefined>(url, data, undefined, 'Delete')
}
export const PUT = <T, D>(url: string, data?: D): Promise<T> => {
return http<T, D, undefined>(url, data, undefined, 'PUT')
}
export const uploadFile = async (file: File, uploadUrl: string, additionalData: Record<string, string> = {}) => {
const formData = new FormData();
formData.append('file', file); // 'file'是后端接收文件的字段名
@ -149,5 +171,4 @@ export const uploadFile = async (file: File, uploadUrl: string, additionalData:
console.error('文件上传失败:', error);
throw error;
}
}
}

View File

@ -3,7 +3,7 @@
<LoginPrompt
v-if="!isLoggedIn"
class="login-fullscreen"
title="请先登录后管理 API Key"
title="请先登录"
description="登录后可创建、复制、查看和删除自己的访问令牌。"
full-height
/>

View File

@ -1,6 +1,14 @@
<template>
<div class="bill-container">
<el-card class="bill-card">
<LoginPrompt
v-if="!isLoggedIn"
class="bill-login-prompt"
title="请先登录"
description="登录后可查看模型调用和消费记录。"
full-height
/>
<el-card v-else class="bill-card">
<!-- 头部筛选区 -->
<div class="bill-header">
<div class="header-title">
@ -30,14 +38,8 @@
<!-- 内容区 -->
<div class="bill-content">
<LoginPrompt
v-if="!isLoggedIn"
class="login-require"
title="请先登录后查看消费记录"
/>
<!-- 加载中 -->
<div v-else-if="isLoading" class="loading-container">
<div v-if="isLoading" class="loading-container">
<div class="loading-spinner">
<div class="spinner-circle"></div>
</div>
@ -89,6 +91,7 @@
<div class="amount-value">
<span class="currency">¥</span>
<span class="number">{{ order.amount !== null && order.amount !== undefined ? order.amount.toFixed(2) : '-' }}</span>
<span class="unit" v-if="order.amount !== null && order.amount !== undefined"></span>
</div>
</div>
</div>
@ -110,6 +113,7 @@
:page-sizes="[5, 10, 20, 30,50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
popper-class="bill-pagination-popper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
@ -345,7 +349,7 @@ watch(
height: 100%;
padding-bottom: 40px;
box-sizing: border-box;
// background-color: #1a1c20; //
// background-color: #1a1c20; //
}
.bill-card {
@ -363,6 +367,10 @@ watch(
flex-direction: column;
overflow: hidden;
}
}
.bill-login-prompt {
height: 100%;
}
//
@ -433,29 +441,6 @@ watch(
overflow: hidden;
}
.login-require {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.login-require-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.login-require-text {
font-size: 18px;
color: rgba(255, 255, 255, 0.8);
}
.login-btn {
min-width: 120px;
}
//
.loading-container {
flex: 1;
@ -695,6 +680,12 @@ watch(
.number {
font-family: 'DIN Alternate', 'Courier New', monospace;
}
.unit {
margin-left: 4px;
font-size: 16px;
font-weight: 600;
}
}
}
@ -727,68 +718,52 @@ watch(
border-top: 1px solid #2a2d32;
display: flex;
justify-content: center;
:deep(.el-tooltip__trigger){
background-color: #14161a!important;
border: none!important;
}
:deep(.el-pagination) {
.el-pager li {
background-color: #1e2024;
border: 1px solid #2a2d32;
color: rgba(255, 255, 255, 0.8);
border-radius: 6px;
// transition: all 0.3s;
margin:0 4px;
--el-pagination-bg-color: transparent;
--el-pagination-text-color: rgba(235, 244, 255, 0.9);
--el-pagination-button-disabled-bg-color: transparent;
--el-pagination-button-bg-color: transparent;
--el-pagination-hover-color: #94d8ff;
color: rgba(235, 244, 255, 0.9);
}
&.is-active {
background-color: #00c4b3;
border-color: #00c4b3;
color: #1a1c20;
font-weight: 600;
}
:deep(.el-pagination__total),
:deep(.el-pagination__jump) {
color: rgba(235, 244, 255, 0.86);
}
&:hover:not(.is-active) {
border-color: #00c4b3;
color: #00c4b3;
}
}
:deep(.el-pager li),
:deep(.btn-prev),
:deep(.btn-next) {
color: rgba(235, 244, 255, 0.74);
background: transparent;
}
.btn-prev,
.btn-next {
background-color: #1e2024;
border: 1px solid #2a2d32;
color: rgba(255, 255, 255, 0.8);
border-radius: 6px;
transition: all 0.3s;
:deep(.el-pager li.is-active) {
color: #94d8ff;
font-weight: 700;
}
&:hover:not(:disabled) {
border-color: #00c4b3;
color: #00c4b3;
}
}
:deep(.el-select__wrapper),
:deep(.el-input__wrapper) {
border-radius: 10px;
background: rgba(15, 24, 36, 0.82);
box-shadow: 0 0 0 1px rgba(148, 216, 255, 0.16) inset;
}
.el-pagination__sizes,
.el-pagination__jump {
.el-input__wrapper {
background-color: #25282e;
border: 1px solid #3a3e45;
border-radius: 6px;
transition: all 0.3s;
:deep(.el-select__selected-item),
:deep(.el-input__inner) {
color: rgba(235, 244, 255, 0.92);
}
&:hover {
border-color: #00c4b3;
}
:deep(.el-select__caret) {
color: rgba(235, 244, 255, 0.58);
}
.el-input__inner {
color: rgba(255, 255, 255, 0.9);
}
}
}
.el-pagination__total {
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
}
:deep(.el-select__wrapper:hover),
:deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px rgba(148, 216, 255, 0.34) inset;
}
}
</style>
@ -803,7 +778,7 @@ watch(
.custom-select-popup .el-select-dropdown__item {
color: #ffffff !important;
background-color: transparent !important;
background-color: transparent !important;
font-size: 14px !important;
}
@ -811,4 +786,37 @@ watch(
.custom-select-popup .el-select-dropdown__item:hover {
background-color: #3a3d46 !important;
}
.bill-pagination-popper {
border: 1px solid rgba(148, 216, 255, 0.18) !important;
border-radius: 10px !important;
background: rgba(15, 24, 36, 0.96) !important;
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.32) !important;
}
.bill-pagination-popper .el-select-dropdown__list {
padding: 6px;
}
.bill-pagination-popper .el-select-dropdown__item {
border-radius: 8px;
color: rgba(235, 244, 255, 0.78);
}
.bill-pagination-popper .el-select-dropdown__item.is-selected {
color: #94d8ff;
background: rgba(148, 216, 255, 0.12);
font-weight: 700;
}
.bill-pagination-popper .el-select-dropdown__item.hover,
.bill-pagination-popper .el-select-dropdown__item:hover {
color: #fff;
background: rgba(148, 216, 255, 0.1);
}
.bill-pagination-popper .el-popper__arrow::before {
border-color: rgba(148, 216, 255, 0.18) !important;
background: rgba(15, 24, 36, 0.96) !important;
}
</style>

View File

@ -51,7 +51,7 @@ interface SyncUserResponse {
const route = useRoute();
const router = useRouter();
const loginStore = useLoginStore();
const cookieApiBaseURL = 'https://token.opencomputing.cn' ;
const cookieApiBaseURL = import.meta.env.VITE_API_BASE_URL;
const status = ref<'loading' | 'success' | 'error'>('loading');
const message = ref('同步用户信息中...');
const syncing = ref(false);

View File

@ -0,0 +1,784 @@
<template>
<div class="history-page">
<LoginPrompt
v-if="!isLoggedIn"
class="history-login-prompt"
title="请先登录"
description="登录后可查看和管理你生成过的图片、视频作品。"
full-height
/>
<template v-else>
<div class="page-head">
<div>
<div class="page-title">历史创作</div>
<div class="page-desc">查看和管理你生成过的图片视频作品</div>
</div>
<button type="button" class="refresh-btn" :disabled="loading" @click="loadHistory">
{{ loading ? '刷新中...' : '刷新' }}
</button>
</div>
<div class="toolbar">
<div class="toolbar-left">
<button
v-for="item in categoryOptions"
:key="item.value"
type="button"
class="category-chip"
:class="{ active: activeCategory === item.value }"
@click="activeCategory = item.value; handleCategoryChange()"
>
{{ item.label }}
</button>
<button type="button" class="tool-btn" @click="toggleSort">
日期 <i class="iconfont icon-jiantou_shangxiaqiehuan_o"></i>
</button>
<button type="button" class="tool-btn reset-btn" @click="resetFilters">重置</button>
</div>
<div class="toolbar-right">
<div class="search-box">
<span class="search-icon"><i class="iconfont icon-sousuo"></i></span>
<input v-model="keyword" type="text" placeholder="名字 / 提示词 / ID" />
</div>
</div>
</div>
<div class="library-meta">
<div class="folder-name">所有作品</div>
</div>
<div class="content-area" v-loading="loading">
<div v-if="!loading && !filteredWorks.length" class="empty-container">
<img src="../assets/images/preparation.png" alt="" />
<div>暂无历史创作</div>
</div>
<div v-else class="works-grid">
<div
v-for="work in filteredWorks"
:key="work.id"
class="work-card"
@click="selectWork(work)"
>
<div class="thumb-wrap">
<img
v-if="work.mediaType === 'image' && work.mediaUrl"
:src="work.mediaUrl"
alt="作品预览"
class="thumb-media"
/>
<video
v-else-if="work.mediaType === 'video' && work.mediaUrl"
:src="work.mediaUrl"
:poster="work.coverUrl"
class="thumb-media"
muted
loop
playsinline
preload="metadata"
@mouseenter="playVideo"
@mouseleave="pauseVideo"
/>
<div v-else class="thumb-empty">
{{ work.status === 'SUCCEEDED' ? '暂无预览' : getStatusText(work.status) }}
</div>
<div class="media-badge">{{ getCategoryLabel(work.llmcatelogid) }}</div>
</div>
<div class="work-info">
<div class="work-name" :title="work.prompt || work.id">
{{ work.prompt || work.id }}
</div>
<div class="work-size">{{ work.sizeText }}</div>
</div>
<div class="work-sub-info">
<span class="model-name">{{ work.model || getCategoryLabel(work.llmcatelogid) }}</span>
<span>{{ work.useTime || '-' }}</span>
</div>
</div>
</div>
</div>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 30, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
popper-class="history-pagination-popper"
@size-change="loadHistory"
@current-change="loadHistory"
/>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { getHistoryRecord } from '@/apis/Model';
import LoginPrompt from '@/components/LoginPrompt/LoginPrompt.vue';
import { useLoginStore } from '@/store/LoginStore';
import type { HistoryRecordParams } from '@/types/model';
import { ElMessage } from 'element-plus';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
type CategoryValue = 't2v' | 'i2v' | 'r2v' | 't2i' | 'i2i' | 'r2i';
type MediaType = 'image' | 'video';
interface HistoryRecordResponse {
success?: boolean;
status?: string;
rows?: RawHistoryRecord[];
total?: number;
}
interface RawHistoryRecord {
id?: string;
taskid?: string;
status?: string;
use_time?: string;
use_date?: string;
usages?: unknown;
io_content?: {
input?: Record<string, unknown>;
output?: Array<Record<string, unknown>>;
} | null;
}
interface WorkItem {
id: string;
taskid: string;
status: string;
prompt: string;
model: string;
resolution: string;
duration: string;
useTime: string;
llmcatelogid: CategoryValue;
mediaType: MediaType;
mediaUrl: string;
coverUrl: string;
sizeText: string;
}
const categoryOptions: Array<{ label: string; value: CategoryValue }> = [
{ label: '文生视频', value: 't2v' },
{ label: '图生视频', value: 'i2v' },
{ label: '参考生视频', value: 'r2v' },
{ label: '文生图', value: 't2i' },
{ label: '图生图', value: 'i2i' },
{ label: '参考生图', value: 'r2i' },
];
const activeCategory = ref<CategoryValue>('t2v');
const loginStore = useLoginStore();
const router = useRouter();
const keyword = ref('');
const works = ref<WorkItem[]>([]);
const loading = ref(false);
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const sortDesc = ref(true);
const isLoggedIn = computed(() => loginStore.isLoggedIn);
const filteredWorks = computed(() => {
const search = keyword.value.trim().toLowerCase();
const list = [...works.value].sort((a, b) => (
sortDesc.value
? b.useTime.localeCompare(a.useTime)
: a.useTime.localeCompare(b.useTime)
));
if (!search) return list;
return list.filter((work) => [
work.id,
work.taskid,
work.prompt,
work.model,
work.resolution,
].some((value) => value.toLowerCase().includes(search)));
});
const readString = (source: Record<string, unknown> | undefined, keys: string[]) => {
if (!source) return '';
for (const key of keys) {
const value = source[key];
if (typeof value === 'string' && value) return value;
if (typeof value === 'number') return String(value);
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return '';
};
const parseUsage = (usage: unknown) => {
if (!usage) return {};
if (typeof usage === 'object') return usage as Record<string, unknown>;
if (typeof usage !== 'string') return {};
try {
return JSON.parse(usage.replace(/'/g, '"')) as Record<string, unknown>;
} catch {
return {};
}
};
const getFinalOutput = (output?: Array<Record<string, unknown>>) => {
if (!Array.isArray(output) || !output.length) return {};
return output.find((item) => readString(item, ['video', 'video_url', 'image', 'image_url', 'url']))
|| output[output.length - 1]
|| {};
};
const normalizeStatus = (status?: string) => {
const upperStatus = String(status || '').toUpperCase();
const statusMap: Record<string, string> = {
CREATED: 'CREATED',
CREATEDD: 'CREATED',
PROCESSING: 'RUNNING',
RUNNING: 'RUNNING',
PENDING: 'PENDING',
QUEUEING: 'PENDING',
SUCCEEDED: 'SUCCEEDED',
COMPLETED: 'SUCCEEDED',
SUCCESS: 'SUCCEEDED',
FAILED: 'FAILED',
};
return statusMap[upperStatus] || upperStatus || 'UNKNOWN';
};
const normalizeCategory = (value: string): CategoryValue => {
return categoryOptions.some((item) => item.value === value)
? value as CategoryValue
: activeCategory.value;
};
const normalizeWork = (record: RawHistoryRecord): WorkItem => {
const usage = parseUsage(record.usages);
const input = record.io_content?.input || {};
const output = getFinalOutput(record.io_content?.output);
const action = normalizeCategory(readString(usage, ['action']));
const videoUrl = readString(output, ['video', 'video_url']);
const imageUrl = readString(output, ['image', 'image_url', 'url', 'output_image', 'output_url', 'images']);
const mediaType: MediaType = videoUrl || action.endsWith('v') ? 'video' : 'image';
const resolution = readString(input, ['resolution', 'size', 'ratio'])
|| readString(usage, ['resolution', 'video_ratio'])
|| '';
const duration = readString(input, ['duration'])
|| readString(usage, ['duration', 'video_duration'])
|| '';
return {
id: record.id || record.taskid || '',
taskid: record.taskid || '',
status: normalizeStatus(record.status || readString(output, ['status'])),
prompt: readString(input, ['prompt']),
model: readString(input, ['model']) || readString(usage, ['model']),
resolution,
duration,
useTime: record.use_time || record.use_date || '',
llmcatelogid: action,
mediaType,
mediaUrl: videoUrl || imageUrl,
coverUrl: imageUrl,
sizeText: [resolution, duration ? `${duration}s` : ''].filter(Boolean).join(' / ') || getCategoryLabel(action),
};
};
const loadHistory = async () => {
if (!isLoggedIn.value) {
works.value = [];
total.value = 0;
return;
}
loading.value = true;
try {
const params: HistoryRecordParams = {
page: page.value,
pageSize: pageSize.value,
llmcatelogid: activeCategory.value,
};
const res = await getHistoryRecord<HistoryRecordResponse, HistoryRecordParams>(params);
const rows = Array.isArray(res.rows) ? res.rows : [];
works.value = rows.map(normalizeWork);
total.value = Number(res.total || rows.length);
} catch (error) {
console.error('获取历史创作失败:', error);
ElMessage.error('获取历史创作失败');
works.value = [];
total.value = 0;
} finally {
loading.value = false;
}
};
const handleCategoryChange = () => {
page.value = 1;
loadHistory();
};
const selectWork = (work: WorkItem) => {
sessionStorage.setItem('history_creation_detail', JSON.stringify(work));
router.push({
name: 'history-creation-detail',
params: { id: work.id || work.taskid },
query: { llmcatelogid: work.llmcatelogid },
});
};
const toggleSort = () => {
sortDesc.value = !sortDesc.value;
};
const resetFilters = () => {
keyword.value = '';
activeCategory.value = 't2v';
sortDesc.value = true;
page.value = 1;
loadHistory();
};
const getCategoryLabel = (value: CategoryValue) => {
return categoryOptions.find((item) => item.value === value)?.label || '创作记录';
};
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
CREATED: '已创建',
PENDING: '排队中',
RUNNING: '运行中',
SUCCEEDED: '成功',
FAILED: '失败',
UNKNOWN: '未知',
};
return statusMap[normalizeStatus(status)] || status;
};
const playVideo = (event: MouseEvent) => {
const video = event.currentTarget as HTMLVideoElement;
video.play().catch(() => undefined);
};
const pauseVideo = (event: MouseEvent) => {
const video = event.currentTarget as HTMLVideoElement;
video.pause();
};
onMounted(() => {
if (isLoggedIn.value) {
loadHistory();
}
});
watch(isLoggedIn, (value) => {
if (value) {
loadHistory();
} else {
works.value = [];
total.value = 0;
}
});
</script>
<style scoped lang="less">
.history-page {
height: 100%;
display: flex;
flex-direction: column;
color: #fff;
padding: 18px 20px 14px;
overflow: hidden;
}
.page-head {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.page-title {
color: #ffffff;
font-size: 24px;
font-weight: 700;
letter-spacing: 0.5px;
}
.page-desc {
margin-top: 6px;
color: rgba(220, 232, 255, 0.5);
font-size: 13px;
}
.refresh-btn {
height: 34px;
padding: 0 16px;
border: 1px solid rgba(142, 164, 198, 0.18);
border-radius: 10px;
color: rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.06);
cursor: pointer;
&:hover {
color: #fff;
border-color: rgba(148, 216, 255, 0.42);
background: rgba(148, 216, 255, 0.12);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin: 14px 0 12px;
padding: 12px;
border: 1px solid rgba(142, 164, 198, 0.1);
border-radius: 14px;
background: rgba(14, 24, 36, 0.76);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.18);
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.tool-btn {
height: 32px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
border: none;
border-radius: 9px;
color: rgba(220, 232, 255, 0.78);
background: rgba(255, 255, 255, 0.07);
cursor: pointer;
transition: all 0.18s ease;
&:hover {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
}
.category-chip {
height: 32px;
padding: 0 12px;
border: 1px solid transparent;
border-radius: 999px;
color: rgba(220, 232, 255, 0.68);
background: rgba(255, 255, 255, 0.06);
cursor: pointer;
transition: all 0.18s ease;
&.active {
color: #07101a;
background: linear-gradient(270deg, #94d8ff 11.11%, #e0ddff);
box-shadow: 0 8px 20px rgba(148, 216, 255, 0.18);
}
&:hover:not(.active) {
color: #fff;
border-color: rgba(148, 216, 255, 0.26);
background: rgba(148, 216, 255, 0.1);
}
}
.search-box {
width: 220px;
height: 34px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
border: 1px solid rgba(142, 164, 198, 0.1);
border-radius: 10px;
background: rgba(4, 10, 18, 0.58);
color: rgba(255, 255, 255, 0.42);
input {
flex: 1;
min-width: 0;
border: none;
outline: none;
color: #fff;
background: transparent;
font-size: 13px;
&::placeholder {
color: rgba(255, 255, 255, 0.38);
}
}
}
.library-meta {
flex-shrink: 0;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
padding: 2px 4px 14px;
}
.folder-name {
color: #fff;
font-size: 16px;
font-weight: 700;
}
.content-area {
flex: 1;
min-height: 0;
overflow: hidden;
padding: 0 4px 18px;
}
.empty-container {
height: 100%;
min-height: 360px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.62);
img {
width: 180px;
margin-bottom: 16px;
}
}
.works-grid {
height: 100%;
min-height: 0;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
align-content: start;
gap: 18px;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: rgba(148, 216, 255, 0.28) transparent;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(148, 216, 255, 0.28);
}
}
.work-card {
min-width: 0;
padding: 8px;
border: 1px solid transparent;
border-radius: 14px;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
&:hover {
transform: translateY(-3px);
border-color: rgba(148, 216, 255, 0.24);
background: rgba(23, 37, 54, 0.72);
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.28);
.thumb-media {
transform: scale(1.04);
}
}
}
.thumb-wrap {
position: relative;
height: 260px;
overflow: hidden;
border-radius: 10px;
background:
linear-gradient(135deg, rgba(148, 216, 255, 0.08), transparent),
#162231;
}
.thumb-media {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.25s ease;
}
.thumb-empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.42);
background: linear-gradient(135deg, rgba(148, 216, 255, 0.08), rgba(224, 221, 255, 0.05));
}
.media-badge {
position: absolute;
left: 8px;
top: 8px;
height: 24px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
color: #dce8ff;
background: rgba(0, 0, 0, 0.46);
backdrop-filter: blur(8px);
font-size: 12px;
}
.work-info {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 10px 2px 0;
color: rgba(255, 255, 255, 0.86);
font-size: 12px;
}
.work-sub-info {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 4px 2px 0;
color: rgba(255, 255, 255, 0.48);
font-size: 12px;
}
.model-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
}
.work-size {
flex-shrink: 0;
color: rgba(255, 255, 255, 0.52);
}
.pagination-wrap {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
padding: 12px 4px 0;
border-top: 1px solid rgba(142, 164, 198, 0.08);
:deep(.el-pagination) {
--el-pagination-bg-color: transparent;
--el-pagination-text-color: rgba(235, 244, 255, 0.9);
--el-pagination-button-disabled-bg-color: transparent;
--el-pagination-button-bg-color: transparent;
--el-pagination-hover-color: #94d8ff;
color: rgba(235, 244, 255, 0.9);
}
:deep(.el-pagination__total),
:deep(.el-pagination__jump) {
color: rgba(235, 244, 255, 0.86);
}
:deep(.el-pager li),
:deep(.btn-prev),
:deep(.btn-next) {
color: rgba(235, 244, 255, 0.74);
background: transparent;
}
:deep(.el-pager li.is-active) {
color: #94d8ff;
font-weight: 700;
}
:deep(.el-select__wrapper),
:deep(.el-input__wrapper) {
border-radius: 10px;
background: rgba(15, 24, 36, 0.82);
box-shadow: 0 0 0 1px rgba(148, 216, 255, 0.16) inset;
}
:deep(.el-select__selected-item),
:deep(.el-input__inner) {
color: rgba(235, 244, 255, 0.92);
}
:deep(.el-select__caret) {
color: rgba(235, 244, 255, 0.58);
}
:deep(.el-select__wrapper:hover),
:deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px rgba(148, 216, 255, 0.34) inset;
}
}
:global(.history-pagination-popper) {
border: 1px solid rgba(148, 216, 255, 0.18) !important;
border-radius: 10px !important;
background: rgba(15, 24, 36, 0.96) !important;
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.32) !important;
}
:global(.history-pagination-popper .el-select-dropdown__list) {
padding: 6px;
}
:global(.history-pagination-popper .el-select-dropdown__item) {
border-radius: 8px;
color: rgba(235, 244, 255, 0.78);
}
:global(.history-pagination-popper .el-select-dropdown__item.is-selected) {
color: #94d8ff;
background: rgba(148, 216, 255, 0.12);
font-weight: 700;
}
:global(.history-pagination-popper .el-select-dropdown__item.hover),
:global(.history-pagination-popper .el-select-dropdown__item:hover) {
color: #fff;
background: rgba(148, 216, 255, 0.1);
}
:global(.history-pagination-popper .el-popper__arrow::before) {
border-color: rgba(148, 216, 255, 0.18) !important;
background: rgba(15, 24, 36, 0.96) !important;
}
</style>

View File

@ -0,0 +1,491 @@
<template>
<div class="history-detail-page">
<LoginPrompt
v-if="!isLoggedIn"
class="history-login-prompt"
title="请先登录"
description="登录后可查看和下载历史创作作品。"
full-height
/>
<template v-else-if="work">
<button type="button" class="back-btn" @click="goBack"> <i class="iconfont icon-zuo"></i> 返回</button>
<div class="detail-layout">
<section class="preview-panel">
<img
v-if="work.mediaType === 'image' && work.mediaUrl"
:src="work.mediaUrl"
alt="作品预览"
/>
<video
v-else-if="work.mediaType === 'video' && work.mediaUrl"
:src="work.mediaUrl"
:poster="work.coverUrl"
controls
playsinline
/>
<div v-else class="empty-preview">暂无预览</div>
</section>
<aside class="info-panel">
<div class="info-time">{{ work.useTime || '-' }}</div>
<!-- <div class="author">
<div class="author-avatar"></div>
<div>
<div class="author-name">元境官方</div>
<div class="author-type">{{ getCategoryLabel(work.llmcatelogid) }}</div>
</div>
</div> -->
<div class="info-title">{{ work.prompt || work.id }}</div>
<div class="info-list">
<div class="info-line">
<span>模型</span>
<strong>{{ work.model || '-' }}</strong>
</div>
<div class="info-line">
<span>规格</span>
<strong>{{ work.sizeText }}</strong>
</div>
<div class="info-line">
<span>状态</span>
<strong>{{ getStatusText(work.status) }}</strong>
</div>
</div>
<div class="info-stats">
<div class="stat-card">
<span>类型</span>
<strong>{{ work.mediaType === 'video' ? '视频' : '图片' }}</strong>
</div>
<div class="stat-card">
<span>时长</span>
<strong>{{ work.duration || '-' }}</strong>
</div>
</div>
<button
type="button"
class="download-btn"
:disabled="!work.mediaUrl"
@click="downloadMedia"
>
<i class="iconfont icon-xiazai"></i>
下载作品
</button>
</aside>
</div>
</template>
<div v-else class="empty-detail">
<div class="empty-title">未找到作品详情</div>
<button type="button" class="back-btn" @click="goBack">返回历史创作</button>
</div>
</div>
</template>
<script setup lang="ts">
import LoginPrompt from '@/components/LoginPrompt/LoginPrompt.vue';
import { useLoginStore } from '@/store/LoginStore';
import { ElMessage } from 'element-plus';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
type CategoryValue = 't2v' | 'i2v' | 'r2v' | 't2i' | 'i2i' | 'r2i';
type MediaType = 'image' | 'video';
interface WorkItem {
id: string;
taskid: string;
status: string;
prompt: string;
model: string;
resolution: string;
duration: string;
useTime: string;
llmcatelogid: CategoryValue;
mediaType: MediaType;
mediaUrl: string;
coverUrl: string;
sizeText: string;
}
const categoryOptions: Array<{ label: string; value: CategoryValue }> = [
{ label: '文生视频', value: 't2v' },
{ label: '图生视频', value: 'i2v' },
{ label: '参考生视频', value: 'r2v' },
{ label: '文生图', value: 't2i' },
{ label: '图生图', value: 'i2i' },
{ label: '参考生图', value: 'r2i' },
];
const detailStorageKey = 'history_creation_detail';
const route = useRoute();
const router = useRouter();
const loginStore = useLoginStore();
const work = ref<WorkItem | null>(null);
const isLoggedIn = computed(() => loginStore.isLoggedIn);
const goBack = () => {
router.push('/history-creation');
};
const getCategoryLabel = (value: CategoryValue) => {
return categoryOptions.find((item) => item.value === value)?.label || '创作记录';
};
const normalizeStatus = (status?: string) => {
const upperStatus = String(status || '').toUpperCase();
const statusMap: Record<string, string> = {
CREATED: 'CREATED',
CREATEDD: 'CREATED',
PROCESSING: 'RUNNING',
RUNNING: 'RUNNING',
PENDING: 'PENDING',
QUEUEING: 'PENDING',
SUCCEEDED: 'SUCCEEDED',
COMPLETED: 'SUCCEEDED',
SUCCESS: 'SUCCEEDED',
FAILED: 'FAILED',
};
return statusMap[upperStatus] || upperStatus || 'UNKNOWN';
};
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
CREATED: '已创建',
PENDING: '排队中',
RUNNING: '运行中',
SUCCEEDED: '成功',
FAILED: '失败',
UNKNOWN: '未知',
};
return statusMap[normalizeStatus(status)] || status;
};
const getMediaExtension = (target: WorkItem, mimeType = '') => {
if (mimeType.includes('jpeg')) return 'jpg';
if (mimeType.includes('png')) return 'png';
if (mimeType.includes('webp')) return 'webp';
if (mimeType.includes('gif')) return 'gif';
if (mimeType.includes('mp4')) return 'mp4';
const decodedUrl = decodeURIComponent(target.mediaUrl);
const extension = decodedUrl.match(/\.(png|jpe?g|webp|gif|mp4)(?:\?|&|$)/i)?.[1];
if (extension) return extension.toLowerCase().replace('jpeg', 'jpg');
return target.mediaType === 'image' ? 'png' : 'mp4';
};
const getDownloadFileName = (target: WorkItem, mimeType = '') => {
const safeId = (target.id || target.taskid || 'result').replace(/[\\/:*?"<>|]/g, '_');
return `${getCategoryLabel(target.llmcatelogid)}_${safeId}.${getMediaExtension(target, mimeType)}`;
};
const downloadBlob = (target: WorkItem, blob: Blob) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = getDownloadFileName(target, blob.type);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const triggerDirectDownload = (target: WorkItem) => {
const frameName = 'history-download-frame';
let iframe = document.querySelector<HTMLIFrameElement>(`iframe[name="${frameName}"]`);
if (!iframe) {
iframe = document.createElement('iframe');
iframe.name = frameName;
iframe.style.display = 'none';
document.body.appendChild(iframe);
}
const link = document.createElement('a');
link.href = target.mediaUrl;
link.download = getDownloadFileName(target);
link.target = frameName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const downloadMedia = async () => {
if (!work.value?.mediaUrl) {
ElMessage.warning('暂无可下载作品');
return;
}
try {
const response = await fetch(work.value.mediaUrl, {
credentials: 'omit',
cache: 'no-store',
});
if (!response.ok) throw new Error(`下载请求失败:${response.status}`);
const blob = await response.blob();
downloadBlob(work.value, blob);
} catch {
triggerDirectDownload(work.value);
}
};
const loadDetail = () => {
const raw = sessionStorage.getItem(detailStorageKey);
if (!raw) {
work.value = null;
return;
}
try {
const cachedWork = JSON.parse(raw) as WorkItem;
const routeId = String(route.params.id || '');
work.value = !routeId || cachedWork.id === routeId || cachedWork.taskid === routeId
? cachedWork
: null;
} catch {
work.value = null;
}
};
onMounted(loadDetail);
watch(isLoggedIn, (value) => {
if (!value) {
work.value = null;
}
});
</script>
<style scoped lang="less">
.history-detail-page {
position: fixed;
inset: 0;
z-index: 9999;
width: 100vw;
height: 100vh;
min-height: 0;
display: flex;
flex-direction: column;
padding: 18px 24px 20px;
color: #fff;
overflow: hidden;
background:
radial-gradient(circle at 20% 10%, rgba(16, 79, 120, 0.24), transparent 32%),
linear-gradient(180deg, #070a12 0%, #080b13 100%);
}
.history-login-prompt {
height: 100%;
}
.back-btn {
width: fit-content;
height: 34px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
padding: 0 14px;
margin-bottom: 14px;
border: none;
border-radius: 999px;
color: rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.08);
cursor: pointer;
font-size: 14px;
i{
font-size: 16px;
margin-right: 4px;
}
&:hover {
color: #fff;
background: rgba(255, 255, 255, 0.13);
}
}
.detail-layout {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 20px;
}
.preview-panel {
min-width: 0;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid rgba(142, 164, 198, 0.08);
border-radius: 16px;
background: #000;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.28);
img,
video {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
}
.info-panel {
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 18px;
border: 1px solid rgba(142, 164, 198, 0.1);
border-radius: 16px;
background: linear-gradient(180deg, rgba(24, 30, 43, 0.96), rgba(13, 18, 28, 0.96));
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.22);
}
.info-time {
color: rgba(255, 255, 255, 0.45);
font-size: 12px;
}
.author {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.author-avatar {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #94d8ff;
background: rgba(148, 216, 255, 0.12);
font-weight: 700;
}
.author-name {
color: #fff;
font-size: 14px;
font-weight: 700;
}
.author-type {
margin-top: 2px;
color: rgba(255, 255, 255, 0.48);
font-size: 12px;
}
.info-title {
margin-top: 18px;
color: #fff;
font-size: 18px;
font-weight: 800;
line-height: 1.5;
}
.info-list {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.info-line {
display: flex;
align-items: flex-start;
gap: 10px;
color: rgba(255, 255, 255, 0.58);
font-size: 13px;
line-height: 1.6;
span {
flex-shrink: 0;
color: rgba(148, 216, 255, 0.8);
}
strong {
min-width: 0;
color: rgba(255, 255, 255, 0.78);
font-weight: 500;
word-break: break-all;
}
}
.info-stats {
display: flex;
gap: 10px;
margin-top: 18px;
}
.stat-card {
min-width: 74px;
padding: 12px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
span {
display: block;
color: rgba(255, 255, 255, 0.46);
font-size: 12px;
}
strong {
display: block;
margin-top: 8px;
color: #fff;
font-size: 18px;
}
}
.download-btn {
height: 42px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: auto;
border: none;
border-radius: 12px;
color: #07101a;
background: linear-gradient(270deg, #94d8ff 11.11%, #e0ddff);
font-weight: 700;
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.55;
}
}
.empty-preview,
.empty-detail {
color: rgba(255, 255, 255, 0.58);
}
.empty-detail {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.empty-title {
margin-bottom: 14px;
color: rgba(255, 255, 255, 0.86);
font-size: 18px;
font-weight: 700;
}
</style>

View File

@ -1,18 +1,157 @@
<template>
<div class="app">
<div class="left-panel">
<ImgLeft />
<ImgLeft
@generation-start="handleGenerationStart"
@image-generated="handleImageGenerated"
/>
</div>
<div class="right-panel">
<RightResult />
<el-card class="image-result-card">
<div v-if="isLoggedIn" class="result-header">
<div>
<div class="result-title">生成结果</div>
<div class="result-desc">图片生成成功后会直接展示在这里</div>
</div>
<button
v-if="resultImages.length"
type="button"
class="clear-btn"
@click="clearResults"
>
清空
</button>
</div>
<LoginPrompt
v-if="!isLoggedIn"
class="image-login-prompt"
title="请先登录"
description="登录后可创作图片,并在右侧查看生成结果。"
full-height
/>
<div v-else-if="isGenerating" class="generating-result">
<div class="generating-card">
<div class="generating-spinner"></div>
<div class="generating-title">图片生成中</div>
<div class="generating-desc">正在根据提示词生成图片请稍候...</div>
<div class="generating-progress">
<span></span>
</div>
</div>
</div>
<div v-else-if="!resultImages.length" class="empty-result">
<img src="../../assets/images/preparation.png" alt="" />
<div class="empty-title">暂无生成结果</div>
<div class="empty-desc">点击左侧创作后生成图片会展示在这里</div>
</div>
<div v-else class="result-content">
<div class="usage-bar">
<span v-if="latestUsage.size">尺寸 {{ latestUsage.size }}</span>
<span v-if="latestUsage.image_count">数量 {{ latestUsage.image_count }}</span>
<span v-if="latestUsage.total_tokens">Tokens {{ latestUsage.total_tokens }}</span>
</div>
<div class="image-grid">
<div v-for="item in resultImages" :key="item.id" class="image-item">
<div class="image-preview-box">
<img :src="item.url" alt="生成图片" />
</div>
<button type="button" class="download-mask" @click="downloadImage(item)">
<i class="iconfont icon-xiazai"></i>
</button>
</div>
</div>
</div>
</el-card>
</div>
</div>
</template>
<script setup lang='ts'>
import ImgLeft from '@/components/img/ImgLeft.vue';
import RightResult from '@/components/RightResult/RightResult.vue';
import LoginPrompt from '@/components/LoginPrompt/LoginPrompt.vue';
import { useLoginStore } from '@/store/LoginStore';
import type { TextToImageResponse } from '@/types/model';
import { computed, ref, watch } from 'vue';
//import { ref, reactive } from 'vue';
interface ResultImage {
id: string;
url: string;
createdAt: number;
}
const resultImages = ref<ResultImage[]>([]);
const loginStore = useLoginStore();
const latestResult = ref<TextToImageResponse | null>(null);
const isGenerating = ref(false);
const isLoggedIn = computed(() => loginStore.isLoggedIn);
const latestUsage = computed(() => latestResult.value?.usage || latestResult.value?.data?.usage || {});
const handleGenerationStart = () => {
isGenerating.value = true;
latestResult.value = null;
resultImages.value = [];
};
const handleImageGenerated = (result: TextToImageResponse) => {
const images = result.image || result.data?.image || [];
latestResult.value = result;
isGenerating.value = false;
resultImages.value = images.map((url, index) => ({
id: `${Date.now()}-${index}-${url}`,
url,
createdAt: Date.now(),
}));
};
const clearResults = () => {
isGenerating.value = false;
latestResult.value = null;
resultImages.value = [];
};
const downloadImage = async (item: ResultImage) => {
const fileName = `generated-image-${item.createdAt}.png`;
try {
const response = await fetch(item.url);
if (!response.ok) throw new Error('download failed');
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch {
const frameName = 'image-result-download-frame';
let iframe = document.querySelector<HTMLIFrameElement>(`iframe[name="${frameName}"]`);
if (!iframe) {
iframe = document.createElement('iframe');
iframe.name = frameName;
iframe.style.display = 'none';
document.body.appendChild(iframe);
}
const link = document.createElement('a');
link.href = item.url;
link.download = fileName;
link.target = frameName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
watch(isLoggedIn, (value) => {
if (!value) {
clearResults();
}
});
</script>
<style scoped lang="less">
:deep(.el-card__body){
@ -27,14 +166,16 @@ html, body {
.app {
display: flex;
height: 100%;
height: calc(100vh - 80px);
width: 100%;
gap: 12px;
box-sizing: border-box;
}
.left-panel {
width: 35%; /* 与 leftBox 宽度一致 */
height: 100%;
min-height: 0;
overflow-y: auto; /* 让左侧区域独立滚动 */
&::-webkit-scrollbar {
// /
@ -66,7 +207,287 @@ html, body {
.right-panel {
flex: 1;
height: 100%;
/* 如果右边不需要滚动,就不加 overflow */
min-width: 0;
// min-height: 0;
height: calc(100vh - 100px);
display: flex;
}
.image-result-card {
flex: 1;
height: 100%;
border: 1px solid #1a1c20;
border-radius: 16px;
background: #1a1c20;
overflow: hidden;
:deep(.el-card__body) {
height: 100%;
display: flex;
flex-direction: column;
padding: 22px 22px 24px !important;
}
}
.result-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.result-title {
color: #fff;
font-size: 22px;
font-weight: 700;
}
.result-desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
}
.clear-btn {
height: 34px;
padding: 0 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: rgba(255, 255, 255, 0.78);
background: rgba(255, 255, 255, 0.06);
cursor: pointer;
&:hover {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
}
.image-login-prompt {
flex: 1;
min-height: 0;
}
.empty-result {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.56);
border: 1px dashed rgba(255, 255, 255, 0.1);
border-radius: 16px;
background: rgba(255, 255, 255, 0.025);
img {
width: 160px;
margin-bottom: 16px;
opacity: 0.86;
}
}
.empty-title {
color: rgba(255, 255, 255, 0.82);
font-size: 18px;
font-weight: 600;
}
.empty-desc {
margin-top: 8px;
font-size: 13px;
}
.generating-result {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
}
.generating-card {
width: min(420px, 80%);
min-height: 260px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
border: 1px solid rgba(148, 216, 255, 0.12);
border-radius: 16px;
background: rgba(255, 255, 255, 0.035);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);
}
.generating-spinner {
width: 44px;
height: 44px;
border: 4px solid rgba(255, 255, 255, 0.16);
border-top-color: #94d8ff;
border-radius: 50%;
animation: result-rotate 0.9s linear infinite;
}
.generating-title {
margin-top: 18px;
color: #fff;
font-size: 18px;
font-weight: 700;
}
.generating-desc {
margin-top: 8px;
color: rgba(255, 255, 255, 0.54);
font-size: 13px;
}
.generating-progress {
width: 240px;
height: 6px;
overflow: hidden;
margin-top: 22px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
span {
display: block;
width: 42%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #82f0ff, #e0ddff);
animation: result-progress 1.2s ease-in-out infinite alternate;
}
}
.result-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.usage-bar {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 18px;
color: rgba(255, 255, 255, 0.66);
font-size: 13px;
span {
height: 28px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
}
}
.image-grid {
flex: 1;
min-height: 0;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 360px));
align-content: start;
justify-content: start;
gap: 18px;
padding: 2px 6px 16px 0;
scrollbar-width: thin;
scrollbar-color: rgba(148, 216, 255, 0.24) transparent;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(148, 216, 255, 0.24);
}
}
.image-item {
position: relative;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
background: rgba(255, 255, 255, 0.035);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.22);
&:hover {
border-color: rgba(148, 216, 255, 0.28);
}
&:hover .download-mask {
opacity: 1;
pointer-events: auto;
}
}
.image-preview-box {
min-height: 260px;
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
background:
linear-gradient(45deg, rgba(255, 255, 255, 0.035) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255, 255, 255, 0.035) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.035) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.035) 75%);
background-color: #1a1f2a;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
background-size: 16px 16px;
img {
max-width: 100%;
max-height: 520px;
display: block;
border-radius: 10px;
object-fit: contain;
}
}
.download-mask {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border: none;
color: #fff;
background: rgba(0, 0, 0, 0.38);
backdrop-filter: blur(2px);
opacity: 0;
pointer-events: none;
cursor: pointer;
transition: opacity 0.18s ease;
.iconfont {
font-size: 34px;
}
}
@keyframes result-rotate {
to {
transform: rotate(360deg);
}
}
@keyframes result-progress {
from {
transform: translateX(-80%);
}
to {
transform: translateX(170%);
}
}
</style>

View File

@ -1,308 +1,565 @@
<template>
<div class="text-chat-page">
<aside class="chat-sidebar">
<div class="brand">
<div class="brand-mark">YJ</div>
<span>元境文本</span>
</div>
<div class="text-generate-page">
<header class="page-header">
<el-select v-model="selectedModel" class="model-select" popper-class="text-model-popper">
<template #prefix>
<span class="model-icon"></span>
</template>
<el-option
v-for="model in modelOptions"
:key="model.value"
:label="model.label"
:value="model.value"
/>
</el-select>
<button class="new-chat-btn">+ 开启新对话</button>
</header>
<div class="history-section">
<div class="history-title">今天</div>
<div class="history-item active">设置默认浏览器方法</div>
</div>
<main class="welcome-panel">
<div class="aurora aurora-left"></div>
<div class="aurora aurora-right"></div>
<div class="history-section">
<div class="history-title">昨天</div>
<div class="history-item">简化模型编辑保存代码</div>
<div class="history-item">AI准备平台分类介绍</div>
</div>
<section class="welcome-content">
<LoginPrompt
v-if="!isLoggedIn"
class="text-login-prompt"
title="请先登录"
description="登录后可使用文本对话生成能力,并查看生成结果。"
full-height
/>
<div class="history-section">
<div class="history-title">7 天内</div>
<div class="history-item">利用模板页面代码优化</div>
<div class="history-item">用户发送数字1寻求帮助</div>
</div>
</aside>
<template v-else-if="!messages.length && !isGenerating">
<div class="chat-bubble-icon">
<span></span>
<span></span>
<span></span>
</div>
<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">
<h1>开启智能对话</h1>
<p>
通用中操作提供步骤设置默认浏览器的方法取决于你使用的操作系统
下面列出常见系统的步骤
{{ selectedModel || '模型' }} 开始一场流畅的对话支持多轮上下文理解随时调整参数探索不同的输出效果
</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 class="prompt-examples">
<button
v-for="example in promptExamples"
:key="example"
type="button"
@click="useExample(example)"
>
{{ example }}
</button>
</div>
<div class="feature-list">
<span><i class="blue"></i>用心理解输出</span>
</div>
</template>
<div v-else ref="messageListRef" class="message-list">
<article
v-for="message in messages"
:key="message.id"
:class="['message-card', message.role]"
>
<div class="message-title">
<span v-if="message.role === 'assistant'" class="model-icon"></span>
<span>{{ message.role === 'assistant' ? message.model : '' }}</span>
<!-- <span v-if="message.id === currentAssistantMessageId && isGenerating" class="typing">生成中...</span> -->
</div>
<div class="message-content">{{ message.content || '正在思考...' }}</div>
</article>
</div>
</div>
</section>
</main>
<footer class="composer-bar">
<div class="composer">
<el-input
v-model="prompt"
class="composer-input"
type="textarea"
:autosize="{ minRows: 10, maxRows: 5 }"
:disabled="!isLoggedIn"
:placeholder="isLoggedIn ? '在这里输入内容,探索模型的无限可能...' : '请先登录后使用文本生成'"
@keydown="handleComposerKeydown"
/>
<button
class="send-btn"
type="button"
:disabled="isGenerating || !isLoggedIn"
@click="textToText"
>
<span v-if="isGenerating" class="sending-text">...</span>
<i v-else class="iconfont icon-a-17Afasong"></i>
</button>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { AllModel, textToTextStream } from '@/apis/Model';
import LoginPrompt from '@/components/LoginPrompt/LoginPrompt.vue';
import { useLoginStore } from '@/store/LoginStore';
import type { AllModelResponse, TextToTextParams } from '@/types/model';
import { ElMessage } from 'element-plus';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
model?: string;
}
const prompt = ref('');
const loginStore = useLoginStore();
const selectedModel = ref('qwen3.5-plus');
const modelOptions = ref([
{ label: 'qwen3.5-plus', value: 'qwen3.5-plus' },
{ label: 'qwen-plus', value: 'qwen-plus' },
{ label: 'qwen-max', value: 'qwen-max' },
]);
const messages = ref<ChatMessage[]>([]);
const messageListRef = ref<HTMLElement | null>(null);
const currentAssistantMessageId = ref('');
const isGenerating = ref(false);
const isLoggedIn = computed(() => loginStore.isLoggedIn);
const promptExamples = [
'解析什么是人工智能',
'给我讲个有趣的冷知识',
'帮我写一份打卡计划',
];
//
const getModel = async () => {
interface ModelParams {
llmcatelogid: string;
}
const Props: ModelParams = {
llmcatelogid:'t2t'
}
const res = await AllModel<AllModelResponse, ModelParams>(Props);
if(res.status === 'ok' && res.data?.length) {
modelOptions.value = res.data.map((item) => ({
label: item.model || item.name,
value: item.model,
}));
selectedModel.value = modelOptions.value[0]?.value || selectedModel.value;
}
}
onMounted(() => {
getModel();
})
const scrollMessagesToBottom = async () => {
await nextTick();
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
}
}
//
const textToText = async () => {
if (isGenerating.value) return;
if (!isLoggedIn.value) {
ElMessage.warning('请先登录后使用文本生成');
return;
}
const content = prompt.value.trim();
if (!content) {
ElMessage.warning('请输入内容');
return;
}
if (!selectedModel.value) {
ElMessage.warning('请选择模型');
return;
}
const params: TextToTextParams = {
model: selectedModel.value,
catelogid: 't2t',
prompt: content,
sys_prompt: '',
stream: true,
};
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
content,
};
const assistantMessage: ChatMessage = {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: '',
model: selectedModel.value,
};
messages.value.push(userMessage, assistantMessage);
currentAssistantMessageId.value = assistantMessage.id;
prompt.value = '';
isGenerating.value = true;
scrollMessagesToBottom();
try {
await textToTextStream(params, (chunk) => {
const assistantIndex = messages.value.findIndex(message => message.id === assistantMessage.id);
if (assistantIndex !== -1) {
messages.value[assistantIndex].content += chunk;
scrollMessagesToBottom();
}
});
} catch (error) {
console.error('文生文失败:', error);
const assistantIndex = messages.value.findIndex(message => message.id === assistantMessage.id);
if (assistantIndex !== -1 && !messages.value[assistantIndex].content) {
messages.value[assistantIndex].content = '生成失败,请稍后重试。';
}
ElMessage.error(error instanceof Error ? error.message : '生成失败,请重试');
} finally {
isGenerating.value = false;
currentAssistantMessageId.value = '';
}
}
const useExample = (example: string) => {
prompt.value = example;
}
const handleComposerKeydown = (event: KeyboardEvent) => {
if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
return;
}
event.preventDefault();
textToText();
}
watch(isLoggedIn, (value) => {
if (!value) {
messages.value = [];
isGenerating.value = false;
currentAssistantMessageId.value = '';
}
});
</script>
<style scoped lang="less">
.text-chat-page {
.text-generate-page {
height: calc(100vh - 80px);
min-height: 0;
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
color: #fff;
background: #0f0f19;
background:
radial-gradient(circle at 12% 12%, rgba(44, 255, 248, 0.16), transparent 28%),
radial-gradient(circle at 88% 78%, rgba(44, 104, 255, 0.2), transparent 34%),
linear-gradient(135deg, #0f0f19 0%, #141621 54%, #10131f 100%);
}
.chat-sidebar {
min-height: 0;
padding: 18px 12px;
background: #141621;
border-right: 1px solid rgba(255, 255, 255, 0.06);
overflow-y: auto;
}
.brand {
.page-header {
flex-shrink: 0;
height: 56px;
padding: 0 10px;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 18px;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(15, 15, 25, 0.82);
backdrop-filter: blur(16px);
}
.model-select {
width: 16%;
}
.model-icon {
color: #00ffcc;
font-size: 16px;
font-weight: 700;
}
.brand-mark {
width: 26px;
height: 26px;
display: flex;
.params-empty {
height: 32px;
display: inline-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;
padding: 0 12px;
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);
border-radius: 16px;
color: rgba(255, 255, 255, 0.46);
background: rgba(255, 255, 255, 0.04);
font-size: 13px;
}
.history-item {
padding: 10px 12px;
border-radius: 10px;
color: rgba(255, 255, 255, 0.68);
font-size: 14px;
white-space: nowrap;
.welcome-panel {
flex: 1;
min-height: 0;
position: relative;
display: flex;
align-items: stretch;
justify-content: center;
padding: 20px 4px 150px;
overflow: hidden;
text-overflow: ellipsis;
&.active,
&:hover {
background: linear-gradient(90deg, #2cfff84d, #2c68ff4d);
color: #00ffcc;
}
}
.chat-main {
min-width: 0;
.aurora {
position: absolute;
width: 340px;
height: 340px;
border-radius: 50%;
filter: blur(58px);
opacity: 0.42;
}
.aurora-left {
left: 40px;
top: 12px;
background: rgba(0, 255, 204, 0.24);
}
.aurora-right {
right: 24px;
bottom: 64px;
background: rgba(44, 104, 255, 0.28);
}
.welcome-content {
position: relative;
z-index: 1;
width: min(1120px, 100%);
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);
justify-content: center;
text-align: center;
h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
h1 {
margin: 22px 0 10px;
font-size: 28px;
line-height: 1.35;
font-weight: 700;
background: linear-gradient(90deg, #00ffcc, #94d8ff 56%, #e0ddff);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
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;
max-width: 560px;
margin: 0;
color: rgba(255, 255, 255, 0.62);
font-size: 14px;
line-height: 1.8;
}
}
.composer-wrap {
position: absolute;
right: 32px;
bottom: 22px;
left: calc(220px + 32px);
.text-login-prompt {
width: 100%;
min-height: 0;
}
.chat-bubble-icon {
width: 58px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
border-radius: 14px;
background: linear-gradient(135deg, #2cfff8, #2c68ff);
box-shadow: 0 14px 30px rgba(44, 104, 255, 0.36);
span {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.86);
}
}
.prompt-examples {
margin-top: 28px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
button {
height: 34px;
padding: 0 18px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.76);
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.12);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: rgba(0, 255, 204, 0.38);
color: #00ffcc;
background: rgba(0, 255, 204, 0.08);
}
}
}
.feature-list {
margin-top: 28px;
display: flex;
justify-content: center;
pointer-events: none;
gap: 28px;
color: rgba(255, 255, 255, 0.52);
font-size: 12px;
span {
display: inline-flex;
align-items: center;
gap: 6px;
}
i {
width: 7px;
height: 7px;
border-radius: 50%;
}
.green {
background: #35d399;
}
.blue {
background: #5b8cff;
}
.purple {
background: #a66cff;
}
}
.message-list {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 18px;
overflow-y: auto;
padding: 8px 6px 72px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.message-card {
width: fit-content;
max-width: min(860px, 90%);
padding: 16px 18px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
box-shadow: 0 14px 38px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(18px);
text-align: left;
&.assistant {
align-self: flex-start;
background: rgba(20, 22, 33, 0.86);
}
&.user {
align-self: flex-end;
max-width: min(520px, 78%);
border-color: rgba(0, 255, 204, 0.18);
background: linear-gradient(135deg, rgba(0, 255, 204, 0.18), rgba(44, 104, 255, 0.14));
.message-title {
justify-content: flex-end;
}
}
}
.message-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: #fff;
font-size: 14px;
font-weight: 600;
}
.typing {
margin-left: auto;
color: rgba(255, 255, 255, 0.48);
font-size: 12px;
font-weight: 400;
}
.message-content {
white-space: pre-wrap;
word-break: break-word;
color: rgba(255, 255, 255, 0.82);
font-size: 15px;
line-height: 1.8;
}
.composer-bar {
position: absolute;
left: 50%;
right: auto;
bottom: 10px;
width: min(1120px, calc(100% - 8px));
transform: translateX(-50%);
}
.composer {
width: min(680px, 100%);
padding: 14px;
min-height: 80px;
width: 100%;
display: flex;
align-items: flex-end;
gap: 10px;
padding: 10px 10px 10px 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);
border-radius: 14px;
background: rgba(20, 22, 33, 0.92);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.36);
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;
}
.composer-input {
flex: 1;
}
.send-btn {
width: 34px;
height: 34px;
width: 36px;
height: 36px;
flex-shrink: 0;
border: none;
border-radius: 50%;
background: linear-gradient(270deg, #94d8ff 11.11%, #e0ddff);
color: #0f0f19;
font-size: 18px;
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.55;
}
}
.sending-text {
font-size: 14px;
line-height: 1;
}
:deep(.composer-input .el-textarea__inner) {
@ -311,9 +568,58 @@ const prompt = ref('');
resize: none;
color: #fff;
background: transparent;
min-height: 40px !important;
max-height: 120px;
padding: 8px 0 6px;
line-height: 24px;
overflow-y: auto;
scrollbar-width: none;
}
:deep(.composer-input .el-textarea__inner::-webkit-scrollbar) {
display: none;
}
:deep(.composer-input .el-textarea__inner::placeholder) {
color: rgba(255, 255, 255, 0.42);
}
:deep(.model-select .el-select__wrapper) {
min-height: 34px;
border-radius: 17px;
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08) inset;
}
:deep(.model-select .el-select__selected-item) {
color: #fff;
font-weight: 600;
}
:deep(.model-select .el-select__caret) {
color: rgba(255, 255, 255, 0.62);
}
:global(.text-model-popper) {
border: 1px solid rgba(255, 255, 255, 0.1) !important;
background: #141621 !important;
}
:global(.text-model-popper .el-select-dropdown__item) {
color: rgba(255, 255, 255, 0.72) !important;
background: transparent !important;
}
:global(.text-model-popper .el-select-dropdown__item.is-selected),
:global(.text-model-popper .el-select-dropdown__item.selected),
:global(.text-model-popper .el-select-dropdown__item.is-hovering),
:global(.text-model-popper .el-select-dropdown__item.hover),
:global(.text-model-popper .el-select-dropdown__item:hover) {
color: #00ffcc !important;
background: rgba(0, 255, 204, 0.08) !important;
}
:global(.text-model-popper .el-select-dropdown__list) {
background: #141621 !important;
}
</style>

View File

@ -4,17 +4,25 @@
<VideoLeft ref="videoLeftRef" @task-created="handleTaskCreated" />
</div>
<div class="right-panel">
<RightResult ref="rightResultRef" />
<RightResult ref="rightResultRef" :llmcatelogid="currentLlmcatelogid" />
</div>
</div>
</template>
<script setup lang='ts'>
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import VideoLeft from '@/components/video/VideoLeft.vue';
import RightResult from '@/components/RightResult/RightResult.vue';
// const videoLeftRef = ref<InstanceType<typeof VideoLeft> | null>(null);
const rightResultRef = ref<InstanceType<typeof RightResult> | null>(null);
const route = useRoute();
const currentLlmcatelogid = computed(() => {
if (route.path.includes('/videoGenerate/image')) return 'i2v';
if (route.path.includes('/videoGenerate/text')) return 't2v';
return 'r2v';
});
//
const handleTaskCreated = () => {

View File

@ -1,30 +1,42 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(),],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
host: 'localhost',
port: 5173,
// 配置代理解决跨域问题
proxy: {
'/api': {
target: 'https://opencomputing.ai',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const apiBaseURL = env.VITE_API_BASE_URL || 'https://token.opencomputing.cn'
return {
plugins: [vue(),],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
},
// optimizeDeps: {
// exclude: ['@techui/scifi']
// },
},
server: {
host: 'localhost',
port: 5173,
// 配置代理解决跨域问题
proxy: {
'/api': {
target: apiBaseURL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/token':{
target: apiBaseURL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/token/, ''),
}
}
},
// optimizeDeps: {
// exclude: ['@techui/scifi']
// },
}
})

1
yuanjing Submodule

@ -0,0 +1 @@
Subproject commit ef717c6c46a0c261e885443d6878e34f046adfbe